Extract "create vault" form to separate component (#13461)
The form used for creating a vault on the "Import" page of onboarding and on the "Restore vault" page is nearly identical, yet the implementation is totally separate. It has now been extracted to a separate component, consolidating the two implementations. There is a "terms of use" checkbox on the import page that isn't on the restore vault page, so that part has been made optional. The "submit" button text differs between the two uses as well, so that is customizable. There are slight styling differences between the old and new versions of this form. The fonts and spacing are all using our new standard design system guidelines, and we're using our standard checkbox now as well. The spacing and font sizes were chosen somewhat arbitrarily by me to resemble the old styles, so please feel free to suggest changes if you think they can be improved upon. There are some slight copy changes to the "Restore vault" page as well; the placeholder text and the label for the "Secret Recovery Phrase" field now matches the "Import" page copy.feature/default_network_editable
parent
760ed3457d
commit
429451de23
@ -0,0 +1,249 @@ |
||||
import { ethers } from 'ethers'; |
||||
import React, { useCallback, useContext, useState } from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { useI18nContext } from '../../../hooks/useI18nContext'; |
||||
import { MetaMetricsContext } from '../../../contexts/metametrics'; |
||||
import TextField from '../../ui/text-field'; |
||||
import Button from '../../ui/button'; |
||||
import { clearClipboard } from '../../../helpers/utils/util'; |
||||
import CheckBox from '../../ui/check-box'; |
||||
import Typography from '../../ui/typography'; |
||||
import { COLORS } from '../../../helpers/constants/design-system'; |
||||
import { parseSecretRecoveryPhrase } from './parse-secret-recovery-phrase'; |
||||
|
||||
const { isValidMnemonic } = ethers.utils; |
||||
|
||||
export default function CreateNewVault({ |
||||
disabled = false, |
||||
includeTerms = false, |
||||
onSubmit, |
||||
submitText, |
||||
}) { |
||||
const [confirmPassword, setConfirmPassword] = useState(''); |
||||
const [confirmPasswordError, setConfirmPasswordError] = useState(''); |
||||
const [password, setPassword] = useState(''); |
||||
const [passwordError, setPasswordError] = useState(''); |
||||
const [seedPhrase, setSeedPhrase] = useState(''); |
||||
const [seedPhraseError, setSeedPhraseError] = useState(''); |
||||
const [showSeedPhrase, setShowSeedPhrase] = useState(false); |
||||
const [termsChecked, setTermsChecked] = useState(false); |
||||
|
||||
const t = useI18nContext(); |
||||
const metricsEvent = useContext(MetaMetricsContext); |
||||
|
||||
const onSeedPhraseChange = useCallback( |
||||
(rawSeedPhrase) => { |
||||
let newSeedPhraseError = ''; |
||||
|
||||
if (rawSeedPhrase) { |
||||
const parsedSeedPhrase = parseSecretRecoveryPhrase(rawSeedPhrase); |
||||
const wordCount = parsedSeedPhrase.split(/\s/u).length; |
||||
if (wordCount % 3 !== 0 || wordCount > 24 || wordCount < 12) { |
||||
newSeedPhraseError = t('seedPhraseReq'); |
||||
} else if (!isValidMnemonic(parsedSeedPhrase)) { |
||||
newSeedPhraseError = t('invalidSeedPhrase'); |
||||
} |
||||
} |
||||
|
||||
setSeedPhrase(rawSeedPhrase); |
||||
setSeedPhraseError(newSeedPhraseError); |
||||
}, |
||||
[setSeedPhrase, setSeedPhraseError, t], |
||||
); |
||||
|
||||
const onPasswordChange = useCallback( |
||||
(newPassword) => { |
||||
let newConfirmPasswordError = ''; |
||||
let newPasswordError = ''; |
||||
|
||||
if (newPassword && newPassword.length < 8) { |
||||
newPasswordError = t('passwordNotLongEnough'); |
||||
} |
||||
|
||||
if (confirmPassword && newPassword !== confirmPassword) { |
||||
newConfirmPasswordError = t('passwordsDontMatch'); |
||||
} |
||||
|
||||
setPassword(newPassword); |
||||
setPasswordError(newPasswordError); |
||||
setConfirmPasswordError(newConfirmPasswordError); |
||||
}, |
||||
[confirmPassword, t], |
||||
); |
||||
|
||||
const onConfirmPasswordChange = useCallback( |
||||
(newConfirmPassword) => { |
||||
let newConfirmPasswordError = ''; |
||||
|
||||
if (password !== newConfirmPassword) { |
||||
newConfirmPasswordError = t('passwordsDontMatch'); |
||||
} |
||||
|
||||
setConfirmPassword(newConfirmPassword); |
||||
setConfirmPasswordError(newConfirmPasswordError); |
||||
}, |
||||
[password, t], |
||||
); |
||||
|
||||
const isValid = |
||||
!disabled && |
||||
password && |
||||
confirmPassword && |
||||
password === confirmPassword && |
||||
seedPhrase && |
||||
(!includeTerms || termsChecked) && |
||||
!passwordError && |
||||
!confirmPasswordError && |
||||
!seedPhraseError; |
||||
|
||||
const onImport = useCallback( |
||||
async (event) => { |
||||
event.preventDefault(); |
||||
|
||||
if (!isValid) { |
||||
return; |
||||
} |
||||
|
||||
await onSubmit(password, parseSecretRecoveryPhrase(seedPhrase)); |
||||
}, |
||||
[isValid, onSubmit, password, seedPhrase], |
||||
); |
||||
|
||||
const toggleTermsCheck = useCallback(() => { |
||||
metricsEvent({ |
||||
eventOpts: { |
||||
category: 'Onboarding', |
||||
action: 'Import Seed Phrase', |
||||
name: 'Check ToS', |
||||
}, |
||||
}); |
||||
|
||||
setTermsChecked((currentTermsChecked) => !currentTermsChecked); |
||||
}, [metricsEvent]); |
||||
|
||||
const toggleShowSeedPhrase = useCallback(() => { |
||||
setShowSeedPhrase((currentShowSeedPhrase) => !currentShowSeedPhrase); |
||||
}, []); |
||||
|
||||
const termsOfUse = t('acceptTermsOfUse', [ |
||||
<a |
||||
className="create-new-vault__terms-link" |
||||
key="create-new-vault__link-text" |
||||
href="https://metamask.io/terms.html" |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
> |
||||
{t('terms')} |
||||
</a>, |
||||
]); |
||||
|
||||
return ( |
||||
<form className="create-new-vault__form" onSubmit={onImport}> |
||||
<div className="create-new-vault__srp-section"> |
||||
<label |
||||
htmlFor="create-new-vault__srp" |
||||
className="create-new-vault__srp-label" |
||||
> |
||||
<Typography>{t('secretRecoveryPhrase')}</Typography> |
||||
</label> |
||||
{showSeedPhrase ? ( |
||||
<textarea |
||||
id="create-new-vault__srp" |
||||
className="create-new-vault__srp-shown" |
||||
onChange={(e) => onSeedPhraseChange(e.target.value)} |
||||
onPaste={clearClipboard} |
||||
value={seedPhrase} |
||||
placeholder={t('seedPhrasePlaceholder')} |
||||
autoComplete="off" |
||||
/> |
||||
) : ( |
||||
<TextField |
||||
id="create-new-vault__srp" |
||||
type="password" |
||||
onChange={(e) => onSeedPhraseChange(e.target.value)} |
||||
value={seedPhrase} |
||||
placeholder={t('seedPhrasePlaceholderPaste')} |
||||
autoComplete="off" |
||||
onPaste={clearClipboard} |
||||
/> |
||||
)} |
||||
{seedPhraseError ? ( |
||||
<Typography |
||||
color={COLORS.ERROR1} |
||||
tag="span" |
||||
className="create-new-vault__srp-error" |
||||
> |
||||
{seedPhraseError} |
||||
</Typography> |
||||
) : null} |
||||
<div className="create-new-vault__show-srp"> |
||||
<CheckBox |
||||
id="create-new-vault__show-srp-checkbox" |
||||
checked={showSeedPhrase} |
||||
onClick={toggleShowSeedPhrase} |
||||
title={t('showSeedPhrase')} |
||||
/> |
||||
<label |
||||
className="create-new-vault__show-srp-label" |
||||
htmlFor="create-new-vault__show-srp-checkbox" |
||||
> |
||||
<Typography tag="span">{t('showSeedPhrase')}</Typography> |
||||
</label> |
||||
</div> |
||||
</div> |
||||
<TextField |
||||
id="password" |
||||
label={t('newPassword')} |
||||
type="password" |
||||
value={password} |
||||
onChange={(event) => onPasswordChange(event.target.value)} |
||||
error={passwordError} |
||||
autoComplete="new-password" |
||||
margin="normal" |
||||
largeLabel |
||||
/> |
||||
<TextField |
||||
id="confirm-password" |
||||
label={t('confirmPassword')} |
||||
type="password" |
||||
value={confirmPassword} |
||||
onChange={(event) => onConfirmPasswordChange(event.target.value)} |
||||
error={confirmPasswordError} |
||||
autoComplete="new-password" |
||||
margin="normal" |
||||
largeLabel |
||||
/> |
||||
{includeTerms ? ( |
||||
<div className="create-new-vault__terms"> |
||||
<CheckBox |
||||
id="create-new-vault__terms-checkbox" |
||||
dataTestId="create-new-vault__terms-checkbox" |
||||
checked={termsChecked} |
||||
onClick={toggleTermsCheck} |
||||
/> |
||||
<label |
||||
className="create-new-vault__terms-label" |
||||
htmlFor="create-new-vault__terms-checkbox" |
||||
> |
||||
<Typography tag="span">{termsOfUse}</Typography> |
||||
</label> |
||||
</div> |
||||
) : null} |
||||
<Button |
||||
className="create-new-vault__submit-button" |
||||
type="primary" |
||||
submit |
||||
disabled={!isValid} |
||||
> |
||||
{submitText} |
||||
</Button> |
||||
</form> |
||||
); |
||||
} |
||||
|
||||
CreateNewVault.propTypes = { |
||||
disabled: PropTypes.bool, |
||||
includeTerms: PropTypes.bool, |
||||
onSubmit: PropTypes.func.isRequired, |
||||
submitText: PropTypes.string.isRequired, |
||||
}; |
@ -0,0 +1,53 @@ |
||||
.create-new-vault { |
||||
&__form { |
||||
display: flex; |
||||
flex-direction: column; |
||||
width: 360px; |
||||
} |
||||
|
||||
&__srp-shown { |
||||
@include Paragraph; |
||||
|
||||
padding: 8px 16px; |
||||
} |
||||
|
||||
&__srp-section { |
||||
display: flex; |
||||
flex-direction: column; |
||||
} |
||||
|
||||
&__srp-label { |
||||
margin-bottom: 8px; |
||||
} |
||||
|
||||
&__srp-error { |
||||
margin-top: 4px; |
||||
} |
||||
|
||||
&__show-srp { |
||||
margin-top: 16px; |
||||
margin-bottom: 16px; |
||||
} |
||||
|
||||
&__show-srp-label { |
||||
margin-left: 8px; |
||||
} |
||||
|
||||
&__terms { |
||||
margin-top: 16px; |
||||
margin-bottom: 16px; |
||||
} |
||||
|
||||
&__terms-label { |
||||
margin-left: 8px; |
||||
} |
||||
|
||||
&__terms-link { |
||||
color: var(--primary-1); |
||||
} |
||||
|
||||
&__submit-button#{&}__submit-button { |
||||
margin-top: 16px; |
||||
width: 170px; |
||||
} |
||||
} |
@ -0,0 +1,29 @@ |
||||
import React from 'react'; |
||||
import CreateNewVault from '.'; |
||||
|
||||
export default { |
||||
title: 'Components/App/CreateNewVault', |
||||
id: __filename, |
||||
argTypes: { |
||||
disabled: { control: 'boolean' }, |
||||
submitText: { control: 'text' }, |
||||
}, |
||||
args: { |
||||
submitText: 'Import', |
||||
}, |
||||
}; |
||||
|
||||
const Template = (args) => { |
||||
return ( |
||||
<div style={{ width: '600px' }}> |
||||
<CreateNewVault {...args} /> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export const DefaultStory = Template.bind({}); |
||||
|
||||
DefaultStory.storyName = 'Default'; |
||||
|
||||
export const WithTerms = Template.bind({}); |
||||
WithTerms.args = { includeTerms: true }; |
@ -0,0 +1 @@ |
||||
export { default } from './create-new-vault'; |
@ -0,0 +1,40 @@ |
||||
import { parseSecretRecoveryPhrase } from './parse-secret-recovery-phrase'; |
||||
|
||||
describe('parseSecretRecoveryPhrase', () => { |
||||
it('should handle a regular Secret Recovery Phrase', () => { |
||||
expect(parseSecretRecoveryPhrase('foo bar baz')).toStrictEqual( |
||||
'foo bar baz', |
||||
); |
||||
}); |
||||
|
||||
it('should handle a mixed-case Secret Recovery Phrase', () => { |
||||
expect(parseSecretRecoveryPhrase('FOO bAr baZ')).toStrictEqual( |
||||
'foo bar baz', |
||||
); |
||||
}); |
||||
|
||||
it('should handle an upper-case Secret Recovery Phrase', () => { |
||||
expect(parseSecretRecoveryPhrase('FOO BAR BAZ')).toStrictEqual( |
||||
'foo bar baz', |
||||
); |
||||
}); |
||||
|
||||
it('should trim extraneous whitespace from the given Secret Recovery Phrase', () => { |
||||
expect(parseSecretRecoveryPhrase(' foo bar baz ')).toStrictEqual( |
||||
'foo bar baz', |
||||
); |
||||
}); |
||||
|
||||
it('should return an empty string when given a whitespace-only string', () => { |
||||
expect(parseSecretRecoveryPhrase(' ')).toStrictEqual(''); |
||||
}); |
||||
|
||||
it('should return an empty string when given a string with only symbols', () => { |
||||
expect(parseSecretRecoveryPhrase('$')).toStrictEqual(''); |
||||
}); |
||||
|
||||
it('should return an empty string for both null and undefined', () => { |
||||
expect(parseSecretRecoveryPhrase(undefined)).toStrictEqual(''); |
||||
expect(parseSecretRecoveryPhrase(null)).toStrictEqual(''); |
||||
}); |
||||
}); |
@ -0,0 +1,2 @@ |
||||
export const parseSecretRecoveryPhrase = (seedPhrase) => |
||||
(seedPhrase || '').trim().toLowerCase().match(/\w+/gu)?.join(' ') || ''; |
@ -1,99 +0,0 @@ |
||||
import React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
import sinon from 'sinon'; |
||||
import ImportWithSeedPhrase from './import-with-seed-phrase.component'; |
||||
|
||||
function shallowRender(props = {}, context = {}) { |
||||
return shallow(<ImportWithSeedPhrase {...props} />, { |
||||
context: { |
||||
t: (str) => `${str}_t`, |
||||
metricsEvent: sinon.spy(), |
||||
...context, |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
describe('ImportWithSeedPhrase Component', () => { |
||||
it('should render without error', () => { |
||||
const component = shallowRender({ |
||||
onSubmit: sinon.spy(), |
||||
}); |
||||
const textareaCount = component.find('.first-time-flow__textarea').length; |
||||
expect(textareaCount).toStrictEqual(1); |
||||
}); |
||||
|
||||
describe('parseSeedPhrase', () => { |
||||
it('should handle a regular Secret Recovery Phrase', () => { |
||||
const component = shallowRender({ |
||||
onSubmit: sinon.spy(), |
||||
}); |
||||
|
||||
const { parseSeedPhrase } = component.instance(); |
||||
|
||||
expect(parseSeedPhrase('foo bar baz')).toStrictEqual('foo bar baz'); |
||||
}); |
||||
|
||||
it('should handle a mixed-case Secret Recovery Phrase', () => { |
||||
const component = shallowRender({ |
||||
onSubmit: sinon.spy(), |
||||
}); |
||||
|
||||
const { parseSeedPhrase } = component.instance(); |
||||
|
||||
expect(parseSeedPhrase('FOO bAr baZ')).toStrictEqual('foo bar baz'); |
||||
}); |
||||
|
||||
it('should handle an upper-case Secret Recovery Phrase', () => { |
||||
const component = shallowRender({ |
||||
onSubmit: sinon.spy(), |
||||
}); |
||||
|
||||
const { parseSeedPhrase } = component.instance(); |
||||
|
||||
expect(parseSeedPhrase('FOO BAR BAZ')).toStrictEqual('foo bar baz'); |
||||
}); |
||||
|
||||
it('should trim extraneous whitespace from the given Secret Recovery Phrase', () => { |
||||
const component = shallowRender({ |
||||
onSubmit: sinon.spy(), |
||||
}); |
||||
|
||||
const { parseSeedPhrase } = component.instance(); |
||||
|
||||
expect(parseSeedPhrase(' foo bar baz ')).toStrictEqual( |
||||
'foo bar baz', |
||||
); |
||||
}); |
||||
|
||||
it('should return an empty string when given a whitespace-only string', () => { |
||||
const component = shallowRender({ |
||||
onSubmit: sinon.spy(), |
||||
}); |
||||
|
||||
const { parseSeedPhrase } = component.instance(); |
||||
|
||||
expect(parseSeedPhrase(' ')).toStrictEqual(''); |
||||
}); |
||||
|
||||
it('should return an empty string when given a string with only symbols', () => { |
||||
const component = shallowRender({ |
||||
onSubmit: sinon.spy(), |
||||
}); |
||||
|
||||
const { parseSeedPhrase } = component.instance(); |
||||
|
||||
expect(parseSeedPhrase('$')).toStrictEqual(''); |
||||
}); |
||||
|
||||
it('should return an empty string for both null and undefined', () => { |
||||
const component = shallowRender({ |
||||
onSubmit: sinon.spy(), |
||||
}); |
||||
|
||||
const { parseSeedPhrase } = component.instance(); |
||||
|
||||
expect(parseSeedPhrase(undefined)).toStrictEqual(''); |
||||
expect(parseSeedPhrase(null)).toStrictEqual(''); |
||||
}); |
||||
}); |
||||
}); |
Loading…
Reference in new issue