Repository: polkadot-js/extension
Pull request #619 (2021-01-11 14:31:05)

Refactor seed import and add explicit derivation path field
What this PR contains:
- `AccountNamePasswordCreation` component created from what was previously in `Popup/CreateAccountAccountName`. This is the component with the name, password and password repeat.
- Use this new component in "Derive", "Account creation" and "Account import from seed" screens
- Create tests for it (moved most tests from CreateAccount.test)
- Enhance Derive tests to test the actual creation
- Add derivation path field in account import from seed screen (closes https://github.com/polkadot-js/extension/issues/579), I still went for an "advanced" drop down because I figured that having an empty locked field like the derivation was unintuitive.

All the  "Derive", "Account creation" and "Account import from seed" flows are well tested (with #620)

https://user-images.githubusercontent.com/33178835/104341509-2be64300-54fa-11eb-92d5-73ad56788325.mp4



Tbaut: Moving to WIP, I identified some other areas we can change while building the tests.
Tbaut: I'll stop there because this is already too big, although a lot of the changes concerns tests. I've built #620 alongside so everything I touched here is well tested.
Tbaut: yes sorry about that, I didn't expect to fall in the rabbit hole there :)

Patch:
diff --git a/packages/extension-ui/src/Popup/CreateAccount/index.tsx b/packages/extension-ui/src/Popup/CreateAccount/index.tsx
index 426814c6..c990060c 100644
--- a/packages/extension-ui/src/Popup/CreateAccount/index.tsx
+++ b/packages/extension-ui/src/Popup/CreateAccount/index.tsx
@@ -4,11 +4,11 @@
 import React, { useCallback, useContext, useEffect, useState } from 'react';
 
 import { ActionContext, Address, Loading } from '../../components';
+import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation';
 import useTranslation from '../../hooks/useTranslation';
 import { createAccountSuri, createSeed } from '../../messaging';
 import { HeaderWithSteps } from '../../partials';
 import { DEFAULT_TYPE } from '../../util/defaultType';
-import AccountName from './AccountName';
 import Mnemonic from './Mnemonic';
 
 export default function CreateAccount (): React.ReactElement {
@@ -26,7 +26,6 @@ export default function CreateAccount (): React.ReactElement {
       .catch((error: Error) => console.error(error));
   }, [type]);
 
-  // FIXME Duplicated between here and Import.tsx
   const _onCreate = useCallback(
     (name: string, password: string): void => {
       // this should always be the case
@@ -69,8 +68,8 @@ export default function CreateAccount (): React.ReactElement {
               />
             )
             : (
-              <AccountName
-                address={account.address}
+              <AccountNamePasswordCreation
+                buttonLabel={t<string>('Add the account with the generated seed')}
                 isBusy={isBusy}
                 onBackClick={_onPreviousStep}
                 onCreate={_onCreate}
diff --git a/packages/extension-ui/src/Popup/Derive/SelectParent.tsx b/packages/extension-ui/src/Popup/Derive/SelectParent.tsx
index d7d3f449..6ef92761 100644
--- a/packages/extension-ui/src/Popup/Derive/SelectParent.tsx
+++ b/packages/extension-ui/src/Popup/Derive/SelectParent.tsx
@@ -1,10 +1,7 @@
 // Copyright 2019-2021 @polkadot/extension-ui authors & contributors
 // SPDX-License-Identifier: Apache-2.0
 
-import type { ThemeProps } from '../../types';
-
 import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
-import styled from 'styled-components';
 
 import { canDerive } from '@polkadot/extension-base/utils';
 import { assert } from '@polkadot/util';
@@ -16,7 +13,7 @@ import { nextDerivationPath } from '../../util/nextDerivationPath';
 import AddressDropdown from './AddressDropdown';
 import DerivationPath from './DerivationPath';
 
-interface Props extends ThemeProps {
+interface Props {
   className?: string;
   isLocked?: boolean;
   parentAddress: string;
@@ -24,7 +21,7 @@ interface Props extends ThemeProps {
   onDerivationConfirmed: (derivation: { account: { address: string; suri: string }; parentPassword: string }) => void;
 }
 
-function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddress, parentGenesis }: Props): React.ReactElement<Props> {
+export default function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddress, parentGenesis }: Props): React.ReactElement<Props> {
   const { t } = useTranslation();
   const onAction = useContext(ActionContext);
   const [isBusy, setIsBusy] = useState(false);
@@ -165,6 +162,3 @@ function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddre
     </>
   );
 }
-
-export default styled(SelectParent)`
-`;
diff --git a/packages/extension-ui/src/Popup/Derive/index.tsx b/packages/extension-ui/src/Popup/Derive/index.tsx
index 9fc0556b..1a22926c 100644
--- a/packages/extension-ui/src/Popup/Derive/index.tsx
+++ b/packages/extension-ui/src/Popup/Derive/index.tsx
@@ -4,10 +4,10 @@
 import React, { useCallback, useContext, useMemo, useState } from 'react';
 import { useParams } from 'react-router';
 
-import { AccountContext, ActionContext, Address, BackButton, ButtonArea, NextStepButton, VerticalSpace } from '../../components';
+import { AccountContext, AccountNamePasswordCreation, ActionContext, Address } from '../../components';
 import useTranslation from '../../hooks/useTranslation';
 import { deriveAccount } from '../../messaging';
-import { HeaderWithSteps, Name, Password } from '../../partials';
+import { HeaderWithSteps } from '../../partials';
 import SelectParent from './SelectParent';
 
 interface Props {
@@ -35,7 +35,6 @@ function Derive ({ isLocked }: Props): React.ReactElement<Props> {
   const [isBusy, setIsBusy] = useState(false);
   const [account, setAccount] = useState<null | PathState>(null);
   const [name, setName] = useState<string | null>(null);
-  const [password, setPassword] = useState<string | null>(null);
   const [parentPassword, setParentPassword] = useState<string | null>(null);
 
   const parentGenesis = useMemo(
@@ -43,7 +42,7 @@ function Derive ({ isLocked }: Props): React.ReactElement<Props> {
     [accounts, parentAddress]
   );
 
-  const _onCreate = useCallback(() => {
+  const _onCreate = useCallback((name: string, password: string) => {
     if (!account || !name || !password || !parentPassword) {
       return;
     }
@@ -55,7 +54,7 @@ function Derive ({ isLocked }: Props): React.ReactElement<Props> {
         setIsBusy(false);
         console.error(error);
       });
-  }, [account, name, password, onAction, parentAddress, parentGenesis, parentPassword]);
+  }, [account, onAction, parentAddress, parentGenesis, parentPassword]);
 
   const _onDerivationConfirmed = useCallback(({ account, parentPassword }: ConfirmState) => {
     setAccount(account);
@@ -89,22 +88,13 @@ function Derive ({ isLocked }: Props): React.ReactElement<Props> {
               name={name}
             />
           </div>
-          <Name
-            isFocused
-            onChange={setName}
+          <AccountNamePasswordCreation
+            buttonLabel={t<string>('Create derived account')}
+            isBusy={isBusy}
+            onBackClick={_onBackClick}
+            onCreate={_onCreate}
+            onNameChange={setName}
           />
-          <Password onChange={setPassword} />
-          <VerticalSpace/>
-          <ButtonArea>
-            <BackButton onClick={_onBackClick}/>
-            <NextStepButton
-              isBusy={isBusy}
-              isDisabled={!password}
-              onClick={_onCreate}
-            >
-              {t<string>('Create derived account')}
-            </NextStepButton>
-          </ButtonArea>
         </>
       )}
     </>
diff --git a/packages/extension-ui/src/Popup/ImportSeed.tsx b/packages/extension-ui/src/Popup/ImportSeed.tsx
deleted file mode 100644
index 3e1d8229..00000000
--- a/packages/extension-ui/src/Popup/ImportSeed.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
-// SPDX-License-Identifier: Apache-2.0
-
-import React, { useCallback, useContext, useEffect, useState } from 'react';
-import styled from 'styled-components';
-
-import { AccountContext, ActionContext, Address, ButtonArea, NextStepButton, TextAreaWithLabel, ValidatedInput, VerticalSpace } from '../components';
-import useTranslation from '../hooks/useTranslation';
-import { createAccountSuri, validateSeed } from '../messaging';
-import { Header, Name, Password } from '../partials';
-import { allOf, isNotShorterThan, Result } from '../util/validators';
-
-async function validate (suri: string): Promise<Result<string>> {
-  try {
-    await validateSeed(suri);
-
-    return Result.ok(suri);
-  } catch (error) {
-    return Result.error((error as Error).message);
-  }
-}
-
-const isSeedValid = allOf(
-  isNotShorterThan(1, 'Seed is empty'),
-  validate
-);
-
-export default function Import (): React.ReactElement {
-  const { t } = useTranslation();
-  const { accounts } = useContext(AccountContext);
-  const onAction = useContext(ActionContext);
-  const [isBusy, setIsBusy] = useState(false);
-  const [account, setAccount] = useState<null | { address: string; suri: string }>(null);
-  const [name, setName] = useState<string | null>(null);
-  const [password, setPassword] = useState<string | null>(null);
-
-  useEffect((): void => {
-    !accounts.length && onAction();
-  }, [accounts, onAction]);
-
-  const _onChangeSeed = useCallback(
-    async (suri: string | null): Promise<void> => {
-      if (suri) {
-        try {
-          return setAccount(await validateSeed(suri));
-        } catch (error) {
-          console.error(error);
-        }
-      }
-
-      setAccount(null);
-    },
-    []
-  );
-
-  // FIXME Duplicated between here and Create.tsx
-  const _onCreate = useCallback((): void => {
-    // this should always be the case
-    if (name && password && account) {
-      setIsBusy(true);
-
-      createAccountSuri(name, password, account.suri)
-        .then(() => onAction('/'))
-        .catch((error): void => {
-          setIsBusy(false);
-          console.error(error);
-        });
-    }
-  }, [account, name, onAction, password]);
-
-  return (
-    <>
-      <Header
-        showBackArrow
-        smallMargin
-        text={t<string>('Import account')}
-      />
-      <div>
-        <Address
-          address={account?.address}
-          name={name}
-        />
-      </div>
-      <ValidatedInput
-        // eslint-disable-next-line @typescript-eslint/no-use-before-define
-        component={SeedInput}
-        isFocused
-        label={t<string>('existing 12 or 24-word mnemonic seed')}
-        onValidatedChange={_onChangeSeed}
-        rowsCount={2}
-        validator={isSeedValid}
-      />
-      {account && (
-        <>
-          <Name onChange={setName} />
-          <Password onChange={setPassword} />
-        </>
-      )}
-      {account && name && password && (
-        <>
-          <VerticalSpace />
-          <ButtonArea>
-            <NextStepButton
-              isBusy={isBusy}
-              onClick={_onCreate}
-            >
-              {t<string>('Add the account with the supplied seed')}
-            </NextStepButton>
-          </ButtonArea>
-        </>
-      )}
-    </>
-  );
-}
-
-const SeedInput = styled(TextAreaWithLabel)`
-  margin-bottom: 16px;
-  textarea {
-    height: unset;
-  }
-`;
diff --git a/packages/extension-ui/src/Popup/ImportSeed/SeedAndPath.tsx b/packages/extension-ui/src/Popup/ImportSeed/SeedAndPath.tsx
new file mode 100644
index 00000000..05d8a7cc
--- /dev/null
+++ b/packages/extension-ui/src/Popup/ImportSeed/SeedAndPath.tsx
@@ -0,0 +1,148 @@
+// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ThemeProps } from '../../types';
+
+import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import React, { useCallback, useEffect, useState } from 'react';
+import styled from 'styled-components';
+
+import { validateSeed } from '@polkadot/extension-ui/messaging';
+
+import { ButtonArea, InputWithLabel, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components';
+import useTranslation from '../../hooks/useTranslation';
+
+interface AccountInfo {
+  address: string;
+  suri: string;
+}
+
+interface Props {
+  className? : string;
+  onNextStep: () => void;
+  onAccountChange: (account: AccountInfo | null) => void;
+}
+
+function ImportSeed ({ className, onAccountChange, onNextStep }: Props): React.ReactElement {
+  const { t } = useTranslation();
+  const [address, setAddress] = useState('');
+  const [seed, setSeed] = useState<string | null>(null);
+  const [path, setPath] = useState<string | null>(null);
+  const [advanced, setAdvances] = useState(false);
+  const [error, setError] = useState('');
+
+  useEffect(() => {
+    // No need to validate an empty seed
+    // we have a dedicated error for this
+    if (!seed) {
+      onAccountChange(null);
+
+      return;
+    }
+
+    const suri = `${seed || ''}${path || ''}`;
+
+    validateSeed(suri).then((newAccount) => {
+      setError('');
+      setAddress(newAccount.address);
+      onAccountChange(newAccount);
+    }).catch(() => {
+      setAddress('');
+      onAccountChange(null);
+      setError(path
+        ? t<string>('Invalid mnemonic seed or derivation path')
+        : t<string>('Invalid mnemonic seed')
+      );
+    });
+  }, [t, seed, path, onAccountChange]);
+
+  const _onToggleAdvanced = useCallback(() => {
+    setAdvances(!advanced);
+  }, [advanced]);
+
+  return (
+    <>
+      <div className={className}>
+        <TextAreaWithLabel
+          className='seedInput'
+          isError={!!error}
+          isFocused
+          label={t<string>('existing 12 or 24-word mnemonic seed')}
+          onChange={setSeed}
+          rowsCount={2}
+          value={seed || ''}
+        />
+        {!!error && !seed && (
+          <Warning
+            className='seedError'
+            isBelowInput
+            isDanger
+          >
+            {t<string>('Mnemonic needs to contain 12, 15, 18, 21, 24 words')}
+          </Warning>
+        )}
+        <div
+          className='advancedToggle'
+          onClick={_onToggleAdvanced}
+        >
+          <FontAwesomeIcon icon={advanced ? faCaretDown : faCaretRight}/>
+          <span>{t<string>('advanced')}</span>
+        </div>
+        { advanced && (
+          <InputWithLabel
+            className='derivationPath'
+            isError={!!path && !!error}
+            label={t<string>('derivation path')}
+            onChange={setPath}
+            value={path || ''}
+          />
+        )}
+        {!!error && !!seed && (
+          <Warning
+            isDanger
+          >
+            {error}
+          </Warning>
+        )}
+      </div>
+      <VerticalSpace />
+      <ButtonArea>
+        <NextStepButton
+          isDisabled={!address || !!error}
+          onClick={onNextStep}
+        >
+          {t<string>('Next')}
+        </NextStepButton>
+      </ButtonArea>
+    </>
+  );
+}
+
+export default styled(ImportSeed)(({ theme }: ThemeProps) => `
+  .advancedToggle {
+    color: ${theme.textColor};
+    cursor: pointer;
+    line-height: 14px;
+    letter-spacing: 0.04em;
+    opacity: 0.65;
+    text-transform: uppercase;
+
+    > span {
+      font-size: 10px;
+      margin-left: .5rem;
+      vertical-align: middle;
+    }
+  }
+
+  .seedInput {
+    margin-bottom: 16px;
+    textarea {
+      height: unset;
+    }
+  }
+
+  .seedError {
+    margin-bottom: 1rem;
+  }
+`);
diff --git a/packages/extension-ui/src/Popup/ImportSeed/index.tsx b/packages/extension-ui/src/Popup/ImportSeed/index.tsx
new file mode 100644
index 00000000..0f3184a7
--- /dev/null
+++ b/packages/extension-ui/src/Popup/ImportSeed/index.tsx
@@ -0,0 +1,73 @@
+// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import React, { useCallback, useContext, useEffect, useState } from 'react';
+
+import { AccountContext, ActionContext, Address } from '../../components';
+import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation';
+import useTranslation from '../../hooks/useTranslation';
+import { createAccountSuri } from '../../messaging';
+import { HeaderWithSteps } from '../../partials';
+import SeedAndPath from './SeedAndPath';
+
+function ImportSeed (): React.ReactElement {
+  const { t } = useTranslation();
+  const { accounts } = useContext(AccountContext);
+  const onAction = useContext(ActionContext);
+  const [isBusy, setIsBusy] = useState(false);
+  const [account, setAccount] = useState<null | { address: string; suri: string }>(null);
+  const [name, setName] = useState<string | null>(null);
+  const [step1, setStep1] = useState(true);
+
+  useEffect((): void => {
+    !accounts.length && onAction();
+  }, [accounts, onAction]);
+
+  const _onCreate = useCallback((name: string, password: string): void => {
+    // this should always be the case
+    if (name && password && account) {
+      setIsBusy(true);
+
+      createAccountSuri(name, password, account.suri)
+        .then(() => onAction('/'))
+        .catch((error): void => {
+          setIsBusy(false);
+          console.error(error);
+        });
+    }
+  }, [account, onAction]);
+
+  const _onNextStep = useCallback(() => { setStep1(false); }, []);
+  const _onBackClick = useCallback(() => { setStep1(true); }, []);
+
+  return (
+    <>
+      <HeaderWithSteps
+        step={step1 ? 1 : 2}
+        text={t<string>('Import account')}
+      />
+      <div>
+        <Address
+          address={account?.address}
+          name={name}
+        />
+      </div>
+      {step1
+        ? <SeedAndPath
+          onAccountChange={setAccount}
+          onNextStep={_onNextStep}
+        />
+        : <AccountNamePasswordCreation
+          buttonLabel={t<string>('Add the account with the supplied seed')}
+          isBusy={isBusy}
+          onBackClick={_onBackClick}
+          onCreate={_onCreate}
+          onNameChange={setName}
+        />
+
+      }
+    </>
+  );
+}
+
+export default ImportSeed;
diff --git a/packages/extension-ui/src/Popup/CreateAccount/AccountName.tsx b/packages/extension-ui/src/components/AccountNamePasswordCreation.tsx
similarity index 62%
rename from packages/extension-ui/src/Popup/CreateAccount/AccountName.tsx
rename to packages/extension-ui/src/components/AccountNamePasswordCreation.tsx
index 4704035d..89a422a4 100644
--- a/packages/extension-ui/src/Popup/CreateAccount/AccountName.tsx
+++ b/packages/extension-ui/src/components/AccountNamePasswordCreation.tsx
@@ -3,20 +3,18 @@
 
 import React, { useCallback, useState } from 'react';
 
-import { BackButton, ButtonArea, NextStepButton, VerticalSpace } from '../../components';
-import useTranslation from '../../hooks/useTranslation';
-import { Name, Password } from '../../partials';
+import { Name, Password } from '../partials';
+import { BackButton, ButtonArea, NextStepButton, VerticalSpace } from '.';
 
 interface Props {
-  address: string;
+  buttonLabel: string;
   isBusy: boolean;
   onBackClick: () => void;
   onCreate: (name: string, password: string) => void | Promise<void | boolean>;
   onNameChange: (name: string) => void;
 }
 
-function AccountName ({ isBusy, onBackClick, onCreate, onNameChange }: Props): React.ReactElement<Props> {
-  const { t } = useTranslation();
+function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCreate, onNameChange }: Props): React.ReactElement<Props> {
   const [name, setName] = useState<string | null>(null);
   const [password, setPassword] = useState<string | null>(null);
 
@@ -25,10 +23,22 @@ function AccountName ({ isBusy, onBackClick, onCreate, onNameChange }: Props): R
     [name, password, onCreate]
   );
 
-  const _onNameChange = useCallback((name: string | null) => {
-    onNameChange(name || '');
-    setName(name);
-  }, [onNameChange]);
+  const _onNameChange = useCallback(
+    (name: string | null) => {
+      onNameChange(name || '');
+      setName(name);
+    },
+    [onNameChange]
+  );
+
+  const _onBackClick = useCallback(
+    () => {
+      _onNameChange(null);
+      setPassword(null);
+      onBackClick();
+    },
+    [_onNameChange, onBackClick]
+  );
 
   return (
     <>
@@ -39,18 +49,18 @@ function AccountName ({ isBusy, onBackClick, onCreate, onNameChange }: Props): R
       <Password onChange={setPassword} />
       <VerticalSpace />
       <ButtonArea>
-        <BackButton onClick={onBackClick} />
+        <BackButton onClick={_onBackClick} />
         <NextStepButton
           data-button-action='add new root'
           isBusy={isBusy}
           isDisabled={!password || !name}
           onClick={_onCreate}
         >
-          {t<string>('Add the account with the generated seed')}
+          {buttonLabel}
         </NextStepButton>
       </ButtonArea>
     </>
   );
 }
 
-export default React.memo(AccountName);
+export default React.memo(AccountNamePasswordCreation);
diff --git a/packages/extension-ui/src/components/Address.tsx b/packages/extension-ui/src/components/Address.tsx
index b1e88660..e2bc4fac 100644
--- a/packages/extension-ui/src/components/Address.tsx
+++ b/packages/extension-ui/src/components/Address.tsx
@@ -107,6 +107,8 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
 
   useEffect((): void => {
     if (!address) {
+      setRecoded(defaultRecoded);
+
       return;
     }
 
diff --git a/packages/extension-ui/src/components/index.ts b/packages/extension-ui/src/components/index.ts
index c0b13270..c5339479 100644
--- a/packages/extension-ui/src/components/index.ts
+++ b/packages/extension-ui/src/components/index.ts
@@ -1,6 +1,7 @@
 // Copyright 2019-2021 @polkadot/extension-ui authors & contributors
 // SPDX-License-Identifier: Apache-2.0
 
+export { default as AccountNamePasswordCreation } from './AccountNamePasswordCreation';
 export { default as ActionBar } from './ActionBar';
 export { default as ActionText } from './ActionText';
 export { default as Address } from './Address';


Test patch:
diff --git a/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx b/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx
index ea2b8598..1e935e96 100644
--- a/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx
+++ b/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx
@@ -9,7 +9,7 @@ import React from 'react';
 import { act } from 'react-dom/test-utils';
 import { ThemeProvider } from 'styled-components';
 
-import { ActionContext, ActionText, Button, Input, InputWithLabel, themes } from '../../components';
+import { ActionContext, ActionText, Button, themes } from '../../components';
 import * as messaging from '../../messaging';
 import { Header } from '../../partials';
 import { flushAllPromises } from '../../testHelpers';
@@ -42,12 +42,6 @@ describe('Create Account', () => {
     wrapper.update();
   };
 
-  const capsLockOn = async (input: ReactWrapper): Promise<void> => {
-    input.simulate('keyPress', { getModifierState: () => true });
-    await act(flushAllPromises);
-    wrapper.update();
-  };
-
   const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), name);
   const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
   const repeat = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').last(), password);
@@ -100,77 +94,6 @@ describe('Create Account', () => {
       wrapper.update();
     });
 
-    it('only account name input is visible at first', () => {
-      expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input)).toHaveLength(1);
-      expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
-      expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
-      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
-    });
-
-    it('input should not be highlighted as error until first interaction', () => {
-      expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(false);
-    });
-
-    it('after typing less than 3 characters into name input, password input is not visible', async () => {
-      await enterName('ab');
-      expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true);
-      expect(wrapper.find('.warning-message').first().text()).toBe('Account name is too short');
-      expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
-      expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
-      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
-    });
-
-    it('input should keep showing error when something has been typed but then erased', async () => {
-      await enterName('ab');
-      await enterName('');
-      expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true);
-    });
-
-    it('after typing 3 characters into name input, the name is reflected in the account card', async () => {
-      const NAME = 'abc';
-
-      await enterName(NAME);
-      expect(wrapper.find('[data-field="name"]').first().text()).toBe(NAME);
-    });
-
-    it('after typing 3 characters into name input, first password input is visible', async () => {
-      await enterName('abc');
-      expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).first().prop('withError')).toBe(false);
-      expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
-      expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
-      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
-    });
-
-    it('password with caps lock should show a warning', async () => {
-      await enterName('abc').then(password('abcde'));
-      await capsLockOn(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input));
-
-      expect(wrapper.find('.warning-message').first().text()).toBe('Warning: Caps lock is on');
-    });
-
-    it('password shorter than 6 characters should be not valid', async () => {
-      await enterName('abc').then(password('abcde'));
-      expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input).prop('withError')).toBe(true);
-      expect(wrapper.find('.warning-message').text()).toBe('Password is too short');
-      expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
-      expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
-      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
-    });
-
-    it('submit button is not enabled until both passwords are equal', async () => {
-      await enterName('abc').then(password('abcdef')).then(repeat('abcdeg'));
-      expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match');
-      expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(true);
-      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
-    });
-
-    it('submit button is enabled when both passwords are equal', async () => {
-      await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
-      expect(wrapper.find('.warning-message')).toHaveLength(0);
-      expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(false);
-      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
-    });
-
     it('saves account with provided name and password', async () => {
       await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
       wrapper.find('[data-button-action="add new root"] button').simulate('click');
@@ -180,37 +103,4 @@ describe('Create Account', () => {
       expect(onActionStub).toBeCalledWith('/');
     });
   });
