diff --git a/src/renderer/entities/wallet/ui/Cards/WalletCardLg.tsx b/src/renderer/entities/wallet/ui/Cards/WalletCardLg.tsx index 6ee62c916..e8ca0d375 100644 --- a/src/renderer/entities/wallet/ui/Cards/WalletCardLg.tsx +++ b/src/renderer/entities/wallet/ui/Cards/WalletCardLg.tsx @@ -3,7 +3,7 @@ import { type ReactNode } from 'react'; import { type Wallet } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { cnTw } from '@/shared/lib/utils'; -import { FootnoteText, StatusLabel } from '@/shared/ui'; +import { BodyText, FootnoteText, StatusLabel } from '@/shared/ui'; import { walletUtils } from '../../lib/wallet-utils'; import { WalletIcon } from '../WalletIcon/WalletIcon'; @@ -33,7 +33,7 @@ export const WalletCardLg = ({ wallet, description, full, className }: Props) => )}
- {wallet.name} + {wallet.name} {typeof description === 'string' ? ( {description} ) : ( diff --git a/src/renderer/entities/wallet/ui/Cards/WalletCardMd.tsx b/src/renderer/entities/wallet/ui/Cards/WalletCardMd.tsx index a1d2d0dae..cee06e2f0 100644 --- a/src/renderer/entities/wallet/ui/Cards/WalletCardMd.tsx +++ b/src/renderer/entities/wallet/ui/Cards/WalletCardMd.tsx @@ -1,8 +1,8 @@ -import { type MouseEvent, type ReactNode } from 'react'; +import { type MouseEvent, type PropsWithChildren, type ReactNode } from 'react'; import { type Wallet } from '@/shared/core'; -import { cnTw } from '@/shared/lib/utils'; -import { FootnoteText, IconButton } from '@/shared/ui'; +import { cnTw, nonNullable, nullable } from '@/shared/lib/utils'; +import { BodyText, FootnoteText } from '@/shared/ui'; import { walletUtils } from '../../lib/wallet-utils'; import { WalletIcon } from '../WalletIcon/WalletIcon'; @@ -11,12 +11,17 @@ type Props = { description?: string | ReactNode; prefix?: ReactNode; hideIcon?: boolean; - className?: string; onClick?: () => void; - onInfoClick?: () => void; }; -export const WalletCardMd = ({ wallet, description, prefix, hideIcon, className, onClick, onInfoClick }: Props) => { +export const WalletCardMd = ({ + wallet, + description, + prefix, + hideIcon, + children, + onClick, +}: PropsWithChildren) => { const isWalletConnect = walletUtils.isWalletConnectGroup(wallet); const handleClick = (fn?: () => void) => { @@ -33,19 +38,28 @@ export const WalletCardMd = ({ wallet, description, prefix, hideIcon, className, className={cnTw( 'group relative flex w-full items-center rounded transition-colors', 'focus-within:bg-action-background-hover hover:bg-action-background-hover', - className, )} > - {onInfoClick && ( - - )} +
+ {children} +
); }; diff --git a/src/renderer/entities/wallet/ui/Cards/WalletCardSm.tsx b/src/renderer/entities/wallet/ui/Cards/WalletCardSm.tsx index c38c06329..81fc942d4 100644 --- a/src/renderer/entities/wallet/ui/Cards/WalletCardSm.tsx +++ b/src/renderer/entities/wallet/ui/Cards/WalletCardSm.tsx @@ -42,6 +42,7 @@ export const WalletCardSm = ({ wallet, className, iconSize = 16, onClick, onInfo {wallet.name} + {/* TODO: do the same as in WalletCardMd */} ); diff --git a/src/renderer/entities/wallet/ui/ContactItem/ContactItem.tsx b/src/renderer/entities/wallet/ui/ContactItem/ContactItem.tsx index 6272a93ee..02cda6533 100644 --- a/src/renderer/entities/wallet/ui/ContactItem/ContactItem.tsx +++ b/src/renderer/entities/wallet/ui/ContactItem/ContactItem.tsx @@ -1,67 +1,87 @@ -import { type MouseEvent } from 'react'; +import { type PropsWithChildren } from 'react'; import { type AccountId, type Address, type KeyType } from '@/shared/core'; -import { cnTw, toAddress } from '@/shared/lib/utils'; -import { BodyText, HelpText, Icon, IconButton, Identicon } from '@/shared/ui'; +import { cnTw, nonNullable, toAddress } from '@/shared/lib/utils'; +import { BodyText, HelpText, Icon, Identicon } from '@/shared/ui'; +import { Hash } from '@/shared/ui-entities'; import { KeyIcon } from '../../lib/constants'; -type Props = { +type Props = PropsWithChildren<{ name?: string; address: Address | AccountId; addressPrefix?: number; keyType?: KeyType; - size?: number; + iconSize?: number; className?: string; hideAddress?: boolean; - onInfoClick?: () => void; -}; +}>; + export const ContactItem = ({ name, address, addressPrefix, - size = 20, + iconSize = 20, hideAddress = false, keyType, - className, - onInfoClick, + children, }: Props) => { const formattedAddress = toAddress(address, { prefix: addressPrefix }); - const handleClick = (event: MouseEvent) => { - event.stopPropagation(); - }; - return ( -
-
+
+
- + {keyType && ( )}
-
- {name && ( +
+ {name ? ( {name} + ) : ( + + + + )} + + {nonNullable(name) && !hideAddress && ( + {formattedAddress} )} - {!hideAddress && {formattedAddress}}
- +
+ {children} +
); }; diff --git a/src/renderer/entities/wallet/ui/ExplorersPopover/ExplorersPopover.tsx b/src/renderer/entities/wallet/ui/ExplorersPopover/ExplorersPopover.tsx index 4a58bb4fb..e151e472d 100644 --- a/src/renderer/entities/wallet/ui/ExplorersPopover/ExplorersPopover.tsx +++ b/src/renderer/entities/wallet/ui/ExplorersPopover/ExplorersPopover.tsx @@ -61,6 +61,10 @@ const PopoverGroup = ({ title, active = true, children }: PropsWithChildren diff --git a/src/renderer/features/assets-balances/subscription/lib/balance-sub-utils.ts b/src/renderer/features/assets-balances/subscription/lib/balance-sub-utils.ts index fe50a4624..89f6129c8 100644 --- a/src/renderer/features/assets-balances/subscription/lib/balance-sub-utils.ts +++ b/src/renderer/features/assets-balances/subscription/lib/balance-sub-utils.ts @@ -13,7 +13,7 @@ export const balanceSubUtils = { function getSiblingAccounts(wallet: Wallet, wallets: Wallet[], chains: Record): Account[] { if (walletUtils.isMultisig(wallet)) { - const signatoriesMap = dictionary(wallet.accounts[0].signatories, 'accountId'); + const signatoriesMap = dictionary(wallet.accounts[0].signatories, 'accountId', true); const signatories = walletUtils.getAccountsBy(wallets, (account) => signatoriesMap[account.accountId]); return wallet.accounts.concat(uniqBy(signatories, 'accountId') as MultisigAccount[]); diff --git a/src/renderer/features/fellowship-voting-history/components/Vote.tsx b/src/renderer/features/fellowship-voting-history/components/Vote.tsx index f19dd612a..eb09fa1f2 100644 --- a/src/renderer/features/fellowship-voting-history/components/Vote.tsx +++ b/src/renderer/features/fellowship-voting-history/components/Vote.tsx @@ -4,7 +4,7 @@ import { type Chain } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { BodyText } from '@/shared/ui'; import { RankedAccount } from '@/shared/ui-entities'; -import { type Vote as VoteType } from '@/domains/collectives'; +import { type CoreMember, type Vote as VoteType } from '@/domains/collectives'; import { identityModel } from '../model/identity'; import { membersModel } from '../model/members'; @@ -31,7 +31,7 @@ export const Vote = ({ item, chain }: Props) => { return ( { return wallet.accounts.at(0) ?? null; }); -const $signatoryContacts = combine( +const $signatories = combine( { account: $multisigAccount, wallets: walletModel.$wallets, contacts: contactModel.$contacts, }, - ({ account, wallets, contacts }): Signatory[] => { - if (nullable(account)) return []; - - const contactsMap = dictionary(contacts, 'accountId'); - const signatoriesMap = dictionary(account.signatories, 'accountId'); - const allSignatories = walletUtils.getAccountsBy(wallets, ({ accountId }) => signatoriesMap[accountId]); - const signatoriesSet = new Set(allSignatories.map((signatory) => signatory.accountId)); - - return account.signatories - .filter((signatory) => !signatoriesSet.has(signatory.accountId)) - .map((signatory) => ({ ...signatory, name: contactsMap[signatory.accountId]?.name })); - }, -); - -const $signatoryWallets = combine( - { - account: $multisigAccount, - wallets: walletModel.$wallets, - }, - ({ account, wallets }): [AccountId, Wallet][] => { - if (nullable(account)) return []; - - const signatoriesMap = dictionary(account.signatories, 'accountId', () => true); - - const walletsAndAccounts = walletUtils.getWalletsFilteredAccounts(wallets, { - accountFn: (a) => signatoriesMap[a.accountId], - }); - - if (!walletsAndAccounts) return []; - - return walletsAndAccounts.map((wallet) => [wallet.accounts[0].accountId, wallet]); - }, -); - -const $signatoryAccounts = combine( - { - account: $multisigAccount, - wallets: walletModel.$wallets, - }, - ({ account, wallets }): Signatory[] => { - if (nullable(account)) return []; - - const signatoriesMap = dictionary(account.signatories, 'accountId'); - const allSignatories = walletUtils.getAccountsBy(wallets, ({ accountId }) => signatoriesMap[accountId]); - const uniqueSignatories = uniqBy(allSignatories, 'accountId'); - const uniqueSignatoriesMap = dictionary(uniqueSignatories, 'accountId'); - - return account.signatories - .filter((signatory) => uniqueSignatoriesMap[signatory.accountId]) - .map((signatory) => ({ ...signatory, name: uniqueSignatoriesMap[signatory.accountId]?.name })); + ({ account, wallets, contacts }): { wallets: [Wallet, AccountId][]; contacts: Contact[]; people: AccountId[] } => { + if (!account) { + return { wallets: [], contacts: [], people: [] }; + } + + const signatoriesMap = dictionary(account.signatories, 'accountId', true); + + const walletSignatories: [Wallet, AccountId][] = []; + for (const wallet of wallets) { + if (walletUtils.isWatchOnly(wallet)) continue; + + for (const account of wallet.accounts) { + if (!signatoriesMap[account.accountId]) continue; + + delete signatoriesMap[account.accountId]; + walletSignatories.push([wallet, account.accountId]); + } + } + + const contactSignatories: Contact[] = []; + for (const contact of contacts) { + if (!signatoriesMap[contact.accountId]) continue; + + contactSignatories.push(contact); + delete signatoriesMap[contact.accountId]; + } + + return { + wallets: walletSignatories, + contacts: contactSignatories, + people: Object.keys(signatoriesMap) as AccountId[], + }; }, ); @@ -178,9 +159,7 @@ export const walletDetailsModel = { $vaultAccounts, $multiShardAccounts, - $signatoryContacts, - $signatoryWallets, - $signatoryAccounts, + $signatories, $chainsProxies, $walletProxyGroups, diff --git a/src/renderer/features/wallet-details/ui/components/WalletDetails.tsx b/src/renderer/features/wallet-details/ui/components/WalletDetails.tsx index 8eb66c074..90aab6140 100644 --- a/src/renderer/features/wallet-details/ui/components/WalletDetails.tsx +++ b/src/renderer/features/wallet-details/ui/components/WalletDetails.tsx @@ -21,10 +21,8 @@ export const WalletDetails = ({ isOpen, wallet, onClose }: Props) => { useGate(walletDetailsModel.flow, { wallet }); const multiShardAccounts = useUnit(walletDetailsModel.$multiShardAccounts); - const contacts = useUnit(walletDetailsModel.$signatoryContacts); const vaultAccounts = useUnit(walletDetailsModel.$vaultAccounts); - const signatoryWallets = useUnit(walletDetailsModel.$signatoryWallets); - const signatoryAccounts = useUnit(walletDetailsModel.$signatoryAccounts); + const signatories = useUnit(walletDetailsModel.$signatories); const proxyWallet = useUnit(walletDetailsModel.$proxyWallet); if (!isOpen || nullable(wallet)) { @@ -43,9 +41,9 @@ export const WalletDetails = ({ isOpen, wallet, onClose }: Props) => { return ( ); diff --git a/src/renderer/features/wallet-details/ui/components/index.ts b/src/renderer/features/wallet-details/ui/components/index.ts new file mode 100644 index 000000000..40cce87f5 --- /dev/null +++ b/src/renderer/features/wallet-details/ui/components/index.ts @@ -0,0 +1,7 @@ +export { NoProxiesAction } from './NoProxiesAction'; +export { ProxiesList } from './ProxiesList'; +export { ProxyAccountWithActions } from './ProxyAccountWithActions'; +export { ShardsList } from './ShardsList'; +export { WalletConnectAccounts } from './WalletConnectAccounts'; +export { WalletDetails } from './WalletDetails'; +export { WalletFiatBalance } from './WalletFiatBalance'; diff --git a/src/renderer/features/wallet-details/ui/wallets/MultisigWalletDetails.tsx b/src/renderer/features/wallet-details/ui/wallets/MultisigWalletDetails.tsx index 84a5c7a99..d675ed289 100644 --- a/src/renderer/features/wallet-details/ui/wallets/MultisigWalletDetails.tsx +++ b/src/renderer/features/wallet-details/ui/wallets/MultisigWalletDetails.tsx @@ -1,18 +1,20 @@ import { useUnit } from 'effector-react'; import { useMemo } from 'react'; +import { Trans } from 'react-i18next'; -import { type AccountId, type MultisigWallet, type Signatory, type Wallet } from '@/shared/core'; +import { type AccountId, type Contact, type MultisigWallet, type Wallet } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { useModalClose, useToggle } from '@/shared/lib/hooks'; -import { RootExplorers } from '@/shared/lib/utils'; -import { BaseModal, DropdownIconButton, FootnoteText, Tabs } from '@/shared/ui'; +import { toAddress } from '@/shared/lib/utils'; +import { BaseModal, DropdownIconButton, FootnoteText, Icon, Tabs } from '@/shared/ui'; import { type IconNames } from '@/shared/ui/Icon/data'; import { type TabItem } from '@/shared/ui/types'; +import { AccountExplorers, Address, RootExplorers } from '@/shared/ui-entities'; +import { ChainTitle } from '@/entities/chain'; import { networkModel, networkUtils } from '@/entities/network'; import { AccountsList, ContactItem, - ExplorersPopover, WalletCardLg, WalletCardMd, accountUtils, @@ -20,12 +22,10 @@ import { } from '@/entities/wallet'; import { proxyAddFeature } from '@/features/proxy-add'; import { proxyAddPureFeature } from '@/features/proxy-add-pure'; -import { walletsFiatBalanceFeature } from '@/features/wallet-fiat-balance'; import { ForgetWalletModal } from '@/features/wallets/ForgetWallet'; import { RenameWalletModal } from '@/features/wallets/RenameWallet'; import { walletDetailsModel } from '../../model/wallet-details-model'; -import { NoProxiesAction } from '../components/NoProxiesAction'; -import { ProxiesList } from '../components/ProxiesList'; +import { NoProxiesAction, ProxiesList } from '../components'; const { models: { addProxy }, @@ -37,22 +37,18 @@ const { views: { AddPureProxied }, } = proxyAddPureFeature; -const { - views: { WalletFiatBalance }, -} = walletsFiatBalanceFeature; - type Props = { wallet: MultisigWallet; - signatoryWallets: [AccountId, Wallet][]; - signatoryContacts: Signatory[]; - signatoryAccounts: Signatory[]; + signatoryWallets: [Wallet, AccountId][]; + signatoryContacts: Contact[]; + signatoryPeople: AccountId[]; onClose: () => void; }; export const MultisigWalletDetails = ({ wallet, signatoryWallets = [], signatoryContacts = [], - signatoryAccounts = [], + signatoryPeople = [], onClose, }: Props) => { const { t } = useI18n(); @@ -65,8 +61,7 @@ export const MultisigWalletDetails = ({ const [isConfirmForgetOpen, toggleConfirmForget] = useToggle(); const multisigAccount = wallet.accounts[0]; - const singleChain = multisigAccount.chainId && chains[multisigAccount.chainId]; - const explorers = singleChain?.explorers || RootExplorers; + const singleChain = multisigAccount.chainId ? chains[multisigAccount.chainId] : undefined; const multisigChains = useMemo(() => { return Object.values(chains).filter((chain) => { @@ -140,113 +135,156 @@ export const MultisigWalletDetails = ({ ); - const TabAccountList = { - id: 1, - title: t('walletDetails.multisig.networksTab'), - panel: , - }; - - const TabSignatories = { - id: 2, - title: t('walletDetails.multisig.signatoriesTab'), - panel: ( -
- - {t('walletDetails.multisig.thresholdLabel', { - min: multisigAccount.threshold, - max: multisigAccount.signatories.length, - })} - - -
- {!singleChain && signatoryWallets.length > 0 && ( -
- - {t('walletDetails.multisig.walletsGroup')} {signatoryWallets.length} - - -
    - {signatoryWallets.map(([accountId, wallet]) => ( -
  • - } - /> - } - /> -
  • - ))} -
-
- )} - - {singleChain && signatoryAccounts?.length && ( -
- - {t('walletDetails.multisig.accountsGroup')} {signatoryAccounts.length} - - -
    - {signatoryAccounts.map((signatory) => ( -
  • - - } - /> -
  • - ))} -
+ const TabItems: TabItem[] = []; + + if (singleChain) { + const TabAccount = { + id: 1, + title: t('walletDetails.multisig.accountTab'), + panel: ( +
+
+ {t('walletDetails.multisig.accountGroup')} + +
+ + +
- )} - - {signatoryContacts.length > 0 && ( -
- - {t('walletDetails.multisig.contactsGroup')} {signatoryContacts.length} - - -
    - {signatoryContacts.map((signatory) => ( -
  • - } - /> -
  • - ))} -
-
- )} +
+ +
+ + {t('walletDetails.multisig.signatoriesGroup', { amount: multisigAccount.signatories.length })} + + +
    + {signatoryWallets.map(([wallet, accountId]) => ( +
  • + +
    +
+ } + > + + + + ))} + {signatoryContacts.map((signatory) => ( +
  • + + + +
  • + ))} + {signatoryPeople.map((accountId) => ( +
  • + + + +
  • + ))} + +
    -
    - ), - }; + ), + }; + TabItems.push(TabAccount); + } - const TabProxy = { - id: 3, - title: t('walletDetails.common.proxiesTabTitle'), - panel: hasProxies ? ( - - ) : ( - - ), - }; + if (!singleChain) { + const TabAccountList = { + id: 1, + title: t('walletDetails.multisig.networksTab'), + panel: , + }; + + const TabSignatories = { + id: 2, + title: t('walletDetails.multisig.signatoriesTab'), + panel: ( +
    + + {t('walletDetails.multisig.thresholdLabel', { + min: multisigAccount.threshold, + max: multisigAccount.signatories.length, + })} + + +
    + {signatoryWallets.length > 0 && ( +
    + + {t('walletDetails.multisig.walletsGroup')} {signatoryWallets.length} + + +
      + {signatoryWallets.map(([wallet, accountId]) => ( +
    • + +
      +
    + } + > + + + + ))} + +
    + )} + + {signatoryContacts.length > 0 && ( +
    + + {t('walletDetails.multisig.contactsGroup')} {signatoryContacts.length} + + +
      + {signatoryContacts.map((signatory) => ( +
    • + + + +
    • + ))} +
    +
    + )} +
    +
    + ), + }; - const TabItems: TabItem[] = [TabAccountList, TabSignatories]; + TabItems.push(TabAccountList); + TabItems.push(TabSignatories); + } if (canCreateProxy) { + const TabProxy = { + id: 3, + title: t('walletDetails.common.proxiesTabTitle'), + panel: hasProxies ? ( + + ) : ( + + ), + }; + TabItems.push(TabProxy); } @@ -261,9 +299,34 @@ export const MultisigWalletDetails = ({ onClose={closeModal} >
    -
    - -
    + {singleChain ? ( +
    + +
    + +
    + + ), + }} + values={{ threshold: multisigAccount.threshold, signatories: multisigAccount.signatories.length }} + /> +
    +
    +
    + ) : ( +
    + +
    + )}
    diff --git a/src/renderer/features/wallet-select/components/WalletGroup.tsx b/src/renderer/features/wallet-select/components/WalletGroup.tsx index a3f1cc142..27f7a029b 100644 --- a/src/renderer/features/wallet-select/components/WalletGroup.tsx +++ b/src/renderer/features/wallet-select/components/WalletGroup.tsx @@ -1,6 +1,6 @@ import { type Wallet, type WalletFamily, WalletType } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; -import { Accordion, CaptionText, Icon } from '@/shared/ui'; +import { Accordion, CaptionText, Icon, IconButton } from '@/shared/ui'; import { WalletCardMd, WalletIcon, walletUtils } from '@/entities/wallet'; import { walletsFiatBalanceFeature } from '@/features/wallet-fiat-balance'; import { walletSelectModel } from '../model/wallet-select-model'; @@ -60,8 +60,9 @@ export const WalletGroup = ({ type, wallets, onInfoClick }: Props) => { ) } onClick={() => walletSelectModel.events.walletSelected(wallet.id)} - onInfoClick={() => onInfoClick(wallet)} - /> + > + onInfoClick(wallet)} /> + ))} diff --git a/src/renderer/pages/Onboarding/Vault/ManageVault/ManageVault.tsx b/src/renderer/pages/Onboarding/Vault/ManageVault/ManageVault.tsx index 8faebafbc..748355b7b 100644 --- a/src/renderer/pages/Onboarding/Vault/ManageVault/ManageVault.tsx +++ b/src/renderer/pages/Onboarding/Vault/ManageVault/ManageVault.tsx @@ -87,10 +87,10 @@ export const ManageVault = ({ seedInfo, onBack, onClose, onComplete }: Props) => useEffect(() => { const chains = chainsService.getChainsData({ sort: true }); - const chainsMap = dictionary(chains, 'chainId', () => []); + const chainsMap = dictionary(chains, 'chainId', [] as (ChainAccount | ShardAccount[])[]); for (const account of keysGroups) { - const chainId = Array.isArray(account) ? account[0].chainId : account.chainId; + const chainId = accountUtils.isAccountWithShards(account) ? account[0].chainId : account.chainId; chainsMap[chainId].push(account); } diff --git a/src/renderer/shared/i18n/locales/en.json b/src/renderer/shared/i18n/locales/en.json index 5bf2da942..c7da872a0 100644 --- a/src/renderer/shared/i18n/locales/en.json +++ b/src/renderer/shared/i18n/locales/en.json @@ -1658,10 +1658,14 @@ "walletRemoved": "Wallet removed" }, "multisig": { + "accountGroup": "Account", + "accountTab": "Account and signatories", "accountsGroup": "Your accounts", "contactsGroup": "Contacts", "networksTab": "Networks", + "signatoriesGroup": "Signatories { amount }", "signatoriesTab": "Signatories", + "singleChainTitle": "on with threshold { threshold } out of { signatories } signatories", "thresholdLabel": "Threshold { min } out of { max }", "walletsGroup": "Your wallets" }, diff --git a/src/renderer/shared/lib/utils/__tests__/arrays.test.ts b/src/renderer/shared/lib/utils/__tests__/arrays.test.ts index 58a82a4ca..b738d370d 100644 --- a/src/renderer/shared/lib/utils/__tests__/arrays.test.ts +++ b/src/renderer/shared/lib/utils/__tests__/arrays.test.ts @@ -1,6 +1,6 @@ -import { addUnique, merge, splice } from '../arrays'; +import { addUnique, dictionary, merge, splice } from '../arrays'; -describe('shared/lib/onChainUtils/arrays', () => { +describe('Arrays utils', () => { test('should insert element in the beginning', () => { const array = splice([1, 2, 3], 100, 0); expect(array).toEqual([100, 2, 3]); @@ -23,126 +23,204 @@ describe('shared/lib/onChainUtils/arrays', () => { expect(array1).toEqual([100]); expect(array2).toEqual([100]); }); +}); + +describe('addUniq', () => { + test('should replace element', () => { + const array = addUnique([1, 2, 3], 2); + expect(array).toEqual([1, 2, 3]); + }); + + test('should add new element', () => { + const array = addUnique([1, 2, 3], 4); + expect(array).toEqual([1, 2, 3, 4]); + }); - describe('addUniq', () => { - test('should replace element', () => { - const array = addUnique([1, 2, 3], 2); - expect(array).toEqual([1, 2, 3]); + test('should replace element according to compare function', () => { + const array = addUnique([{ id: 1 }, { id: 2 }], { id: 2, name: 'test' }, (x) => x.id); + expect(array).toEqual([{ id: 1 }, { id: 2, name: 'test' }]); + }); + + test('should add element according to compare function', () => { + const array = addUnique([{ id: 1 }, { id: 2 }], { id: 3 }, (x) => x.id); + expect(array).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + }); +}); + +describe('merge', () => { + test('should array of strings', () => { + const list1 = ['1', '2', '3', '4']; + const list2 = ['2', '5']; + + const res = merge({ + a: list1, + b: list2, + mergeBy: (s) => s, + }); + expect(res).toEqual(['1', '2', '3', '4', '5']); + }); + + test('should return firrt array if second is empty', () => { + const list1 = ['1', '2', '3', '4']; + + const res = merge({ + a: list1, + b: [], + mergeBy: (s) => s, }); + expect(res).toBe(list1); + }); + + test('should return second array if first is empty', () => { + const list2 = ['1', '2', '3', '4']; - test('should add new element', () => { - const array = addUnique([1, 2, 3], 4); - expect(array).toEqual([1, 2, 3, 4]); + const res = merge({ + a: [], + b: list2, + mergeBy: (s) => s, }); + expect(res).toBe(list2); + }); + + test('should sort', () => { + const list1 = [2, 4, 3]; + const list2 = [1, 5]; - test('should replace element according to compare function', () => { - const array = addUnique([{ id: 1 }, { id: 2 }], { id: 2, name: 'test' }, (x) => x.id); - expect(array).toEqual([{ id: 1 }, { id: 2, name: 'test' }]); + const res = merge({ + a: list1, + b: list2, + mergeBy: (s) => s, + sort: (a, b) => a - b, }); + expect(res).toEqual([1, 2, 3, 4, 5]); + }); - test('should add element according to compare function', () => { - const array = addUnique([{ id: 1 }, { id: 2 }], { id: 3 }, (x) => x.id); - expect(array).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + test('should merge objects', () => { + const list1 = [{ id: 1 }, { id: 4 }, { id: 5 }]; + const list2 = [{ id: 3 }, { id: 2 }, { id: 3, test: true }, { id: 6 }, { id: 7 }]; + + const res = merge({ + a: list1, + b: list2, + mergeBy: (s) => s.id, }); + expect(res).toEqual([{ id: 1 }, { id: 2 }, { id: 3, test: true }, { id: 4 }, { id: 5 }, { id: 6 }, { id: 7 }]); }); - describe('merge', () => { - it('should array of strings', () => { - const list1 = ['1', '2', '3', '4']; - const list2 = ['2', '5']; + test('should merge and sort objects', () => { + const list1 = [{ id: 1 }, { id: 5 }, { id: 4 }]; + const list2 = [{ id: 3 }, { id: 2 }]; - const res = merge({ - a: list1, - b: list2, - mergeBy: (s) => s, - }); - expect(res).toEqual(['1', '2', '3', '4', '5']); + const res = merge({ + a: list1, + b: list2, + mergeBy: (s) => s.id, + sort: (a, b) => b.id - a.id, }); + expect(res).toEqual([{ id: 5 }, { id: 4 }, { id: 3 }, { id: 2 }, { id: 1 }]); + }); - it('should return firrt array if second is empty', () => { - const list1 = ['1', '2', '3', '4']; + test('should sort objects by complex value', () => { + const list1 = [ + { id: 1, date: new Date(1) }, + { id: 5, date: new Date(5) }, + { id: 4, date: new Date(4) }, + ]; + const list2 = [ + { id: 3, date: new Date(3) }, + { id: 2, date: new Date(2) }, + ]; + + const res = merge({ + a: list1, + b: list2, + mergeBy: (s) => s.id, + sort: (a, b) => a.date.getTime() - b.date.getTime(), + }); + expect(res).toEqual([ + { id: 1, date: new Date(1) }, + { id: 2, date: new Date(2) }, + { id: 3, date: new Date(3) }, + { id: 4, date: new Date(4) }, + { id: 5, date: new Date(5) }, + ]); + }); +}); - const res = merge({ - a: list1, - b: [], - mergeBy: (s) => s, - }); - expect(res).toBe(list1); +describe('dictionary', () => { + type TestData = { + id: number; + name: string; + value?: string; + }; + + const data: TestData[] = [ + { id: 1, name: 'Alice', value: 'Developer' }, + { id: 2, name: 'Bob', value: 'Designer' }, + { id: 3, name: 'Charlie', value: 'Manager' }, + ]; + + test('should create a dictionary with no transformer provided', () => { + const result = dictionary(data, 'id'); + + expect(result).toEqual({ + 1: { id: 1, name: 'Alice', value: 'Developer' }, + 2: { id: 2, name: 'Bob', value: 'Designer' }, + 3: { id: 3, name: 'Charlie', value: 'Manager' }, }); + }); - it('should return second array if first is empty', () => { - const list2 = ['1', '2', '3', '4']; + test('should create a dictionary using transformer function', () => { + const result = dictionary(data, 'id', (item) => item.name); - const res = merge({ - a: [], - b: list2, - mergeBy: (s) => s, - }); - expect(res).toBe(list2); + expect(result).toEqual({ + 1: 'Alice', + 2: 'Bob', + 3: 'Charlie', }); + }); + + test('should create a dictionary with a plain value as transformer', () => { + const result = dictionary(data, 'id', 'constant value'); - it('should sort', () => { - const list1 = [2, 4, 3]; - const list2 = [1, 5]; - - const res = merge({ - a: list1, - b: list2, - mergeBy: (s) => s, - sort: (a, b) => a - b, - }); - expect(res).toEqual([1, 2, 3, 4, 5]); + expect(result).toEqual({ + 1: 'constant value', + 2: 'constant value', + 3: 'constant value', }); + }); + + test('should skip items where the key is undefined or missing', () => { + const incompleteData = [ + { id: undefined, name: 'Alice', value: 'Developer' }, // undefined 'id' + { name: 'Bob', value: 'Designer' }, // Missing 'id' + { id: 3, name: 'Charlie' }, + ] as TestData[]; - it('should merge objects', () => { - const list1 = [{ id: 1 }, { id: 4 }, { id: 5 }]; - const list2 = [{ id: 3 }, { id: 2 }, { id: 3, test: true }, { id: 6 }, { id: 7 }]; + const result = dictionary(incompleteData, 'id', 'constant value'); - const res = merge({ - a: list1, - b: list2, - mergeBy: (s) => s.id, - }); - expect(res).toEqual([{ id: 1 }, { id: 2 }, { id: 3, test: true }, { id: 4 }, { id: 5 }, { id: 6 }, { id: 7 }]); + expect(result).toEqual({ + 3: 'constant value', }); + }); + + test('should handle cases where transformer is undefined', () => { + const result = dictionary(data, 'id', undefined); - it('should merge and sort objects', () => { - const list1 = [{ id: 1 }, { id: 5 }, { id: 4 }]; - const list2 = [{ id: 3 }, { id: 2 }]; - - const res = merge({ - a: list1, - b: list2, - mergeBy: (s) => s.id, - sort: (a, b) => b.id - a.id, - }); - expect(res).toEqual([{ id: 5 }, { id: 4 }, { id: 3 }, { id: 2 }, { id: 1 }]); + expect(result).toEqual({ + 1: { id: 1, name: 'Alice', value: 'Developer' }, + 2: { id: 2, name: 'Bob', value: 'Designer' }, + 3: { id: 3, name: 'Charlie', value: 'Manager' }, }); + }); + + test('should handle complex transformer functions', () => { + const result = dictionary(data, 'id', (item) => `${item.name}: ${item.value}`); - it('should sort objects by complex value', () => { - const list1 = [ - { id: 1, date: new Date(1) }, - { id: 5, date: new Date(5) }, - { id: 4, date: new Date(4) }, - ]; - const list2 = [ - { id: 3, date: new Date(3) }, - { id: 2, date: new Date(2) }, - ]; - - const res = merge({ - a: list1, - b: list2, - mergeBy: (s) => s.id, - sort: (a, b) => a.date.getTime() - b.date.getTime(), - }); - expect(res).toEqual([ - { id: 1, date: new Date(1) }, - { id: 2, date: new Date(2) }, - { id: 3, date: new Date(3) }, - { id: 4, date: new Date(4) }, - { id: 5, date: new Date(5) }, - ]); + expect(result).toEqual({ + 1: 'Alice: Developer', + 2: 'Bob: Designer', + 3: 'Charlie: Manager', }); }); }); diff --git a/src/renderer/shared/lib/utils/arrays.ts b/src/renderer/shared/lib/utils/arrays.ts index b76e16a5c..2e29369b7 100644 --- a/src/renderer/shared/lib/utils/arrays.ts +++ b/src/renderer/shared/lib/utils/arrays.ts @@ -16,34 +16,37 @@ export function splice(collection: T[], item: T, position: number): T[] { } /** - * Create dictionary with given key and value Keys can only be type of string, - * number or symbol + * Create dictionary with given key and transformer value. Keys can only be type + * of string, number or symbol * * @param collection Array of items - * @param property Field to be used as key - * @param predicate Transformer function + * @param key Property to be used as key + * @param transformer Transformer function or plain value * * @returns {Object} */ -export function dictionary, K extends KeysOfType>( +export function dictionary, K extends KeysOfType, R = T>( collection: T[], - property: K, - predicate?: (item: T) => any, -): Record { - return collection.reduce( - (acc, item) => { - const element = item[property]; - - if (predicate) { - acc[element] = predicate(item); - } else { - acc[element] = item; - } - - return acc; - }, - {} as Record, - ); + key: K, + transformer?: ((item: T) => R) | R, +): Record { + const result: Record = {} as Record; + + for (const item of collection) { + const element = item[key]; + + if (!element) continue; + + if (!transformer) { + result[element] = item as unknown as R; + } else if (typeof transformer === 'function') { + result[element] = (transformer as (item: T) => R)(item); + } else { + result[element] = transformer as R; + } + } + + return result; } export function getRepeatedIndex(index: number, base: number): number { diff --git a/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.stories.tsx b/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.stories.tsx index 8c480cbea..2c4aa8c1e 100644 --- a/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.stories.tsx +++ b/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.stories.tsx @@ -5,22 +5,20 @@ import { FootnoteText } from '@/shared/ui'; import { AccountExplorers } from './AccountExplorers'; -const testAccountId = '0xd180LUV5yfqBC9i8Lfssufw2434ef24f3f7AhBDDcaHEF03a8'; -const testChain: Chain = { - name: 'Polkadot', - specName: 'polkadot', - addressPrefix: 0, - chainId: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3', - icon: '', - options: [], - nodes: [], - assets: [], +const testAccountId = '0x9e9bf57d2420cc050723e9609afd5a1c326aceaf6b3f4175fda2eb26044d1f64'; + +const kusamaChain = { + name: 'Kusama Asset Hub', + addressPrefix: 2, + chainId: '0x48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a', explorers: [ { name: 'Subscan', - extrinsic: 'https://polkadot.subscan.io/extrinsic/{hash}', - account: 'https://polkadot.subscan.io/account/{address}', - multisig: 'https://polkadot.subscan.io/multisig_extrinsic/{index}?call_hash={callHash}', + account: 'https://assethub-kusama.subscan.io/account/{address}', + }, + { + name: 'Statescan', + account: 'https://statemine.statescan.io/#/accounts/{address}', }, { name: 'Sub.ID', @@ -34,7 +32,7 @@ const meta: Meta = { component: AccountExplorers, args: { accountId: testAccountId, - chain: testChain, + chain: kusamaChain as Chain, }, parameters: { layout: 'centered', diff --git a/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.tsx b/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.tsx index d6cafe335..8ff37db2e 100644 --- a/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.tsx +++ b/src/renderer/shared/ui-entities/AccountExplorer/AccountExplorers.tsx @@ -14,8 +14,9 @@ type Props = PropsWithChildren<{ export const AccountExplorers = memo(({ accountId, chain, children }: Props) => { const { t } = useI18n(); - const { explorers } = chain; - const address = toAddress(accountId, { prefix: chain.addressPrefix }); + + const { explorers, addressPrefix } = chain; + const address = toAddress(accountId, { prefix: addressPrefix }); return ( diff --git a/src/renderer/shared/ui-entities/Address/Address.tsx b/src/renderer/shared/ui-entities/Address/Address.tsx index 1e1a9de84..0f1c96007 100644 --- a/src/renderer/shared/ui-entities/Address/Address.tsx +++ b/src/renderer/shared/ui-entities/Address/Address.tsx @@ -2,7 +2,7 @@ import { memo } from 'react'; import { type Address as AddressType, type XOR } from '@/shared/core'; import { cnTw } from '@/shared/lib/utils'; -import { Identicon } from '@/shared/ui/Identicon/Identicon'; +import { Identicon } from '@/shared/ui'; import { Hash } from '../Hash/Hash'; type IconProps = XOR<{ @@ -14,7 +14,6 @@ type IconProps = XOR<{ type Props = IconProps & { address: AddressType; title?: string; - replaceAddressWithTitle?: boolean; variant?: 'full' | 'truncate' | 'short'; testId?: string; }; diff --git a/src/renderer/shared/ui-entities/RootExplorer/RootExplorers.stories.tsx b/src/renderer/shared/ui-entities/RootExplorer/RootExplorers.stories.tsx new file mode 100644 index 000000000..4636665e4 --- /dev/null +++ b/src/renderer/shared/ui-entities/RootExplorer/RootExplorers.stories.tsx @@ -0,0 +1,30 @@ +import { type Meta, type StoryObj } from '@storybook/react'; + +import { FootnoteText } from '@/shared/ui'; + +import { RootExplorers } from './RootExplorers'; + +const testAccountId = '0x9e9bf57d2420cc050723e9609afd5a1c326aceaf6b3f4175fda2eb26044d1f64'; + +const meta: Meta = { + title: 'Design System/entities/RootExplorers', + component: RootExplorers, + args: { + accountId: testAccountId, + }, + parameters: { + layout: 'centered', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithAdditionalContent: Story = { + args: { + children: Derivation path: //polkadot//pub, + }, +}; diff --git a/src/renderer/shared/ui-entities/RootExplorer/RootExplorers.tsx b/src/renderer/shared/ui-entities/RootExplorer/RootExplorers.tsx new file mode 100644 index 000000000..9166504c6 --- /dev/null +++ b/src/renderer/shared/ui-entities/RootExplorer/RootExplorers.tsx @@ -0,0 +1,58 @@ +import { type PropsWithChildren, memo } from 'react'; + +import { type AccountId } from '@/shared/core'; +import { useI18n } from '@/shared/i18n'; +import { SS58_DEFAULT_PREFIX, copyToClipboard, getAccountExplorer, toAddress } from '@/shared/lib/utils'; +import { ExplorerLink, FootnoteText, HelpText, IconButton, Separator } from '@/shared/ui'; +import { Box, Popover } from '@/shared/ui-kit'; +import { Hash } from '../Hash/Hash'; + +export const EXPLORERS = [ + { name: 'Subscan', account: 'https://subscan.io/account/{address}' }, + { name: 'Sub.ID', account: 'https://sub.id/{address}' }, +]; + +type Props = PropsWithChildren<{ + accountId: AccountId; +}>; + +export const RootExplorers = memo(({ accountId, children }: Props) => { + const { t } = useI18n(); + + const address = toAddress(accountId, { prefix: SS58_DEFAULT_PREFIX }); + + return ( + + + e.stopPropagation()} /> + + + + + {t('general.explorers.addressTitle')} + + + + + copyToClipboard(address)} /> + + + + {children ? ( + <> + + {children} + + ) : null} + + +
    + {EXPLORERS.map((explorer) => ( + + ))} +
    +
    +
    +
    + ); +}); diff --git a/src/renderer/shared/ui-entities/index.ts b/src/renderer/shared/ui-entities/index.ts index fd3fca3f4..bbc8fbc20 100644 --- a/src/renderer/shared/ui-entities/index.ts +++ b/src/renderer/shared/ui-entities/index.ts @@ -4,5 +4,6 @@ export { Address } from './Address/Address'; export { Account } from './Account/Account'; export { AccountSelectModal } from './AccountSelectModal/AccountSelectModal'; export { AccountExplorers } from './AccountExplorer/AccountExplorers'; +export { RootExplorers } from './RootExplorer/RootExplorers'; export { TransactionDetails } from './TransactionDetails/TransactionDetails'; export { RankedAccount } from './RankedAccount/RankedAccount'; diff --git a/src/renderer/widgets/DelegateDetails/ui/YourDelegations.tsx b/src/renderer/widgets/DelegateDetails/ui/YourDelegations.tsx index 2b0ab0955..1bbcf0d9f 100644 --- a/src/renderer/widgets/DelegateDetails/ui/YourDelegations.tsx +++ b/src/renderer/widgets/DelegateDetails/ui/YourDelegations.tsx @@ -6,10 +6,11 @@ import { type Account } from '@/shared/core'; import { useI18n } from '@/shared/i18n'; import { nonNullable, toAddress } from '@/shared/lib/utils'; import { BodyText, Button, FootnoteText, Icon, IconButton, Tooltip } from '@/shared/ui'; +import { AccountExplorers } from '@/shared/ui-entities'; import { Box, Checkbox, Modal } from '@/shared/ui-kit'; import { AssetBalance } from '@/entities/asset'; import { allTracks, votingService } from '@/entities/governance'; -import { ContactItem, ExplorersPopover, accountUtils, walletModel } from '@/entities/wallet'; +import { ContactItem, accountUtils, walletModel } from '@/entities/wallet'; import { editDelegationModel } from '@/widgets/EditDelegationModal'; import { revokeDelegationModel } from '@/widgets/RevokeDelegationModal'; import { delegateDetailsModel } from '../model/delegate-details-model'; @@ -102,21 +103,17 @@ export const YourDelegations = () => { toggleAccount(account)} />
    - + keyType={ + accountUtils.isShardAccount(account) || accountUtils.isChainAccount(account) + ? account.keyType + : undefined } - /> + > + +