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:
2026-01-07 13:05:27 +03:00
commit d21bfb1320
5867 changed files with 329019 additions and 0 deletions
@@ -0,0 +1,147 @@
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AddressFlags } from '@pezkuwi/react-hooks/types';
import React, { useCallback } from 'react';
import { useApi, useToggle } from '@pezkuwi/react-hooks';
import { isFunction } from '@pezkuwi/util';
import Button from '../Button/index.js';
import { TransferModal } from '../modals/index.js';
import { styled } from '../styled.js';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
flags: AddressFlags;
isEditingName: boolean;
isEditing: boolean;
toggleIsEditingName: () => void;
toggleIsEditingTags: () => void;
onCancel: () => void;
onSaveName: () => void;
onSaveTags: () => void;
onForgetAddress: () => void;
onUpdateName?: (() => void) | null;
recipientId: string;
}
function AccountMenuButtons ({ className = '', flags, isEditing, isEditingName, onCancel, onForgetAddress, onSaveName, onSaveTags, onUpdateName, recipientId, toggleIsEditingName, toggleIsEditingTags }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [isTransferOpen, toggleIsTransferOpen] = useToggle();
const api = useApi();
const _onForgetAddress = useCallback(
(): void => {
onForgetAddress();
onUpdateName && onUpdateName();
},
[onForgetAddress, onUpdateName]
);
const toggleIsEditing = useCallback(() => {
flags.isEditable && toggleIsEditingName();
toggleIsEditingTags();
}, [flags.isEditable, toggleIsEditingName, toggleIsEditingTags]);
const _onUpdateName = useCallback(
(): void => {
onSaveName();
onUpdateName && onUpdateName();
},
[onSaveName, onUpdateName]
);
const updateName = useCallback(() => {
if (isEditingName && (flags.isInContacts || flags.isOwned)) {
_onUpdateName();
toggleIsEditingName();
}
}, [isEditingName, flags.isInContacts, flags.isOwned, _onUpdateName, toggleIsEditingName]);
const onEdit = useCallback(() => {
if (isEditing) {
updateName();
onSaveTags();
}
toggleIsEditing();
}, [isEditing, toggleIsEditing, updateName, onSaveTags]);
return (
<StyledDiv className={`${className} ui--AddressMenu-buttons`}>
{isEditing
? (
<Button.Group>
<Button
icon='times'
label={t('Cancel')}
onClick={onCancel}
/>
<Button
icon='save'
label={t('Save')}
onClick={onEdit}
/>
</Button.Group>
)
: (
<Button.Group>
{(isFunction(api.api.tx.balances?.transferAllowDeath) || isFunction(api.api.tx.balances?.transfer)) && (
<Button
icon='paper-plane'
isDisabled={isEditing}
label={t('Send')}
onClick={toggleIsTransferOpen}
/>
)}
{!flags.isOwned && !flags.isInContacts && (
<Button
icon='plus'
isDisabled={isEditing}
label={t('Save')}
onClick={_onUpdateName}
/>
)}
{!flags.isOwned && flags.isInContacts && (
<Button
icon='ban'
isDisabled={isEditing}
label={t('Remove')}
onClick={_onForgetAddress}
/>
)}
<Button
icon='edit'
isDisabled={!flags.isEditable}
label={t('Edit')}
onClick={onEdit}
/>
</Button.Group>
)
}
{isTransferOpen && (
<TransferModal
key='modal-transfer'
onClose={toggleIsTransferOpen}
recipientId={recipientId}
/>
)}
</StyledDiv>
);
}
const StyledDiv = styled.div`
width: 100%;
.ui--Button-Group {
display: flex;
flex-direction: row;
justify-content: space-around;
margin-bottom: 0;
}
`;
export default React.memo(AccountMenuButtons);
@@ -0,0 +1,88 @@
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AddressFlags } from '@pezkuwi/react-hooks/types';
import React from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
import { useToggle } from '@pezkuwi/react-hooks';
import AccountName from '../AccountName.js';
import Button from '../Button/index.js';
import IdentityIcon from '../IdentityIcon/index.js';
import Input from '../Input.js';
import { useTranslation } from '../translate.js';
interface Props {
value: string,
editingName: boolean,
defaultValue: string,
onChange: (value: string) => void,
flags: AddressFlags,
accountIndex: string | undefined,
}
function AddressSection ({ accountIndex, defaultValue, editingName, flags, onChange, value }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [isCopyShown, toggleIsCopyShown] = useToggle();
const NOOP = () => undefined;
return (
<div className='ui--AddressSection'>
<IdentityIcon
size={80}
value={value}
/>
<div className='ui--AddressSection__AddressColumn'>
<AccountName
override={
editingName
? (
<Input
className='name--input'
defaultValue={defaultValue}
label='name-input'
onChange={onChange}
withLabel={false}
/>
)
: flags.isEditable
? (defaultValue.toUpperCase() || t('<unknown>'))
: undefined
}
value={value}
withSidebar={false}
/>
<div className='ui--AddressMenu-addr'>
{value}
</div>
{accountIndex && (
<div className='ui--AddressMenu-index'>
<label>{t('index')}:</label> {accountIndex}
</div>
)}
</div>
<div className='ui--AddressSection__CopyColumn'>
<div className='ui--AddressMenu-copyaddr'>
<CopyToClipboard
text={value}
>
<span>
<Button.Group>
<Button
icon={isCopyShown ? 'check' : 'copy'}
label={isCopyShown ? t('Copied') : t('Copy')}
onClick={isCopyShown ? NOOP : toggleIsCopyShown }
onMouseLeave={isCopyShown ? toggleIsCopyShown : NOOP }
/>
</Button.Group>
</span>
</CopyToClipboard>
</div>
</div>
</div>
);
}
export default React.memo(AddressSection);
@@ -0,0 +1,56 @@
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import AddressInfo from '../AddressInfo.js';
import { styled } from '../styled.js';
import { useTranslation } from '../translate.js';
interface Props {
address: string;
className?: string;
}
const WITH_BALANCE = { available: true, bonded: true, free: true, locked: true, reserved: true, total: true };
function Balances ({ address, className }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
return (
<StyledSection className={className}>
<div className='ui--AddressMenu-sectionHeader'>
{t('balance')}
</div>
<AddressInfo
address={address}
className='balanceExpander'
key={address}
withBalance={WITH_BALANCE}
withLabel
/>
</StyledSection>
);
}
const StyledSection = styled.section`
.balanceExpander {
justify-content: flex-start;
.column {
width: auto;
max-width: 18.57rem;
label {
text-align: left;
color: inherit;
}
.ui--Expander-content .ui--FormatBalance-value {
font-size: var(--font-size-small);
}
}
}
`;
export default React.memo(Balances);
@@ -0,0 +1,114 @@
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AddressFlags } from '@pezkuwi/react-hooks/types';
import React from 'react';
import Flag from '../Flag.js';
import { styled } from '../styled.js';
import { useTranslation } from '../translate.js';
interface Props {
flags: AddressFlags;
className?: string;
}
function Flags ({ className = '', flags: { isCouncil, isDevelopment, isExternal, isInjected, isMultisig, isNominator, isProxied, isSociety, isSudo, isTechCommittee, isValidator } }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const hasFlags = isCouncil || isDevelopment || isExternal || isInjected || isMultisig || isProxied || isSociety || isSudo || isTechCommittee || isValidator || isNominator;
if (!hasFlags) {
return null;
}
return (
<StyledDiv className={`${className} ui--AddressMenu-flags`}>
{
hasFlags && (
<h5>{t('Flags')}</h5>
)
}
<div>
{isValidator && (
<Flag
color='theme'
label={t('Validator')}
/>
)}
{isNominator && (
<Flag
color='theme'
label={t('Nominator')}
/>
)}
{isExternal && (
isMultisig
? (
<Flag
color='green'
label={t('Multisig')}
/>
)
: isProxied
? (
<Flag
color='grey'
label={t('Proxied')}
/>
)
: (
<Flag
color='grey'
label={t('External')}
/>
)
)}
{isInjected && (
<Flag
color='grey'
label={t('Injected')}
/>
)}
{isDevelopment && (
<Flag
color='grey'
label={t('Test account')}
/>
)}
{isCouncil && (
<Flag
color='blue'
label={t('Council')}
/>
)}
{isSociety && (
<Flag
color='green'
label={t('Society')}
/>
)}
{isTechCommittee && (
<Flag
color='orange'
label={t('Technical committee')}
/>
)}
{isSudo && (
<Flag
color='pink'
label={t('Sudo key')}
/>
)}
</div>
</StyledDiv>
);
}
const StyledDiv = styled.div`
.ui--Tag {
margin: 0.2rem 1rem 0.2rem 0.571rem;
}
`;
export default React.memo(Flags);
@@ -0,0 +1,237 @@
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useMemo } from 'react';
import { useApi, useRegistrars, useSubidentities, useToggle } from '@pezkuwi/react-hooks';
import { AddressIdentityOtherDiscordKey } from '@pezkuwi/react-hooks/constants';
import { type AddressIdentity } from '@pezkuwi/react-hooks/types';
import { isHex } from '@pezkuwi/util';
import AddressMini from '../AddressMini.js';
import AvatarItem from '../AvatarItem.js';
import Expander from '../Expander.js';
import IconLink from '../IconLink.js';
import { useTranslation } from '../translate.js';
import Judgements from './Judgements.js';
import RegistrarJudgement from './RegistrarJudgement.js';
import UserIcon from './UserIcon.js';
interface Props {
address: string;
identity?: AddressIdentity;
}
const SUBS_DISPLAY_THRESHOLD = 4;
function Identity ({ address, identity }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { apiIdentity } = useApi();
const { isRegistrar, registrars } = useRegistrars();
const [isJudgementOpen, toggleIsJudgementOpen] = useToggle();
const subs = useSubidentities(address);
const subsList = useMemo(() =>
subs?.map((sub) =>
<AddressMini
className='subs'
isPadded={false}
key={sub.toString()}
value={sub}
/>
)
, [subs]
);
if (!identity || !identity.isExistent || !apiIdentity.query.identity?.identityOf) {
return null;
}
return (
<section
className='withDivider'
data-testid='identity-section'
>
<div className='ui--AddressMenu-section ui--AddressMenu-identity'>
<div className='ui--AddressMenu-sectionHeader'>
{t('identity')}
</div>
<div>
<AvatarItem
icon={
// This won't work - images are IPFS hashes
// identity.image
// ? <img src={identity.image} />
// : <i className='icon user ui--AddressMenu-identityIcon' />
//
<UserIcon />
}
subtitle={identity.legal}
title={identity.display}
/>
<Judgements address={address} />
<div className='ui--AddressMenu-identityTable'>
{identity.parent && (
<div className='tr parent'>
<div className='th'>{t('parent')}</div>
<div className='td'>
<AddressMini
className='parent'
isPadded={false}
value={identity.parent}
/>
</div>
</div>
)}
{identity.email && (
<div className='tr'>
<div className='th'>{t('email')}</div>
<div className='td'>
{isHex(identity.email) || !identity.isKnownGood
? identity.email
: (
<a
href={`mailto:${identity.email}`}
rel='noopener noreferrer'
target='_blank'
>
{identity.email}
</a>
)}
</div>
</div>
)}
{identity.web && (
<div className='tr'>
<div className='th'>{t('website')}</div>
<div className='td'>
{isHex(identity.web) || !identity.isKnownGood
? identity.web
: (
<a
href={(identity.web).replace(/^(https?:\/\/)?/g, 'https://')}
rel='noopener noreferrer'
target='_blank'
>
{identity.web}
</a>
)}
</div>
</div>
)}
{identity.twitter && (
<div className='tr'>
<div className='th'>{t('twitter')}</div>
<div className='td'>
{isHex(identity.twitter) || !identity.isKnownGood
? identity.twitter
: (
<a
href={
(identity.twitter).startsWith('https://twitter.com/')
? (identity.twitter)
: `https://twitter.com/${identity.twitter}`
}
rel='noopener noreferrer'
target='_blank'
>
{identity.twitter}
</a>
)}
</div>
</div>
)}
{identity.other && AddressIdentityOtherDiscordKey in identity.other && (
<div className='tr'>
<div className='th'>{t('discord')}</div>
<div className='td'>
{identity.other[AddressIdentityOtherDiscordKey]}
</div>
</div>
)}
{identity.github && (
<div className='tr'>
<div className='th'>{t('github')}</div>
<div className='td'>
{identity.github}
</div>
</div>
)}
{identity.matrix && (
<div className='tr'>
<div className='th'>{t('matrix')}</div>
<div className='td'>
{identity.matrix}
</div>
</div>
)}
{identity.discord && (
<div className='tr'>
<div className='th'>{t('discord')}</div>
<div className='td'>
{identity.discord}
</div>
</div>
)}
{identity.riot && (
<div className='tr'>
<div className='th'>{t('riot')}</div>
<div className='td'>
{identity.riot}
</div>
</div>
)}
{!!subs?.length && (
<div className='tr'>
<div className='th top'>{t('subs')}</div>
<div
className='td'
data-testid='subs'
>
{subs.length > SUBS_DISPLAY_THRESHOLD
? (
<Expander summary={subs.length}>
{subsList}
</Expander>
)
: (
<>
<div className='subs-number'>{subs.length}</div>
{subsList}
</>
)
}
</div>
</div>
)}
</div>
</div>
</div>
{isRegistrar && (
<div className='ui--AddressMenu-section'>
<div className='ui--AddressMenu-actions'>
<ul>
<li>
<IconLink
icon='address-card'
label={t('Add identity judgment')}
onClick={toggleIsJudgementOpen}
/>
</li>
</ul>
</div>
</div>
)}
{isJudgementOpen && isRegistrar && (
<RegistrarJudgement
address={address}
key='modal-judgement'
registrars={registrars}
toggleJudgement={toggleIsJudgementOpen}
/>
)}
</section>
);
}
export default React.memo(Identity);
@@ -0,0 +1,50 @@
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DisplayedJudgement, Judgement } from '../types.js';
import React, { useMemo } from 'react';
import AddressSmall from '../AddressSmall.js';
import Menu from '../Menu/index.js';
import Popup from '../Popup/index.js';
import Tag from '../Tag.js';
interface Props {
judgement: Judgement
}
export function getJudgementColor (name: DisplayedJudgement): 'green' | 'red' {
return (name === 'Erroneous' || name === 'Low quality')
? 'red'
: 'green';
}
function JudgementTag ({ judgement: { judgementName, registrars } }: Props): React.ReactElement<Props> {
const judgementColor = useMemo(() => getJudgementColor(judgementName), [judgementName]);
return (
<Popup
closeOnScroll
position='middle'
value={
<Menu>
{registrars.map((registrar) => registrar && (
<AddressSmall
key={registrar.address}
value={registrar.address}
/>
))}
</Menu>
}
>
<Tag
color={judgementColor}
label={`${registrars.length} ${judgementName}`}
size='tiny'
/>
</Popup>
);
}
export default React.memo(JudgementTag);
@@ -0,0 +1,64 @@
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { useJudgements } from '@pezkuwi/react-hooks';
import { styled } from '../styled.js';
import Tag from '../Tag.js';
import { useTranslation } from '../translate.js';
import JudgementTag from './JudgementTag.js';
interface Props {
address: string;
className?: string;
}
function Judgements ({ address, className = '' }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const judgements = useJudgements(address);
if (judgements.length === 0) {
return (
<div
className={`${className} no-judgements`}
data-testid='judgements'
>
<Tag
color='yellow'
key='NoJudgements'
label={t('No judgements')}
size='tiny'
/>
</div>
);
}
return (
<StyledDiv
className={className}
data-testid='judgements'
>
{judgements.map((judgement) =>
<JudgementTag
judgement={judgement}
key={`${address}${judgement.judgementName}`}
/>
)}
</StyledDiv>
);
}
const StyledDiv = styled.div`
margin-top: 0.714rem;
&:not(.no-judgements) {
.ui--Tag:hover {
cursor: pointer;
}
}
`;
export default React.memo(Judgements);
@@ -0,0 +1,53 @@
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { KeyringJson$Meta } from '@pezkuwi/ui-keyring/types';
import React from 'react';
import AddressMini from '../AddressMini.js';
import { useTranslation } from '../translate.js';
interface Props {
isMultisig: boolean;
meta?: KeyringJson$Meta;
}
function Multisig ({ isMultisig, meta }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
if (!isMultisig || !meta) {
return null;
}
const { threshold, who } = meta;
return (
<section className='ui--AddressMenu-multisig withDivider'>
<div className='ui--AddressMenu-sectionHeader'>
{t('multisig')}
</div>
<div className='ui--AddressMenu-multisigTable'>
<div className='tr'>
<div className='th'>{t('threshold')}</div>
<div className='td'>
{threshold}/{who?.length}
</div>
</div>
<div className='tr'>
<div className='th signatories'>{t('signatories')}</div>
<div className='td'>
{who?.map((address) => (
<AddressMini
key={address}
value={address}
/>
))}
</div>
</div>
</div>
</section>
);
}
export default React.memo(Multisig);
@@ -0,0 +1,128 @@
// Copyright 2017-2025 @pezkuwi/react-query authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Bytes, Option } from '@pezkuwi/types';
import type { PalletIdentityRegistration } from '@pezkuwi/types/lookup';
import type { ITuple } from '@pezkuwi/types/types';
import type { HexString } from '@pezkuwi/util/types';
import React, { useEffect, useState } from 'react';
import { useApi, useCall } from '@pezkuwi/react-hooks';
import Dropdown from '../Dropdown.js';
import Input from '../Input.js';
import InputAddress from '../InputAddress/index.js';
import MarkError from '../MarkError.js';
import Modal from '../Modal/index.js';
import Spinner from '../Spinner.js';
import { useTranslation } from '../translate.js';
import TxButton from '../TxButton.js';
interface Props {
address: string;
registrars: { address: string; index: number }[];
toggleJudgement: () => void;
}
const JUDGEMENT_ENUM = [
{ text: 'Unknown', value: 0 },
{ text: 'Fee paid', value: 1 },
{ text: 'Reasonable', value: 2 },
{ text: 'Known good', value: 3 },
{ text: 'Out of date', value: 4 },
{ text: 'Low quality', value: 5 }
];
const OPT_ID = {
transform: (optId: Option<ITuple<[PalletIdentityRegistration, Option<Bytes>]>>): HexString | null => {
const id = optId.isSome
? optId.unwrap()
: null;
// Backwards compatibility - https://github.com/pezkuwi-js/apps/issues/10493
return !id
? null
: Array.isArray(id)
? id[0].info.hash.toHex()
: (id as unknown as PalletIdentityRegistration).info.hash.toHex();
}
};
function RegistrarJudgement ({ address, registrars, toggleJudgement }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { apiIdentity, enableIdentity } = useApi();
const identityHash = useCall(apiIdentity.query.identity.identityOf, [address], OPT_ID);
const [addresses] = useState(() => registrars.map(({ address }) => address));
const [judgementAccountId, setJudgementAccountId] = useState<string | null>(null);
const [judgementEnum, setJudgementEnum] = useState(2); // Reasonable
const [registrarIndex, setRegistrarIndex] = useState(-1);
// find the id of our registrar in the list
useEffect((): void => {
const registrar = registrars.find(({ address }) => judgementAccountId === address);
setRegistrarIndex(
registrar
? registrar.index
: -1
);
}, [judgementAccountId, registrars]);
return (
<Modal
header={t('Provide judgement')}
onClose={toggleJudgement}
size='small'
>
<Modal.Content>
<InputAddress
filter={addresses}
label={t('registrar account')}
onChange={setJudgementAccountId}
type='account'
/>
<Input
isDisabled
label={t('registrar index')}
value={registrarIndex === -1 ? t('invalid/unknown registrar account') : registrarIndex.toString()}
/>
<Dropdown
label={t('judgement')}
onChange={setJudgementEnum}
options={JUDGEMENT_ENUM}
value={judgementEnum}
/>
{identityHash
? (
<Input
defaultValue={identityHash}
isDisabled
label={t('identity hash')}
/>
)
: identityHash === null
? <MarkError content={t('No identity associated with account')} />
: <Spinner noLabel />
}
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={judgementAccountId}
icon='check'
isDisabled={!enableIdentity || !identityHash || registrarIndex === -1}
label={t('Judge')}
onStart={toggleJudgement}
params={
apiIdentity.tx.identity.provideJudgement.meta.args.length === 4
? [registrarIndex, address, judgementEnum, identityHash]
: [registrarIndex, address, judgementEnum]
}
tx={apiIdentity.tx.identity.provideJudgement}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(RegistrarJudgement);
@@ -0,0 +1,268 @@
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
import type { AddressFlags } from '@pezkuwi/react-hooks/types';
import type { Sidebar } from '@pezkuwi/test-support/pagesElements';
import type { RegistrationJudgement } from '@pezkuwi/types/interfaces';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import i18next from '@pezkuwi/react-components/i18n';
import { anAccount, anAccountWithInfo, anAccountWithMeta } from '@pezkuwi/test-support/creation/account';
import { alice, bob, MemoryStore } from '@pezkuwi/test-support/keyring';
import { charlieShortAddress, ferdieShortAddress, mockRegistration, registrars } from '@pezkuwi/test-support/mockData';
import { mockApiHooks } from '@pezkuwi/test-support/utils';
import { keyring } from '@pezkuwi/ui-keyring';
import { AccountsPage } from '../../../page-accounts/test/pages/accountsPage.js';
// FIXME: these all need to be wrapped in waitFor ....
// eslint-disable-next-line jest/no-disabled-tests
describe.skip('Sidebar', () => {
let accountsPage: AccountsPage;
let sideBar: Sidebar;
beforeAll(async () => {
await i18next.changeLanguage('en');
if (keyring.getAccounts().length === 0) {
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
}
});
beforeEach(() => {
accountsPage = new AccountsPage();
accountsPage.clearAccounts();
});
describe('editing', () => {
const initialName = 'INITIAL_NAME';
const newName = 'NEW_NAME';
const defaultTag = 'Default';
const nameInputNotFoundError = 'Unable to find an element by: [data-testid="name-input"]';
describe('changes name', () => {
beforeEach(async () => {
// Cannot get this to work on React 18 ... the first one fails :(
// However... with a delay, it seems to get through the queue
accountsPage.render([[alice, anAccountWithMeta({ isDevelopment: false, name: initialName })]]);
await new Promise((resolve) => setTimeout(resolve, 1000));
sideBar = await accountsPage.openSidebarForRow(0);
await sideBar.changeAccountName(newName);
});
it('within keyring', () => {
const changedAccount = keyring.getAccount(alice);
expect(changedAccount?.meta?.name).toEqual(newName);
});
// eslint-disable-next-line jest/expect-expect
it('within sidebar', async () => {
await sideBar.assertAccountName(newName);
});
// eslint-disable-next-line jest/expect-expect
it('within account row', async () => {
const accountRows = await accountsPage.getAccountRows();
await accountRows[0].assertAccountName(newName);
});
});
it('cannot be edited if edit button has not been pressed', async () => {
accountsPage.renderDefaultAccounts(1);
sideBar = await accountsPage.openSidebarForRow(0);
await sideBar.clickByText('none');
expect(sideBar.queryByRole('combobox')).toBeFalsy();
await expect(sideBar.typeAccountName(newName)).rejects.toThrow(nameInputNotFoundError);
});
it('when isEditable is false account name is not editable', async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithInfo({ flags: { isEditable: false } as AddressFlags })
);
sideBar = await accountsPage.openSidebarForRow(0);
sideBar.edit();
await expect(sideBar.typeAccountName(newName)).rejects.toThrow(nameInputNotFoundError);
});
describe('on edit cancel', () => {
beforeEach(async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithMeta({ isDevelopment: false, name: initialName, tags: [] })
);
sideBar = await accountsPage.openSidebarForRow(0);
await sideBar.assertTags('none');
sideBar.edit();
});
// eslint-disable-next-line jest/expect-expect
it('restores tags and name to state from keyring', async () => {
await sideBar.typeAccountName(newName);
await sideBar.selectTag(defaultTag);
sideBar.cancel();
await sideBar.assertTags('none');
await sideBar.assertAccountName(initialName);
});
it('Cancel button disappears', () => {
sideBar.cancel();
expect(sideBar.queryByRole('button', { name: 'Cancel' })).toBeFalsy();
});
});
describe('outside click', () => {
beforeEach(async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithMeta({ name: 'alice' }),
anAccountWithMeta({ name: 'bob' })
);
sideBar = await accountsPage.openSidebarForRow(0);
sideBar.edit();
});
it('cancels editing', async () => {
await sideBar.typeAccountName(newName);
await sideBar.selectTag(defaultTag);
fireEvent.click(await screen.findByText('accounts'));
await sideBar.assertTags('none');
await sideBar.assertAccountName('ALICE');
expect(sideBar.queryByRole('button', { name: 'Cancel' })).toBeFalsy();
});
it('within sidebar does not cancel editing', async () => {
await sideBar.clickByText('Tags');
expect(sideBar.queryByRole('button', { name: 'Cancel' })).toBeTruthy();
});
// eslint-disable-next-line jest/expect-expect
it('cancels editing and changes name when opening sidebar for another account', async () => {
await waitFor(() => sideBar.assertAccountInput('alice'));
sideBar = await accountsPage.openSidebarForRow(1);
await sideBar.assertAccountName('BOB');
});
});
});
describe('identity section', () => {
describe('subs', () => {
describe('when do not exist', () => {
it('does not display subs', async () => {
accountsPage.renderDefaultAccounts(1);
sideBar = await accountsPage.openSidebarForRow(0);
const subs = await sideBar.findSubs();
expect(subs).toHaveLength(0);
});
});
describe('when exist', () => {
let subs: HTMLElement[];
beforeEach(async () => {
mockApiHooks.setSubs([bob]);
accountsPage.renderAccountsWithDefaultAddresses(
anAccount(),
anAccountWithMeta({ name: 'Bob' })
);
sideBar = await accountsPage.openSidebarForRow(0);
subs = await sideBar.findSubs();
});
it('displays count of subs and account names', () => {
const subsNumber = subs[0].childNodes[0];
const subAccount = subs[0].childNodes[1];
expect(subsNumber).toHaveClass('subs-number');
expect(subsNumber).toHaveTextContent('1');
expect(subAccount).toHaveTextContent('BOB');
});
// eslint-disable-next-line jest/expect-expect
it('displays picked sub in sidebar', async () => {
const subAccount = subs[0].childNodes[1];
fireEvent.click(await within(subAccount as HTMLElement).findByTestId('account-name'));
await sideBar.assertAccountName('BOB');
});
});
});
describe('judgements', () => {
// eslint-disable-next-line jest/expect-expect
it('displays several judgements', async () => {
mockApiHooks.setJudgements(mockRegistration.judgements as RegistrationJudgement[]);
accountsPage.renderDefaultAccounts(1);
sideBar = await accountsPage.openSidebarForRow(0);
await sideBar.assertJudgement('1 Known good');
await sideBar.assertJudgement('2 Reasonable');
await sideBar.assertJudgement('1 Erroneous');
});
// eslint-disable-next-line jest/expect-expect
it('displays no judgements', async () => {
accountsPage.renderDefaultAccounts(1);
sideBar = await accountsPage.openSidebarForRow(0);
await sideBar.assertJudgement('No judgements');
});
describe('displays registrars', () => {
beforeEach(async () => {
mockApiHooks.setJudgements(mockRegistration.judgements as RegistrationJudgement[]);
mockApiHooks.setRegistrars(registrars);
accountsPage.render([
[alice, anAccountWithMeta({ name: 'Alice' })],
[bob, anAccountWithMeta({ name: 'Bob' })]
]);
sideBar = await accountsPage.openSidebarForRow(0);
});
// eslint-disable-next-line jest/expect-expect
it('singular registrar', async () => {
const judgementTag = await sideBar.getJudgement('1 Known good');
await judgementTag.assertRegistrars([charlieShortAddress]);
});
// eslint-disable-next-line jest/expect-expect
it('multiple registrars', async () => {
const judgementTag = await sideBar.getJudgement('2 Reasonable');
await judgementTag.assertRegistrars(['BOB', ferdieShortAddress]);
});
it('opens clicked registrar in sidebar and closes popup', async () => {
const judgementTag = await sideBar.getJudgement('2 Reasonable');
await judgementTag.clickRegistrar('BOB');
expect(screen.queryByTestId('popup-window')).toBeFalsy();
await sideBar.assertAccountName('BOB');
});
});
afterEach(() => {
mockApiHooks.setJudgements([]);
});
});
});
});
@@ -0,0 +1,331 @@
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useRef, useState } from 'react';
import { useAccountInfo } from '@pezkuwi/react-hooks';
import LinkExternal from '../LinkExternal.js';
import Sidebar from '../Sidebar.js';
import { styled } from '../styled.js';
import { colorLink } from '../styles/theme.js';
import Balances from './Balances.js';
import Identity from './Identity.js';
import Multisig from './Multisig.js';
import SidebarEditableSection from './SidebarEditableSection.js';
interface Props {
address: string;
className?: string;
dataTestId?: string;
onClose?: () => void;
onUpdateName?: (() => void) | null;
}
function FullSidebar ({ address, className = '', dataTestId, onClose, onUpdateName }: Props): React.ReactElement<Props> {
const [inEditMode, setInEditMode] = useState<boolean>(false);
const { accountIndex, flags, identity, meta } = useAccountInfo(address);
const sidebarRef = useRef<HTMLDivElement>(null);
return (
<StyledSidebar
className={`${className}${inEditMode ? ' inEditMode' : ''}`}
dataTestId={dataTestId}
onClose={onClose}
position='right'
sidebarRef={sidebarRef}
>
<div
className='ui--AddressMenu-header'
data-testid='sidebar-address-menu'
>
<SidebarEditableSection
accountIndex={accountIndex}
address={address}
isBeingEdited={setInEditMode}
onUpdateName={onUpdateName}
sidebarRef={sidebarRef}
/>
</div>
<div className='ui--ScrollSection'>
<Balances address={address} />
<Identity
address={address}
identity={identity}
/>
<Multisig
isMultisig={flags.isMultisig}
meta={meta}
/>
</div>
<section className='ui--LinkSection'>
<LinkExternal
data={address}
isSidebar
type='address'
/>
</section>
</StyledSidebar>
);
}
const StyledSidebar = styled(Sidebar)`
display: flex;
flex-direction: column;
background-color: var(--bg-sidebar);
max-width: 30.42rem;
min-width: 30.42rem;
overflow-y: hidden;
padding: 0 0 3.286rem;
input {
width: auto !important;
}
.ui--AddressMenu-header {
align-items: center;
background: var(--bg-tabs);
border-bottom: 1px solid var(--border-table);
display: flex;
flex-direction: column;
justify-content: center;
padding: 1.35rem 1rem 1rem 1rem;
}
.ui--AddressSection {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
width: 100%;
.ui--AddressSection__AddressColumn {
flex: 1;
margin-left: 1rem;
.ui--AccountName {
max-width: 21.5rem;
overflow: hidden;
white-space: normal;
}
}
.ui--AddressSection__CopyColumn {
margin-left: 1rem;
.ui--AccountName {
max-width: 10rem;
overflow: hidden;
}
}
}
.ui--AddressMenu-addr,
.ui--AddressMenu-index {
text-align: left;
font-size: var(--font-size-small);
}
.ui--AddressMenu-addr {
word-break: break-all;
width: 24ch;
margin: 0.571rem 0;
color: var(--color-label);
}
.ui--AddressMenu-copyaddr,
.ui--AddressMenu-index {
text-align: left;
font-size: var(--font-size-small);
}
.ui--AddressMenu-copyaaddr {
word-break: break-all;
width: 12ch;
margin: 0.371rem 0;
color: var(--color-label);
}
.ui--AddressMenu-index {
display: flex;
flex-direction: row;
label {
font-size: var(--font-size-small);
margin-right: 0.4rem;
text-transform: capitalize;
}
}
section {
position: relative;
&:not(:last-child) {
margin-bottom: 1rem;
}
.ui--AddressMenu-sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
text-transform: capitalize;
margin-bottom: 0.57rem;
width: 100%;
color: var(--color-text);
font-size: 1.143rem;
}
&.withDivider {
padding-top: 1rem;
::before {
position: absolute;
top: 0;
left: 0;
content: '';
width: 100%;
height: 1px;
background-color: var(--border-table);
}
}
}
.ui--AddressMenu-identity,
.ui--AddressMenu-multisig {
.ui--AddressMenu-identityTable,
.ui--AddressMenu-multisigTable {
font-size: var(--font-size-small);
margin-top: 0.6rem;
.tr {
padding: 0.25rem 0;
display: inline-flex;
align-items: center;
width: 100%;
.th {
text-transform: uppercase;
color: var(--color-label);
font-weight: var(--font-weight-normal);
text-align: left;
flex-basis: 25%;
font-size: var(--font-size-tiny);
&.top {
align-self: flex-start;
}
}
.td {
flex: 1;
overflow: hidden;
padding-left: 0.6rem;
text-overflow: ellipsis;
}
}
.ui--AddressMini, .subs-number {
margin-bottom: 0.4rem;
padding: 0;
}
.subs-number {
font-size: var(--font-size-base);
margin-bottom: 0.714rem;
}
}
.parent {
padding: 0 !important;
}
}
&& .column {
align-items: center;
.ui--FormatBalance:first-of-type {
margin-bottom: 0.4rem;
}
.ui--FormatBalance {
line-height: 1rem;
}
}
.ui--AddressMenu-buttons {
.ui--Button-Group {
margin-bottom: 0;
}
}
.ui--AddressMenu-tags,
.ui--AddressMenu-flags {
margin: 0.75rem 0 0;
width: 100%;
}
.ui--AddressMenu-identityIcon {
background: ${colorLink}66;
}
.ui--AddressMenu-actions {
ul {
list-style-type: none;
margin-block-start: 0;
margin-block-end: 0;
padding-inline-start: 1rem;
li {
margin: 0.2rem 0;
}
}
}
.inline-icon {
cursor: pointer;
margin: 0 0 0 0.5rem;
color: ${colorLink};
}
.name--input {
.ui.input {
margin: 0 !important;
> input {
}
}
}
&.inEditMode {
.ui--AddressMenu-flags {
opacity: 60%;
}
}
.ui--AddressMenu-multisig .th.signatories {
align-self: flex-start;
}
.ui--ScrollSection {
padding: 1rem;
overflow: auto;
}
.ui--LinkSection {
border-top: 1px solid var(--border-table);
padding: 0.5rem 0 0.571rem;
width: 100%;
position: absolute;
bottom: 0;
span {
margin: 0 0.5rem;
}
}
`;
export default React.memo(FullSidebar);
@@ -0,0 +1,93 @@
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useEffect, useMemo } from 'react';
import { useAccountInfo, useOutsideClick } from '@pezkuwi/react-hooks';
import { keyring } from '@pezkuwi/ui-keyring';
import Tags from '../Tags.js';
import AccountMenuButtons from './AccountMenuButtons.js';
import AddressSection from './AddressSection.js';
import Flags from './Flags.js';
interface Props {
accountIndex: string | undefined;
address: string;
isBeingEdited: (arg: boolean) => void;
onUpdateName?: (() => void) | null;
sidebarRef: React.RefObject<HTMLDivElement>;
}
function SidebarEditableSection ({ accountIndex, address, isBeingEdited, onUpdateName, sidebarRef }: Props): React.ReactElement<Props> {
const { flags, isEditing, isEditingName, isEditingTags, name, onForgetAddress, onSaveName, onSaveTags, setIsEditingName, setIsEditingTags, setName, setTags, tags, toggleIsEditingName, toggleIsEditingTags } = useAccountInfo(address);
const refs = useMemo(
() => [sidebarRef],
[sidebarRef]
);
useEffect((): void => {
isBeingEdited(isEditing());
}, [isBeingEdited, isEditing]);
const onCancel = useCallback(
(): void => {
if (isEditing()) {
try {
const accountOrAddress = keyring.getAccount(address) || keyring.getAddress(address);
setName(accountOrAddress?.meta.name || '');
setTags(accountOrAddress?.meta.tags ? (accountOrAddress.meta.tags).sort() : []);
setIsEditingName(false);
setIsEditingTags(false);
} catch {
// ignore
}
}
}, [isEditing, setName, setTags, setIsEditingName, setIsEditingTags, address]);
useOutsideClick(refs, onCancel);
return (
<>
<AddressSection
accountIndex={accountIndex}
defaultValue={name}
editingName={isEditingName}
flags={flags}
onChange={setName}
value={address}
/>
<div
className='ui--AddressMenu-tags'
data-testid='sidebar-tags'
>
<Tags
isEditable
isEditing={isEditingTags}
onChange={setTags}
value={tags}
withEditButton={false}
withTitle
/>
</div>
<Flags flags={flags} />
<AccountMenuButtons
flags={flags}
isEditing={isEditing()}
isEditingName={isEditingName}
onCancel={onCancel}
onForgetAddress={onForgetAddress}
onSaveName={onSaveName}
onSaveTags={onSaveTags}
onUpdateName={onUpdateName}
recipientId={address}
toggleIsEditingName={toggleIsEditingName}
toggleIsEditingTags={toggleIsEditingTags}
/>
</>
);
}
export default React.memo(SidebarEditableSection);
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useState } from 'react';
import { AccountSidebarCtx } from '@pezkuwi/react-hooks/ctx/AccountSidebar';
import Sidebar from './Sidebar.js';
interface Props {
children: React.ReactNode;
}
type State = [string | null, (() => void) | null];
const EMPTY_STATE: State = [null, null];
function AccountSidebar ({ children }: Props): React.ReactElement<Props> {
const [[address, onUpdateName], setAddress] = useState<State>(EMPTY_STATE);
const onClose = useCallback(
() => setAddress([null, null]),
[]
);
return (
<AccountSidebarCtx.Provider value={setAddress}>
{children}
{address && (
<Sidebar
address={address}
dataTestId='account-sidebar'
onClose={onClose}
onUpdateName={onUpdateName}
/>
)}
</AccountSidebarCtx.Provider>
);
}
export default React.memo(AccountSidebar);