mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-06-15 13:51:13 +00:00
feat: initial Pezkuwi Apps rebrand from polkadot-apps
Rebranded terminology: - Polkadot → Pezkuwi - Kusama → Dicle - Westend → Zagros - Rococo → PezkuwiChain - Substrate → Bizinikiwi - parachain → teyrchain Custom logos with Kurdistan brand colors (#e6007a → #86e62a): - bizinikiwi-hexagon.svg - sora-bizinikiwi.svg - hezscanner.svg - heztreasury.svg - pezkuwiscan.svg - pezkuwistats.svg - pezkuwiassembly.svg - pezkuwiholic.svg
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { KeyringAddress } from '@pezkuwi/ui-keyring/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { AddressInfo, AddressSmall, Button, ChainLock, Columar, Forget, LinkExternal, Menu, Popup, Table, Tags, TransferModal } from '@pezkuwi/react-components';
|
||||
import { MATCHERS } from '@pezkuwi/react-components/AccountName';
|
||||
import { useApi, useBalancesAll, useDeriveAccountInfo, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { isFunction } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
className?: string;
|
||||
filter: string;
|
||||
isFavorite: boolean;
|
||||
isVisible: boolean;
|
||||
toggleFavorite: (address: string) => void;
|
||||
toggleVisible: (address: string, isVisible: boolean) => void
|
||||
}
|
||||
|
||||
const isEditable = true;
|
||||
|
||||
const BAL_OPTS_DEFAULT = {
|
||||
available: false,
|
||||
bonded: false,
|
||||
locked: false,
|
||||
redeemable: false,
|
||||
reserved: false,
|
||||
total: true,
|
||||
unlocking: false,
|
||||
vested: false
|
||||
};
|
||||
|
||||
const BAL_OPTS_EXPANDED = {
|
||||
available: true,
|
||||
bonded: true,
|
||||
locked: true,
|
||||
nonce: true,
|
||||
redeemable: true,
|
||||
reserved: true,
|
||||
total: false,
|
||||
unlocking: true,
|
||||
vested: true
|
||||
};
|
||||
|
||||
function Address ({ address, className = '', filter, isFavorite, isVisible, toggleFavorite, toggleVisible }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const api = useApi();
|
||||
const info = useDeriveAccountInfo(address);
|
||||
const balancesAll = useBalancesAll(address);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [accName, setAccName] = useState('');
|
||||
const [current, setCurrent] = useState<KeyringAddress | null>(null);
|
||||
const [genesisHash, setGenesisHash] = useState<string | null>(null);
|
||||
const [isForgetOpen, setIsForgetOpen] = useState(false);
|
||||
const [isTransferOpen, setIsTransferOpen] = useState(false);
|
||||
const [isExpanded, toggleIsExpanded] = useToggle(false);
|
||||
|
||||
const _setTags = useCallback(
|
||||
(tags: string[]): void => setTags(tags.sort()),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect((): void => {
|
||||
const current = keyring.getAddress(address);
|
||||
|
||||
setCurrent(current || null);
|
||||
setGenesisHash((current?.meta.genesisHash) || null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect((): void => {
|
||||
let known: string | null = null;
|
||||
|
||||
for (let i = 0; known === null && i < MATCHERS.length; i++) {
|
||||
known = MATCHERS[i](address);
|
||||
}
|
||||
|
||||
if (known) {
|
||||
setAccName(known);
|
||||
} else {
|
||||
const { identity, nickname } = info || {};
|
||||
|
||||
if (isFunction(api.apiIdentity.query.identity?.identityOf)) {
|
||||
if (identity?.display) {
|
||||
setAccName(identity.display);
|
||||
}
|
||||
} else if (nickname) {
|
||||
setAccName(nickname);
|
||||
}
|
||||
}
|
||||
}, [address, api, info]);
|
||||
|
||||
useEffect((): void => {
|
||||
const account = keyring.getAddress(address);
|
||||
|
||||
_setTags(account?.meta?.tags || []);
|
||||
setAccName(account?.meta?.name || '');
|
||||
}, [_setTags, address]);
|
||||
|
||||
useEffect((): void => {
|
||||
const _filter = filter.trim().toLowerCase();
|
||||
let isVisible = true;
|
||||
|
||||
if (_filter.length !== 0) {
|
||||
isVisible = keyring.encodeAddress(address, 0).toLowerCase().includes(_filter) ||
|
||||
address.toLowerCase().includes(_filter) ||
|
||||
tags.reduce((result: boolean, tag: string): boolean => {
|
||||
return result || tag.toLowerCase().includes(_filter);
|
||||
}, accName.toLowerCase().includes(_filter));
|
||||
}
|
||||
|
||||
toggleVisible(address, isVisible);
|
||||
}, [accName, address, filter, tags, toggleVisible]);
|
||||
|
||||
const _onGenesisChange = useCallback(
|
||||
(genesisHash: HexString | null): void => {
|
||||
setGenesisHash(genesisHash);
|
||||
|
||||
const account = keyring.getAddress(address);
|
||||
|
||||
account && keyring.saveAddress(address, { ...account.meta, genesisHash });
|
||||
|
||||
setGenesisHash(genesisHash);
|
||||
},
|
||||
[address]
|
||||
);
|
||||
|
||||
const _toggleForget = useCallback(
|
||||
(): void => setIsForgetOpen(!isForgetOpen),
|
||||
[isForgetOpen]
|
||||
);
|
||||
|
||||
const _toggleTransfer = useCallback(
|
||||
(): void => setIsTransferOpen(!isTransferOpen),
|
||||
[isTransferOpen]
|
||||
);
|
||||
|
||||
const _onForget = useCallback(
|
||||
(): void => {
|
||||
if (address) {
|
||||
const status: Partial<ActionStatus> = {
|
||||
account: address,
|
||||
action: 'forget'
|
||||
};
|
||||
|
||||
try {
|
||||
keyring.forgetAddress(address);
|
||||
status.status = 'success';
|
||||
status.message = t('address forgotten');
|
||||
} catch (error) {
|
||||
status.status = 'error';
|
||||
status.message = (error as Error).message;
|
||||
}
|
||||
}
|
||||
},
|
||||
[address, t]
|
||||
);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const PopupDropdown = (
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
isDisabled={!isEditable}
|
||||
label={t('Forget this address')}
|
||||
onClick={_toggleForget}
|
||||
/>
|
||||
{isEditable && !api.isDevelopment && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<ChainLock
|
||||
className='addresses--network-toggle'
|
||||
genesisHash={genesisHash}
|
||||
onChange={_onGenesisChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={`${className} isExpanded isFirst packedBottom`}>
|
||||
<Table.Column.Favorite
|
||||
address={address}
|
||||
isFavorite={isFavorite}
|
||||
toggle={toggleFavorite}
|
||||
/>
|
||||
<td className='address all'>
|
||||
<AddressSmall
|
||||
value={address}
|
||||
withShortAddress
|
||||
/>
|
||||
{address && current && (
|
||||
<>
|
||||
{isForgetOpen && (
|
||||
<Forget
|
||||
address={current.address}
|
||||
key='modal-forget-account'
|
||||
mode='address'
|
||||
onClose={_toggleForget}
|
||||
onForget={_onForget}
|
||||
/>
|
||||
)}
|
||||
{isTransferOpen && (
|
||||
<TransferModal
|
||||
key='modal-transfer'
|
||||
onClose={_toggleTransfer}
|
||||
recipientId={address}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className='actions button'>
|
||||
<Button.Group>
|
||||
{(isFunction(api.api.tx.balances?.transferAllowDeath) || isFunction(api.api.tx.balances?.transfer)) && (
|
||||
<Button
|
||||
className='send-button'
|
||||
icon='paper-plane'
|
||||
key='send'
|
||||
label={t('send')}
|
||||
onClick={_toggleTransfer}
|
||||
/>
|
||||
)}
|
||||
<Popup value={PopupDropdown} />
|
||||
</Button.Group>
|
||||
</td>
|
||||
<Table.Column.Expand
|
||||
isExpanded={isExpanded}
|
||||
toggle={toggleIsExpanded}
|
||||
/>
|
||||
</tr>
|
||||
<tr className={`${className} isExpanded ${isExpanded ? '' : 'isLast'} packedTop`}>
|
||||
<td />
|
||||
<td
|
||||
className='balance all'
|
||||
colSpan={2}
|
||||
>
|
||||
<AddressInfo
|
||||
address={address}
|
||||
balancesAll={balancesAll}
|
||||
withBalance={BAL_OPTS_DEFAULT}
|
||||
/>
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr className={`${className} ${isExpanded ? 'isExpanded isLast' : 'isCollapsed'} packedTop`}>
|
||||
<td />
|
||||
<td
|
||||
className='balance columar'
|
||||
colSpan={2}
|
||||
>
|
||||
<AddressInfo
|
||||
address={address}
|
||||
balancesAll={balancesAll}
|
||||
withBalance={BAL_OPTS_EXPANDED}
|
||||
/>
|
||||
<Columar size='tiny'>
|
||||
<Columar.Column>
|
||||
<div data-testid='tags'>
|
||||
<Tags
|
||||
value={tags}
|
||||
withTitle
|
||||
/>
|
||||
</div>
|
||||
</Columar.Column>
|
||||
</Columar>
|
||||
<Columar is100>
|
||||
<Columar.Column>
|
||||
<LinkExternal
|
||||
data={address}
|
||||
type='address'
|
||||
withTitle
|
||||
/>
|
||||
</Columar.Column>
|
||||
</Columar>
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Address);
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SortedAddress } from './types.js';
|
||||
|
||||
import FileSaver from 'file-saver';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { Button } from '@pezkuwi/react-components';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
sortedAddresses?: SortedAddress[]
|
||||
}
|
||||
|
||||
function Export ({ sortedAddresses }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onExport = useCallback(() => {
|
||||
const accounts = sortedAddresses?.map(({ address, isFavorite }) => {
|
||||
const account = keyring.getAddress(address); // get account info
|
||||
|
||||
return { address, isFavorite, name: account?.meta.name || address };
|
||||
});
|
||||
|
||||
/** **************** Export accounts as JSON ******************/
|
||||
|
||||
const blob = new Blob([JSON.stringify(accounts, null, 2)], { type: 'application/json; charset=utf-8' });
|
||||
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
FileSaver.saveAs(blob, `batch_exported_address_book_${new Date().getTime()}.json`);
|
||||
|
||||
/** ********************* ************** ************************/
|
||||
}, [sortedAddresses]);
|
||||
|
||||
return sortedAddresses?.length
|
||||
? (
|
||||
<Button
|
||||
icon='file-export'
|
||||
label={t('Export')}
|
||||
onClick={onExport}
|
||||
/>
|
||||
)
|
||||
: <></>;
|
||||
}
|
||||
|
||||
export default React.memo(Export);
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveAccountInfo } from '@pezkuwi/api-derive/types';
|
||||
import type { ActionStatus, ActionStatusBase } from '@pezkuwi/react-components/Status/types';
|
||||
import type { FunInputFile, SaveFile } from './types.js';
|
||||
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
|
||||
import { Button, InputAddress } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import keyring from '@pezkuwi/ui-keyring';
|
||||
import { hexToU8a } from '@pezkuwi/util';
|
||||
import { ethereumEncode } from '@pezkuwi/util-crypto';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
favorites: string[];
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
toggleFavorite: (address: string) => void;
|
||||
}
|
||||
|
||||
function Import ({ favorites, onStatusChange, toggleFavorite }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api, isEthereum } = useApi();
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const _onImportResult = useCallback<(m: string, s?: ActionStatusBase['status']) => void>(
|
||||
(message, status = 'queued') => {
|
||||
onStatusChange?.({
|
||||
action: t('Import file'),
|
||||
message,
|
||||
status
|
||||
});
|
||||
},
|
||||
[onStatusChange, t]
|
||||
);
|
||||
|
||||
const validateAccountInfo = useCallback(({ address: addressInput, name }: SaveFile) => {
|
||||
let address = '';
|
||||
let isAddressValid = true;
|
||||
let isAddressExisting = false;
|
||||
let isPublicKey = false;
|
||||
let isNameValid = !!name.trim();
|
||||
|
||||
try {
|
||||
if (isEthereum) {
|
||||
const rawAddress = hexToU8a(addressInput);
|
||||
|
||||
address = ethereumEncode(rawAddress);
|
||||
isPublicKey = rawAddress.length === 20;
|
||||
} else {
|
||||
const publicKey = keyring.decodeAddress(addressInput);
|
||||
|
||||
address = keyring.encodeAddress(publicKey);
|
||||
isPublicKey = publicKey.length === 32;
|
||||
}
|
||||
|
||||
const old = keyring.getAddress(address);
|
||||
|
||||
if (old) {
|
||||
const newName = old.meta.name || name;
|
||||
|
||||
isAddressExisting = true;
|
||||
isAddressValid = true;
|
||||
isNameValid = !!(newName || '').trim();
|
||||
}
|
||||
} catch {
|
||||
isAddressValid = false;
|
||||
}
|
||||
|
||||
return {
|
||||
address,
|
||||
isAddressExisting,
|
||||
isAddressValid,
|
||||
isNameValid,
|
||||
isPublicKey
|
||||
};
|
||||
}, [isEthereum]);
|
||||
|
||||
const _onAddAccount = useCallback(
|
||||
async (account: SaveFile): Promise<boolean> => {
|
||||
const { address, name } = account;
|
||||
const info: DeriveAccountInfo = await api.derive.accounts.info(address);
|
||||
const { isAddressExisting, isAddressValid, isNameValid } = validateAccountInfo(account);
|
||||
const isValid = (isAddressValid && isNameValid) && !!info?.accountId;
|
||||
|
||||
if (!isValid || !info?.accountId || isAddressExisting) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const address = info.accountId.toString();
|
||||
|
||||
// Save address
|
||||
keyring.saveAddress(address, { genesisHash: keyring.genesisHash, name: name.trim(), tags: [] });
|
||||
InputAddress.setLastValue('address', address);
|
||||
|
||||
if (account.isFavorite && !favorites.includes(address)) {
|
||||
toggleFavorite(address);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[api.derive.accounts, favorites, toggleFavorite, validateAccountInfo]
|
||||
);
|
||||
|
||||
const onImport = useCallback(() => {
|
||||
if (!importInputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
importInputRef.current.value = '';
|
||||
importInputRef.current.click();
|
||||
}, []);
|
||||
|
||||
const _onInputImportFile = useCallback<FunInputFile>((e) => {
|
||||
try {
|
||||
_onImportResult(t('Importing'), 'queued');
|
||||
const fileReader = new FileReader();
|
||||
const files = e.target.files;
|
||||
|
||||
if (!files) {
|
||||
return _onImportResult(t('no file chosen'), 'error');
|
||||
}
|
||||
|
||||
// Read uploaded file
|
||||
fileReader.readAsText(files[0], 'UTF-8');
|
||||
|
||||
// Check if the selected file does not have a .json extension.
|
||||
// If invalid, return error message.
|
||||
if (!(/(.json)$/i.test(e.target.value))) {
|
||||
return _onImportResult(t('file error'), 'error');
|
||||
}
|
||||
|
||||
fileReader.onload = async (e) => {
|
||||
try {
|
||||
// Try parsing file data
|
||||
const _list = JSON.parse(e.target?.result as string) as SaveFile[];
|
||||
|
||||
if (!Array.isArray(_list)) {
|
||||
return _onImportResult(t('file content error'), 'error');
|
||||
}
|
||||
|
||||
const fitter: SaveFile[] = [];
|
||||
|
||||
// Filter out items that match the required schema, ensuring only valid entries are retained.
|
||||
for (const item of _list) {
|
||||
if (item.name && item.address) {
|
||||
fitter.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let importedAccounts = 0;
|
||||
|
||||
// Add each valid account
|
||||
for (const account of fitter) {
|
||||
try {
|
||||
const flag = await _onAddAccount(account);
|
||||
|
||||
importedAccounts += Number(flag);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if (importedAccounts > 0) {
|
||||
_onImportResult(t('Success'), 'success');
|
||||
} else {
|
||||
_onImportResult(t('no account imported'), 'eventWarn');
|
||||
}
|
||||
} catch {
|
||||
_onImportResult(t('file content error'), 'error');
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
_onImportResult(t('file content error'), 'error');
|
||||
}
|
||||
}, [_onAddAccount, _onImportResult, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
accept='application/json'
|
||||
onChange={_onInputImportFile}
|
||||
ref={importInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type={'file'}
|
||||
/>
|
||||
<Button
|
||||
icon='file-import'
|
||||
label={t('Import')}
|
||||
onClick={onImport}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Import);
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import type { Table } from '@pezkuwi/test-support/pagesElements';
|
||||
|
||||
import { screen } from '@testing-library/react';
|
||||
|
||||
import i18next from '@pezkuwi/react-components/i18n';
|
||||
import { aContactWithBalance } from '@pezkuwi/test-support/creation/contact';
|
||||
import { MemoryStore } from '@pezkuwi/test-support/keyring';
|
||||
import { balance } from '@pezkuwi/test-support/utils';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
|
||||
import { AddressesPage } from '../../test/pages/addressesPage.js';
|
||||
|
||||
// FIXME isSplit Table
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
describe.skip('Addresses page', () => {
|
||||
let addressesPage: AddressesPage;
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18next.changeLanguage('en');
|
||||
|
||||
if (keyring.getAccounts().length === 0) {
|
||||
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
addressesPage = new AddressesPage();
|
||||
addressesPage.clearAccounts();
|
||||
});
|
||||
|
||||
describe('when no contacts', () => {
|
||||
let addressesTable: Table;
|
||||
|
||||
beforeEach(async () => {
|
||||
addressesPage.render([]);
|
||||
addressesTable = await addressesPage.getTable();
|
||||
});
|
||||
|
||||
it('shows a table', () => {
|
||||
expect(addressesTable).not.toBeNull();
|
||||
});
|
||||
|
||||
it('the contacts table contains no contact rows', async () => {
|
||||
expect(await addressesTable.getRows()).toHaveLength(0);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('the contacts table contains a message about no contacts available', async () => {
|
||||
const noContactsMessage = 'no addresses saved yet, add any existing address';
|
||||
|
||||
await addressesTable.assertText(noContactsMessage);
|
||||
});
|
||||
|
||||
it('no summary is displayed', () => {
|
||||
const summaries = screen.queryAllByTestId(/card-summary:total \w+/i);
|
||||
|
||||
expect(summaries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when some contacts exist', () => {
|
||||
it('the contacts table contains some contact rows', async () => {
|
||||
addressesPage.renderDefaultContacts(2);
|
||||
|
||||
const rows = await addressesPage.getAddressesRows();
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('contact rows display the total balance info', async () => {
|
||||
addressesPage.renderContactsWithDefaultAddresses(
|
||||
aContactWithBalance({ freeBalance: balance(500) }),
|
||||
aContactWithBalance({ freeBalance: balance(200), reservedBalance: balance(150) })
|
||||
);
|
||||
|
||||
const rows = await addressesPage.getAddressesRows();
|
||||
|
||||
await rows[0].assertBalancesTotal(balance(500));
|
||||
await rows[1].assertBalancesTotal(balance(350));
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('contact rows display the details balance info', async () => {
|
||||
addressesPage.renderContactsWithDefaultAddresses(
|
||||
aContactWithBalance({ freeBalance: balance(500), lockedBalance: balance(30) }),
|
||||
aContactWithBalance({ availableBalance: balance(50), freeBalance: balance(200), reservedBalance: balance(150) })
|
||||
);
|
||||
|
||||
const rows = await addressesPage.getAddressesRows();
|
||||
|
||||
await rows[0].assertBalancesDetails([
|
||||
{ amount: balance(0), name: 'transferable' },
|
||||
{ amount: balance(30), name: 'locked' }]);
|
||||
await rows[1].assertBalancesDetails([
|
||||
{ amount: balance(50), name: 'transferable' },
|
||||
{ amount: balance(150), name: 'reserved' }]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('when a contact is not tagged, details row displays no tags info', async () => {
|
||||
addressesPage.renderDefaultContacts(1);
|
||||
const rows = await addressesPage.getAddressesRows();
|
||||
|
||||
await rows[0].assertTags('none');
|
||||
});
|
||||
|
||||
it('when a contact is tagged, the details row displays tags', async () => {
|
||||
const injectedAddress = '5CMCFVfsauWXmKaUB6tbznVUpBxcUZyU78DzvPTYrhdXe8Xp';
|
||||
|
||||
addressesPage.renderContacts([
|
||||
[injectedAddress, { meta: { tags: ['my tag', 'Super Tag'] } }]
|
||||
]);
|
||||
|
||||
const rows = await addressesPage.getAddressesRows();
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
await rows[0].assertTags('Super Tagmy tag');
|
||||
});
|
||||
|
||||
it('contact details rows toggled on icon toggle click', async () => {
|
||||
addressesPage.renderDefaultContacts(1);
|
||||
const row = (await addressesPage.getAddressesRows())[0];
|
||||
|
||||
expect(row.detailsRow).toHaveClass('isCollapsed');
|
||||
|
||||
await row.expand();
|
||||
|
||||
expect(row.detailsRow).toHaveClass('isExpanded');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { SortedAddress } from './types.js';
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Button, FilterInput, styled, SummaryBox, Table } from '@pezkuwi/react-components';
|
||||
import { useAddresses, useFavorites, useNextTick, useToggle } from '@pezkuwi/react-hooks';
|
||||
|
||||
import CreateModal from '../modals/Create.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Address from './Address.js';
|
||||
import Export from './Export.js';
|
||||
import Import from './Import.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
const STORE_FAVS = 'accounts:favorites';
|
||||
|
||||
function Overview ({ className = '', onStatusChange }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { allAddresses } = useAddresses();
|
||||
const [isCreateOpen, toggleCreate] = useToggle(false);
|
||||
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS);
|
||||
const [sortedAddresses, setSortedAddresses] = useState<SortedAddress[] | undefined>();
|
||||
const [filterOn, setFilter] = useState<string>('');
|
||||
const isNextTick = useNextTick();
|
||||
|
||||
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
|
||||
[t('contacts'), 'start', 4]
|
||||
]);
|
||||
|
||||
useEffect((): void => {
|
||||
setSortedAddresses(
|
||||
allAddresses
|
||||
.map((address): SortedAddress => ({ address, isFavorite: favorites.includes(address), isVisible: true }))
|
||||
.sort((a, b): number =>
|
||||
a.isFavorite === b.isFavorite
|
||||
? 0
|
||||
: b.isFavorite
|
||||
? 1
|
||||
: -1
|
||||
)
|
||||
);
|
||||
}, [allAddresses, favorites]);
|
||||
|
||||
const toggleVisible = useCallback((address: string, isVisible: boolean) => {
|
||||
setSortedAddresses((account) => account
|
||||
?.map((e) => e.address === address ? { ...e, isVisible } : e)
|
||||
.sort((a, b) => a.isVisible === b.isVisible ? 0 : b.isVisible ? 1 : -1)
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
{isCreateOpen && (
|
||||
<CreateModal
|
||||
onClose={toggleCreate}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
)}
|
||||
<SummaryBox className='summary-box-contacts'>
|
||||
<section>
|
||||
<FilterInput
|
||||
className='media--1000'
|
||||
filterOn={filterOn}
|
||||
label={t('filter by name or tags')}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
</section>
|
||||
<Button.Group>
|
||||
<Import
|
||||
favorites={favorites}
|
||||
onStatusChange={onStatusChange}
|
||||
toggleFavorite={toggleFavorite}
|
||||
/>
|
||||
<Export sortedAddresses={sortedAddresses} />
|
||||
<Button
|
||||
icon='plus'
|
||||
label={t('Add contact')}
|
||||
onClick={toggleCreate}
|
||||
/>
|
||||
</Button.Group>
|
||||
</SummaryBox>
|
||||
<Table
|
||||
empty={isNextTick && sortedAddresses && t('no addresses saved yet, add any existing address')}
|
||||
header={headerRef.current}
|
||||
isSplit
|
||||
>
|
||||
{isNextTick && sortedAddresses?.map(({ address, isFavorite, isVisible }): React.ReactNode => (
|
||||
<Address
|
||||
address={address}
|
||||
filter={filterOn}
|
||||
isFavorite={isFavorite}
|
||||
isVisible={isVisible}
|
||||
key={address}
|
||||
toggleFavorite={toggleFavorite}
|
||||
toggleVisible={toggleVisible}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.summary-box-contacts {
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Overview);
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
export interface SortedAddress { address: string; isFavorite: boolean, isVisible: boolean }
|
||||
|
||||
export interface SaveFile { address: string; isFavorite: boolean, name: string }
|
||||
|
||||
export type FunInputFile = (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AppProps as Props } from '@pezkuwi/react-components/types';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { Route, Routes } from 'react-router';
|
||||
|
||||
import { Tabs } from '@pezkuwi/react-components';
|
||||
|
||||
import Contacts from './Contacts/index.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
function AddressesApp ({ basePath, onStatusChange }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const itemsRef = useRef([
|
||||
{
|
||||
isRoot: true,
|
||||
name: 'contacts',
|
||||
text: t('My contacts')
|
||||
}
|
||||
]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Tabs
|
||||
basePath={basePath}
|
||||
items={itemsRef.current}
|
||||
/>
|
||||
<Routes>
|
||||
<Route path={basePath}>
|
||||
<Route
|
||||
element={
|
||||
<Contacts onStatusChange={onStatusChange} />
|
||||
}
|
||||
index
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(AddressesApp);
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveAccountInfo } from '@pezkuwi/api-derive/types';
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { ModalProps as Props } from '../types.js';
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { AddressRow, Button, Input, InputAddress, Modal } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { hexToU8a } from '@pezkuwi/util';
|
||||
import { ethereumEncode } from '@pezkuwi/util-crypto';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface AddrState {
|
||||
address: string;
|
||||
addressInput: string;
|
||||
isAddressExisting: boolean;
|
||||
isAddressValid: boolean;
|
||||
isPublicKey: boolean;
|
||||
}
|
||||
|
||||
interface NameState {
|
||||
isNameValid: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function Create ({ onClose, onStatusChange }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api, isEthereum } = useApi();
|
||||
const [{ isNameValid, name }, setName] = useState<NameState>({ isNameValid: false, name: '' });
|
||||
const [{ address, addressInput, isAddressExisting, isAddressValid }, setAddress] = useState<AddrState>({ address: '', addressInput: '', isAddressExisting: false, isAddressValid: false, isPublicKey: false });
|
||||
const info = useCall<DeriveAccountInfo>(!!address && isAddressValid && api.derive.accounts.info, [address]);
|
||||
const isValid = useMemo(() => (isAddressValid && isNameValid) && !!info?.accountId, [isAddressValid, isNameValid, info]);
|
||||
|
||||
const _onChangeAddress = useCallback(
|
||||
(addressInput: string): void => {
|
||||
let address = '';
|
||||
let isAddressValid = true;
|
||||
let isAddressExisting = false;
|
||||
let isPublicKey = false;
|
||||
|
||||
try {
|
||||
if (isEthereum) {
|
||||
const rawAddress = hexToU8a(addressInput);
|
||||
|
||||
address = ethereumEncode(rawAddress);
|
||||
isPublicKey = rawAddress.length === 20;
|
||||
} else {
|
||||
const publicKey = keyring.decodeAddress(addressInput);
|
||||
|
||||
address = keyring.encodeAddress(publicKey);
|
||||
isPublicKey = publicKey.length === 32;
|
||||
}
|
||||
|
||||
if (!isAddressValid) {
|
||||
const old = keyring.getAddress(address);
|
||||
|
||||
if (old) {
|
||||
const newName = old.meta.name || name;
|
||||
|
||||
isAddressExisting = true;
|
||||
isAddressValid = true;
|
||||
setName({ isNameValid: !!(newName || '').trim(), name: newName });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
isAddressValid = false;
|
||||
}
|
||||
|
||||
setAddress({ address: isAddressValid ? address : '', addressInput, isAddressExisting, isAddressValid, isPublicKey });
|
||||
},
|
||||
[isEthereum, name]
|
||||
);
|
||||
|
||||
const _onChangeName = useCallback(
|
||||
(name: string) => setName({ isNameValid: !!name.trim(), name }),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onCommit = useCallback(
|
||||
(): void => {
|
||||
const status = { action: 'create' } as ActionStatus;
|
||||
|
||||
if (!isValid || !info?.accountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const address = info.accountId.toString();
|
||||
|
||||
keyring.saveAddress(address, { genesisHash: keyring.genesisHash, name: name.trim(), tags: [] });
|
||||
|
||||
status.account = address;
|
||||
status.status = address ? 'success' : 'error';
|
||||
status.message = isAddressExisting
|
||||
? t('address edited')
|
||||
: t('address created');
|
||||
|
||||
InputAddress.setLastValue('address', address);
|
||||
} catch (error) {
|
||||
status.status = 'error';
|
||||
status.message = (error as Error).message;
|
||||
}
|
||||
|
||||
onStatusChange(status);
|
||||
onClose();
|
||||
},
|
||||
[info, isAddressExisting, isValid, name, onClose, onStatusChange, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
header={t('Add an address')}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Modal.Content>
|
||||
<AddressRow
|
||||
defaultName={name}
|
||||
noDefaultNameOpacity
|
||||
value={
|
||||
isAddressValid
|
||||
? info?.accountId?.toString()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className='full'
|
||||
isError={!isAddressValid}
|
||||
label={t('address')}
|
||||
onChange={_onChangeAddress}
|
||||
onEnter={_onCommit}
|
||||
placeholder={t('new address')}
|
||||
value={addressInput}
|
||||
/>
|
||||
<Input
|
||||
className='full'
|
||||
isError={!isNameValid}
|
||||
label={t('name')}
|
||||
onChange={_onChangeName}
|
||||
onEnter={_onCommit}
|
||||
value={name}
|
||||
/>
|
||||
</AddressRow>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
icon='save'
|
||||
isDisabled={!isValid}
|
||||
label={t('Save')}
|
||||
onClick={_onCommit}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Create);
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useTranslation as useTranslationBase } from 'react-i18next';
|
||||
|
||||
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
|
||||
return useTranslationBase('app-addresses');
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { WithTranslation } from 'react-i18next';
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { Balance, Conviction } from '@pezkuwi/types/interfaces';
|
||||
import type { KeyringAddress } from '@pezkuwi/ui-keyring/types';
|
||||
|
||||
export type { AppProps as ComponentProps } from '@pezkuwi/react-components/types';
|
||||
|
||||
export interface BareProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface I18nProps extends BareProps, WithTranslation {}
|
||||
|
||||
export interface ModalProps {
|
||||
onClose: () => void;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
export interface Delegation {
|
||||
accountDelegated: string
|
||||
amount: Balance
|
||||
conviction: Conviction
|
||||
}
|
||||
|
||||
export interface SortedAccount {
|
||||
account: KeyringAddress;
|
||||
children: SortedAccount[];
|
||||
delegation?: Delegation;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeyringAddress } from '@pezkuwi/ui-keyring/types';
|
||||
import type { SortedAccount } from './types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Menu } from '@pezkuwi/react-components';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
|
||||
export function createMenuGroup (items: (React.ReactNode | false | undefined | null)[]): React.ReactNode | null {
|
||||
const filtered = items.filter((item): item is React.ReactNode => !!item);
|
||||
|
||||
return filtered.length
|
||||
? <>{filtered}<Menu.Divider /></>
|
||||
: null;
|
||||
}
|
||||
|
||||
function expandList (mapped: SortedAccount[], entry: SortedAccount): SortedAccount[] {
|
||||
mapped.push(entry);
|
||||
|
||||
entry.children.forEach((entry): void => {
|
||||
expandList(mapped, entry);
|
||||
});
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export function sortAccounts (addresses: string[], favorites: string[]): SortedAccount[] {
|
||||
const mapped = addresses
|
||||
.map((address) => keyring.getAccount(address))
|
||||
.filter((account): account is KeyringAddress => !!account)
|
||||
.map((account): SortedAccount => ({
|
||||
account,
|
||||
children: [],
|
||||
isFavorite: favorites.includes(account.address)
|
||||
}))
|
||||
.sort((a, b) => (a.account.meta.whenCreated || 0) - (b.account.meta.whenCreated || 0));
|
||||
|
||||
return mapped
|
||||
.filter((entry): boolean => {
|
||||
const parentAddress = entry.account.meta.parentAddress;
|
||||
|
||||
if (parentAddress) {
|
||||
const parent = mapped.find(({ account: { address } }) => address === parentAddress);
|
||||
|
||||
if (parent) {
|
||||
parent.children.push(entry);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.reduce(expandList, [])
|
||||
.sort((a, b): number =>
|
||||
a.isFavorite === b.isFavorite
|
||||
? 0
|
||||
: b.isFavorite
|
||||
? 1
|
||||
: -1
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user