Unverified Commit c89ee27b authored by Hanwen Cheng's avatar Hanwen Cheng Committed by GitHub
Browse files

feat: Enable create custom path (#497)

* add network icon to legacy account

* refactor screen heading

* fix stylesheet creation

* remove protocol from AccountIcon

* add root account derivation

* enable derivation root from derivationPath screen

* add '//kusama' into test suites

* fix match and remove console.log

* flatten styles in screenHeadings

* fix: enable creating custom path

* enable signing with unknown network

* fall back unknown network to substrate network

* change workflow

* adjust e2e test

* add custom path test

* adjust test and fix group with unknown network root path

* remove redundant component

* fix nits

* enable root address

* use corresponding network key when generating known network

* root identity in the network list screen

* fix ui of root account

* fix delete navigation

* remove root from customized paths

* update warning text

* grammar ...

* rephrase and fix network list calculation

* display account info in transaction screen

* fix signing and naming

* rephrase warnings

* fix signing problem

* fix scanner extra cleanup error

* refine navigation
parent 6921ac7d
Pipeline #72644 passed with stages
in 33 minutes and 42 seconds
......@@ -48,7 +48,8 @@ const {
const pinCode = '123456';
const mockIdentityName = 'mockIdentity';
const substrateNetworkButtonIndex = AccountNetworkChooser.networkButton + '2'; //Need change if network list changes
const fundingPath = '//funding/0';
const defaultPath = '//default',
customPath = '//sunny_day/1';
const mockSeedPhrase =
'split cradle example drum veteran swear cruel pizza guilt surface mansion film grant benefit educate marble cargo ignore bind include advance grunt exile grow';
......@@ -62,7 +63,9 @@ const testSetUpDefaultPath = async () => {
testIDs.AccountNetworkChooser.chooserScreen
);
await testUnlockPin(pinCode);
await testExist(PathsList.pathCard + '//kusama//default');
await testVisible(PathDetail.screen);
await tapBack();
await testExist(PathsList.screen);
};
describe('Load test', async () => {
......@@ -90,9 +93,19 @@ describe('Load test', async () => {
await testSetUpDefaultPath();
});
it('derive a new key', async () => {
await testTap(PathsList.deriveButton);
await testInput(PathDerivation.nameInput, 'first one');
await testInput(PathDerivation.pathInput, defaultPath);
await testTap(PathDerivation.deriveButton);
await testUnlockPin(pinCode);
await testExist(PathsList.pathCard + `//kusama${defaultPath}`);
});
it('create a new identity with default substrate account', async () => {
await tapBack();
await testTap(IdentitiesSwitch.toggleButton);
await element(by.id(IdentitiesSwitch.toggleButton))
.atIndex(0)
.tap();
await testTap(IdentitiesSwitch.addIdentityButton);
await testNotVisible(IdentityNew.seedInput);
await testTap(IdentityNew.createButton);
......@@ -102,24 +115,24 @@ describe('Load test', async () => {
await testSetUpDefaultPath();
});
it('derive a new key', async () => {
await testTap(PathsList.deriveButton);
await testInput(PathDerivation.nameInput, 'first one');
await testInput(PathDerivation.pathInput, fundingPath);
await testTap(PathDerivation.deriveButton);
await testUnlockPin(pinCode);
await testExist(PathsList.pathCard + `//kusama${fundingPath}`);
it('starts with a root account', async () => {
await testTap(PathsList.rootButton);
await expect(element(by.text('Root'))).toExist();
await tapBack();
});
it('delete a path', async () => {
it('is able to create custom path', async () => {
await tapBack();
await testTap(AccountNetworkChooser.networkButton + '0');
await testTap(PathsList.pathCard + `//kusama${fundingPath}`);
await testTap(PathDetail.popupMenuButton);
await testTap(PathDetail.deleteButton);
await element(by.text('Delete')).tap();
await testTap(testIDs.AccountNetworkChooser.addNewNetworkButton);
await testScrollAndTap(
testIDs.AccountNetworkChooser.addCustomNetworkButton,
testIDs.AccountNetworkChooser.chooserScreen
);
await testInput(PathDerivation.nameInput, 'custom network');
await testInput(PathDerivation.pathInput, customPath);
await testTap(PathDerivation.deriveButton);
await testUnlockPin(pinCode);
await testNotExist(PathsList.pathCard + `//kusama${fundingPath}`);
await testExist(PathsList.pathCard + customPath);
});
it('should sign the transaction', async () => {
......@@ -130,6 +143,17 @@ describe('Load test', async () => {
await testVisible(SignedTx.qrView);
});
it('delete a path', async () => {
await tapBack();
await testTap(AccountNetworkChooser.networkButton + '0');
await testTap(PathsList.pathCard + `//kusama${defaultPath}`);
await testTap(PathDetail.popupMenuButton);
await testTap(PathDetail.deleteButton);
await element(by.text('Delete')).tap();
await testUnlockPin(pinCode);
await testNotExist(PathsList.pathCard + `//kusama${defaultPath}`);
});
it('delete identity', async () => {
await element(by.id(IdentitiesSwitch.toggleButton))
.atIndex(0)
......
......@@ -21,8 +21,9 @@ const testIDs = {
accountList: 'accountList'
},
AccountNetworkChooser: {
addNewNetworkButton: 'anc_add_button',
chooserScreen: 'anc_chooser_scree',
addCustomNetworkButton: 'anc_add_custom_button',
addNewNetworkButton: 'anc_add_new_button',
chooserScreen: 'anc_chooser_screen',
createButton: 'anc_create_button',
networkButton: 'anc_network_button',
noAccountScreen: 'anc_no_account_screen',
......@@ -67,11 +68,15 @@ const testIDs = {
},
PathDetail: {
deleteButton: 'path_detail_delete_button',
popupMenuButton: 'path_detail_popup_menu_button'
popupMenuButton: 'path_detail_popup_menu_button',
screen: 'path_detail_screen'
},
PathsList: {
deriveButton: 'path_list_derive_button',
pathCard: 'path_list_path_card'
pathCard: 'path_list_path_card',
rootButton: 'path_list_root_button',
scanButton: 'path_list_scan_button',
screen: 'path_list_screen'
},
SecurityHeader: {
scanButton: 'security_header_scan_button'
......
......@@ -36,7 +36,9 @@ const addressFunding1 = 'address1',
addressStaking = 'address4',
addressEthereum = 'address6',
addressDefault = 'addressDefault',
addressKusamaRoot = 'addressRoot',
addressKusamaRoot = 'addressKusamaRoot',
addressRoot = 'addressRoot',
addressCustom = 'addressCustom',
paths = [
'//kusama//default',
'//kusama//funding/1',
......@@ -45,8 +47,24 @@ const addressFunding1 = 'address1',
'//kusama//staking/1',
'//polkadot//default',
'1',
'//kusama',
'',
'//custom'
],
kusamaPaths = [
'//kusama//default',
'//kusama//funding/1',
'//kusama/softKey1',
'//kusama//funding/2',
'//kusama//staking/1',
'//kusama'
],
metaCustom = {
address: addressCustom,
createdAt: 1571068850409,
name: 'custom Path',
updatedAt: 1571068850409
},
metaDefault = {
address: addressDefault,
createdAt: 1571068850409,
......@@ -89,6 +107,12 @@ const addressFunding1 = 'address1',
name: '',
updatedAt: 1573142786972
},
metaRoot = {
address: addressRoot,
createdAt: 1573142786972,
name: '',
updatedAt: 1573142786972
},
metaSoftKey = {
address: addressSoft,
createdAt: 1573142786972,
......@@ -103,7 +127,9 @@ const addressesMap = new Map([
[addressStaking, paths[4]],
[addressPolkadot, paths[5]],
[addressEthereum, paths[6]],
[addressKusamaRoot, paths[7]]
[addressKusamaRoot, paths[7]],
[addressRoot, paths[8]],
[addressCustom, paths[9]]
]);
const metaMap = new Map([
[paths[0], metaDefault],
......@@ -113,7 +139,9 @@ const metaMap = new Map([
[paths[4], metaFunding2],
[paths[5], metaPolkadot],
[paths[6], metaEthereum],
[paths[7], metaKusamaRoot]
[paths[7], metaKusamaRoot],
[paths[8], metaRoot],
[paths[9], metaCustom]
]);
const testIdentities = [
{
......@@ -139,9 +167,7 @@ describe('IdentitiesUtils', () => {
expect(originItem).toEqual(testIdentities);
});
it('regroup the paths', () => {
const kusamaPaths = paths.slice();
kusamaPaths.splice(5, 2);
it('regroup the kusama paths', () => {
const groupResult = groupPaths(kusamaPaths);
expect(groupResult).toEqual([
{
......@@ -163,6 +189,21 @@ describe('IdentitiesUtils', () => {
]);
});
it('regroup the unknown paths', () => {
const unKnownPaths = ['//polkadot//default', '', '//custom'];
const groupResult = groupPaths(unKnownPaths);
expect(groupResult).toEqual([
{
paths: ['//polkadot//default'],
title: '//default'
},
{
paths: ['//custom'],
title: 'custom'
}
]);
});
it('get the path name', () => {
const expectNames = [
'default',
......@@ -172,7 +213,9 @@ describe('IdentitiesUtils', () => {
'staking1',
'PolkadotFirst',
'Eth account',
''
'',
'',
'custom Path'
];
paths.forEach((path, index) => {
const name = getPathName(path, testIdentities[0]);
......
......@@ -20,20 +20,28 @@ import PropTypes from 'prop-types';
import AccountCard from './AccountCard';
import PathCard from './PathCard';
import React from 'react';
import { getIdentityName } from '../util/identitiesUtils';
import { defaultNetworkKey } from '../constants';
const CompatibleCard = ({ account, accountsStore, titlePrefix }) =>
account.isLegacy === false ? (
<PathCard
identity={accountsStore.getIdentityByAccountId(account.accountId)}
path={account.path}
titlePrefix={titlePrefix}
/>
) : (
account.isLegacy === true || account.isLegacy === undefined ? (
<AccountCard
title={account.name}
address={account.address}
networkKey={account.networkKey || ''}
/>
) : account.path === '' ? (
<AccountCard
title={getIdentityName(account, accountsStore.state.identities)}
address={account.address}
networkKey={defaultNetworkKey}
/>
) : (
<PathCard
identity={accountsStore.getIdentityByAccountId(account.accountId)}
path={account.path}
titlePrefix={titlePrefix}
/>
);
CompatibleCard.propTypes = {
......
......@@ -29,7 +29,8 @@ import { getIdentityName } from '../util/identitiesUtils';
import testIDs from '../../e2e/testIDs';
import {
navigateToLegacyAccountList,
resetNavigationTo
resetNavigationTo,
resetNavigationWithNetworkChooser
} from '../util/navigationHelpers';
function IdentitiesSwitch({ navigation, accounts }) {
......@@ -49,7 +50,11 @@ function IdentitiesSwitch({ navigation, accounts }) {
) => {
await accounts.selectIdentity(identity);
setVisible(false);
resetNavigationTo(navigation, screenName, params);
if (screenName === 'AccountNetworkChooser') {
resetNavigationTo(navigation, screenName, params);
} else {
resetNavigationWithNetworkChooser(navigation, screenName, params);
}
};
const onLegacyListClicked = async () => {
......
......@@ -25,7 +25,12 @@ import {
getNetworkKeyByPath,
getPathName
} from '../util/identitiesUtils';
import { NETWORK_LIST, NetworkProtocols } from '../constants';
import {
defaultNetworkKey,
NETWORK_LIST,
NetworkProtocols,
UnknownNetworkKeys
} from '../constants';
import Separator from '../components/Separator';
import AccountIcon from './AccountIcon';
import Address from './Address';
......@@ -54,9 +59,13 @@ export default function PathCard({
const isNotEmptyName = name && name !== '';
const pathName = isNotEmptyName ? name : getPathName(path, identity);
const address = getAddressWithPath(path, identity);
const isUnknownAddress = address === '';
const networkKey = getNetworkKeyByPath(path);
const network = NETWORK_LIST[networkKey];
const network =
networkKey === UnknownNetworkKeys.UNKNOWN && !isUnknownAddress
? NETWORK_LIST[defaultNetworkKey]
: NETWORK_LIST[networkKey];
const nonSubstrateCard = (
<View testID={testID}>
......
......@@ -44,16 +44,21 @@ export default class PopupMenu extends React.PureComponent {
<Menu onSelect={onSelect}>
<MenuTrigger children={menuTriggerIcon} />
<MenuOptions customStyles={menuOptionsStyles}>
{menuItems.map((menuItem, index) => (
<MenuOption key={index} value={menuItem.value}>
<Text
style={[menuOptionsStyles.optionText, menuItem.textStyle]}
testID={menuItem.testID}
>
{menuItem.text}
</Text>
</MenuOption>
))}
{menuItems.map((menuItem, index) => {
if (menuItem.hide === true) {
return null;
}
return (
<MenuOption key={index} value={menuItem.value}>
<Text
style={[menuOptionsStyles.optionText, menuItem.textStyle]}
testID={menuItem.testID}
>
{menuItem.text}
</Text>
</MenuOption>
);
})}
</MenuOptions>
</Menu>
);
......
......@@ -30,11 +30,12 @@ import colors from '../colors';
import AccountIcon from './AccountIcon';
import { NETWORK_LIST } from '../constants';
import TouchableItem from './TouchableItem';
import FontAwesome from 'react-native-vector-icons/FontAwesome';
const composeStyle = StyleSheet.compose;
const renderSubtitle = (subtitle, subtitleIcon, isAlignLeft, isError) => {
if (!subtitle) return;
const renderSubtitle = (subtitle, hasSubtitleIcon, isAlignLeft, isError) => {
if (!subtitle || subtitle === '') return;
let subtitleBodyStyle = [baseStyles.subtitleBody],
subtitleTextStyle = [fontStyles.t_codeS];
if (isAlignLeft) {
......@@ -47,13 +48,15 @@ const renderSubtitle = (subtitle, subtitleIcon, isAlignLeft, isError) => {
return (
<View style={subtitleBodyStyle}>
{renderSubtitleIcon(subtitleIcon)}
<Text style={subtitleTextStyle}>{subtitle}</Text>
{renderSubtitleIcon(hasSubtitleIcon)}
<Text style={subtitleTextStyle} numberOfLines={1} ellipsizeMode="middle">
{subtitle}
</Text>
</View>
);
};
const renderSubtitleIcon = subtitleIcon => {
if (!subtitleIcon) return;
const renderSubtitleIcon = hasSubtitleIcon => {
if (!hasSubtitleIcon) return;
return <AntIcon name="user" size={10} color={colors.bg_text_sec} />;
};
......@@ -101,12 +104,17 @@ export function PathCardHeading({ title, networkKey }) {
export function PathListHeading({
title,
subtitle,
subtitleIcon,
hasSubtitleIcon,
testID,
networkKey,
onPress
}) {
return (
<TouchableItem style={baseStyles.bodyWithIcon} onPress={onPress}>
<TouchableItem
style={baseStyles.bodyWithIcon}
onPress={onPress}
testID={testID}
>
<AccountIcon
address={''}
network={NETWORK_LIST[networkKey]}
......@@ -114,7 +122,32 @@ export function PathListHeading({
/>
<View>
<Text style={[baseStyles.text, baseStyles.t_left]}>{title}</Text>
{renderSubtitle(subtitle, subtitleIcon, true)}
{renderSubtitle(subtitle, hasSubtitleIcon, true)}
</View>
</TouchableItem>
);
}
export function IdentityHeading({ onPress, title, subtitle, hasSubtitleIcon }) {
return (
<TouchableItem style={baseStyles.bodyWithIdentity} onPress={onPress}>
<View style={baseStyles.touchable}>
<View style={baseStyles.identityName}>
<Text
style={[baseStyles.text, baseStyles.t_left]}
numberOfLines={1}
ellipsizeMode="middle"
>
{title}
</Text>
<FontAwesome
style={baseStyles.linkIcon}
name="external-link"
color={colors.bg_button}
size={18}
/>
</View>
{renderSubtitle(subtitle, hasSubtitleIcon, true)}
</View>
</TouchableItem>
);
......@@ -131,7 +164,7 @@ export default class ScreenHeading extends React.PureComponent {
title,
subtitle,
subtitleL,
subtitleIcon,
hasSubtitleIcon,
error,
onPress,
iconName,
......@@ -141,7 +174,7 @@ export default class ScreenHeading extends React.PureComponent {
return (
<View style={baseStyles.body}>
<Text style={baseStyles.text}>{title}</Text>
{renderSubtitle(subtitle, subtitleIcon, subtitleL, error)}
{renderSubtitle(subtitle, hasSubtitleIcon, subtitleL, error)}
{renderBack(onPress)}
{renderIcon(iconName, iconType)}
</View>
......@@ -159,10 +192,22 @@ const baseStyles = StyleSheet.create({
flexDirection: 'row',
marginBottom: 16
},
bodyWithIdentity: {
height: 42,
paddingLeft: 72,
paddingRight: 32
},
icon: {
marginLeft: 5,
position: 'absolute'
},
identityName: {
flexDirection: 'row'
},
linkIcon: {
marginLeft: 10,
paddingTop: 4
},
networkIcon: {
paddingHorizontal: 16
},
......@@ -186,5 +231,10 @@ const baseStyles = StyleSheet.create({
text: {
...fontStyles.h1,
textAlign: 'center'
},
touchable: {
flex: 1,
flexDirection: 'column',
justifyContent: 'center'
}
});
......@@ -25,6 +25,7 @@ import colors from '../colors';
import IdentitiesSwitch from '../components/IdentitiesSwitch';
import ButtonIcon from './ButtonIcon';
import testIDs from '../../e2e/testIDs';
import { navigateToQrScanner } from '../util/navigationHelpers';
function SecurityHeader({ navigation }) {
const [isConnected, setIsConnected] = useState(false);
......@@ -49,7 +50,7 @@ function SecurityHeader({ navigation }) {
/>
)}
<ButtonIcon
onPress={() => navigation.navigate('QrScanner')}
onPress={() => navigateToQrScanner(navigation)}
iconName="qrcode-scan"
iconType="material-community"
iconBgStyle={styles.scannerIconBgStyle}
......
......@@ -21,19 +21,31 @@ import { StyleSheet, Text, View } from 'react-native';
import colors from '../colors';
import fonts from '../fonts';
export default function UnknownAccountWarning() {
export default function UnknownAccountWarning({ isPath }) {
return (
<View style={styles.warningView}>
<Text style={styles.warningTitle}>Warning</Text>
<Text style={styles.warningText}>
This account wasn't retrieved successfully. This could be because its
network isn't supported, or you upgraded Parity Signer without wiping
your device and this account couldn't be migrated.
{'\n'}
{'\n'}
To be able to use this account you need to:{'\n'}- write down its
recovery phrase{'\n'}- delete it{'\n'}- recover/derive it{'\n'}
</Text>
{isPath ? (
<Text style={styles.warningText}>
This account is not bond to a specific network.
{'\n'}
The address currently displayed is using Kusama format, however, the
address for this account may be different on another network.
</Text>
) : (
<Text style={styles.warningText}>
This account wasn't retrieved successfully. This could be because its
network isn't supported, or you upgraded Parity Signer without wiping
your device and this account couldn't be migrated.
{'\n'}
{'\n'}
To be able to use this account you need to:
{'\n'}- write down its recovery phrase
{'\n'}- delete it
{'\n'}- recover/derive it
{'\n'}
</Text>
)}
</View>
);
}
......
......@@ -56,6 +56,8 @@ export const SubstrateNetworkKeys = Object.freeze({
const unknownNetworkBase = {
[UnknownNetworkKeys.UNKNOWN]: {
color: colors.bg_alert,
pathId: '',
prefix: 2,
protocol: NetworkProtocols.UNKNOWN,
secondaryColor: colors.card_bgSolid,
title: 'Unknown network'
......@@ -181,3 +183,5 @@ export const NETWORK_LIST = Object.freeze(
UNKNOWN_NETWORK
)
);
export const defaultNetworkKey = SubstrateNetworkKeys.KUSAMA;
......@@ -26,10 +26,12 @@ import {
NETWORK_LIST,
UnknownNetworkKeys,
SubstrateNetworkKeys,
NetworkProtocols
NetworkProtocols,
defaultNetworkKey
} from '../constants';
import {
navigateToPathsList,
navigateToRootPath,
navigateToSubstrateRoot,
unlockSeedPhrase
} from '../util/navigationHelpers';
......@@ -37,10 +39,11 @@ import { withAccountStore } from '../util/HOC';
import { alertPathDerivationError } from '../util/alertUtils';
import {
getExistedNetworkKeys,
getIdentityName,
getPathsWithSubstrateNetwork
} from '../util/identitiesUtils';
import testIDs from '../../e2e/testIDs';
import ScreenHeading from '../components/ScreenHeading';
import