-
-  describe('Both passwords are equal, but then', () => {
-    beforeEach(async () => {
-      check(wrapper.find('input[type="checkbox"]'));
-      wrapper.find('button').simulate('click');
-      await act(flushAllPromises);
-      await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
-    });
-
-    it('first password input is cleared - second one disappears and button get disabled', async () => {
-      await type(wrapper.find('input[type="password"]').first(), '');
-      expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
-      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
-    });
-
-    it('first password changes - button is disabled', async () => {
-      await type(wrapper.find('input[type="password"]').first(), 'aaaaaa');
-      expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match');
-      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
-    });
-
-    it('first password changes, then second changes too - button is enabled', async () => {
-      await type(wrapper.find('input[type="password"]').first(), 'aaaaaa');
-      await type(wrapper.find('input[type="password"]').last(), 'aaaaaa');
-      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
-    });
-
-    it('second password changes, then first changes too - button is enabled', async () => {
-      await type(wrapper.find('input[type="password"]').last(), 'aaaaaa');
-      await type(wrapper.find('input[type="password"]').first(), 'aaaaaa');
-      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
-    });
-  });
 });
diff --git a/packages/extension-ui/src/Popup/Derive/Derive.test.tsx b/packages/extension-ui/src/Popup/Derive/Derive.test.tsx
index 1b99ea83..a434ce1e 100644
--- a/packages/extension-ui/src/Popup/Derive/Derive.test.tsx
+++ b/packages/extension-ui/src/Popup/Derive/Derive.test.tsx
@@ -22,9 +22,14 @@ import Derive from '.';
 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
 configure({ adapter: new Adapter() });
 
+const parentPassword = 'pass';
+const westendGenesis = '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e';
+const defaultDerivation = '//0';
+const derivedAddress = '5GYQRJj3NUznYDzCduENRcocMsyxmb6tjb5xW87ZMErBe9R7';
+
 const accounts = [
   { address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A', type: 'sr25519' },
-  { address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', name: 'B', type: 'sr25519' },
+  { address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', genesisHash: westendGenesis, name: 'B', type: 'sr25519' },
   { address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', type: 'sr25519' },
   { address: '5GhGENSJBWQZ8d8mARKgqEkiAxiW3hHeznQDW2iG4XzNieb6', isExternal: true, name: 'C', type: 'sr25519' },
   { address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', name: 'Ethereum', type: 'ethereum' },
@@ -70,6 +75,10 @@ describe('Derive', () => {
     input.update();
   };
 
+  const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), name);
+  const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
+  const repeat = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').last(), password);
+
   describe('Parent selection screen', () => {
     beforeEach(async () => {
       const mountedComponent = await mountComponent();
@@ -79,14 +88,14 @@ describe('Derive', () => {
     });
 
     // eslint-disable-next-line @typescript-eslint/require-await
-    jest.spyOn(messaging, 'validateAccount').mockImplementation(async (_, pass: string) => pass === 'pass');
+    jest.spyOn(messaging, 'validateAccount').mockImplementation(async (_, pass: string) => pass === parentPassword);
     // eslint-disable-next-line @typescript-eslint/require-await
     jest.spyOn(messaging, 'validateDerivationPath').mockImplementation(async (_, path) => {
       if (path === '//') {
         throw new Error('wrong suri');
       }
 
-      return {} as ResponseDeriveValidate;
+      return { address: derivedAddress, suri: defaultDerivation } as ResponseDeriveValidate;
     });
 
     it('Button is disabled and password field visible', () => {
@@ -137,7 +146,7 @@ describe('Derive', () => {
     });
 
     it('Button is enabled when password is set', async () => {
-      await type(wrapper.find('input[type="password"]'), 'pass');
+      await type(wrapper.find('input[type="password"]'), parentPassword);
 
       const button = wrapper.find('[data-button-action="create derived account"] button');
 
@@ -146,7 +155,7 @@ describe('Derive', () => {
     });
 
     it('An error is visible and the button is disabled when suri is incorrect', async () => {
-      await type(wrapper.find('input[type="password"]'), 'pass');
+      await type(wrapper.find('input[type="password"]'), parentPassword);
       await type(wrapper.find('[data-input-suri] input'), '//');
       wrapper.find('[data-button-action="create derived account"] button').simulate('click');
       await act(flushAllPromises);
@@ -160,7 +169,7 @@ describe('Derive', () => {
     });
 
     it('The error disappears and "Create derived account" is enabled when typing a new suri', async () => {
-      await type(wrapper.find('input[type="password"]'), 'pass');
+      await type(wrapper.find('input[type="password"]'), parentPassword);
       await type(wrapper.find('[data-input-suri] input'), '//');
       wrapper.find('[data-button-action="create derived account"] button').simulate('click');
       await act(flushAllPromises);
@@ -192,7 +201,10 @@ describe('Derive', () => {
 
   describe('Locked parent selection', () => {
     beforeAll(async () => {
-      wrapper = (await mountComponent(true)).wrapper;
+      const mountedComponent = (await mountComponent(true));
+
+      wrapper = mountedComponent.wrapper;
+      onActionStub = mountedComponent.onActionStub;
     });
 
     it('address dropdown does not exist', () => {
@@ -202,5 +214,29 @@ describe('Derive', () => {
     it('parent is taken from URL', () => {
       expect(wrapper.find('[data-field="name"]').first().text()).toBe('B');
     });
+
+    describe('Second phase', () => {
+      it('correctly creates the derived account', async () => {
+        const newAccount = {
+          name: 'newName',
+          password: 'somePassword'
+        };
+        const deriveMock = jest.spyOn(messaging, 'deriveAccount');
+
+        await type(wrapper.find('input[type="password"]'), parentPassword);
+        wrapper.find('[data-button-action="create derived account"] button').simulate('click');
+        await act(flushAllPromises);
+        wrapper.update();
+        await enterName(newAccount.name).then(password(newAccount.password)).then(repeat(newAccount.password));
+        await act(flushAllPromises);
+        wrapper.update();
+        wrapper.find('[data-button-action="add new root"] button').simulate('click');
+        await act(flushAllPromises);
+        wrapper.update();
+
+        expect(deriveMock).toBeCalledWith(accounts[1].address, defaultDerivation, parentPassword, newAccount.name, newAccount.password, westendGenesis);
+        expect(onActionStub).toBeCalledWith('/');
+      });
+    });
   });
 });
diff --git a/packages/extension-ui/src/components/AccountNamePasswordCreation.test.tsx b/packages/extension-ui/src/components/AccountNamePasswordCreation.test.tsx
new file mode 100644
index 00000000..e2a7b49c
--- /dev/null
+++ b/packages/extension-ui/src/components/AccountNamePasswordCreation.test.tsx
@@ -0,0 +1,198 @@
+// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import '../../../../__mocks__/chrome';
+
+import type { ReactWrapper } from 'enzyme';
+
+import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
+import { configure, mount } from 'enzyme';
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+
+import { flushAllPromises } from '../testHelpers';
+import BackButton from './BackButton';
+import { AccountNamePasswordCreation, Input, InputWithLabel, NextStepButton } from '.';
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
+configure({ adapter: new Adapter() });
+
+const account = {
+  name: 'My Polkadot Account',
+  password: 'somepassword'
+};
+
+const buttonLabel = 'create account';
+
+let wrapper: ReactWrapper;
+const onBackClick = jest.fn();
+const onCreate = jest.fn();
+const onNameChange = jest.fn();
+
+const type = async (input: ReactWrapper, value: string): Promise<void> => {
+  input.simulate('change', { target: { value } });
+  await act(flushAllPromises);
+  wrapper.update();
+};
+
+const capsLockOn = async (input: ReactWrapper): Promise<void> => {
+  input.simulate('keyPress', { getModifierState: () => true });
+  await act(flushAllPromises);
+  wrapper.update();
+};
+
+const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), name);
+const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
+const repeat = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').last(), password);
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-return
+const mountComponent = (isBusy = false): ReactWrapper => mount(
+  <AccountNamePasswordCreation
+    buttonLabel={buttonLabel}
+    isBusy={isBusy}
+    onBackClick={onBackClick}
+    onCreate={onCreate}
+    onNameChange={onNameChange}
+  />
+);
+
+describe('AccountNamePasswordCreation', () => {
+  beforeEach(async () => {
+    wrapper = mountComponent();
+    await act(flushAllPromises);
+    wrapper.update();
+  });
+
+  it('only account name input is visible at first', () => {
+    expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input)).toHaveLength(1);
+    expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
+    expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
+    expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
+  });
+
+  it('next step button has the correct label', () => {
+    expect(wrapper.find(NextStepButton).text()).toBe(buttonLabel);
+  });
+
+  it('back button calls onBackClick', () => {
+    wrapper.find(BackButton).simulate('click');
+    expect(onBackClick).toHaveBeenCalledTimes(1);
+  });
+
+  it('input should not be highlighted as error until first interaction', () => {
+    expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(false);
+  });
+
+  it('after typing less than 3 characters into name input, password input is not visible', async () => {
+    await enterName('ab');
+    expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true);
+    expect(wrapper.find('.warning-message').first().text()).toBe('Account name is too short');
+    expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
+    expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
+    expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
+  });
+
+  it('input should keep showing error when something has been typed but then erased', async () => {
+    await enterName(account.name);
+    await enterName('');
+    expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true);
+  });
+
+  it('after typing 3 characters into name input, onNameChange is called', async () => {
+    await enterName(account.name);
+    expect(onNameChange).toHaveBeenLastCalledWith(account.name);
+  });
+
+  it('after typing 3 characters into name input, first password input is visible', async () => {
+    await enterName(account.name);
+    expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).first().prop('withError')).toBe(false);
+    expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
+    expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
+    expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
+  });
+
+  it('password with caps lock should show a warning', async () => {
+    await enterName('abc').then(password('abcde'));
+    await capsLockOn(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input));
+
+    expect(wrapper.find('.warning-message').first().text()).toBe('Warning: Caps lock is on');
+  });
+
+  it('password shorter than 6 characters should be not valid', async () => {
+    await enterName('abc').then(password('abcde'));
+    expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input).prop('withError')).toBe(true);
+    expect(wrapper.find('.warning-message').text()).toBe('Password is too short');
+    expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
+    expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
+    expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
+  });
+
+  it('submit button is not enabled until both passwords are equal', async () => {
+    await enterName('abc').then(password('abcdef')).then(repeat('abcdeg'));
+    expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match');
+    expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(true);
+    expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
+  });
+
+  it('submit button is enabled when both passwords are equal', async () => {
+    await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
+    expect(wrapper.find('.warning-message')).toHaveLength(0);
+    expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(false);
+    expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
+  });
+
+  it('calls onCreate with provided name and password', async () => {
+    await enterName(account.name).then(password(account.password)).then(repeat(account.password));
+    wrapper.find('[data-button-action="add new root"] button').simulate('click');
+    await act(flushAllPromises);
+
+    expect(onCreate).toBeCalledWith(account.name, account.password);
+  });
+
+  describe('All fields are filled correctly, but then', () => {
+    beforeEach(async () => {
+      await enterName(account.name).then(password(account.password)).then(repeat(account.password));
+    });
+
+    it('first password input is cleared - second one disappears and button get disabled', async () => {
+      await type(wrapper.find('input[type="password"]').first(), '');
+      expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
+      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
+    });
+
+    it('first password changes - button is disabled', async () => {
+      await type(wrapper.find('input[type="password"]').first(), 'abcdef');
+      expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match');
+      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
+    });
+
+    it('first password changes, then second changes too - button is enabled', async () => {
+      await type(wrapper.find('input[type="password"]').first(), 'abcdef');
+      await type(wrapper.find('input[type="password"]').last(), 'abcdef');
+      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
+    });
+
+    it('second password changes, then first changes too - button is enabled', async () => {
+      await type(wrapper.find('input[type="password"]').last(), 'abcdef');
+      await type(wrapper.find('input[type="password"]').first(), 'abcdef');
+      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
+    });
+
+    it('name is removed - button is disabled', async () => {
+      await enterName('');
+      expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
+    });
+  });
+});
+
+describe('AccountNamePasswordCreation busy button', () => {
+  beforeAll(async () => {
+    wrapper = mountComponent(true);
+    await act(flushAllPromises);
+    wrapper.update();
+  });
+
+  it('button is busy', () => {
+    expect(wrapper.find(NextStepButton).prop('isBusy')).toBe(true);
+  });
+});