From 14d59f6f6587070d527a39a1f149a851ea662016 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Wed, 2 Oct 2024 20:25:00 +0100 Subject: [PATCH 01/25] feat: add account group type --- .../components/AccountAvatar/AccountAvatar.tsx | 2 +- .../AccountRepository/AccountRepository.ts | 2 ++ src/extension/types/accounts/IAccount.ts | 18 ++++++++++-------- src/extension/types/accounts/IAccountGroup.ts | 12 ++++++++++++ src/extension/types/accounts/index.ts | 1 + .../mapAccountWithExtendedPropsToAccount.ts | 2 ++ 6 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 src/extension/types/accounts/IAccountGroup.ts diff --git a/src/extension/components/AccountAvatar/AccountAvatar.tsx b/src/extension/components/AccountAvatar/AccountAvatar.tsx index 865961b3..734841cc 100644 --- a/src/extension/components/AccountAvatar/AccountAvatar.tsx +++ b/src/extension/components/AccountAvatar/AccountAvatar.tsx @@ -1,4 +1,4 @@ -import { Avatar, Icon } from '@chakra-ui/react'; +import { Avatar } from '@chakra-ui/react'; import React, { type FC } from 'react'; // hooks diff --git a/src/extension/repositories/AccountRepository/AccountRepository.ts b/src/extension/repositories/AccountRepository/AccountRepository.ts index 700bf4b1..23e83eef 100644 --- a/src/extension/repositories/AccountRepository/AccountRepository.ts +++ b/src/extension/repositories/AccountRepository/AccountRepository.ts @@ -84,6 +84,7 @@ export default class AccountRepository extends BaseRepository { return { color: null, createdAt: createdAtOrNow, + groupID: null, icon: null, id: id || uuid(), name: name || null, @@ -197,6 +198,7 @@ export default class AccountRepository extends BaseRepository { return { color: account.color, createdAt: account.createdAt, + groupID: account.groupID, icon: account.icon, id: account.id, name: account.name, diff --git a/src/extension/types/accounts/IAccount.ts b/src/extension/types/accounts/IAccount.ts index 9ab518d8..04c89792 100644 --- a/src/extension/types/accounts/IAccount.ts +++ b/src/extension/types/accounts/IAccount.ts @@ -6,21 +6,23 @@ import TAccountIcons from './TAccountIcons'; /** * @property {TAccountColors | null} color - The background color. - * @property {number} createdAt - a timestamp (in milliseconds) when this account was created in storage. + * @property {number} createdAt - A timestamp (in milliseconds) when this account was created in storage. * @property {TAccountIcons | null} icon - An icon for the account. - * @property {string} id - a unique identifier (in UUID). - * @property {number | null} index - the position of the account as it appears in a list. - * @property {string | null} name - a canonical name given to this account. - * @property {Record} networkInformation - information specific for each network, indexed by + * @property {string | null} groupID - The ID of the group this account belongs to. + * @property {string} id - A unique identifier (in UUIDv4). + * @property {number | null} index - The position of the account as it appears in a list. + * @property {string | null} name - A canonical name given to this account. + * @property {Record} networkInformation - Information specific for each network, indexed by * their hex encoded genesis hash. - * @property {Record} networkInformation - transactions specific for each network, indexed + * @property {Record} networkInformation - Transactions specific for each network, indexed * by their hex encoded genesis hash. - * @property {string} publicKey - the hexadecimal encoded public key. - * @property {number} updatedAt - a timestamp (in milliseconds) for when this account was last saved to storage. + * @property {string} publicKey - The hexadecimal encoded public key. + * @property {number} updatedAt - A timestamp (in milliseconds) for when this account was last saved to storage. */ interface IAccount { color: TAccountColors | null; createdAt: number; + groupID: string | null; icon: TAccountIcons | null; id: string; index: number | null; diff --git a/src/extension/types/accounts/IAccountGroup.ts b/src/extension/types/accounts/IAccountGroup.ts new file mode 100644 index 00000000..75c991ee --- /dev/null +++ b/src/extension/types/accounts/IAccountGroup.ts @@ -0,0 +1,12 @@ +/** + * @property {number} createdAt - a timestamp (in milliseconds) when this account was created in storage. + * @property {string} id - a unique identifier (in UUIDv4 format). + * @property {string} name - The name of the group. Limited to 32 bytes. + */ +interface IAccountGroup { + createdAt: number; + id: string; + name: string; +} + +export default IAccountGroup; diff --git a/src/extension/types/accounts/index.ts b/src/extension/types/accounts/index.ts index d483f4cc..2e0d88cc 100644 --- a/src/extension/types/accounts/index.ts +++ b/src/extension/types/accounts/index.ts @@ -1,4 +1,5 @@ export type { default as IAccount } from './IAccount'; +export type { default as IAccountGroup } from './IAccountGroup'; export type { default as IAccountInformation } from './IAccountInformation'; export type { default as IAccountTransactions } from './IAccountTransactions'; export type { default as IAccountWithExtendedProps } from './IAccountWithExtendedProps'; diff --git a/src/extension/utils/mapAccountWithExtendedPropsToAccount/mapAccountWithExtendedPropsToAccount.ts b/src/extension/utils/mapAccountWithExtendedPropsToAccount/mapAccountWithExtendedPropsToAccount.ts index 2b2cda4e..25c1e7fc 100644 --- a/src/extension/utils/mapAccountWithExtendedPropsToAccount/mapAccountWithExtendedPropsToAccount.ts +++ b/src/extension/utils/mapAccountWithExtendedPropsToAccount/mapAccountWithExtendedPropsToAccount.ts @@ -10,6 +10,7 @@ import type { IAccount, IAccountWithExtendedProps } from '@extension/types'; export default function mapAccountWithExtendedPropsToAccount({ color, createdAt, + groupID, icon, id, name, @@ -22,6 +23,7 @@ export default function mapAccountWithExtendedPropsToAccount({ return { color, createdAt, + groupID, icon, id, name, From 595ee15e44eb5c3a7918b2094e76ac510870fff7 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Wed, 2 Oct 2024 20:25:30 +0100 Subject: [PATCH 02/25] feat: add account group repository --- src/extension/constants/Keys.ts | 1 + .../AccountGroupRepository.ts | 52 +++++++++++++++++++ .../AccountGroupRepository/index.ts | 1 + .../types/storage/TStorageItemTypes.ts | 7 ++- 4 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts create mode 100644 src/extension/repositories/AccountGroupRepository/index.ts diff --git a/src/extension/constants/Keys.ts b/src/extension/constants/Keys.ts index b89c2817..cbc1c404 100644 --- a/src/extension/constants/Keys.ts +++ b/src/extension/constants/Keys.ts @@ -1,4 +1,5 @@ export const ACCOUNTS_ITEM_KEY_PREFIX: string = 'accounts_'; +export const ACCOUNT_GROUPS_ITEM_KEY: string = 'account_groups'; export const ACTIVE_ACCOUNT_DETAILS_KEY: string = 'active_account_details'; export const APP_WINDOW_KEY_PREFIX: string = 'app_window_'; export const ARC0072_ASSETS_KEY_PREFIX: string = 'arc0072_assets_'; diff --git a/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts b/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts new file mode 100644 index 00000000..c5174985 --- /dev/null +++ b/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts @@ -0,0 +1,52 @@ +// constants +import { ACCOUNT_GROUPS_ITEM_KEY } from '@extension/constants'; + +// repositories +import BaseRepository from '@extension/repositories/BaseRepository'; + +// types +import type { IAccountGroup } from '@extension/types'; + +// utils +import upsertItemsById from '@extension/utils/upsertItemsById'; + +export default class AccountGroupRepository extends BaseRepository { + /** + * public functions + */ + + /** + * Fetches the account groups from storage. + * @returns {Promise} A promise that resolves to the account groups. + * @public + */ + public async fetchAll(): Promise { + const items = await this._fetchByKey( + ACCOUNT_GROUPS_ITEM_KEY + ); + + if (!items) { + return []; + } + + return items; + } + + /** + * Saves the account group to storage. + * @param {IAccountGroup} value - The account group to upsert. + * @returns {Promise} A promise that resolves to the account group. + * @public + */ + public async save(value: IAccountGroup): Promise { + let items = await this.fetchAll(); + + items = upsertItemsById(items, [value]); + + await this._save({ + [ACCOUNT_GROUPS_ITEM_KEY]: items, + }); + + return value; + } +} diff --git a/src/extension/repositories/AccountGroupRepository/index.ts b/src/extension/repositories/AccountGroupRepository/index.ts new file mode 100644 index 00000000..bc863901 --- /dev/null +++ b/src/extension/repositories/AccountGroupRepository/index.ts @@ -0,0 +1 @@ +export { default } from './AccountGroupRepository'; diff --git a/src/extension/types/storage/TStorageItemTypes.ts b/src/extension/types/storage/TStorageItemTypes.ts index 09dc8864..5afb37a5 100644 --- a/src/extension/types/storage/TStorageItemTypes.ts +++ b/src/extension/types/storage/TStorageItemTypes.ts @@ -1,6 +1,10 @@ // types import type { ISerializableNetworkWithTransactionParams } from '@extension/repositories/NetworksRepository'; -import type { IAccount, IActiveAccountDetails } from '../accounts'; +import type { + IAccount, + IAccountGroup, + IActiveAccountDetails, +} from '../accounts'; import type { IARC0072Asset, IARC0200Asset, IStandardAsset } from '../assets'; import type { IPasskeyCredential, @@ -21,6 +25,7 @@ import type { ISystemInfo } from '../system'; type TStorageItemTypes = | IAccount + | IAccountGroup[] | IActiveAccountDetails | IAdvancedSettings | IAppearanceSettings From 43b3cefd5568e3885f80f2f195f48a79a27c7871 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Wed, 2 Oct 2024 20:43:00 +0100 Subject: [PATCH 03/25] feat: add account group to accounts slice --- src/extension/features/accounts/slice.ts | 1 + .../thunks/fetchAccountsFromStorageThunk.ts | 7 +++---- .../types/IFetchAccountsFromStorageResult.ts | 7 +++++-- src/extension/features/accounts/types/IState.ts | 5 ++++- .../features/accounts/utils/getInitialState.ts | 3 ++- .../AccountGroupRepository.ts | 14 ++++++++++++++ src/extension/selectors/accounts/index.ts | 1 + .../selectors/accounts/useSelectAccountGroups.ts | 14 ++++++++++++++ 8 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 src/extension/selectors/accounts/useSelectAccountGroups.ts diff --git a/src/extension/features/accounts/slice.ts b/src/extension/features/accounts/slice.ts index d88b8703..1f539b9e 100644 --- a/src/extension/features/accounts/slice.ts +++ b/src/extension/features/accounts/slice.ts @@ -117,6 +117,7 @@ const slice = createSlice({ fetchAccountsFromStorageThunk.fulfilled, (state: IState, action) => { state.activeAccountDetails = action.payload.activeAccountDetails; + state.groups = action.payload.groups; state.items = action.payload.accounts; state.fetching = false; } diff --git a/src/extension/features/accounts/thunks/fetchAccountsFromStorageThunk.ts b/src/extension/features/accounts/thunks/fetchAccountsFromStorageThunk.ts index c2776555..12fe29ac 100644 --- a/src/extension/features/accounts/thunks/fetchAccountsFromStorageThunk.ts +++ b/src/extension/features/accounts/thunks/fetchAccountsFromStorageThunk.ts @@ -10,7 +10,6 @@ import ActiveAccountRepositoryService from '@extension/repositories/ActiveAccoun // types import type { IAccount, - IActiveAccountDetails, IBackgroundRootState, IBaseAsyncThunkConfig, IMainRootState, @@ -19,6 +18,7 @@ import type { IFetchAccountsFromStorageResult } from '../types'; // utils import isWatchAccount from '@extension/utils/isWatchAccount'; +import AccountGroupRepository from '@extension/repositories/AccountGroupRepository'; const fetchAccountsFromStorageThunk: AsyncThunk< IFetchAccountsFromStorageResult, // return @@ -31,14 +31,12 @@ const fetchAccountsFromStorageThunk: AsyncThunk< >(ThunkEnum.FetchAccountsFromStorage, async (_, { getState }) => { const logger = getState().system.logger; let accounts: IAccount[]; - let activeAccountDetails: IActiveAccountDetails | null; logger.debug( `${ThunkEnum.FetchAccountsFromStorage}: fetching accounts from storage` ); accounts = await new AccountRepository().fetchAll(); - activeAccountDetails = await new ActiveAccountRepositoryService().fetch(); return { accounts: await Promise.all( @@ -47,7 +45,8 @@ const fetchAccountsFromStorageThunk: AsyncThunk< watchAccount: await isWatchAccount(value), })) ), - activeAccountDetails, + activeAccountDetails: await new ActiveAccountRepositoryService().fetch(), + groups: await new AccountGroupRepository().fetchAll(), }; }); diff --git a/src/extension/features/accounts/types/IFetchAccountsFromStorageResult.ts b/src/extension/features/accounts/types/IFetchAccountsFromStorageResult.ts index 8884409a..aaa24d5c 100644 --- a/src/extension/features/accounts/types/IFetchAccountsFromStorageResult.ts +++ b/src/extension/features/accounts/types/IFetchAccountsFromStorageResult.ts @@ -1,15 +1,18 @@ import type { + IAccountGroup, IAccountWithExtendedProps, IActiveAccountDetails, } from '@extension/types'; /** - * @property {IAccount[]} accounts - all the accounts stored in storage. - * @property {IActiveAccountDetails | null} activeAccountDetails - the details of the saved active account. + * @property {IAccount[]} accounts - All the accounts stored in storage. + * @property {IActiveAccountDetails | null} activeAccountDetails - The details of the saved active account. + * @property {IAccountGroup[]} groups - All account groups in storage. */ interface IFetchAccountsFromStorageResult { accounts: IAccountWithExtendedProps[]; activeAccountDetails: IActiveAccountDetails | null; + groups: IAccountGroup[]; } export default IFetchAccountsFromStorageResult; diff --git a/src/extension/features/accounts/types/IState.ts b/src/extension/features/accounts/types/IState.ts index f17bb7c1..a8990f75 100644 --- a/src/extension/features/accounts/types/IState.ts +++ b/src/extension/features/accounts/types/IState.ts @@ -1,5 +1,6 @@ // types import type { + IAccountGroup, IAccountWithExtendedProps, IActiveAccountDetails, } from '@extension/types'; @@ -8,7 +9,8 @@ import type IAccountUpdateRequest from './IAccountUpdateRequest'; /** * @property {IActiveAccountDetails | null} activeAccountDetails - details of the active account. * @property {boolean} fetching - true when fetching accounts from storage. - * @property {IAccount[]} items - all accounts + * @property {IAccountGroup[]} groups - All account groups. + * @property {IAccount[]} items - All accounts. * @property {number | null} pollingId - id of the polling interval. * @property {boolean} saving - true when the account is being saved to storage. * @property {IAccountUpdateRequest[]} updateRequests - a list of account update events being updated. @@ -16,6 +18,7 @@ import type IAccountUpdateRequest from './IAccountUpdateRequest'; interface IState { activeAccountDetails: IActiveAccountDetails | null; fetching: boolean; + groups: IAccountGroup[]; items: IAccountWithExtendedProps[]; pollingId: number | null; saving: boolean; diff --git a/src/extension/features/accounts/utils/getInitialState.ts b/src/extension/features/accounts/utils/getInitialState.ts index 54fe91fb..6478662f 100644 --- a/src/extension/features/accounts/utils/getInitialState.ts +++ b/src/extension/features/accounts/utils/getInitialState.ts @@ -1,10 +1,11 @@ // types -import { IState } from '../types'; +import type { IState } from '../types'; export default function getInitialState(): IState { return { activeAccountDetails: null, fetching: false, + groups: [], items: [], pollingId: null, saving: false, diff --git a/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts b/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts index c5174985..519d90ae 100644 --- a/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts +++ b/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts @@ -1,3 +1,5 @@ +import { v4 as uuid } from 'uuid'; + // constants import { ACCOUNT_GROUPS_ITEM_KEY } from '@extension/constants'; @@ -11,6 +13,18 @@ import type { IAccountGroup } from '@extension/types'; import upsertItemsById from '@extension/utils/upsertItemsById'; export default class AccountGroupRepository extends BaseRepository { + /** + * public static functions + */ + + public static initializeDefaultAccountGroup(name: string): IAccountGroup { + return { + createdAt: new Date().getTime(), + id: uuid(), + name, + }; + } + /** * public functions */ diff --git a/src/extension/selectors/accounts/index.ts b/src/extension/selectors/accounts/index.ts index 58a9444c..5bcf0897 100644 --- a/src/extension/selectors/accounts/index.ts +++ b/src/extension/selectors/accounts/index.ts @@ -1,5 +1,6 @@ export { default as useSelectAccountByAddress } from './useSelectAccountByAddress'; export { default as useSelectAccountById } from './useSelectAccountById'; +export { default as useSelectAccountGroups } from './useSelectAccountGroups'; export { default as useSelectAccountsFetching } from './useSelectAccountsFetching'; export { default as useSelectAccounts } from './useSelectAccounts'; export { default as useSelectAccountsSaving } from './useSelectAccountsSaving'; diff --git a/src/extension/selectors/accounts/useSelectAccountGroups.ts b/src/extension/selectors/accounts/useSelectAccountGroups.ts new file mode 100644 index 00000000..554212f4 --- /dev/null +++ b/src/extension/selectors/accounts/useSelectAccountGroups.ts @@ -0,0 +1,14 @@ +import { useSelector } from 'react-redux'; + +// types +import type { IAccountGroup, IMainRootState } from '@extension/types'; + +/** + * Selects all account groups. + * @returns {IAccountGroup[]} All account groups. + */ +export default function useSelectAccounts(): IAccountGroup[] { + return useSelector( + (state) => state.accounts.groups + ); +} From 0948e684a6a93676f40b9d7451334fa7e3f5e97b Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Wed, 2 Oct 2024 21:22:29 +0100 Subject: [PATCH 04/25] feat: implement add/remove account group to account page overflow menu --- .../features/accounts/enums/ThunkEnum.ts | 1 + .../features/accounts/thunks/index.ts | 1 + .../thunks/saveAccountGroupIDThunk.ts | 67 +++++++++++++++++++ .../types/ISaveAccountGroupIDPayload.ts | 6 ++ .../features/accounts/types/index.ts | 1 + .../pages/AccountPage/AccountPage.tsx | 59 ++++++++++++++-- src/extension/selectors/accounts/index.ts | 1 + .../accounts/useSelectActiveAccountGroup.ts | 24 +++++++ src/extension/translations/en.ts | 1 + 9 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 src/extension/features/accounts/thunks/saveAccountGroupIDThunk.ts create mode 100644 src/extension/features/accounts/types/ISaveAccountGroupIDPayload.ts create mode 100644 src/extension/selectors/accounts/useSelectActiveAccountGroup.ts diff --git a/src/extension/features/accounts/enums/ThunkEnum.ts b/src/extension/features/accounts/enums/ThunkEnum.ts index 1237e630..ce6f09bd 100644 --- a/src/extension/features/accounts/enums/ThunkEnum.ts +++ b/src/extension/features/accounts/enums/ThunkEnum.ts @@ -6,6 +6,7 @@ enum ThunkEnum { RemoveARC0200AssetHoldings = 'accounts/removeARC0200AssetHoldings', RemoveStandardAssetHoldings = 'accounts/removeStandardAssetHoldings', SaveAccountDetails = 'accounts/saveAccountDetails', + SaveAccountGroupID = 'accounts/saveAccountGroupID', SaveAccounts = 'accounts/saveAccounts', SaveActiveAccountDetails = 'accounts/saveActiveAccountDetails', SaveNewAccounts = 'accounts/saveNewAccounts', diff --git a/src/extension/features/accounts/thunks/index.ts b/src/extension/features/accounts/thunks/index.ts index 70c9ca1c..b5659814 100644 --- a/src/extension/features/accounts/thunks/index.ts +++ b/src/extension/features/accounts/thunks/index.ts @@ -5,6 +5,7 @@ export { default as removeAccountByIdThunk } from './removeAccountByIdThunk'; export { default as removeARC0200AssetHoldingsThunk } from './removeARC0200AssetHoldingsThunk'; export { default as removeStandardAssetHoldingsThunk } from './removeStandardAssetHoldingsThunk'; export { default as saveAccountDetailsThunk } from './saveAccountDetailsThunk'; +export { default as saveAccountGroupIDThunk } from './saveAccountGroupIDThunk'; export { default as saveAccountsThunk } from './saveAccountsThunk'; export { default as saveActiveAccountDetails } from './saveActiveAccountDetails'; export { default as saveNewAccountsThunk } from './saveNewAccountsThunk'; diff --git a/src/extension/features/accounts/thunks/saveAccountGroupIDThunk.ts b/src/extension/features/accounts/thunks/saveAccountGroupIDThunk.ts new file mode 100644 index 00000000..eb20c381 --- /dev/null +++ b/src/extension/features/accounts/thunks/saveAccountGroupIDThunk.ts @@ -0,0 +1,67 @@ +import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; + +// enums +import { ThunkEnum } from '../enums'; + +// repositories +import AccountRepository from '@extension/repositories/AccountRepository'; + +// types +import type { + IAccountWithExtendedProps, + IBaseAsyncThunkConfig, + IMainRootState, +} from '@extension/types'; +import type { ISaveAccountGroupIDPayload } from '../types'; + +// utils +import isWatchAccount from '@extension/utils/isWatchAccount/isWatchAccount'; +import serialize from '@extension/utils/serialize'; +import { findAccountWithoutExtendedProps } from '../utils'; + +const saveAccountGroupIDThunk: AsyncThunk< + IAccountWithExtendedProps | null, // return + ISaveAccountGroupIDPayload, // args + IBaseAsyncThunkConfig +> = createAsyncThunk< + IAccountWithExtendedProps | null, + ISaveAccountGroupIDPayload, + IBaseAsyncThunkConfig +>( + ThunkEnum.SaveAccountGroupID, + async ({ accountID, groupID }, { getState }) => { + const logger = getState().system.logger; + const accounts = getState().accounts.items; + let account = serialize( + findAccountWithoutExtendedProps(accountID, accounts) + ); + + if (!account) { + logger.debug( + `${ThunkEnum.SaveAccountGroupID}: no account found for "${accountID}", ignoring` + ); + + return null; + } + + logger.debug( + `${ThunkEnum.SaveAccountGroupID}: ${ + groupID ? `adding group "${groupID}"` : `removing group` + } from account "${accountID}"` + ); + + account = { + ...account, + groupID, + }; + + await new AccountRepository().saveMany([account]); + + return { + ...account, + watchAccount: await isWatchAccount(account), + }; + } +); + +export default saveAccountGroupIDThunk; diff --git a/src/extension/features/accounts/types/ISaveAccountGroupIDPayload.ts b/src/extension/features/accounts/types/ISaveAccountGroupIDPayload.ts new file mode 100644 index 00000000..f025257a --- /dev/null +++ b/src/extension/features/accounts/types/ISaveAccountGroupIDPayload.ts @@ -0,0 +1,6 @@ +interface ISaveAccountGroupIDPayload { + accountID: string; + groupID: string | null; +} + +export default ISaveAccountGroupIDPayload; diff --git a/src/extension/features/accounts/types/index.ts b/src/extension/features/accounts/types/index.ts index ebc0a253..faeb0fc1 100644 --- a/src/extension/features/accounts/types/index.ts +++ b/src/extension/features/accounts/types/index.ts @@ -1,6 +1,7 @@ export type { default as IAccountUpdateRequest } from './IAccountUpdateRequest'; export type { default as IFetchAccountsFromStorageResult } from './IFetchAccountsFromStorageResult'; export type { default as ISaveAccountDetailsPayload } from './ISaveAccountDetailsPayload'; +export type { default as ISaveAccountGroupIDPayload } from './ISaveAccountGroupIDPayload'; export type { default as ISaveNewAccountsPayload } from './ISaveNewAccountsPayload'; export type { default as ISaveNewWatchAccountPayload } from './ISaveNewWatchAccountPayload'; export type { default as IState } from './IState'; diff --git a/src/extension/pages/AccountPage/AccountPage.tsx b/src/extension/pages/AccountPage/AccountPage.tsx index bdb72976..686f1bf2 100644 --- a/src/extension/pages/AccountPage/AccountPage.tsx +++ b/src/extension/pages/AccountPage/AccountPage.tsx @@ -16,6 +16,7 @@ import { import BigNumber from 'bignumber.js'; import React, { type FC } from 'react'; import { useTranslation } from 'react-i18next'; +import { BsFolderMinus, BsFolderPlus } from 'react-icons/bs'; import { IoAdd, IoCloudOfflineOutline, @@ -60,14 +61,13 @@ import { AccountTabEnum } from '@extension/enums'; import { removeAccountByIdThunk, saveActiveAccountDetails, + saveAccountGroupIDThunk, updateAccountsThunk, } from '@extension/features/accounts'; import { setConfirmModal, setWhatsNewModal } from '@extension/features/layout'; +import { create as createNotification } from '@extension/features/notifications'; import { updateTransactionParamsForSelectedNetworkThunk } from '@extension/features/networks'; -import { - setAccountAndType as setReKeyAccount, - TReKeyType, -} from '@extension/features/re-key-account'; +import { setAccountAndType as setReKeyAccount } from '@extension/features/re-key-account'; import { saveToStorageThunk as saveSettingsToStorageThunk } from '@extension/features/settings'; import { savePolisAccountIDThunk } from '@extension/features/system'; @@ -88,6 +88,7 @@ import { useSelectAccounts, useSelectActiveAccount, useSelectActiveAccountDetails, + useSelectActiveAccountGroup, useSelectActiveAccountInformation, useSelectActiveAccountTransactions, useSelectAccountsFetching, @@ -101,7 +102,9 @@ import { } from '@extension/selectors'; // types +import type { TReKeyType } from '@extension/features/re-key-account'; import type { + IAccountWithExtendedProps, IAppThunkDispatch, IMainRootState, INetwork, @@ -134,6 +137,7 @@ const AccountPage: FC = () => { const activeAccountDetails = useSelectActiveAccountDetails(); const fetchingAccounts = useSelectAccountsFetching(); const fetchingSettings = useSelectSettingsFetching(); + const group = useSelectActiveAccountGroup(); const online = useSelectIsOnline(); const network = useSelectSettingsSelectedNetwork(); const networks = useSelectNetworks(); @@ -175,6 +179,9 @@ const AccountPage: FC = () => { } }; const handleAddAccountClick = () => navigate(ADD_ACCOUNT_ROUTE); + const handleOnAddGroupClick = () => { + // TODO: open select group modal + }; const handleOnEditAccountClick = () => onEditAccountModalOpen(); const handleOnMakePrimaryClick = () => account && dispatch(savePolisAccountIDThunk(account.id)); @@ -189,6 +196,32 @@ const AccountPage: FC = () => { }) ); }; + const handleOnRemoveGroupClick = async () => { + let _account: IAccountWithExtendedProps | null; + + if (!account || !group) { + return; + } + + _account = await dispatch( + saveAccountGroupIDThunk({ + accountID: account.id, + groupID: null, + }) + ).unwrap(); + + if (!_account) { + return; + } + + dispatch( + createNotification({ + ephemeral: true, + title: t('headings.removedGroup'), + type: 'info', + }) + ); + }; const handleNetworkSelect = async (value: INetwork) => { await dispatch( saveSettingsToStorageThunk({ @@ -433,6 +466,24 @@ const AccountPage: FC = () => { }, ] : []), + // add/remove to group + ...(group + ? [ + { + icon: BsFolderMinus, + label: t('labels.removeFromGroup', { + name: group.name, + }), + onSelect: handleOnRemoveGroupClick, + }, + ] + : [ + { + icon: BsFolderPlus, + label: t('labels.addToGroup'), + onSelect: handleOnAddGroupClick, + }, + ]), // re-key ...(canReKeyAccount() ? [ diff --git a/src/extension/selectors/accounts/index.ts b/src/extension/selectors/accounts/index.ts index 5bcf0897..3df79a1f 100644 --- a/src/extension/selectors/accounts/index.ts +++ b/src/extension/selectors/accounts/index.ts @@ -6,6 +6,7 @@ export { default as useSelectAccounts } from './useSelectAccounts'; export { default as useSelectAccountsSaving } from './useSelectAccountsSaving'; export { default as useSelectActiveAccount } from './useSelectActiveAccount'; export { default as useSelectActiveAccountDetails } from './useSelectActiveAccountDetails'; +export { default as useSelectActiveAccountGroup } from './useSelectActiveAccountGroup'; export { default as useSelectActiveAccountInformation } from './useSelectActiveAccountInformation'; export { default as useSelectActiveAccountTransactions } from './useSelectActiveAccountTransactions'; export { default as useSelectActiveAccountTransactionsUpdating } from './useSelectActiveAccountTransactionsUpdating'; diff --git a/src/extension/selectors/accounts/useSelectActiveAccountGroup.ts b/src/extension/selectors/accounts/useSelectActiveAccountGroup.ts new file mode 100644 index 00000000..423a5876 --- /dev/null +++ b/src/extension/selectors/accounts/useSelectActiveAccountGroup.ts @@ -0,0 +1,24 @@ +import { useSelector } from 'react-redux'; + +// selectors +import useSelectActiveAccount from './useSelectActiveAccount'; + +// types +import type { IAccountGroup, IMainRootState } from '@extension/types'; + +/** + * Selects the active account group, or null if it doesn't exist. + * @returns {IAccountGroup | null} The active account group, or null. + */ +export default function useSelectActiveAccountGroup(): IAccountGroup | null { + const account = useSelectActiveAccount(); + + if (!account || !account.groupID) { + return null; + } + + return useSelector( + ({ accounts }) => + accounts.groups.find(({ id }) => id === account.groupID) || null + ); +} diff --git a/src/extension/translations/en.ts b/src/extension/translations/en.ts index 47d888d6..0be03dcc 100644 --- a/src/extension/translations/en.ts +++ b/src/extension/translations/en.ts @@ -402,6 +402,7 @@ const translation: IResourceLanguage = { [`removedAsset_${AssetTypeEnum.ARC0200}`]: 'Asset {{symbol}} Hidden!', removeCustomNode: 'Remove Custom Node', removedCustomNode: 'Removed Custom Node', + removedGroup: 'Removed Group', removePasskey: 'Remove Passkey', scanQrCode: 'Scan QR Code(s)', selectAccount: 'Select Account', From b60631cbcad86a545cf37defaf4669c2b4a88821 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Wed, 9 Oct 2024 09:10:05 +0100 Subject: [PATCH 05/25] chore: squash --- src/extension/modals/WhatsNewModal/WhatsNewModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/extension/modals/WhatsNewModal/WhatsNewModal.tsx b/src/extension/modals/WhatsNewModal/WhatsNewModal.tsx index 57698ad5..17d197ac 100644 --- a/src/extension/modals/WhatsNewModal/WhatsNewModal.tsx +++ b/src/extension/modals/WhatsNewModal/WhatsNewModal.tsx @@ -67,6 +67,7 @@ const WhatsNewModal: FC = ({ onClose }) => { const features = [ 'šŸ’… Change account icon.', 'šŸ’… Change account background color.', + 'šŸ—ƒļø Group accounts.', ]; const fixes: string[] = []; // handlers From 740b49baae460e48e5f41b5156b34f4251173df1 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Thu, 31 Oct 2024 19:11:27 +0000 Subject: [PATCH 06/25] ci: add pr title validation --- .github/workflows/pull_request_checks.yml | 38 ++++++++++++++++++++--- package.json | 3 +- src/manifest.common.json | 2 +- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pull_request_checks.yml b/.github/workflows/pull_request_checks.yml index 7fe68372..9274314b 100644 --- a/.github/workflows/pull_request_checks.yml +++ b/.github/workflows/pull_request_checks.yml @@ -17,13 +17,41 @@ jobs: - name: "šŸ”§ Setup" uses: ./.github/actions/use-dependencies + ## + # validation + ## + + validate_pr_title: + name: "Validate PR Title" + needs: install + runs-on: ubuntu-latest + steps: + - name: "šŸ›Ž Checkout" + uses: actions/checkout@v4 + - name: "šŸ”§ Setup" + uses: ./.github/actions/use-dependencies + - name: "šŸ“„ Get PR Title" + id: get_pr_title + uses: actions/github-script@v7 + with: + result-encoding: string + script: | + const { data } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + return data.title; + - name: "āœ… Validate" + run: echo "${{ steps.get_pr_title.outputs.result }}" | yarn commitlint + ## # lint, type-check, build and test ## lint: name: "Lint" - needs: install + needs: [install, validate_pr_title] runs-on: ubuntu-latest steps: - name: "šŸ›Ž Checkout" @@ -35,7 +63,7 @@ jobs: type_check: name: "Type Check" - needs: install + needs: [install, validate_pr_title] runs-on: ubuntu-latest steps: - name: "šŸ›Ž Checkout" @@ -63,7 +91,7 @@ jobs: build_chrome: name: "Build Chrome" - needs: [install, type_check] + needs: [install, validate_pr_title, type_check] runs-on: ubuntu-latest environment: development steps: @@ -80,7 +108,7 @@ jobs: build_edge: name: "Build Edge" - needs: [install, type_check] + needs: [install, validate_pr_title, type_check] runs-on: ubuntu-latest environment: development steps: @@ -97,7 +125,7 @@ jobs: build_firefox: name: "Build Firefox" - needs: [install, type_check] + needs: [install, validate_pr_title, type_check] runs-on: ubuntu-latest environment: development steps: diff --git a/package.json b/package.json index e3da44dc..b1b312f6 100644 --- a/package.json +++ b/package.json @@ -174,5 +174,6 @@ "webpack-dev-middleware": "^5.3.4", "word-wrap": "^1.2.4", "yaml": "^2.2.2" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/manifest.common.json b/src/manifest.common.json index e5f280b2..01b3b55f 100644 --- a/src/manifest.common.json +++ b/src/manifest.common.json @@ -1,7 +1,7 @@ { "name": "Kibisis", "version": "2.4.0", - "description": "The wallet for your lifestyle.", + "description": "Kibisis is more than just a wallet. It is your gateway to a secure and self-custodial lifestyle.", "author": "Kibisis OƜ", "icons": { "48": "icons/icon-48.png", From fb4679bcdb90611c3266fcd2e3743b88ed361a03 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Tue, 12 Nov 2024 17:27:49 +0000 Subject: [PATCH 07/25] feat: add convert to kebab case utility function --- .../convertToKebabCase.test.ts | 38 +++++++++++++++++++ .../convertToKebabCase/convertToKebabCase.ts | 12 ++++++ .../utils/convertToKebabCase/index.ts | 1 + 3 files changed, 51 insertions(+) create mode 100644 src/extension/utils/convertToKebabCase/convertToKebabCase.test.ts create mode 100644 src/extension/utils/convertToKebabCase/convertToKebabCase.ts create mode 100644 src/extension/utils/convertToKebabCase/index.ts diff --git a/src/extension/utils/convertToKebabCase/convertToKebabCase.test.ts b/src/extension/utils/convertToKebabCase/convertToKebabCase.test.ts new file mode 100644 index 00000000..303d7bd6 --- /dev/null +++ b/src/extension/utils/convertToKebabCase/convertToKebabCase.test.ts @@ -0,0 +1,38 @@ +// utils +import convertToKebabCase from './convertToKebabCase'; + +interface ITestParams { + expected: string; + input: string; +} + +describe('convertToKebabCase()', () => { + it.each([ + { + expected: 'lowercase', + input: 'lowercase', + }, + { + expected: 'capital', + input: 'Capital', + }, + { + expected: 'pascal-case', + input: 'PascalCase', + }, + { + expected: 'title-case', + input: 'Title Case', + }, + { + expected: 'cases-with-apostrophe', + input: `Cases' with apostrophe`, + }, + { + expected: 'special-symbols', + input: 'Special symbols!!!', + }, + ])(`should convert "$input" to "$expected"`, ({ expected, input }) => { + expect(convertToKebabCase(input)).toBe(expected); + }); +}); diff --git a/src/extension/utils/convertToKebabCase/convertToKebabCase.ts b/src/extension/utils/convertToKebabCase/convertToKebabCase.ts new file mode 100644 index 00000000..1df44263 --- /dev/null +++ b/src/extension/utils/convertToKebabCase/convertToKebabCase.ts @@ -0,0 +1,12 @@ +/** + * Convenience function that converts a string value to kebab-case. + * @param {string} value - a string to convert. + * @returns {string} the converted value to kebab-case. + */ +export default function convertToKebabCase(value: string): string { + return value + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[\s_',"!?<>Ā£$%^&*()+={}]+/g, '-') // replace whitespace and ',"!?<>Ā£$%^&*()+={} with a hyphen "-" + .replace(/^-+|-+$/g, '') // trim any hyphens from the beginning and end of the string + .toLowerCase(); +} diff --git a/src/extension/utils/convertToKebabCase/index.ts b/src/extension/utils/convertToKebabCase/index.ts new file mode 100644 index 00000000..b68b8d3c --- /dev/null +++ b/src/extension/utils/convertToKebabCase/index.ts @@ -0,0 +1 @@ +export { default } from './convertToKebabCase'; From 10cf9c34e83e3de675771ef4fde482f23ec83241 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Tue, 12 Nov 2024 17:29:07 +0000 Subject: [PATCH 08/25] feat: add save account group thunk --- .../features/accounts/enums/ThunkEnum.ts | 1 + src/extension/features/accounts/slice.ts | 36 ++++++++++++++++++- .../features/accounts/thunks/index.ts | 1 + .../accounts/thunks/saveAccountGroupThunk.ts | 30 ++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/extension/features/accounts/thunks/saveAccountGroupThunk.ts diff --git a/src/extension/features/accounts/enums/ThunkEnum.ts b/src/extension/features/accounts/enums/ThunkEnum.ts index ce6f09bd..4252ab2e 100644 --- a/src/extension/features/accounts/enums/ThunkEnum.ts +++ b/src/extension/features/accounts/enums/ThunkEnum.ts @@ -6,6 +6,7 @@ enum ThunkEnum { RemoveARC0200AssetHoldings = 'accounts/removeARC0200AssetHoldings', RemoveStandardAssetHoldings = 'accounts/removeStandardAssetHoldings', SaveAccountDetails = 'accounts/saveAccountDetails', + SaveAccountGroup = 'accounts/saveAccountGroup', SaveAccountGroupID = 'accounts/saveAccountGroupID', SaveAccounts = 'accounts/saveAccounts', SaveActiveAccountDetails = 'accounts/saveActiveAccountDetails', diff --git a/src/extension/features/accounts/slice.ts b/src/extension/features/accounts/slice.ts index 1f539b9e..6db2232c 100644 --- a/src/extension/features/accounts/slice.ts +++ b/src/extension/features/accounts/slice.ts @@ -15,6 +15,8 @@ import { removeARC0200AssetHoldingsThunk, removeStandardAssetHoldingsThunk, saveAccountDetailsThunk, + saveAccountGroupIDThunk, + saveAccountGroupThunk, saveAccountsThunk, saveActiveAccountDetails, saveNewAccountsThunk, @@ -25,7 +27,10 @@ import { } from './thunks'; // types -import type { IAccountWithExtendedProps } from '@extension/types'; +import type { + IAccountGroup, + IAccountWithExtendedProps, +} from '@extension/types'; import type { IState } from './types'; // utils @@ -242,6 +247,35 @@ const slice = createSlice({ builder.addCase(saveAccountDetailsThunk.rejected, (state: IState) => { state.saving = false; }); + /** save account group id **/ + builder.addCase( + saveAccountGroupIDThunk.fulfilled, + (state: IState, action) => { + if (action.payload) { + state.items = upsertItemsById( + state.items, + [action.payload] + ); + } + + state.saving = false; + } + ); + builder.addCase(saveAccountGroupIDThunk.pending, (state: IState) => { + state.saving = true; + }); + builder.addCase(saveAccountGroupIDThunk.rejected, (state: IState) => { + state.saving = false; + }); + /** save account group **/ + builder.addCase( + saveAccountGroupThunk.fulfilled, + (state: IState, action) => { + state.groups = upsertItemsById(state.groups, [ + action.payload, + ]); + } + ); /** save accounts **/ builder.addCase(saveAccountsThunk.fulfilled, (state: IState, action) => { state.items = AccountRepository.sort( diff --git a/src/extension/features/accounts/thunks/index.ts b/src/extension/features/accounts/thunks/index.ts index b5659814..6ce3952c 100644 --- a/src/extension/features/accounts/thunks/index.ts +++ b/src/extension/features/accounts/thunks/index.ts @@ -5,6 +5,7 @@ export { default as removeAccountByIdThunk } from './removeAccountByIdThunk'; export { default as removeARC0200AssetHoldingsThunk } from './removeARC0200AssetHoldingsThunk'; export { default as removeStandardAssetHoldingsThunk } from './removeStandardAssetHoldingsThunk'; export { default as saveAccountDetailsThunk } from './saveAccountDetailsThunk'; +export { default as saveAccountGroupThunk } from './saveAccountGroupThunk'; export { default as saveAccountGroupIDThunk } from './saveAccountGroupIDThunk'; export { default as saveAccountsThunk } from './saveAccountsThunk'; export { default as saveActiveAccountDetails } from './saveActiveAccountDetails'; diff --git a/src/extension/features/accounts/thunks/saveAccountGroupThunk.ts b/src/extension/features/accounts/thunks/saveAccountGroupThunk.ts new file mode 100644 index 00000000..56c95eb9 --- /dev/null +++ b/src/extension/features/accounts/thunks/saveAccountGroupThunk.ts @@ -0,0 +1,30 @@ +import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; + +// enums +import { ThunkEnum } from '../enums'; + +// repositories +import AccountGroupRepository from '@extension/repositories/AccountGroupRepository'; + +// types +import type { + IAccountGroup, + IBaseAsyncThunkConfig, + IMainRootState, +} from '@extension/types'; + +const saveAccountGroupThunk: AsyncThunk< + IAccountGroup, // return + string, // args + IBaseAsyncThunkConfig +> = createAsyncThunk< + IAccountGroup, + string, + IBaseAsyncThunkConfig +>(ThunkEnum.SaveAccountGroup, async (name) => { + return await new AccountGroupRepository().save( + AccountGroupRepository.initializeDefaultAccountGroup(name) + ); +}); + +export default saveAccountGroupThunk; From 71a987ba4428a2f810d6f1b512f2739ee2c3f566 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Tue, 12 Nov 2024 17:29:44 +0000 Subject: [PATCH 09/25] feat: add action item component --- .../components/ActionItem/ActionItem.tsx | 78 +++++++++++++++++++ src/extension/components/ActionItem/index.ts | 1 + .../components/ActionItem/types/IProps.ts | 10 +++ .../components/ActionItem/types/index.ts | 1 + 4 files changed, 90 insertions(+) create mode 100644 src/extension/components/ActionItem/ActionItem.tsx create mode 100644 src/extension/components/ActionItem/index.ts create mode 100644 src/extension/components/ActionItem/types/IProps.ts create mode 100644 src/extension/components/ActionItem/types/index.ts diff --git a/src/extension/components/ActionItem/ActionItem.tsx b/src/extension/components/ActionItem/ActionItem.tsx new file mode 100644 index 00000000..e20df2a0 --- /dev/null +++ b/src/extension/components/ActionItem/ActionItem.tsx @@ -0,0 +1,78 @@ +import { Button as ChakraButton, HStack, Icon, Text } from '@chakra-ui/react'; +import React, { type FC } from 'react'; +import { IoChevronForward } from 'react-icons/io5'; + +// constants +import { DEFAULT_GAP, TAB_ITEM_HEIGHT } from '@extension/constants'; + +// hooks +import useButtonHoverBackgroundColor from '@extension/hooks/useButtonHoverBackgroundColor'; +import useColorModeValue from '@extension/hooks/useColorModeValue'; +import useSubTextColor from '@extension/hooks/useSubTextColor'; + +// theme +import { theme } from '@extension/theme'; + +// types +import type { IProps } from './types'; + +// utils +import calculateIconSize from '@extension/utils/calculateIconSize'; + +const ActionItem: FC = ({ + icon, + isSelected = false, + label, + onClick, +}) => { + // hooks + const buttonHoverBackgroundColor = useButtonHoverBackgroundColor(); + const primaryButtonTextColor: string = useColorModeValue( + theme.colors.primaryLight['600'], + theme.colors.primaryDark['600'] + ); + const subTextColor = useSubTextColor(); + // misc + const iconSize = calculateIconSize('md'); + const textColor = isSelected ? primaryButtonTextColor : subTextColor; + + return ( + + } + variant="ghost" + w="full" + > + + {/*icon*/} + + + {/*content*/} + + {label} + + + + ); +}; + +export default ActionItem; diff --git a/src/extension/components/ActionItem/index.ts b/src/extension/components/ActionItem/index.ts new file mode 100644 index 00000000..b5a2dc78 --- /dev/null +++ b/src/extension/components/ActionItem/index.ts @@ -0,0 +1 @@ +export { default } from './ActionItem'; diff --git a/src/extension/components/ActionItem/types/IProps.ts b/src/extension/components/ActionItem/types/IProps.ts new file mode 100644 index 00000000..20d57ff5 --- /dev/null +++ b/src/extension/components/ActionItem/types/IProps.ts @@ -0,0 +1,10 @@ +import type { IconType } from 'react-icons'; + +interface IProps { + icon: IconType; + isSelected?: boolean; + label: string; + onClick?: () => void; +} + +export default IProps; diff --git a/src/extension/components/ActionItem/types/index.ts b/src/extension/components/ActionItem/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/components/ActionItem/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; From e248c877d95596704744d4f418988fb7020eec8b Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Tue, 12 Nov 2024 18:11:40 +0000 Subject: [PATCH 10/25] feat: add add to group modal --- .../components/GenericInput/GenericInput.tsx | 57 +++- .../components/GenericInput/types/IProps.ts | 4 +- src/extension/constants/Limits.ts | 1 + .../AddAccountToGroupModal.tsx | 272 ++++++++++++++++++ .../modals/AddAccountToGroupModal/index.ts | 2 + .../AddAccountToGroupModal/types/IProps.ts | 8 + .../AddAccountToGroupModal/types/index.ts | 1 + .../pages/AccountPage/AccountPage.tsx | 16 +- .../accounts/useSelectAccountsSaving.ts | 1 + .../accounts/useSelectActiveAccountGroup.ts | 11 +- src/extension/translations/en.ts | 15 + src/extension/types/i18n/IResourceLanguage.ts | 1 + 12 files changed, 366 insertions(+), 23 deletions(-) create mode 100644 src/extension/modals/AddAccountToGroupModal/AddAccountToGroupModal.tsx create mode 100644 src/extension/modals/AddAccountToGroupModal/index.ts create mode 100644 src/extension/modals/AddAccountToGroupModal/types/IProps.ts create mode 100644 src/extension/modals/AddAccountToGroupModal/types/index.ts diff --git a/src/extension/components/GenericInput/GenericInput.tsx b/src/extension/components/GenericInput/GenericInput.tsx index 658edcbc..1a0cca26 100644 --- a/src/extension/components/GenericInput/GenericInput.tsx +++ b/src/extension/components/GenericInput/GenericInput.tsx @@ -1,17 +1,20 @@ import { + HStack, Input, InputGroup, InputRightElement, - Stack, Text, + Tooltip, VStack, } from '@chakra-ui/react'; import { encodeURLSafe as encodeBase64URLSafe } from '@stablelib/base64'; import React, { type FC } from 'react'; import { useTranslation } from 'react-i18next'; +import { IoArrowForwardOutline } from 'react-icons/io5'; import { randomBytes } from 'tweetnacl'; // components +import IconButton from '@extension/components/IconButton'; import InformationIcon from '@extension/components/InformationIcon'; import Label from '@extension/components/Label'; @@ -30,9 +33,11 @@ const GenericInput: FC = ({ error, id, informationText, + isLoading = false, label, required = false, validate, + onSubmit, ...inputProps }) => { const { t } = useTranslation(); @@ -41,6 +46,45 @@ const GenericInput: FC = ({ const subTextColor = useSubTextColor(); // misc const _id = id || encodeBase64URLSafe(randomBytes(6)); + // handlers + const handleOnSubmit = () => onSubmit && onSubmit(); + // renders + const renderRightInputIcon = () => { + if (!informationText && !onSubmit) { + return null; + } + + return ( + + + {informationText && ( + ('ariaLabels.informationIcon')} + tooltipLabel={informationText} + /> + )} + {onSubmit && ( + ('buttons.submit')}> + ('ariaLabels.forwardArrow')} + borderRadius="full" + icon={IoArrowForwardOutline} + isLoading={isLoading} + onClick={handleOnSubmit} + size="sm" + variant="ghost" + /> + + )} + + + ); + }; return ( @@ -65,16 +109,7 @@ const GenericInput: FC = ({ w="full" /> - {informationText && ( - - - - - - )} + {renderRightInputIcon()} {/*character limit*/} diff --git a/src/extension/components/GenericInput/types/IProps.ts b/src/extension/components/GenericInput/types/IProps.ts index 86cdc6c9..ce232760 100644 --- a/src/extension/components/GenericInput/types/IProps.ts +++ b/src/extension/components/GenericInput/types/IProps.ts @@ -1,11 +1,13 @@ import type { InputProps } from '@chakra-ui/react'; -interface IProps extends InputProps { +interface IProps extends Omit { charactersRemaining?: number; error?: string | null; id?: string; informationText?: string; + isLoading?: boolean; label: string; + onSubmit?: () => void; required?: boolean; validate?: (value: string) => string | null; } diff --git a/src/extension/constants/Limits.ts b/src/extension/constants/Limits.ts index 0e3610f2..a0ff318d 100644 --- a/src/extension/constants/Limits.ts +++ b/src/extension/constants/Limits.ts @@ -1,4 +1,5 @@ export const ACCOUNT_NAME_BYTE_LIMIT = 32; +export const ACCOUNT_GROUP_NAME_BYTE_LIMIT = 32; export const CUSTOM_NODE_BYTE_LIMIT = 16; export const DEFAULT_TRANSACTION_INDEXER_LIMIT = 20; export const EXPORT_ACCOUNT_PAGE_LIMIT = 5; diff --git a/src/extension/modals/AddAccountToGroupModal/AddAccountToGroupModal.tsx b/src/extension/modals/AddAccountToGroupModal/AddAccountToGroupModal.tsx new file mode 100644 index 00000000..f0c88d4d --- /dev/null +++ b/src/extension/modals/AddAccountToGroupModal/AddAccountToGroupModal.tsx @@ -0,0 +1,272 @@ +import { + Heading, + HStack, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Text, + Tooltip, + VStack, +} from '@chakra-ui/react'; +import React, { type FC, KeyboardEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { IoFolderOutline } from 'react-icons/io5'; +import { useDispatch } from 'react-redux'; + +// components +import ActionItem from '@extension/components/ActionItem'; +import Button from '@extension/components/Button'; +import GenericInput from '@extension/components/GenericInput'; +import ModalSubHeading from '@extension/components/ModalSubHeading'; +import ScrollableContainer from '@extension/components/ScrollableContainer'; + +// constants +import { + ACCOUNT_GROUP_NAME_BYTE_LIMIT, + BODY_BACKGROUND_COLOR, + DEFAULT_GAP, +} from '@extension/constants'; + +// features +import { + saveAccountGroupIDThunk, + saveAccountGroupThunk, +} from '@extension/features/accounts'; +import { create as createNotification } from '@extension/features/notifications'; + +// hooks +import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; +import useGenericInput from '@extension/hooks/useGenericInput'; +import useSubTextColor from '@extension/hooks/useSubTextColor'; + +// selectors +import { + useSelectActiveAccount, + useSelectAccountsSaving, + useSelectAccountGroups, +} from '@extension/selectors'; + +// theme +import { theme } from '@extension/theme'; + +// types +import type { + IAccountGroup, + IAccountWithExtendedProps, + IAppThunkDispatch, + IMainRootState, +} from '@extension/types'; +import type { IProps } from './types'; + +// utils +import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVMAddress'; +import convertToKebabCase from '@extension/utils/convertToKebabCase'; +import ellipseAddress from '@extension/utils/ellipseAddress'; + +const AddAccountToGroupModal: FC = ({ isOpen, onClose }) => { + const { t } = useTranslation(); + const dispatch = useDispatch>(); + // selectors + const account = useSelectActiveAccount(); + const groups = useSelectAccountGroups(); + const saving = useSelectAccountsSaving(); + // hooks + const defaultTextColor = useDefaultTextColor(); + const { + charactersRemaining: nameCharactersRemaining, + error: nameError, + label: nameLabel, + onBlur: nameOnBlur, + onChange: nameOnChange, + required: isNameRequired, + reset: resetName, + value: nameValue, + validate: validateName, + } = useGenericInput({ + characterLimit: ACCOUNT_GROUP_NAME_BYTE_LIMIT, + label: t('labels.name'), + }); + const subTextColor = useSubTextColor(); + // handlers + const handleOnAddGroupSubmit = async () => { + if (nameValue.length <= 0 || !!validateName(nameValue)) { + return; + } + + // add the new group + await dispatch(saveAccountGroupThunk(nameValue)).unwrap(); + + // reset input + resetName(); + }; + const handleCancelClick = () => handleClose(); + const handleClose = () => { + // reset inputs + resetName(); + // close + onClose && onClose(); + }; + const handleOnKeyUp = async (event: KeyboardEvent) => { + if (event.key === 'Enter') { + await handleOnAddGroupSubmit(); + } + }; + const handleOnSelect = (groupID: string) => async () => { + let _account: IAccountWithExtendedProps | null; + let group: IAccountGroup | null; + + if (!account) { + return; + } + + group = groups.find(({ id }) => id === groupID) || null; + _account = await dispatch( + saveAccountGroupIDThunk({ + accountID: account.id, + groupID, + }) + ).unwrap(); + + if (_account && group) { + dispatch( + createNotification({ + description: t('captions.addedToGroup', { + group: group.name, + }), + ephemeral: true, + title: t('headings.accountUpdated'), + type: 'info', + }) + ); + } + + handleClose(); + }; + // renders + const renderGroupItems = () => { + if (groups.length <= 0) { + return ( + + + {t('captions.noGroupsAvailable')} + + + ); + } + + return ( + + {groups.map((value, index) => ( + + ))} + + ); + }; + + return ( + + + {/*header*/} + + + + {t('headings.selectGroup')} + + + {/*address*/} + {account && ( + + + {ellipseAddress( + convertPublicKeyToAVMAddress(account.publicKey), + { + end: 10, + start: 10, + } + )} + + + )} + + + + {/*body*/} + + + ('headings.addGroup')} /> + {/*add group*/} + ('placeholders.groupName')} + type="text" + validate={validateName} + value={nameValue} + /> + + ('headings.chooseGroup')} /> + + {/*choose group*/} + {renderGroupItems()} + + + + {/*footer*/} + + {/*cancel button*/} + + + + + ); +}; + +export default AddAccountToGroupModal; diff --git a/src/extension/modals/AddAccountToGroupModal/index.ts b/src/extension/modals/AddAccountToGroupModal/index.ts new file mode 100644 index 00000000..79b2be27 --- /dev/null +++ b/src/extension/modals/AddAccountToGroupModal/index.ts @@ -0,0 +1,2 @@ +export { default } from './AddAccountToGroupModal'; +export * from './types'; diff --git a/src/extension/modals/AddAccountToGroupModal/types/IProps.ts b/src/extension/modals/AddAccountToGroupModal/types/IProps.ts new file mode 100644 index 00000000..eeec4756 --- /dev/null +++ b/src/extension/modals/AddAccountToGroupModal/types/IProps.ts @@ -0,0 +1,8 @@ +// types +import type { IModalProps } from '@extension/types'; + +interface IProps extends IModalProps { + isOpen: boolean; +} + +export default IProps; diff --git a/src/extension/modals/AddAccountToGroupModal/types/index.ts b/src/extension/modals/AddAccountToGroupModal/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/modals/AddAccountToGroupModal/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/pages/AccountPage/AccountPage.tsx b/src/extension/pages/AccountPage/AccountPage.tsx index 686f1bf2..f70ada98 100644 --- a/src/extension/pages/AccountPage/AccountPage.tsx +++ b/src/extension/pages/AccountPage/AccountPage.tsx @@ -77,6 +77,7 @@ import usePrimaryColorScheme from '@extension/hooks/usePrimaryColorScheme'; import useSubTextColor from '@extension/hooks/useSubTextColor'; // modals +import AddAccountToGroupModal from '@extension/modals/AddAccountToGroupModal'; import EditAccountModal from '@extension/modals/EditAccountModal'; import ShareAddressModal from '@extension/modals/ShareAddressModal'; @@ -118,6 +119,11 @@ import isReKeyedAuthAccountAvailable from '@extension/utils/isReKeyedAuthAccount const AccountPage: FC = () => { const { t } = useTranslation(); const dispatch = useDispatch>(); + const { + isOpen: isAddToGroupAccountModalOpen, + onClose: onAddToGroupAccountModalClose, + onOpen: onAddToGroupAccountModalOpen, + } = useDisclosure(); const { isOpen: isEditAccountModalOpen, onClose: onEditAccountModalClose, @@ -179,9 +185,7 @@ const AccountPage: FC = () => { } }; const handleAddAccountClick = () => navigate(ADD_ACCOUNT_ROUTE); - const handleOnAddGroupClick = () => { - // TODO: open select group modal - }; + const handleOnAddGroupClick = () => onAddToGroupAccountModalOpen(); const handleOnEditAccountClick = () => onEditAccountModalOpen(); const handleOnMakePrimaryClick = () => account && dispatch(savePolisAccountIDThunk(account.id)); @@ -339,7 +343,7 @@ const AccountPage: FC = () => { {/*what's new*/} ('labels.whatsNew')}> ('labels.whatsNew')} + aria-label={t('ariaLabels.plusIconToAddGroup')} icon={IoGiftOutline} onClick={handleOnWhatsNewClick} size="sm" @@ -656,6 +660,10 @@ const AccountPage: FC = () => { <> {account && ( <> + ((state) => state.accounts.saving); } diff --git a/src/extension/selectors/accounts/useSelectActiveAccountGroup.ts b/src/extension/selectors/accounts/useSelectActiveAccountGroup.ts index 423a5876..d81f183c 100644 --- a/src/extension/selectors/accounts/useSelectActiveAccountGroup.ts +++ b/src/extension/selectors/accounts/useSelectActiveAccountGroup.ts @@ -1,10 +1,9 @@ -import { useSelector } from 'react-redux'; - // selectors import useSelectActiveAccount from './useSelectActiveAccount'; +import useSelectAccountGroups from './useSelectAccountGroups'; // types -import type { IAccountGroup, IMainRootState } from '@extension/types'; +import type { IAccountGroup } from '@extension/types'; /** * Selects the active account group, or null if it doesn't exist. @@ -12,13 +11,11 @@ import type { IAccountGroup, IMainRootState } from '@extension/types'; */ export default function useSelectActiveAccountGroup(): IAccountGroup | null { const account = useSelectActiveAccount(); + const groups = useSelectAccountGroups(); if (!account || !account.groupID) { return null; } - return useSelector( - ({ accounts }) => - accounts.groups.find(({ id }) => id === account.groupID) || null - ); + return groups.find(({ id }) => id === account.groupID) || null; } diff --git a/src/extension/translations/en.ts b/src/extension/translations/en.ts index 0be03dcc..9df45e52 100644 --- a/src/extension/translations/en.ts +++ b/src/extension/translations/en.ts @@ -5,6 +5,11 @@ import { AssetTypeEnum, TransactionTypeEnum } from '@extension/enums'; import { IResourceLanguage } from '@extension/types'; const translation: IResourceLanguage = { + ariaLabels: { + forwardArrow: 'Forward arrow "->".', + informationIcon: 'An "i" icon for information.', + plusIcon: 'Plus "+" icon.', + }, buttons: { add: 'Add', addAccount: 'Add Account', @@ -46,6 +51,7 @@ const translation: IResourceLanguage = { selectReceiverAccount: 'Select Receiver Account', send: 'Send', sign: 'Sign', + submit: 'Submit', undo: 'Undo', unlock: 'Unlock', view: 'View', @@ -65,6 +71,7 @@ const translation: IResourceLanguage = { 'You are about to add the following asset. Select which account your would like to add the asset to.', addedAccount: 'Account {{address}} has been added.', addedAccounts: 'Added {{amount}} accounts.', + addedToGroup: 'Added to "{{group}}" group.', addPasskey1: 'Adding a passkey allows you to sign transactions without your password.', addPasskey2: `The passkey will be used to to encrypt/decrypt the private keys of your accounts.`, @@ -201,6 +208,7 @@ const translation: IResourceLanguage = { noAssetsFound: 'You have not added any assets. Try adding one now.', noBlockExplorersAvailable: 'No block explorers available', noFontsAvailable: 'No fonts available', + noGroupsAvailable: 'No groups available', noNFTExplorersAvailable: 'No NFT explorers available', noNFTsFound: `You don't have any NFTs.`, noSessionsFound: 'Enabled dApps will appear here.', @@ -339,6 +347,7 @@ const translation: IResourceLanguage = { addedAccount: 'Added Account', addedAccounts: 'Added Account(s)', addedAsset: 'Added Asset {{symbol}}!', + addGroup: 'Add Group', addPasskey: 'Add Passkey', addWatchAccount: 'Add A Watch Account', algodDetails: 'Algod Details', @@ -350,6 +359,7 @@ const translation: IResourceLanguage = { beta: 'Beta', cameraDenied: 'Camera Denied', cameraLoading: 'Camera Loading', + chooseGroup: 'Choose Group', comingSoon: 'Coming Soon!', confirm: 'Confirm', congratulations: 'Congratulations!', @@ -412,6 +422,7 @@ const translation: IResourceLanguage = { selectANetwork: 'Select A Network', selectAnOption: 'Select An Option', selectColor: 'Select Color', + selectGroup: 'Select Group', selectIcon: 'Select Icon', selectReceiverAccount: 'Select Receiver Account', selectSenderAccount: 'Select Sender Account', @@ -471,11 +482,13 @@ const translation: IResourceLanguage = { activity: 'Activity', address: 'Address', addressToSign: 'Address To Sign', + addToGroup: 'Add To Group', advanced: 'Advanced', accountName: 'Account Name', accountToFreeze: 'Account To Freeze', accountToUnfreeze: 'Account To Unfreeze', addAccount: 'Add Account', + addGroup: 'Add Group', addMaximumAmount: 'Add Maximum Amount', algorithm: 'Algorithm', allowActionTracking: 'Allow certain actions to be tracked?', @@ -596,6 +609,7 @@ const translation: IResourceLanguage = { removeAsset: 'Remove Asset', [`removeAsset_${AssetTypeEnum.ARC0200}`]: 'Hide Asset', removedAccount: 'Removed Account', + removeFromGroup: 'Remove From Group', removeSession: 'Remove Session', reserveAccount: 'Reserve Account', resetSeedPhrase: 'Reset Seed Phrase', @@ -644,6 +658,7 @@ const translation: IResourceLanguage = { enterAddress: 'Enter address', enterNote: 'Enter an optional note', enterPassword: 'Enter password', + groupName: 'e.g. Work', nameAccount: 'Enter a name for this account', passkeyName: 'e.g. Kibisis', pleaseSelect: 'Please select...', diff --git a/src/extension/types/i18n/IResourceLanguage.ts b/src/extension/types/i18n/IResourceLanguage.ts index 913ed3ad..ec6d6dc8 100644 --- a/src/extension/types/i18n/IResourceLanguage.ts +++ b/src/extension/types/i18n/IResourceLanguage.ts @@ -1,4 +1,5 @@ interface IResourceLanguage { + ariaLabels: Record; buttons: Record; captions: Record; errors: { From 14715b8050eca1a8d79187cd61d1bcc59db611ee Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Wed, 13 Nov 2024 10:45:57 +0000 Subject: [PATCH 11/25] feat: add index to group and add group item --- src/extension/components/SideBar/SideBar.tsx | 34 +++- .../SideBarAccountList/GroupItem.tsx | 148 +++++++++++++++ .../SideBarAccountList/SideBarAccountList.tsx | 79 +++++--- .../types/IGroupItemProps.ts | 13 ++ .../SideBarAccountList/types/IProps.ts | 7 +- .../SideBarAccountList/types/index.ts | 1 + src/extension/enums/DelimiterEnum.ts | 6 + src/extension/enums/index.ts | 1 + .../features/accounts/enums/ThunkEnum.ts | 2 +- src/extension/features/accounts/slice.ts | 19 +- .../features/accounts/thunks/index.ts | 2 +- .../accounts/thunks/saveAccountGroupThunk.ts | 30 --- .../accounts/thunks/saveAccountGroupsThunk.ts | 37 ++++ .../AddAccountToGroupModal.tsx | 11 +- .../pages/AccountPage/AccountPage.tsx | 2 +- .../AccountGroupRepository.ts | 41 +++- .../AccountRepository.test.ts | 178 ------------------ .../AccountRepository/AccountRepository.ts | 59 ++---- .../AccountRepository/types/ISortOptions.ts | 5 - .../AccountRepository/types/index.ts | 1 - src/extension/types/accounts/IAccount.ts | 14 +- src/extension/types/accounts/IAccountGroup.ts | 6 + .../mapAccountWithExtendedPropsToAccount.ts | 8 +- src/extension/utils/sortByIndex/index.ts | 1 + .../utils/sortByIndex/sortByIndex.test.ts | 177 +++++++++++++++++ .../utils/sortByIndex/sortByIndex.ts | 46 +++++ .../utils/sortByIndex/types/IOptions.ts | 5 + .../utils/sortByIndex/types/IType.ts | 6 + .../utils/sortByIndex/types/index.ts | 2 + 29 files changed, 613 insertions(+), 328 deletions(-) create mode 100644 src/extension/components/SideBarAccountList/GroupItem.tsx create mode 100644 src/extension/components/SideBarAccountList/types/IGroupItemProps.ts create mode 100644 src/extension/enums/DelimiterEnum.ts delete mode 100644 src/extension/features/accounts/thunks/saveAccountGroupThunk.ts create mode 100644 src/extension/features/accounts/thunks/saveAccountGroupsThunk.ts delete mode 100644 src/extension/repositories/AccountRepository/AccountRepository.test.ts delete mode 100644 src/extension/repositories/AccountRepository/types/ISortOptions.ts create mode 100644 src/extension/utils/sortByIndex/index.ts create mode 100644 src/extension/utils/sortByIndex/sortByIndex.test.ts create mode 100644 src/extension/utils/sortByIndex/sortByIndex.ts create mode 100644 src/extension/utils/sortByIndex/types/IOptions.ts create mode 100644 src/extension/utils/sortByIndex/types/IType.ts create mode 100644 src/extension/utils/sortByIndex/types/index.ts diff --git a/src/extension/components/SideBar/SideBar.tsx b/src/extension/components/SideBar/SideBar.tsx index 2bbec1c5..544630b1 100644 --- a/src/extension/components/SideBar/SideBar.tsx +++ b/src/extension/components/SideBar/SideBar.tsx @@ -37,10 +37,11 @@ import { } from '@extension/constants'; // enums -import { AccountTabEnum } from '@extension/enums'; +import { AccountTabEnum, DelimiterEnum } from '@extension/enums'; // features import { + saveAccountGroupsThunk, saveAccountsThunk, saveActiveAccountDetails, updateAccountsThunk, @@ -55,6 +56,7 @@ import usePrimaryColor from '@extension/hooks/usePrimaryColor'; // selectors import { + useSelectAccountGroups, useSelectAccounts, useSelectAccountsFetching, useSelectActiveAccount, @@ -66,6 +68,7 @@ import { // types import type { + IAccountGroup, IAccountWithExtendedProps, IAppThunkDispatch, IMainRootState, @@ -73,6 +76,7 @@ import type { // utils import calculateIconSize from '@extension/utils/calculateIconSize'; +import sortByIndex from '@extension/utils/sortByIndex'; const SideBar: FC = () => { const { t } = useTranslation(); @@ -84,6 +88,7 @@ const SideBar: FC = () => { const activeAccountDetails = useSelectActiveAccountDetails(); const availableAccounts = useSelectAvailableAccountsForSelectedNetwork(); const fetchingAccounts = useSelectAccountsFetching(); + const groups = useSelectAccountGroups(); const network = useSelectSettingsSelectedNetwork(); const systemInfo = useSelectSystemInfo(); // hooks @@ -127,15 +132,23 @@ const SideBar: FC = () => { onCloseSideBar(); }; - const handleOnAccountSort = (_accounts: IAccountWithExtendedProps[]) => - dispatch( - saveAccountsThunk( - _accounts.map((value, index) => ({ - ...value, - index, - })) - ) - ); + const handleOnAccountSort = ( + items: (IAccountWithExtendedProps | IAccountGroup)[] + ) => { + const _items = items.map((value, index) => ({ + ...value, + index, + })); + const _accounts = _items.filter( + ({ _delimiter }) => _delimiter === DelimiterEnum.Account + ) as IAccountWithExtendedProps[]; + const _groups = _items.filter( + ({ _delimiter }) => _delimiter === DelimiterEnum.Group + ) as IAccountGroup[]; + + dispatch(saveAccountsThunk(_accounts)); + dispatch(saveAccountGroupsThunk(_groups)); + }; const handleScanQRCodeClick = () => dispatch( setScanQRCodeModal({ @@ -244,6 +257,7 @@ const SideBar: FC = () => { activeAccount={activeAccount} isLoading={fetchingAccounts} isShortForm={!isOpen} + items={sortByIndex([...accounts, ...groups])} network={network} onClick={handleOnAccountClick} onSort={handleOnAccountSort} diff --git a/src/extension/components/SideBarAccountList/GroupItem.tsx b/src/extension/components/SideBarAccountList/GroupItem.tsx new file mode 100644 index 00000000..7abdc652 --- /dev/null +++ b/src/extension/components/SideBarAccountList/GroupItem.tsx @@ -0,0 +1,148 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + Avatar, + Button, + Center, + HStack, + Icon, + type StackProps, + Text, + Tooltip, + VStack, +} from '@chakra-ui/react'; +import React, { type FC } from 'react'; +import { IoFolderOutline, IoReorderTwoOutline } from 'react-icons/io5'; + +// constants +import { + BODY_BACKGROUND_COLOR, + DEFAULT_GAP, + SIDEBAR_ITEM_HEIGHT, + SIDEBAR_MIN_WIDTH, +} from '@extension/constants'; + +// hooks +import useButtonHoverBackgroundColor from '@extension/hooks/useButtonHoverBackgroundColor'; +import useColorModeValue from '@extension/hooks/useColorModeValue'; +import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; +import useSubTextColor from '@extension/hooks/useSubTextColor'; + +// types +import type { IGroupItemProps } from './types'; + +// utils +import calculateIconSize from '@extension/utils/calculateIconSize'; + +const GroupItem: FC = ({ group, isShortForm }) => { + const { + attributes, + isDragging, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ + id: group.id, + }); + // hooks + const buttonHoverBackgroundColor = useButtonHoverBackgroundColor(); + const defaultTextColor = useDefaultTextColor(); + const subTextColor = useSubTextColor(); + const iconBackground = useColorModeValue('gray.300', 'whiteAlpha.400'); + // handlers + const handleOnClick = () => {}; + + return ( + + + + + {/*re-order button*/} + + + + ); +}; + +export default GroupItem; diff --git a/src/extension/components/SideBarAccountList/SideBarAccountList.tsx b/src/extension/components/SideBarAccountList/SideBarAccountList.tsx index ecc919a5..67423976 100644 --- a/src/extension/components/SideBarAccountList/SideBarAccountList.tsx +++ b/src/extension/components/SideBarAccountList/SideBarAccountList.tsx @@ -1,7 +1,7 @@ import { + closestCenter, DndContext, type DragEndEvent, - closestCenter, KeyboardSensor, PointerSensor, useSensor, @@ -16,11 +16,18 @@ import { import React, { type FC, useEffect, useState } from 'react'; // components +import GroupItem from './GroupItem'; import Item from './Item'; import SkeletonItem from './SkeletonItem'; +// enums +import { DelimiterEnum } from '@extension/enums'; + // types -import type { IAccountWithExtendedProps } from '@extension/types'; +import type { + IAccountGroup, + IAccountWithExtendedProps, +} from '@extension/types'; import type { IProps } from './types'; const SideBarAccountList: FC = ({ @@ -28,6 +35,7 @@ const SideBarAccountList: FC = ({ activeAccount, isLoading, isShortForm, + items, network, onClick, onSort, @@ -40,33 +48,33 @@ const SideBarAccountList: FC = ({ }) ); // states - const [_accounts, setAccounts] = - useState(accounts); + const [_items, setItems] = + useState<(IAccountWithExtendedProps | IAccountGroup)[]>(items); // handlers const handleOnClick = async (id: string) => onClick(id); const handleOnDragEnd = (event: DragEndEvent) => { const { active, over } = event; let previousIndex: number; let nextIndex: number; - let updatedAccounts: IAccountWithExtendedProps[]; + let updatedItems: (IAccountWithExtendedProps | IAccountGroup)[]; if (active.id !== over?.id) { - previousIndex = _accounts.findIndex(({ id }) => id === active.id); - nextIndex = _accounts.findIndex(({ id }) => id === over?.id); + previousIndex = items.findIndex(({ id }) => id === active.id); + nextIndex = items.findIndex(({ id }) => id === over?.id); - setAccounts((prevState) => { - updatedAccounts = arrayMove(prevState, previousIndex, nextIndex); + setItems((prevState) => { + updatedItems = arrayMove(prevState, previousIndex, nextIndex); - // update the external account state - onSort(updatedAccounts); + // update the external account/group state + onSort(updatedItems); - return updatedAccounts; + return updatedItems; }); } }; - // update the internal accounts state with the incoming state - useEffect(() => setAccounts(accounts), [accounts]); + // update the internal accounts/groups state with the incoming state + useEffect(() => setItems(items), [items]); return ( <> @@ -80,22 +88,33 @@ const SideBarAccountList: FC = ({ collisionDetection={closestCenter} onDragEnd={handleOnDragEnd} > - - {_accounts.map((value) => ( - - ))} + + {_items.map((value) => { + if (value._delimiter === DelimiterEnum.Account) { + return ( + + ); + } + + return ( + + ); + })} )} diff --git a/src/extension/components/SideBarAccountList/types/IGroupItemProps.ts b/src/extension/components/SideBarAccountList/types/IGroupItemProps.ts new file mode 100644 index 00000000..b132a307 --- /dev/null +++ b/src/extension/components/SideBarAccountList/types/IGroupItemProps.ts @@ -0,0 +1,13 @@ +// types +import type { IAccountGroup } from '@extension/types'; + +/** + * @property {IAccountGroup} group - The group. + * @property {boolean} isShortForm - Whether the full item is being shown or just the avatar. + */ +interface IGroupItemProps { + group: IAccountGroup; + isShortForm: boolean; +} + +export default IGroupItemProps; diff --git a/src/extension/components/SideBarAccountList/types/IProps.ts b/src/extension/components/SideBarAccountList/types/IProps.ts index e434e8cb..bf94f25f 100644 --- a/src/extension/components/SideBarAccountList/types/IProps.ts +++ b/src/extension/components/SideBarAccountList/types/IProps.ts @@ -1,21 +1,20 @@ // types import type { + IAccountGroup, IAccountWithExtendedProps, INetworkWithTransactionParams, ISystemInfo, } from '@extension/types'; -/** - * @property {boolean} isShortForm - Whether the full item is being shown or just the avatar. - */ interface IProps { accounts: IAccountWithExtendedProps[]; activeAccount: IAccountWithExtendedProps | null; isLoading: boolean; isShortForm: boolean; + items: (IAccountWithExtendedProps | IAccountGroup)[]; network: INetworkWithTransactionParams | null; onClick: (id: string) => void; - onSort: (accounts: IAccountWithExtendedProps[]) => void; + onSort: (items: (IAccountWithExtendedProps | IAccountGroup)[]) => void; systemInfo: ISystemInfo | null; } diff --git a/src/extension/components/SideBarAccountList/types/index.ts b/src/extension/components/SideBarAccountList/types/index.ts index ae038a80..b8ff730b 100644 --- a/src/extension/components/SideBarAccountList/types/index.ts +++ b/src/extension/components/SideBarAccountList/types/index.ts @@ -1,2 +1,3 @@ +export type { default as IGroupItemProps } from './IGroupItemProps'; export type { default as IItemProps } from './IItemProps'; export type { default as IProps } from './IProps'; diff --git a/src/extension/enums/DelimiterEnum.ts b/src/extension/enums/DelimiterEnum.ts new file mode 100644 index 00000000..10106f0a --- /dev/null +++ b/src/extension/enums/DelimiterEnum.ts @@ -0,0 +1,6 @@ +enum DelimiterEnum { + Account = 'account', + Group = 'group', +} + +export default DelimiterEnum; diff --git a/src/extension/enums/index.ts b/src/extension/enums/index.ts index 5d8a826d..a53966aa 100644 --- a/src/extension/enums/index.ts +++ b/src/extension/enums/index.ts @@ -8,6 +8,7 @@ export { default as ARC0300AuthorityEnum } from './ARC0300AuthorityEnum'; export { default as ARC0300PathEnum } from './ARC0300PathEnum'; export { default as ARC0300QueryEnum } from './ARC0300QueryEnum'; export { default as AssetTypeEnum } from './AssetTypeEnum'; +export { default as DelimiterEnum } from './DelimiterEnum'; export { default as EncryptionMethodEnum } from './EncryptionMethodEnum'; export { default as EventTypeEnum } from './EventTypeEnum'; export { default as ErrorCodeEnum } from './ErrorCodeEnum'; diff --git a/src/extension/features/accounts/enums/ThunkEnum.ts b/src/extension/features/accounts/enums/ThunkEnum.ts index 4252ab2e..a1a8176f 100644 --- a/src/extension/features/accounts/enums/ThunkEnum.ts +++ b/src/extension/features/accounts/enums/ThunkEnum.ts @@ -6,8 +6,8 @@ enum ThunkEnum { RemoveARC0200AssetHoldings = 'accounts/removeARC0200AssetHoldings', RemoveStandardAssetHoldings = 'accounts/removeStandardAssetHoldings', SaveAccountDetails = 'accounts/saveAccountDetails', - SaveAccountGroup = 'accounts/saveAccountGroup', SaveAccountGroupID = 'accounts/saveAccountGroupID', + SaveAccountGroups = 'accounts/saveAccountGroups', SaveAccounts = 'accounts/saveAccounts', SaveActiveAccountDetails = 'accounts/saveActiveAccountDetails', SaveNewAccounts = 'accounts/saveNewAccounts', diff --git a/src/extension/features/accounts/slice.ts b/src/extension/features/accounts/slice.ts index 6db2232c..7bd83492 100644 --- a/src/extension/features/accounts/slice.ts +++ b/src/extension/features/accounts/slice.ts @@ -3,9 +3,6 @@ import { createSlice } from '@reduxjs/toolkit'; // enums import { StoreNameEnum } from '@extension/enums'; -// repositories -import AccountRepository from '@extension/repositories/AccountRepository'; - // thunks import { addARC0200AssetHoldingsThunk, @@ -16,7 +13,7 @@ import { removeStandardAssetHoldingsThunk, saveAccountDetailsThunk, saveAccountGroupIDThunk, - saveAccountGroupThunk, + saveAccountGroupsThunk, saveAccountsThunk, saveActiveAccountDetails, saveNewAccountsThunk, @@ -34,6 +31,7 @@ import type { import type { IState } from './types'; // utils +import sortByIndex from '@extension/utils/sortByIndex'; import upsertItemsById from '@extension/utils/upsertItemsById'; import { getInitialState } from './utils'; @@ -267,18 +265,19 @@ const slice = createSlice({ builder.addCase(saveAccountGroupIDThunk.rejected, (state: IState) => { state.saving = false; }); - /** save account group **/ + /** save account groups **/ builder.addCase( - saveAccountGroupThunk.fulfilled, + saveAccountGroupsThunk.fulfilled, (state: IState, action) => { - state.groups = upsertItemsById(state.groups, [ - action.payload, - ]); + state.groups = upsertItemsById( + state.groups, + action.payload + ); } ); /** save accounts **/ builder.addCase(saveAccountsThunk.fulfilled, (state: IState, action) => { - state.items = AccountRepository.sort( + state.items = sortByIndex( upsertItemsById(state.items, action.payload) ); state.saving = false; diff --git a/src/extension/features/accounts/thunks/index.ts b/src/extension/features/accounts/thunks/index.ts index 6ce3952c..fc94521c 100644 --- a/src/extension/features/accounts/thunks/index.ts +++ b/src/extension/features/accounts/thunks/index.ts @@ -5,8 +5,8 @@ export { default as removeAccountByIdThunk } from './removeAccountByIdThunk'; export { default as removeARC0200AssetHoldingsThunk } from './removeARC0200AssetHoldingsThunk'; export { default as removeStandardAssetHoldingsThunk } from './removeStandardAssetHoldingsThunk'; export { default as saveAccountDetailsThunk } from './saveAccountDetailsThunk'; -export { default as saveAccountGroupThunk } from './saveAccountGroupThunk'; export { default as saveAccountGroupIDThunk } from './saveAccountGroupIDThunk'; +export { default as saveAccountGroupsThunk } from './saveAccountGroupsThunk'; export { default as saveAccountsThunk } from './saveAccountsThunk'; export { default as saveActiveAccountDetails } from './saveActiveAccountDetails'; export { default as saveNewAccountsThunk } from './saveNewAccountsThunk'; diff --git a/src/extension/features/accounts/thunks/saveAccountGroupThunk.ts b/src/extension/features/accounts/thunks/saveAccountGroupThunk.ts deleted file mode 100644 index 56c95eb9..00000000 --- a/src/extension/features/accounts/thunks/saveAccountGroupThunk.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; - -// enums -import { ThunkEnum } from '../enums'; - -// repositories -import AccountGroupRepository from '@extension/repositories/AccountGroupRepository'; - -// types -import type { - IAccountGroup, - IBaseAsyncThunkConfig, - IMainRootState, -} from '@extension/types'; - -const saveAccountGroupThunk: AsyncThunk< - IAccountGroup, // return - string, // args - IBaseAsyncThunkConfig -> = createAsyncThunk< - IAccountGroup, - string, - IBaseAsyncThunkConfig ->(ThunkEnum.SaveAccountGroup, async (name) => { - return await new AccountGroupRepository().save( - AccountGroupRepository.initializeDefaultAccountGroup(name) - ); -}); - -export default saveAccountGroupThunk; diff --git a/src/extension/features/accounts/thunks/saveAccountGroupsThunk.ts b/src/extension/features/accounts/thunks/saveAccountGroupsThunk.ts new file mode 100644 index 00000000..8802df48 --- /dev/null +++ b/src/extension/features/accounts/thunks/saveAccountGroupsThunk.ts @@ -0,0 +1,37 @@ +import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; + +// enums +import { ThunkEnum } from '../enums'; + +// repositories +import AccountGroupRepository from '@extension/repositories/AccountGroupRepository'; + +// types +import type { + IAccountGroup, + IBaseAsyncThunkConfig, + IMainRootState, +} from '@extension/types'; + +const saveAccountGroupsThunk: AsyncThunk< + IAccountGroup[], // return + IAccountGroup[], // args + IBaseAsyncThunkConfig +> = createAsyncThunk< + IAccountGroup[], + IAccountGroup[], + IBaseAsyncThunkConfig +>(ThunkEnum.SaveAccountGroups, async (groups, { getState }) => { + const logger = getState().system.logger; + const _groups = await new AccountGroupRepository().saveMany(groups); + + logger.debug( + `${ThunkEnum.SaveAccountGroups}: saved groups [${_groups + .map(({ id }) => `"${id}"`) + .join(',')}] to storage` + ); + + return _groups; +}); + +export default saveAccountGroupsThunk; diff --git a/src/extension/modals/AddAccountToGroupModal/AddAccountToGroupModal.tsx b/src/extension/modals/AddAccountToGroupModal/AddAccountToGroupModal.tsx index f0c88d4d..85052750 100644 --- a/src/extension/modals/AddAccountToGroupModal/AddAccountToGroupModal.tsx +++ b/src/extension/modals/AddAccountToGroupModal/AddAccountToGroupModal.tsx @@ -32,7 +32,7 @@ import { // features import { saveAccountGroupIDThunk, - saveAccountGroupThunk, + saveAccountGroupsThunk, } from '@extension/features/accounts'; import { create as createNotification } from '@extension/features/notifications'; @@ -41,6 +41,9 @@ import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import useGenericInput from '@extension/hooks/useGenericInput'; import useSubTextColor from '@extension/hooks/useSubTextColor'; +// repositories +import AccountGroupRepository from '@extension/repositories/AccountGroupRepository'; + // selectors import { useSelectActiveAccount, @@ -96,7 +99,11 @@ const AddAccountToGroupModal: FC = ({ isOpen, onClose }) => { } // add the new group - await dispatch(saveAccountGroupThunk(nameValue)).unwrap(); + await dispatch( + saveAccountGroupsThunk([ + AccountGroupRepository.initializeDefaultAccountGroup(nameValue), + ]) + ).unwrap(); // reset input resetName(); diff --git a/src/extension/pages/AccountPage/AccountPage.tsx b/src/extension/pages/AccountPage/AccountPage.tsx index f70ada98..92d203f7 100644 --- a/src/extension/pages/AccountPage/AccountPage.tsx +++ b/src/extension/pages/AccountPage/AccountPage.tsx @@ -343,7 +343,7 @@ const AccountPage: FC = () => { {/*what's new*/} ('labels.whatsNew')}> ('ariaLabels.plusIconToAddGroup')} + aria-label={t('ariaLabels.plusIcon')} icon={IoGiftOutline} onClick={handleOnWhatsNewClick} size="sm" diff --git a/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts b/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts index 519d90ae..73308c83 100644 --- a/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts +++ b/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts @@ -3,6 +3,9 @@ import { v4 as uuid } from 'uuid'; // constants import { ACCOUNT_GROUPS_ITEM_KEY } from '@extension/constants'; +// enums +import { DelimiterEnum } from '@extension/enums'; + // repositories import BaseRepository from '@extension/repositories/BaseRepository'; @@ -19,12 +22,28 @@ export default class AccountGroupRepository extends BaseRepository { public static initializeDefaultAccountGroup(name: string): IAccountGroup { return { + _delimiter: DelimiterEnum.Group, createdAt: new Date().getTime(), id: uuid(), + index: null, name, }; } + /** + * private functions + */ + + private _sanitize(group: IAccountGroup): IAccountGroup { + return { + _delimiter: DelimiterEnum.Group, + createdAt: group.createdAt, + id: group.id, + index: typeof group.index === 'number' ? group.index : null, // if 0, this is "falsy" in the js world, so let's be specific + name: group.name, + }; + } + /** * public functions */ @@ -43,7 +62,7 @@ export default class AccountGroupRepository extends BaseRepository { return []; } - return items; + return items.map(this._sanitize); } /** @@ -58,9 +77,27 @@ export default class AccountGroupRepository extends BaseRepository { items = upsertItemsById(items, [value]); await this._save({ - [ACCOUNT_GROUPS_ITEM_KEY]: items, + [ACCOUNT_GROUPS_ITEM_KEY]: items.map(this._sanitize), }); return value; } + + /** + * Saves a list of account groups to storage. + * @param {IAccountGroup[]} items - The account groups to upsert. + * @returns {Promise} A promise that resolves to the account groups. + * @public + */ + public async saveMany(items: IAccountGroup[]): Promise { + let _items = await this.fetchAll(); + + _items = upsertItemsById(_items, items); + + await this._save({ + [ACCOUNT_GROUPS_ITEM_KEY]: _items.map(this._sanitize), + }); + + return items; + } } diff --git a/src/extension/repositories/AccountRepository/AccountRepository.test.ts b/src/extension/repositories/AccountRepository/AccountRepository.test.ts deleted file mode 100644 index 5f7ce301..00000000 --- a/src/extension/repositories/AccountRepository/AccountRepository.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { randomBytes } from 'tweetnacl'; - -// repositories -import AccountRepository from './AccountRepository'; - -// types -import type { IAccount } from '@extension/types'; - -interface ISortTestParams { - accounts: IAccount[]; - expectedIDs: string[]; - name: string; -} - -describe(AccountRepository.name, () => { - const now = new Date(); - - describe(`${AccountRepository.name}#sort()`, () => { - it.each([ - { - accounts: [ - { - ...AccountRepository.initializeDefaultAccount({ - id: '2', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 2, - }, - { - ...AccountRepository.initializeDefaultAccount({ - id: '3', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 3, - }, - { - ...AccountRepository.initializeDefaultAccount({ - id: '0', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 0, - }, - { - ...AccountRepository.initializeDefaultAccount({ - id: '1', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 1, - }, - ], - expectedIDs: ['0', '1', '2', '3'], - name: 'should sort by position', - }, - { - accounts: [ - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 2), - id: '2', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 3), - id: '3', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: now.getTime(), - id: '0', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 1), - id: '1', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - ], - expectedIDs: ['0', '1', '2', '3'], - name: 'should sort by createdAt date', - }, - { - accounts: [ - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: now.getTime(), - id: '2', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 1), - id: '3', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 3), - id: '0', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 0, - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 1), - id: '1', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 1, - }, - ], - expectedIDs: ['0', '1', '2', '3'], - name: 'should sort by a mix of position and createdAt date', - }, - ])(`$name`, ({ accounts, expectedIDs }) => { - expect(AccountRepository.sort(accounts).map(({ id }) => id)).toEqual( - expectedIDs - ); - }); - - it('should apply positions to null-indexed elements', () => { - // arrange - const items: IAccount[] = [ - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: now.getTime(), - id: '2', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 1), - id: '3', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 3), - id: '0', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 0, - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 1), - id: '1', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 1, - }, - ]; - // act - const result: IAccount[] = AccountRepository.sort(items, { - mutateIndex: true, - }); - - // assert - expect(result.map(({ id, index }) => [id, index])).toEqual([ - ['0', 0], - ['1', 1], - ['2', 2], - ['3', 3], - ]); - }); - }); -}); diff --git a/src/extension/repositories/AccountRepository/AccountRepository.ts b/src/extension/repositories/AccountRepository/AccountRepository.ts index 23e83eef..5bb2bd1a 100644 --- a/src/extension/repositories/AccountRepository/AccountRepository.ts +++ b/src/extension/repositories/AccountRepository/AccountRepository.ts @@ -7,7 +7,7 @@ import { networks } from '@extension/config'; import { ACCOUNTS_ITEM_KEY_PREFIX } from '@extension/constants'; // enums -import { AssetTypeEnum } from '@extension/enums'; +import { AssetTypeEnum, DelimiterEnum } from '@extension/enums'; // repositories import BaseRepository from '@extension/repositories/BaseRepository'; @@ -20,10 +20,11 @@ import type { IInitializeAccountOptions, INetwork, } from '@extension/types'; -import { ISaveOptions, ISortOptions } from './types'; +import type { ISaveOptions } from './types'; // utils import convertGenesisHashToHex from '@extension/utils/convertGenesisHashToHex'; +import sortByIndex from '@extension/utils/sortByIndex'; export default class AccountRepository extends BaseRepository { /** @@ -82,9 +83,11 @@ export default class AccountRepository extends BaseRepository { const createdAtOrNow: number = createdAt || new Date().getTime(); return { + _delimiter: DelimiterEnum.Account, color: null, createdAt: createdAtOrNow, groupID: null, + groupIndex: null, icon: null, id: id || uuid(), name: name || null, @@ -131,50 +134,6 @@ export default class AccountRepository extends BaseRepository { }; } - /** - * Sorts the accounts by the `index` property, where lower indexes take precedence. If `index` is null they are put - * to the back and sorted by the `createdAt` property, ascending order (oldest first). - * @param {Type extends IAccount[]} items - The accounts to sort. - * @param {ISortOptions} options - [optional] applies indexes on accounts that do not have indexes. - * @returns {Type extends IAccount[]} the sorted accounts. - * @public - * @static - */ - public static sort( - items: Type[], - { mutateIndex }: ISortOptions = { mutateIndex: false } - ): Type[] { - const _items = items.sort((a, b) => { - // if both positions are non-null, sort by position - if (a.index !== null && b.index !== null) { - return a.index - b.index; - } - - // if `a` position is null, place it after a `b` non-null position - if (a.index === null && b.index !== null) { - return 1; // `a` comes after `b` - } - - // if `b` position is null, place it after a `a` non-null position - if (a.index !== null && b.index === null) { - return -1; // `a` comes before `b` - } - - // if both positions are null, sort by `createdat` (ascending) - return a.createdAt - b.createdAt; - }); - - if (!mutateIndex) { - return _items; - } - - // apply the positions to the - return _items.map((value, index) => ({ - ...value, - index, - })); - } - /** * private functions */ @@ -196,9 +155,12 @@ export default class AccountRepository extends BaseRepository { */ private _sanitize(account: IAccount): IAccount { return { + _delimiter: DelimiterEnum.Account, color: account.color, createdAt: account.createdAt, groupID: account.groupID, + groupIndex: + typeof account.groupIndex === 'number' ? account.groupIndex : null, // if 0, this is "falsy" in the js world, so let's be specific icon: account.icon, id: account.id, name: account.name, @@ -224,7 +186,7 @@ export default class AccountRepository extends BaseRepository { }), {} ), - index: typeof account.index === 'number' ? account.index : null, + index: typeof account.index === 'number' ? account.index : null, // if 0, this is "falsy" in the js world, so let's be specific publicKey: account.publicKey, updatedAt: account.updatedAt, }; @@ -281,6 +243,7 @@ export default class AccountRepository extends BaseRepository { accounts = accounts.map((account) => ({ ...account, + _delimiter: DelimiterEnum.Account, // if there are new networks in the config, create default account information and transactions for these new networks networkInformation: networks.reduce>( (acc, { genesisHash }) => { @@ -332,7 +295,7 @@ export default class AccountRepository extends BaseRepository { }, {}), })); - return AccountRepository.sort(accounts); + return sortByIndex(accounts); } /** diff --git a/src/extension/repositories/AccountRepository/types/ISortOptions.ts b/src/extension/repositories/AccountRepository/types/ISortOptions.ts deleted file mode 100644 index dcc5ef39..00000000 --- a/src/extension/repositories/AccountRepository/types/ISortOptions.ts +++ /dev/null @@ -1,5 +0,0 @@ -interface ISortOptions { - mutateIndex?: boolean; -} - -export default ISortOptions; diff --git a/src/extension/repositories/AccountRepository/types/index.ts b/src/extension/repositories/AccountRepository/types/index.ts index b0368256..24398c58 100644 --- a/src/extension/repositories/AccountRepository/types/index.ts +++ b/src/extension/repositories/AccountRepository/types/index.ts @@ -1,2 +1 @@ export type { default as ISaveOptions } from './ISaveOptions'; -export type { default as ISortOptions } from './ISortOptions'; diff --git a/src/extension/types/accounts/IAccount.ts b/src/extension/types/accounts/IAccount.ts index 04c89792..b9c953f0 100644 --- a/src/extension/types/accounts/IAccount.ts +++ b/src/extension/types/accounts/IAccount.ts @@ -1,14 +1,18 @@ +// enums +import { DelimiterEnum } from '@extension/enums'; + // types -import IAccountInformation from './IAccountInformation'; -import IAccountTransactions from './IAccountTransactions'; -import TAccountColors from './TAccountColors'; -import TAccountIcons from './TAccountIcons'; +import type IAccountInformation from './IAccountInformation'; +import type IAccountTransactions from './IAccountTransactions'; +import type TAccountColors from './TAccountColors'; +import type TAccountIcons from './TAccountIcons'; /** * @property {TAccountColors | null} color - The background color. * @property {number} createdAt - A timestamp (in milliseconds) when this account was created in storage. * @property {TAccountIcons | null} icon - An icon for the account. * @property {string | null} groupID - The ID of the group this account belongs to. + * @property {number | null} groupIndex - The index of where this item belongs in the group. * @property {string} id - A unique identifier (in UUIDv4). * @property {number | null} index - The position of the account as it appears in a list. * @property {string | null} name - A canonical name given to this account. @@ -20,9 +24,11 @@ import TAccountIcons from './TAccountIcons'; * @property {number} updatedAt - A timestamp (in milliseconds) for when this account was last saved to storage. */ interface IAccount { + _delimiter: DelimiterEnum.Account; color: TAccountColors | null; createdAt: number; groupID: string | null; + groupIndex: number | null; icon: TAccountIcons | null; id: string; index: number | null; diff --git a/src/extension/types/accounts/IAccountGroup.ts b/src/extension/types/accounts/IAccountGroup.ts index 75c991ee..af0d2e8f 100644 --- a/src/extension/types/accounts/IAccountGroup.ts +++ b/src/extension/types/accounts/IAccountGroup.ts @@ -1,11 +1,17 @@ +// enums +import { DelimiterEnum } from '@extension/enums'; + /** * @property {number} createdAt - a timestamp (in milliseconds) when this account was created in storage. * @property {string} id - a unique identifier (in UUIDv4 format). + * @property {number | null} index - The position of the group as it appears in a list. * @property {string} name - The name of the group. Limited to 32 bytes. */ interface IAccountGroup { + _delimiter: DelimiterEnum.Group; createdAt: number; id: string; + index: number | null; name: string; } diff --git a/src/extension/utils/mapAccountWithExtendedPropsToAccount/mapAccountWithExtendedPropsToAccount.ts b/src/extension/utils/mapAccountWithExtendedPropsToAccount/mapAccountWithExtendedPropsToAccount.ts index 25c1e7fc..73c51281 100644 --- a/src/extension/utils/mapAccountWithExtendedPropsToAccount/mapAccountWithExtendedPropsToAccount.ts +++ b/src/extension/utils/mapAccountWithExtendedPropsToAccount/mapAccountWithExtendedPropsToAccount.ts @@ -1,4 +1,7 @@ -// +// enums +import { DelimiterEnum } from '@extension/enums'; + +// types import type { IAccount, IAccountWithExtendedProps } from '@extension/types'; /** @@ -11,6 +14,7 @@ export default function mapAccountWithExtendedPropsToAccount({ color, createdAt, groupID, + groupIndex, icon, id, name, @@ -21,9 +25,11 @@ export default function mapAccountWithExtendedPropsToAccount({ updatedAt, }: IAccountWithExtendedProps): IAccount { return { + _delimiter: DelimiterEnum.Account, color, createdAt, groupID, + groupIndex, icon, id, name, diff --git a/src/extension/utils/sortByIndex/index.ts b/src/extension/utils/sortByIndex/index.ts new file mode 100644 index 00000000..37b60a1b --- /dev/null +++ b/src/extension/utils/sortByIndex/index.ts @@ -0,0 +1 @@ +export { default } from './sortByIndex'; diff --git a/src/extension/utils/sortByIndex/sortByIndex.test.ts b/src/extension/utils/sortByIndex/sortByIndex.test.ts new file mode 100644 index 00000000..220c9e96 --- /dev/null +++ b/src/extension/utils/sortByIndex/sortByIndex.test.ts @@ -0,0 +1,177 @@ +import { randomBytes } from 'tweetnacl'; + +// repositories +import AccountRepository from '@extension/repositories/AccountRepository'; + +// types +import type { IAccount } from '@extension/types'; + +// utils +import sortByIndex from './sortByIndex'; + +interface ITestParams { + accounts: IAccount[]; + expectedIDs: string[]; + name: string; +} + +describe(`${__dirname}/sortByIndex`, () => { + const now = new Date(); + + it.each([ + { + accounts: [ + { + ...AccountRepository.initializeDefaultAccount({ + id: '2', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 2, + }, + { + ...AccountRepository.initializeDefaultAccount({ + id: '3', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 3, + }, + { + ...AccountRepository.initializeDefaultAccount({ + id: '0', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 0, + }, + { + ...AccountRepository.initializeDefaultAccount({ + id: '1', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 1, + }, + ], + expectedIDs: ['0', '1', '2', '3'], + name: 'should sort by position', + }, + { + accounts: [ + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 2), + id: '2', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 3), + id: '3', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: now.getTime(), + id: '0', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 1), + id: '1', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + ], + expectedIDs: ['0', '1', '2', '3'], + name: 'should sort by createdAt date', + }, + { + accounts: [ + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: now.getTime(), + id: '2', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 1), + id: '3', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 3), + id: '0', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 0, + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 1), + id: '1', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 1, + }, + ], + expectedIDs: ['0', '1', '2', '3'], + name: 'should sort by a mix of position and createdAt date', + }, + ])(`$name`, ({ accounts, expectedIDs }) => { + expect(sortByIndex(accounts).map(({ id }) => id)).toEqual(expectedIDs); + }); + + it('should apply positions to null-indexed elements', () => { + // arrange + const items: IAccount[] = [ + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: now.getTime(), + id: '2', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 1), + id: '3', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 3), + id: '0', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 0, + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 1), + id: '1', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 1, + }, + ]; + // act + const result: IAccount[] = sortByIndex(items, { + mutateIndex: true, + }); + + // assert + expect(result.map(({ id, index }) => [id, index])).toEqual([ + ['0', 0], + ['1', 1], + ['2', 2], + ['3', 3], + ]); + }); +}); diff --git a/src/extension/utils/sortByIndex/sortByIndex.ts b/src/extension/utils/sortByIndex/sortByIndex.ts new file mode 100644 index 00000000..50cc52de --- /dev/null +++ b/src/extension/utils/sortByIndex/sortByIndex.ts @@ -0,0 +1,46 @@ +// types +import type { IOptions, IType } from './types'; + +/** + * Sorts a list by the `index` property, where lower indexes take precedence. If `index` is null they are put + * to the back and sorted by the `createdAt` property, ascending order (oldest first). + * @param {Type extends IType[]} items - The items to sort. + * @param {IOptions} options - [optional] applies indexes on items that do not have indexes. + * @returns {Type extends IType[]} the sorted items. + * @public + * @static + */ +export default function sortByIndex( + items: Type[], + { mutateIndex }: IOptions = { mutateIndex: false } +): Type[] { + const _items = items.sort((a, b) => { + // if both positions are non-null, sort by position + if (a.index !== null && b.index !== null) { + return a.index - b.index; + } + + // if `a` position is null, place it after a `b` non-null position + if (a.index === null && b.index !== null) { + return 1; // `a` comes after `b` + } + + // if `b` position is null, place it after a `a` non-null position + if (a.index !== null && b.index === null) { + return -1; // `a` comes before `b` + } + + // if both positions are null, sort by `createdat` (ascending) + return a.createdAt - b.createdAt; + }); + + if (!mutateIndex) { + return _items; + } + + // apply the positions to the list + return _items.map((value, index) => ({ + ...value, + index, + })); +} diff --git a/src/extension/utils/sortByIndex/types/IOptions.ts b/src/extension/utils/sortByIndex/types/IOptions.ts new file mode 100644 index 00000000..800f7c41 --- /dev/null +++ b/src/extension/utils/sortByIndex/types/IOptions.ts @@ -0,0 +1,5 @@ +interface IOptions { + mutateIndex?: boolean; +} + +export default IOptions; diff --git a/src/extension/utils/sortByIndex/types/IType.ts b/src/extension/utils/sortByIndex/types/IType.ts new file mode 100644 index 00000000..e6949ee1 --- /dev/null +++ b/src/extension/utils/sortByIndex/types/IType.ts @@ -0,0 +1,6 @@ +interface IType { + index: number | null; + createdAt: number; +} + +export default IType; diff --git a/src/extension/utils/sortByIndex/types/index.ts b/src/extension/utils/sortByIndex/types/index.ts new file mode 100644 index 00000000..e0cdd88c --- /dev/null +++ b/src/extension/utils/sortByIndex/types/index.ts @@ -0,0 +1,2 @@ +export type { default as IOptions } from './IOptions'; +export type { default as IType } from './IType'; From f5826a53374a65e6a65aa3fcc7a02364e771e85f Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Wed, 13 Nov 2024 16:16:30 +0000 Subject: [PATCH 12/25] feat: add collapsible to groups --- src/extension/components/SideBar/SideBar.tsx | 10 +- .../{Item.tsx => AccountItem.tsx} | 6 +- .../SideBarAccountList/GroupItem.tsx | 227 +++++++++++------- .../SideBarAccountList/SideBarAccountList.tsx | 19 +- .../{IItemProps.ts => IAccountItemProps.ts} | 4 +- .../types/IGroupItemProps.ts | 13 +- .../SideBarAccountList/types/IProps.ts | 2 +- .../SideBarAccountList/types/index.ts | 2 +- src/extension/constants/Dimensions.ts | 2 +- 9 files changed, 179 insertions(+), 106 deletions(-) rename src/extension/components/SideBarAccountList/{Item.tsx => AccountItem.tsx} (97%) rename src/extension/components/SideBarAccountList/types/{IItemProps.ts => IAccountItemProps.ts} (87%) diff --git a/src/extension/components/SideBar/SideBar.tsx b/src/extension/components/SideBar/SideBar.tsx index 544630b1..108f9356 100644 --- a/src/extension/components/SideBar/SideBar.tsx +++ b/src/extension/components/SideBar/SideBar.tsx @@ -257,9 +257,15 @@ const SideBar: FC = () => { activeAccount={activeAccount} isLoading={fetchingAccounts} isShortForm={!isOpen} - items={sortByIndex([...accounts, ...groups])} + items={sortByIndex([ + ...accounts.filter( + ({ groupID }) => + !groupID || !groups.some(({ id }) => id === groupID) + ), // remove any accounts that are in a group and the group is actually a group + ...groups, + ])} network={network} - onClick={handleOnAccountClick} + onAccountClick={handleOnAccountClick} onSort={handleOnAccountSort} systemInfo={systemInfo} /> diff --git a/src/extension/components/SideBarAccountList/Item.tsx b/src/extension/components/SideBarAccountList/AccountItem.tsx similarity index 97% rename from src/extension/components/SideBarAccountList/Item.tsx rename to src/extension/components/SideBarAccountList/AccountItem.tsx index 68182944..1fcbc477 100644 --- a/src/extension/components/SideBarAccountList/Item.tsx +++ b/src/extension/components/SideBarAccountList/AccountItem.tsx @@ -31,14 +31,14 @@ import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import useSubTextColor from '@extension/hooks/useSubTextColor'; // types -import type { IItemProps } from './types'; +import type { IAccountItemProps } from './types'; // utils import calculateIconSize from '@extension/utils/calculateIconSize'; import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVMAddress'; import ellipseAddress from '@extension/utils/ellipseAddress'; -const Item: FC = ({ +const AccountItem: FC = ({ account, accounts, active, @@ -192,4 +192,4 @@ const Item: FC = ({ ); }; -export default Item; +export default AccountItem; diff --git a/src/extension/components/SideBarAccountList/GroupItem.tsx b/src/extension/components/SideBarAccountList/GroupItem.tsx index 7abdc652..999783f9 100644 --- a/src/extension/components/SideBarAccountList/GroupItem.tsx +++ b/src/extension/components/SideBarAccountList/GroupItem.tsx @@ -1,18 +1,26 @@ -import { useSortable } from '@dnd-kit/sortable'; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Avatar, Button, Center, + Collapse, HStack, Icon, - type StackProps, Text, Tooltip, - VStack, + useDisclosure, } from '@chakra-ui/react'; import React, { type FC } from 'react'; -import { IoFolderOutline, IoReorderTwoOutline } from 'react-icons/io5'; +import { + IoFolderOutline, + IoFolderOpenOutline, + IoReorderTwoOutline, +} from 'react-icons/io5'; // constants import { @@ -22,6 +30,9 @@ import { SIDEBAR_MIN_WIDTH, } from '@extension/constants'; +// components +import AccountItem from './AccountItem'; + // hooks import useButtonHoverBackgroundColor from '@extension/hooks/useButtonHoverBackgroundColor'; import useColorModeValue from '@extension/hooks/useColorModeValue'; @@ -34,7 +45,22 @@ import type { IGroupItemProps } from './types'; // utils import calculateIconSize from '@extension/utils/calculateIconSize'; -const GroupItem: FC = ({ group, isShortForm }) => { +const GroupItem: FC = ({ + activeAccountID, + accounts, + group, + isShortForm, + network, + onAccountClick, + systemInfo, +}) => { + const { isOpen, onToggle } = useDisclosure({ + defaultIsOpen: + !!activeAccountID && + !!accounts + .filter(({ groupID }) => groupID === group.id) + .find(({ id }) => id === activeAccountID), + }); const { attributes, isDragging, @@ -50,98 +76,123 @@ const GroupItem: FC = ({ group, isShortForm }) => { const defaultTextColor = useDefaultTextColor(); const subTextColor = useSubTextColor(); const iconBackground = useColorModeValue('gray.300', 'whiteAlpha.400'); + // misc + const _accounts = accounts.filter(({ groupID }) => groupID === group.id); // handlers - const handleOnClick = () => {}; + const handleOnClick = () => onToggle(); return ( - - - + {/*name*/} + + {group.name} + + + - {/*re-order button*/} - + + + + {/*accounts*/} + + - - - - + {_accounts.map((value) => ( + + ))} + + + ); }; diff --git a/src/extension/components/SideBarAccountList/SideBarAccountList.tsx b/src/extension/components/SideBarAccountList/SideBarAccountList.tsx index 67423976..ce23f80c 100644 --- a/src/extension/components/SideBarAccountList/SideBarAccountList.tsx +++ b/src/extension/components/SideBarAccountList/SideBarAccountList.tsx @@ -16,8 +16,8 @@ import { import React, { type FC, useEffect, useState } from 'react'; // components +import AccountItem from './AccountItem'; import GroupItem from './GroupItem'; -import Item from './Item'; import SkeletonItem from './SkeletonItem'; // enums @@ -37,7 +37,7 @@ const SideBarAccountList: FC = ({ isShortForm, items, network, - onClick, + onAccountClick, onSort, systemInfo, }) => { @@ -51,7 +51,7 @@ const SideBarAccountList: FC = ({ const [_items, setItems] = useState<(IAccountWithExtendedProps | IAccountGroup)[]>(items); // handlers - const handleOnClick = async (id: string) => onClick(id); + const handleOnAccountClick = async (id: string) => onAccountClick(id); const handleOnDragEnd = (event: DragEndEvent) => { const { active, over } = event; let previousIndex: number; @@ -92,16 +92,16 @@ const SideBarAccountList: FC = ({ {_items.map((value) => { if (value._delimiter === DelimiterEnum.Account) { return ( - ); @@ -109,9 +109,14 @@ const SideBarAccountList: FC = ({ return ( ); })} diff --git a/src/extension/components/SideBarAccountList/types/IItemProps.ts b/src/extension/components/SideBarAccountList/types/IAccountItemProps.ts similarity index 87% rename from src/extension/components/SideBarAccountList/types/IItemProps.ts rename to src/extension/components/SideBarAccountList/types/IAccountItemProps.ts index bb1c090b..7aa37be5 100644 --- a/src/extension/components/SideBarAccountList/types/IItemProps.ts +++ b/src/extension/components/SideBarAccountList/types/IAccountItemProps.ts @@ -8,7 +8,7 @@ import type { /** * @property {boolean} isShortForm - Whether the full item is being shown or just the avatar. */ -interface IItemProps { +interface IAccountItemProps { account: IAccountWithExtendedProps; accounts: IAccountWithExtendedProps[]; active: boolean; @@ -18,4 +18,4 @@ interface IItemProps { systemInfo: ISystemInfo | null; } -export default IItemProps; +export default IAccountItemProps; diff --git a/src/extension/components/SideBarAccountList/types/IGroupItemProps.ts b/src/extension/components/SideBarAccountList/types/IGroupItemProps.ts index b132a307..b1d1f18f 100644 --- a/src/extension/components/SideBarAccountList/types/IGroupItemProps.ts +++ b/src/extension/components/SideBarAccountList/types/IGroupItemProps.ts @@ -1,13 +1,24 @@ // types -import type { IAccountGroup } from '@extension/types'; +import type { + IAccountGroup, + IAccountWithExtendedProps, + INetworkWithTransactionParams, + ISystemInfo, +} from '@extension/types'; /** + * @property {IAccountWithExtendedProps[]} accounts - All accounts. * @property {IAccountGroup} group - The group. * @property {boolean} isShortForm - Whether the full item is being shown or just the avatar. */ interface IGroupItemProps { + activeAccountID: string | null; + accounts: IAccountWithExtendedProps[]; group: IAccountGroup; isShortForm: boolean; + network: INetworkWithTransactionParams; + onAccountClick: (id: string) => void; + systemInfo: ISystemInfo | null; } export default IGroupItemProps; diff --git a/src/extension/components/SideBarAccountList/types/IProps.ts b/src/extension/components/SideBarAccountList/types/IProps.ts index bf94f25f..5cebf0b2 100644 --- a/src/extension/components/SideBarAccountList/types/IProps.ts +++ b/src/extension/components/SideBarAccountList/types/IProps.ts @@ -13,7 +13,7 @@ interface IProps { isShortForm: boolean; items: (IAccountWithExtendedProps | IAccountGroup)[]; network: INetworkWithTransactionParams | null; - onClick: (id: string) => void; + onAccountClick: (id: string) => void; onSort: (items: (IAccountWithExtendedProps | IAccountGroup)[]) => void; systemInfo: ISystemInfo | null; } diff --git a/src/extension/components/SideBarAccountList/types/index.ts b/src/extension/components/SideBarAccountList/types/index.ts index b8ff730b..0b803bcd 100644 --- a/src/extension/components/SideBarAccountList/types/index.ts +++ b/src/extension/components/SideBarAccountList/types/index.ts @@ -1,3 +1,3 @@ export type { default as IGroupItemProps } from './IGroupItemProps'; -export type { default as IItemProps } from './IItemProps'; +export type { default as IAccountItemProps } from './IAccountItemProps'; export type { default as IProps } from './IProps'; diff --git a/src/extension/constants/Dimensions.ts b/src/extension/constants/Dimensions.ts index 66e442b8..79861131 100644 --- a/src/extension/constants/Dimensions.ts +++ b/src/extension/constants/Dimensions.ts @@ -12,5 +12,5 @@ export const SIDEBAR_BORDER_WIDTH = 1; export const SIDEBAR_ITEM_HEIGHT = 12; export const SETTINGS_ITEM_HEIGHT = 16; export const SIDEBAR_MIN_WIDTH = 45; -export const SIDEBAR_MAX_WIDTH = 250; +export const SIDEBAR_MAX_WIDTH = 340; export const TAB_ITEM_HEIGHT = 16; From e5e20f499a28733512cc1a64510a76eb354f6985 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 16 Nov 2024 10:24:12 +0000 Subject: [PATCH 13/25] refactor: separate group list into a separate component --- src/extension/components/SideBar/SideBar.tsx | 83 +++++++----- .../SideBarAccountItem.tsx} | 6 +- .../components/SideBarAccountItem/index.ts | 2 + .../types/IProps.ts} | 4 +- .../SideBarAccountItem/types/index.ts | 1 + .../SideBarAccountList/SideBarAccountList.tsx | 122 +++++++----------- .../SideBarAccountList/types/IProps.ts | 7 +- .../SideBarAccountList/types/index.ts | 2 - .../SideBarGroupItem.tsx} | 33 +---- .../components/SideBarGroupItem/index.ts | 1 + .../types/IProps.ts} | 14 +- .../SideBarGroupItem/types/index.ts | 1 + .../SideBarGroupList/SideBarGroupList.tsx | 112 ++++++++++++++++ .../components/SideBarGroupList/index.ts | 2 + .../SideBarGroupList/types/IProps.ts | 20 +++ .../SideBarGroupList/types/index.ts | 3 + .../SideBarSkeletonItem.tsx} | 4 +- .../components/SideBarSkeletonItem/index.ts | 1 + src/extension/features/accounts/slice.ts | 5 +- 19 files changed, 270 insertions(+), 153 deletions(-) rename src/extension/components/{SideBarAccountList/AccountItem.tsx => SideBarAccountItem/SideBarAccountItem.tsx} (97%) create mode 100644 src/extension/components/SideBarAccountItem/index.ts rename src/extension/components/{SideBarAccountList/types/IAccountItemProps.ts => SideBarAccountItem/types/IProps.ts} (87%) create mode 100644 src/extension/components/SideBarAccountItem/types/index.ts rename src/extension/components/{SideBarAccountList/GroupItem.tsx => SideBarGroupItem/SideBarGroupItem.tsx} (82%) create mode 100644 src/extension/components/SideBarGroupItem/index.ts rename src/extension/components/{SideBarAccountList/types/IGroupItemProps.ts => SideBarGroupItem/types/IProps.ts} (64%) create mode 100644 src/extension/components/SideBarGroupItem/types/index.ts create mode 100644 src/extension/components/SideBarGroupList/SideBarGroupList.tsx create mode 100644 src/extension/components/SideBarGroupList/index.ts create mode 100644 src/extension/components/SideBarGroupList/types/IProps.ts create mode 100644 src/extension/components/SideBarGroupList/types/index.ts rename src/extension/components/{SideBarAccountList/SkeletonItem.tsx => SideBarSkeletonItem/SideBarSkeletonItem.tsx} (94%) create mode 100644 src/extension/components/SideBarSkeletonItem/index.ts diff --git a/src/extension/components/SideBar/SideBar.tsx b/src/extension/components/SideBar/SideBar.tsx index 108f9356..eb7c99c1 100644 --- a/src/extension/components/SideBar/SideBar.tsx +++ b/src/extension/components/SideBar/SideBar.tsx @@ -24,6 +24,8 @@ import KibisisIcon from '@extension/components/KibisisIcon'; import ScrollableContainer from '@extension/components/ScrollableContainer'; import SideBarAccountList from '@extension/components/SideBarAccountList'; import SideBarActionItem from '@extension/components/SideBarActionItem'; +import SideBarGroupList from '@extension/components/SideBarGroupList'; +import SideBarSkeletonItem from '@extension/components/SideBarSkeletonItem'; // constants import { @@ -37,7 +39,7 @@ import { } from '@extension/constants'; // enums -import { AccountTabEnum, DelimiterEnum } from '@extension/enums'; +import { AccountTabEnum } from '@extension/enums'; // features import { @@ -76,7 +78,6 @@ import type { // utils import calculateIconSize from '@extension/utils/calculateIconSize'; -import sortByIndex from '@extension/utils/sortByIndex'; const SideBar: FC = () => { const { t } = useTranslation(); @@ -132,22 +133,24 @@ const SideBar: FC = () => { onCloseSideBar(); }; - const handleOnAccountSort = ( - items: (IAccountWithExtendedProps | IAccountGroup)[] - ) => { + const handleOnAccountSort = (items: IAccountWithExtendedProps[]) => { const _items = items.map((value, index) => ({ ...value, index, })); - const _accounts = _items.filter( - ({ _delimiter }) => _delimiter === DelimiterEnum.Account - ) as IAccountWithExtendedProps[]; - const _groups = _items.filter( - ({ _delimiter }) => _delimiter === DelimiterEnum.Group - ) as IAccountGroup[]; - dispatch(saveAccountsThunk(_accounts)); - dispatch(saveAccountGroupsThunk(_groups)); + dispatch(saveAccountsThunk(_items)); + }; + const handleOnGroupSort = (items: IAccountGroup[]) => { + console.log('groups:', groups); + console.log('items:', items); + const _items = items.map((value, index) => ({ + ...value, + index, + })); + console.log('_items:', _items); + + dispatch(saveAccountGroupsThunk(_items)); }; const handleScanQRCodeClick = () => dispatch( @@ -243,7 +246,7 @@ const SideBar: FC = () => { - {/*accounts*/} + {/*groups/accounts*/} { spacing={0} w="full" > - - !groupID || !groups.some(({ id }) => id === groupID) - ), // remove any accounts that are in a group and the group is actually a group - ...groups, - ])} - network={network} - onAccountClick={handleOnAccountClick} - onSort={handleOnAccountSort} - systemInfo={systemInfo} - /> + {!network || fetchingAccounts ? ( + Array.from({ length: 3 }, (_, index) => ( + + )) + ) : ( + <> + {/*groups*/} + {groups.length > 0 && ( + <> + + + + )} + + {/*accounts*/} + + + )} diff --git a/src/extension/components/SideBarAccountList/AccountItem.tsx b/src/extension/components/SideBarAccountItem/SideBarAccountItem.tsx similarity index 97% rename from src/extension/components/SideBarAccountList/AccountItem.tsx rename to src/extension/components/SideBarAccountItem/SideBarAccountItem.tsx index 1fcbc477..50a8b31d 100644 --- a/src/extension/components/SideBarAccountList/AccountItem.tsx +++ b/src/extension/components/SideBarAccountItem/SideBarAccountItem.tsx @@ -31,14 +31,14 @@ import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import useSubTextColor from '@extension/hooks/useSubTextColor'; // types -import type { IAccountItemProps } from './types'; +import type { IProps } from './types'; // utils import calculateIconSize from '@extension/utils/calculateIconSize'; import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVMAddress'; import ellipseAddress from '@extension/utils/ellipseAddress'; -const AccountItem: FC = ({ +const SideBarAccountItem: FC = ({ account, accounts, active, @@ -192,4 +192,4 @@ const AccountItem: FC = ({ ); }; -export default AccountItem; +export default SideBarAccountItem; diff --git a/src/extension/components/SideBarAccountItem/index.ts b/src/extension/components/SideBarAccountItem/index.ts new file mode 100644 index 00000000..9ea0c2c5 --- /dev/null +++ b/src/extension/components/SideBarAccountItem/index.ts @@ -0,0 +1,2 @@ +export { default } from './SideBarAccountItem'; +export * from './types'; diff --git a/src/extension/components/SideBarAccountList/types/IAccountItemProps.ts b/src/extension/components/SideBarAccountItem/types/IProps.ts similarity index 87% rename from src/extension/components/SideBarAccountList/types/IAccountItemProps.ts rename to src/extension/components/SideBarAccountItem/types/IProps.ts index 7aa37be5..07877bbd 100644 --- a/src/extension/components/SideBarAccountList/types/IAccountItemProps.ts +++ b/src/extension/components/SideBarAccountItem/types/IProps.ts @@ -8,7 +8,7 @@ import type { /** * @property {boolean} isShortForm - Whether the full item is being shown or just the avatar. */ -interface IAccountItemProps { +interface IProps { account: IAccountWithExtendedProps; accounts: IAccountWithExtendedProps[]; active: boolean; @@ -18,4 +18,4 @@ interface IAccountItemProps { systemInfo: ISystemInfo | null; } -export default IAccountItemProps; +export default IProps; diff --git a/src/extension/components/SideBarAccountItem/types/index.ts b/src/extension/components/SideBarAccountItem/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/components/SideBarAccountItem/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/components/SideBarAccountList/SideBarAccountList.tsx b/src/extension/components/SideBarAccountList/SideBarAccountList.tsx index ce23f80c..d2b9f492 100644 --- a/src/extension/components/SideBarAccountList/SideBarAccountList.tsx +++ b/src/extension/components/SideBarAccountList/SideBarAccountList.tsx @@ -13,29 +13,19 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable'; -import React, { type FC, useEffect, useState } from 'react'; +import React, { type FC, useEffect, useMemo, useState } from 'react'; // components -import AccountItem from './AccountItem'; -import GroupItem from './GroupItem'; -import SkeletonItem from './SkeletonItem'; - -// enums -import { DelimiterEnum } from '@extension/enums'; +import SideBarAccountItem from '@extension/components/SideBarAccountItem'; // types -import type { - IAccountGroup, - IAccountWithExtendedProps, -} from '@extension/types'; +import type { IAccountWithExtendedProps } from '@extension/types'; import type { IProps } from './types'; const SideBarAccountList: FC = ({ accounts, activeAccount, - isLoading, isShortForm, - items, network, onAccountClick, onSort, @@ -47,83 +37,65 @@ const SideBarAccountList: FC = ({ coordinateGetter: sortableKeyboardCoordinates, }) ); + // memos + const accountsWithoutGroup = useMemo( + () => accounts.filter(({ groupID }) => !groupID), + [accounts] + ); // states - const [_items, setItems] = - useState<(IAccountWithExtendedProps | IAccountGroup)[]>(items); + const [_accounts, setAccounts] = + useState(accountsWithoutGroup); // handlers const handleOnAccountClick = async (id: string) => onAccountClick(id); const handleOnDragEnd = (event: DragEndEvent) => { const { active, over } = event; let previousIndex: number; let nextIndex: number; - let updatedItems: (IAccountWithExtendedProps | IAccountGroup)[]; + let updatedItems: IAccountWithExtendedProps[]; + + if (!over || active.id === over.id) { + return; + } - if (active.id !== over?.id) { - previousIndex = items.findIndex(({ id }) => id === active.id); - nextIndex = items.findIndex(({ id }) => id === over?.id); + previousIndex = _accounts.findIndex(({ id }) => id === active.id); + nextIndex = _accounts.findIndex(({ id }) => id === over.id); - setItems((prevState) => { - updatedItems = arrayMove(prevState, previousIndex, nextIndex); + setAccounts((prevState) => { + updatedItems = arrayMove(prevState, previousIndex, nextIndex); - // update the external account/group state - onSort(updatedItems); + // update the external state + onSort(updatedItems); - return updatedItems; - }); - } + return updatedItems; + }); }; - // update the internal accounts/groups state with the incoming state - useEffect(() => setItems(items), [items]); + useEffect(() => setAccounts(accountsWithoutGroup), [accountsWithoutGroup]); return ( - <> - {isLoading || !network ? ( - Array.from({ length: 3 }, (_, index) => ( - - )) - ) : ( - - - {_items.map((value) => { - if (value._delimiter === DelimiterEnum.Account) { - return ( - - ); - } - - return ( - - ); - })} - - - )} - + + + {accountsWithoutGroup.map((value) => ( + + ))} + + ); }; diff --git a/src/extension/components/SideBarAccountList/types/IProps.ts b/src/extension/components/SideBarAccountList/types/IProps.ts index 5cebf0b2..e3f74b69 100644 --- a/src/extension/components/SideBarAccountList/types/IProps.ts +++ b/src/extension/components/SideBarAccountList/types/IProps.ts @@ -1,6 +1,5 @@ // types import type { - IAccountGroup, IAccountWithExtendedProps, INetworkWithTransactionParams, ISystemInfo, @@ -9,12 +8,10 @@ import type { interface IProps { accounts: IAccountWithExtendedProps[]; activeAccount: IAccountWithExtendedProps | null; - isLoading: boolean; isShortForm: boolean; - items: (IAccountWithExtendedProps | IAccountGroup)[]; - network: INetworkWithTransactionParams | null; + network: INetworkWithTransactionParams; onAccountClick: (id: string) => void; - onSort: (items: (IAccountWithExtendedProps | IAccountGroup)[]) => void; + onSort: (items: IAccountWithExtendedProps[]) => void; systemInfo: ISystemInfo | null; } diff --git a/src/extension/components/SideBarAccountList/types/index.ts b/src/extension/components/SideBarAccountList/types/index.ts index 0b803bcd..f404deed 100644 --- a/src/extension/components/SideBarAccountList/types/index.ts +++ b/src/extension/components/SideBarAccountList/types/index.ts @@ -1,3 +1 @@ -export type { default as IGroupItemProps } from './IGroupItemProps'; -export type { default as IAccountItemProps } from './IAccountItemProps'; export type { default as IProps } from './IProps'; diff --git a/src/extension/components/SideBarAccountList/GroupItem.tsx b/src/extension/components/SideBarGroupItem/SideBarGroupItem.tsx similarity index 82% rename from src/extension/components/SideBarAccountList/GroupItem.tsx rename to src/extension/components/SideBarGroupItem/SideBarGroupItem.tsx index 999783f9..a2b0ebbf 100644 --- a/src/extension/components/SideBarAccountList/GroupItem.tsx +++ b/src/extension/components/SideBarGroupItem/SideBarGroupItem.tsx @@ -30,9 +30,6 @@ import { SIDEBAR_MIN_WIDTH, } from '@extension/constants'; -// components -import AccountItem from './AccountItem'; - // hooks import useButtonHoverBackgroundColor from '@extension/hooks/useButtonHoverBackgroundColor'; import useColorModeValue from '@extension/hooks/useColorModeValue'; @@ -40,19 +37,17 @@ import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import useSubTextColor from '@extension/hooks/useSubTextColor'; // types -import type { IGroupItemProps } from './types'; +import type { IProps } from './types'; // utils import calculateIconSize from '@extension/utils/calculateIconSize'; -const GroupItem: FC = ({ +const SideBarGroupItem: FC = ({ activeAccountID, accounts, + children, group, isShortForm, - network, - onAccountClick, - systemInfo, }) => { const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: @@ -76,8 +71,6 @@ const GroupItem: FC = ({ const defaultTextColor = useDefaultTextColor(); const subTextColor = useSubTextColor(); const iconBackground = useColorModeValue('gray.300', 'whiteAlpha.400'); - // misc - const _accounts = accounts.filter(({ groupID }) => groupID === group.id); // handlers const handleOnClick = () => onToggle(); @@ -174,26 +167,10 @@ const GroupItem: FC = ({ {/*accounts*/} - - {_accounts.map((value) => ( - - ))} - + <>{children} ); }; -export default GroupItem; +export default SideBarGroupItem; diff --git a/src/extension/components/SideBarGroupItem/index.ts b/src/extension/components/SideBarGroupItem/index.ts new file mode 100644 index 00000000..ea848351 --- /dev/null +++ b/src/extension/components/SideBarGroupItem/index.ts @@ -0,0 +1 @@ +export { default } from './SideBarGroupItem'; diff --git a/src/extension/components/SideBarAccountList/types/IGroupItemProps.ts b/src/extension/components/SideBarGroupItem/types/IProps.ts similarity index 64% rename from src/extension/components/SideBarAccountList/types/IGroupItemProps.ts rename to src/extension/components/SideBarGroupItem/types/IProps.ts index b1d1f18f..ff2296e0 100644 --- a/src/extension/components/SideBarAccountList/types/IGroupItemProps.ts +++ b/src/extension/components/SideBarGroupItem/types/IProps.ts @@ -1,3 +1,8 @@ +import type { ReactElement } from 'react'; + +// types +import type { IProps as ISideBarAccountItemProps } from '@extension/components/SideBarAccountItem'; + // types import type { IAccountGroup, @@ -11,9 +16,14 @@ import type { * @property {IAccountGroup} group - The group. * @property {boolean} isShortForm - Whether the full item is being shown or just the avatar. */ -interface IGroupItemProps { +interface IProps { activeAccountID: string | null; accounts: IAccountWithExtendedProps[]; + children: + | ReactElement + | ReactElement[] + | null; + defaultIsOpen?: boolean; group: IAccountGroup; isShortForm: boolean; network: INetworkWithTransactionParams; @@ -21,4 +31,4 @@ interface IGroupItemProps { systemInfo: ISystemInfo | null; } -export default IGroupItemProps; +export default IProps; diff --git a/src/extension/components/SideBarGroupItem/types/index.ts b/src/extension/components/SideBarGroupItem/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/components/SideBarGroupItem/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/components/SideBarGroupList/SideBarGroupList.tsx b/src/extension/components/SideBarGroupList/SideBarGroupList.tsx new file mode 100644 index 00000000..84d28c1a --- /dev/null +++ b/src/extension/components/SideBarGroupList/SideBarGroupList.tsx @@ -0,0 +1,112 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import React, { type FC, useEffect, useState } from 'react'; + +// components +import SideBarAccountItem from '@extension/components/SideBarAccountItem'; +import SideBarGroupItem from '@extension/components/SideBarGroupItem'; + +// types +import type { IAccountGroup } from '@extension/types'; +import type { IProps } from './types'; + +const SideBarGroupList: FC = ({ + accounts, + activeAccount, + groups, + isShortForm, + network, + onAccountClick, + onSort, + systemInfo, +}) => { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + // states + const [_groups, setGroups] = useState(groups); + // handlers + const handleOnAccountClick = async (id: string) => onAccountClick(id); + const handleOnDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + let previousIndex: number; + let nextIndex: number; + let updatedItems: IAccountGroup[]; + + if (!over || active.id === over.id) { + return; + } + + previousIndex = _groups.findIndex(({ id }) => id === active.id); + nextIndex = _groups.findIndex(({ id }) => id === over.id); + + setGroups((prevState) => { + updatedItems = arrayMove(prevState, previousIndex, nextIndex); + + // update the external state + onSort(updatedItems); + + return updatedItems; + }); + }; + + useEffect(() => setGroups(groups), [groups]); + + return ( + + + {groups.map((group) => ( + + {accounts + .filter(({ groupID }) => !!groupID && groupID === group.id) + .map((value) => ( + + ))} + + ))} + + + ); +}; + +export default SideBarGroupList; diff --git a/src/extension/components/SideBarGroupList/index.ts b/src/extension/components/SideBarGroupList/index.ts new file mode 100644 index 00000000..a9dc2534 --- /dev/null +++ b/src/extension/components/SideBarGroupList/index.ts @@ -0,0 +1,2 @@ +export { default } from './SideBarGroupList'; +export * from './types'; diff --git a/src/extension/components/SideBarGroupList/types/IProps.ts b/src/extension/components/SideBarGroupList/types/IProps.ts new file mode 100644 index 00000000..c94a1216 --- /dev/null +++ b/src/extension/components/SideBarGroupList/types/IProps.ts @@ -0,0 +1,20 @@ +// types +import type { + IAccountGroup, + IAccountWithExtendedProps, + INetworkWithTransactionParams, + ISystemInfo, +} from '@extension/types'; + +interface IProps { + accounts: IAccountWithExtendedProps[]; + activeAccount: IAccountWithExtendedProps | null; + groups: IAccountGroup[]; + isShortForm: boolean; + network: INetworkWithTransactionParams; + onAccountClick: (id: string) => void; + onSort: (items: IAccountGroup[]) => void; + systemInfo: ISystemInfo | null; +} + +export default IProps; diff --git a/src/extension/components/SideBarGroupList/types/index.ts b/src/extension/components/SideBarGroupList/types/index.ts new file mode 100644 index 00000000..0b803bcd --- /dev/null +++ b/src/extension/components/SideBarGroupList/types/index.ts @@ -0,0 +1,3 @@ +export type { default as IGroupItemProps } from './IGroupItemProps'; +export type { default as IAccountItemProps } from './IAccountItemProps'; +export type { default as IProps } from './IProps'; diff --git a/src/extension/components/SideBarAccountList/SkeletonItem.tsx b/src/extension/components/SideBarSkeletonItem/SideBarSkeletonItem.tsx similarity index 94% rename from src/extension/components/SideBarAccountList/SkeletonItem.tsx rename to src/extension/components/SideBarSkeletonItem/SideBarSkeletonItem.tsx index ea56216e..20761ced 100644 --- a/src/extension/components/SideBarAccountList/SkeletonItem.tsx +++ b/src/extension/components/SideBarSkeletonItem/SideBarSkeletonItem.tsx @@ -19,7 +19,7 @@ import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; // utils import ellipseAddress from '@extension/utils/ellipseAddress'; -const SkeletonItem: FC = () => { +const SideBarSkeletonItem: FC = () => { // hooks const buttonHoverBackgroundColor = useButtonHoverBackgroundColor(); const defaultTextColor = useDefaultTextColor(); @@ -55,4 +55,4 @@ const SkeletonItem: FC = () => { ); }; -export default SkeletonItem; +export default SideBarSkeletonItem; diff --git a/src/extension/components/SideBarSkeletonItem/index.ts b/src/extension/components/SideBarSkeletonItem/index.ts new file mode 100644 index 00000000..ec0bb613 --- /dev/null +++ b/src/extension/components/SideBarSkeletonItem/index.ts @@ -0,0 +1 @@ +export { default } from './SideBarSkeletonItem'; diff --git a/src/extension/features/accounts/slice.ts b/src/extension/features/accounts/slice.ts index 7bd83492..cb09b0c1 100644 --- a/src/extension/features/accounts/slice.ts +++ b/src/extension/features/accounts/slice.ts @@ -269,9 +269,8 @@ const slice = createSlice({ builder.addCase( saveAccountGroupsThunk.fulfilled, (state: IState, action) => { - state.groups = upsertItemsById( - state.groups, - action.payload + state.groups = sortByIndex( + upsertItemsById(state.groups, action.payload) ); } ); From 94d259dc50c42249c6503bd5268709a403709418 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 16 Nov 2024 11:10:51 +0000 Subject: [PATCH 14/25] feat: allow re-ordering accounts in group --- src/extension/components/SideBar/SideBar.tsx | 30 ++---- .../SideBarAccountList/SideBarAccountList.tsx | 16 +++- .../SideBarAccountList/types/IProps.ts | 2 +- .../SideBarGroupItem/SideBarGroupItem.tsx | 93 ++++++++++++++++++- .../SideBarGroupItem/types/IProps.ts | 13 +-- .../SideBarGroupList/SideBarGroupList.tsx | 50 +++++----- .../SideBarGroupList/types/IProps.ts | 5 +- src/extension/features/accounts/slice.ts | 11 ++- .../AccountRepository/AccountRepository.ts | 33 +++++++ .../utils/sortByIndex/sortByIndex.ts | 2 - 10 files changed, 175 insertions(+), 80 deletions(-) diff --git a/src/extension/components/SideBar/SideBar.tsx b/src/extension/components/SideBar/SideBar.tsx index eb7c99c1..a532322c 100644 --- a/src/extension/components/SideBar/SideBar.tsx +++ b/src/extension/components/SideBar/SideBar.tsx @@ -133,25 +133,10 @@ const SideBar: FC = () => { onCloseSideBar(); }; - const handleOnAccountSort = (items: IAccountWithExtendedProps[]) => { - const _items = items.map((value, index) => ({ - ...value, - index, - })); - - dispatch(saveAccountsThunk(_items)); - }; - const handleOnGroupSort = (items: IAccountGroup[]) => { - console.log('groups:', groups); - console.log('items:', items); - const _items = items.map((value, index) => ({ - ...value, - index, - })); - console.log('_items:', _items); - - dispatch(saveAccountGroupsThunk(_items)); - }; + const handleOnAccountSort = (items: IAccountWithExtendedProps[]) => + dispatch(saveAccountsThunk(items)); + const handleOnGroupSort = (items: IAccountGroup[]) => + dispatch(saveAccountGroupsThunk(items)); const handleScanQRCodeClick = () => dispatch( setScanQRCodeModal({ @@ -266,12 +251,13 @@ const SideBar: FC = () => { <> @@ -281,7 +267,7 @@ const SideBar: FC = () => { {/*accounts*/} = ({ accounts, - activeAccount, + activeAccountID, isShortForm, network, onAccountClick, @@ -39,12 +40,12 @@ const SideBarAccountList: FC = ({ ); // memos const accountsWithoutGroup = useMemo( - () => accounts.filter(({ groupID }) => !groupID), + () => sortByIndex(accounts.filter(({ groupID }) => !groupID)), [accounts] ); // states const [_accounts, setAccounts] = - useState(accountsWithoutGroup); + useState(accountsWithoutGroup); // a local state fixes the delay between the ui and redux updates // handlers const handleOnAccountClick = async (id: string) => onAccountClick(id); const handleOnDragEnd = (event: DragEndEvent) => { @@ -61,7 +62,12 @@ const SideBarAccountList: FC = ({ nextIndex = _accounts.findIndex(({ id }) => id === over.id); setAccounts((prevState) => { - updatedItems = arrayMove(prevState, previousIndex, nextIndex); + updatedItems = arrayMove(prevState, previousIndex, nextIndex).map( + (value, index) => ({ + ...value, + index, + }) + ); // update the external state onSort(updatedItems); @@ -86,7 +92,7 @@ const SideBarAccountList: FC = ({ void; diff --git a/src/extension/components/SideBarGroupItem/SideBarGroupItem.tsx b/src/extension/components/SideBarGroupItem/SideBarGroupItem.tsx index a2b0ebbf..ef9caa1d 100644 --- a/src/extension/components/SideBarGroupItem/SideBarGroupItem.tsx +++ b/src/extension/components/SideBarGroupItem/SideBarGroupItem.tsx @@ -1,5 +1,16 @@ import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, SortableContext, + sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; @@ -15,7 +26,7 @@ import { Tooltip, useDisclosure, } from '@chakra-ui/react'; -import React, { type FC } from 'react'; +import React, { type FC, useMemo, useState } from 'react'; import { IoFolderOutline, IoFolderOpenOutline, @@ -30,13 +41,20 @@ import { SIDEBAR_MIN_WIDTH, } from '@extension/constants'; +// components +import SideBarAccountItem from '@extension/components/SideBarAccountItem'; + // hooks import useButtonHoverBackgroundColor from '@extension/hooks/useButtonHoverBackgroundColor'; import useColorModeValue from '@extension/hooks/useColorModeValue'; import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import useSubTextColor from '@extension/hooks/useSubTextColor'; +// repositories +import AccountRepository from '@extension/repositories/AccountRepository'; + // types +import type { IAccountWithExtendedProps } from '@extension/types'; import type { IProps } from './types'; // utils @@ -45,9 +63,12 @@ import calculateIconSize from '@extension/utils/calculateIconSize'; const SideBarGroupItem: FC = ({ activeAccountID, accounts, - children, group, isShortForm, + network, + onAccountClick, + onAccountSort, + systemInfo, }) => { const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: @@ -56,6 +77,12 @@ const SideBarGroupItem: FC = ({ .filter(({ groupID }) => groupID === group.id) .find(({ id }) => id === activeAccountID), }); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); const { attributes, isDragging, @@ -71,8 +98,46 @@ const SideBarGroupItem: FC = ({ const defaultTextColor = useDefaultTextColor(); const subTextColor = useSubTextColor(); const iconBackground = useColorModeValue('gray.300', 'whiteAlpha.400'); + // memos + const groupAccounts = useMemo( + () => + AccountRepository.sortByGroupIndex( + accounts.filter(({ groupID }) => !!groupID && groupID === group.id) + ), + [accounts] + ); + // states + const [_accounts, setAccounts] = + useState(groupAccounts); // a local state fixes the delay between the ui and redux updates // handlers const handleOnClick = () => onToggle(); + const handleOnDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + let previousIndex: number; + let nextIndex: number; + let updatedItems: IAccountWithExtendedProps[]; + + if (!over || active.id === over.id) { + return; + } + + previousIndex = _accounts.findIndex(({ id }) => id === active.id); + nextIndex = _accounts.findIndex(({ id }) => id === over.id); + + setAccounts((prevState) => { + updatedItems = arrayMove(prevState, previousIndex, nextIndex).map( + (value, index) => ({ + ...value, + groupIndex: index, + }) + ); + + // update the external state + onAccountSort(updatedItems); + + return updatedItems; + }); + }; return ( <> @@ -167,7 +232,29 @@ const SideBarGroupItem: FC = ({ {/*accounts*/} - <>{children} + + + {_accounts.map((value) => ( + + ))} + + ); diff --git a/src/extension/components/SideBarGroupItem/types/IProps.ts b/src/extension/components/SideBarGroupItem/types/IProps.ts index ff2296e0..73d039eb 100644 --- a/src/extension/components/SideBarGroupItem/types/IProps.ts +++ b/src/extension/components/SideBarGroupItem/types/IProps.ts @@ -1,8 +1,3 @@ -import type { ReactElement } from 'react'; - -// types -import type { IProps as ISideBarAccountItemProps } from '@extension/components/SideBarAccountItem'; - // types import type { IAccountGroup, @@ -17,17 +12,13 @@ import type { * @property {boolean} isShortForm - Whether the full item is being shown or just the avatar. */ interface IProps { - activeAccountID: string | null; accounts: IAccountWithExtendedProps[]; - children: - | ReactElement - | ReactElement[] - | null; - defaultIsOpen?: boolean; + activeAccountID: string | null; group: IAccountGroup; isShortForm: boolean; network: INetworkWithTransactionParams; onAccountClick: (id: string) => void; + onAccountSort: (items: IAccountWithExtendedProps[]) => void; systemInfo: ISystemInfo | null; } diff --git a/src/extension/components/SideBarGroupList/SideBarGroupList.tsx b/src/extension/components/SideBarGroupList/SideBarGroupList.tsx index 84d28c1a..79c38b87 100644 --- a/src/extension/components/SideBarGroupList/SideBarGroupList.tsx +++ b/src/extension/components/SideBarGroupList/SideBarGroupList.tsx @@ -13,24 +13,25 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable'; -import React, { type FC, useEffect, useState } from 'react'; +import React, { type FC, useEffect, useMemo, useState } from 'react'; // components -import SideBarAccountItem from '@extension/components/SideBarAccountItem'; import SideBarGroupItem from '@extension/components/SideBarGroupItem'; // types import type { IAccountGroup } from '@extension/types'; import type { IProps } from './types'; +import sortByIndex from '@extension/utils/sortByIndex'; const SideBarGroupList: FC = ({ accounts, - activeAccount, + activeAccountID, groups, isShortForm, network, onAccountClick, - onSort, + onAccountSort, + onGroupSort, systemInfo, }) => { const sensors = useSensors( @@ -39,11 +40,13 @@ const SideBarGroupList: FC = ({ coordinateGetter: sortableKeyboardCoordinates, }) ); + // memos + const sortedGroups = useMemo(() => sortByIndex([...groups]), [groups]); // states - const [_groups, setGroups] = useState(groups); + const [_groups, setGroups] = useState(sortedGroups); // a local state fixes the delay between the ui and redux updates // handlers const handleOnAccountClick = async (id: string) => onAccountClick(id); - const handleOnDragEnd = (event: DragEndEvent) => { + const handleOnGroupDragEnd = (event: DragEndEvent) => { const { active, over } = event; let previousIndex: number; let nextIndex: number; @@ -57,52 +60,41 @@ const SideBarGroupList: FC = ({ nextIndex = _groups.findIndex(({ id }) => id === over.id); setGroups((prevState) => { - updatedItems = arrayMove(prevState, previousIndex, nextIndex); + updatedItems = arrayMove(prevState, previousIndex, nextIndex).map( + (value, index) => ({ + ...value, + index, + }) + ); // update the external state - onSort(updatedItems); + onGroupSort(updatedItems); return updatedItems; }); }; - useEffect(() => setGroups(groups), [groups]); + useEffect(() => setGroups(sortedGroups), [sortedGroups]); return ( {groups.map((group) => ( - {accounts - .filter(({ groupID }) => !!groupID && groupID === group.id) - .map((value) => ( - - ))} - + /> ))} diff --git a/src/extension/components/SideBarGroupList/types/IProps.ts b/src/extension/components/SideBarGroupList/types/IProps.ts index c94a1216..e0722684 100644 --- a/src/extension/components/SideBarGroupList/types/IProps.ts +++ b/src/extension/components/SideBarGroupList/types/IProps.ts @@ -8,12 +8,13 @@ import type { interface IProps { accounts: IAccountWithExtendedProps[]; - activeAccount: IAccountWithExtendedProps | null; + activeAccountID: string | null; groups: IAccountGroup[]; isShortForm: boolean; network: INetworkWithTransactionParams; onAccountClick: (id: string) => void; - onSort: (items: IAccountGroup[]) => void; + onAccountSort: (items: IAccountWithExtendedProps[]) => void; + onGroupSort: (items: IAccountGroup[]) => void; systemInfo: ISystemInfo | null; } diff --git a/src/extension/features/accounts/slice.ts b/src/extension/features/accounts/slice.ts index cb09b0c1..5835d1c9 100644 --- a/src/extension/features/accounts/slice.ts +++ b/src/extension/features/accounts/slice.ts @@ -31,7 +31,6 @@ import type { import type { IState } from './types'; // utils -import sortByIndex from '@extension/utils/sortByIndex'; import upsertItemsById from '@extension/utils/upsertItemsById'; import { getInitialState } from './utils'; @@ -269,15 +268,17 @@ const slice = createSlice({ builder.addCase( saveAccountGroupsThunk.fulfilled, (state: IState, action) => { - state.groups = sortByIndex( - upsertItemsById(state.groups, action.payload) + state.groups = upsertItemsById( + state.groups, + action.payload ); } ); /** save accounts **/ builder.addCase(saveAccountsThunk.fulfilled, (state: IState, action) => { - state.items = sortByIndex( - upsertItemsById(state.items, action.payload) + state.items = upsertItemsById( + state.items, + action.payload ); state.saving = false; }); diff --git a/src/extension/repositories/AccountRepository/AccountRepository.ts b/src/extension/repositories/AccountRepository/AccountRepository.ts index 5bb2bd1a..ea3b67d8 100644 --- a/src/extension/repositories/AccountRepository/AccountRepository.ts +++ b/src/extension/repositories/AccountRepository/AccountRepository.ts @@ -17,6 +17,7 @@ import type { IAccount, IAccountInformation, IAccountTransactions, + IAccountWithExtendedProps, IInitializeAccountOptions, INetwork, } from '@extension/types'; @@ -134,6 +135,38 @@ export default class AccountRepository extends BaseRepository { }; } + /** + * Sorts a list by the `groupIndex` property, where lower indexes take precedence. If `groupIndex` is null they are put + * to the back and sorted by the `createdAt` property, ascending order (oldest first). + * @param {IAccountWithExtendedProps[]} items - The items to sort. + * @returns {IAccountWithExtendedProps[]} the sorted items. + * @public + * @static + */ + public static sortByGroupIndex( + items: IAccountWithExtendedProps[] + ): IAccountWithExtendedProps[] { + return items.sort((a, b) => { + // if both positions are non-null, sort by position + if (a.groupIndex !== null && b.groupIndex !== null) { + return a.groupIndex - b.groupIndex; + } + + // if `a` position is null, place it after a `b` non-null position + if (a.groupIndex === null && b.groupIndex !== null) { + return 1; // `a` comes after `b` + } + + // if `b` position is null, place it after a `a` non-null position + if (a.groupIndex !== null && b.groupIndex === null) { + return -1; // `a` comes before `b` + } + + // if both positions are null, sort by `createdat` (ascending) + return a.createdAt - b.createdAt; + }); + } + /** * private functions */ diff --git a/src/extension/utils/sortByIndex/sortByIndex.ts b/src/extension/utils/sortByIndex/sortByIndex.ts index 50cc52de..00abeac6 100644 --- a/src/extension/utils/sortByIndex/sortByIndex.ts +++ b/src/extension/utils/sortByIndex/sortByIndex.ts @@ -7,8 +7,6 @@ import type { IOptions, IType } from './types'; * @param {Type extends IType[]} items - The items to sort. * @param {IOptions} options - [optional] applies indexes on items that do not have indexes. * @returns {Type extends IType[]} the sorted items. - * @public - * @static */ export default function sortByIndex( items: Type[], From 54a2906357f32e69bba8853849341db7c0c7f69f Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 16 Nov 2024 11:22:49 +0000 Subject: [PATCH 15/25] fix: allow groups to be correctly sorted --- .../SideBarGroupItem/SideBarGroupItem.tsx | 28 +++++++++++-------- .../SideBarGroupList/SideBarGroupList.tsx | 4 +-- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/extension/components/SideBarGroupItem/SideBarGroupItem.tsx b/src/extension/components/SideBarGroupItem/SideBarGroupItem.tsx index ef9caa1d..3461eebf 100644 --- a/src/extension/components/SideBarGroupItem/SideBarGroupItem.tsx +++ b/src/extension/components/SideBarGroupItem/SideBarGroupItem.tsx @@ -25,6 +25,7 @@ import { Text, Tooltip, useDisclosure, + VStack, } from '@chakra-ui/react'; import React, { type FC, useMemo, useState } from 'react'; import { @@ -140,24 +141,27 @@ const SideBarGroupItem: FC = ({ }; return ( - <> + + {/*remove from group button*/} + {account.groupID && ( + + )} + {/*re-order button*/} - {/*remove from group button*/} - {account.groupID && ( + {/*add/remove group button*/} + {account.groupID ? ( + ) : ( + )} {/*re-order button*/} diff --git a/src/extension/components/SideBarAccountItem/types/IProps.ts b/src/extension/components/SideBarAccountItem/types/IProps.ts index 33c66eff..8ea01abc 100644 --- a/src/extension/components/SideBarAccountItem/types/IProps.ts +++ b/src/extension/components/SideBarAccountItem/types/IProps.ts @@ -14,6 +14,7 @@ interface IProps { active: boolean; isShortForm: boolean; network: INetworkWithTransactionParams; + onAddToGroupClick?: (accountID: string) => void; onClick: (id: string) => void; onRemoveFromGroupClick?: (accountID: string) => void; systemInfo: ISystemInfo | null; diff --git a/src/extension/components/SideBarAccountList/SideBarAccountList.tsx b/src/extension/components/SideBarAccountList/SideBarAccountList.tsx index e2d8eb8c..9d4e9bb3 100644 --- a/src/extension/components/SideBarAccountList/SideBarAccountList.tsx +++ b/src/extension/components/SideBarAccountList/SideBarAccountList.tsx @@ -31,6 +31,7 @@ const SideBarAccountList: FC = ({ isShortForm, network, onAccountClick, + onAddToGroupClick, onSort, systemInfo, }) => { @@ -98,6 +99,7 @@ const SideBarAccountList: FC = ({ isShortForm={isShortForm} key={value.id} network={network} + onAddToGroupClick={onAddToGroupClick} onClick={handleOnAccountClick} systemInfo={systemInfo} /> diff --git a/src/extension/components/SideBarAccountList/types/IProps.ts b/src/extension/components/SideBarAccountList/types/IProps.ts index d3a89509..5f0303c8 100644 --- a/src/extension/components/SideBarAccountList/types/IProps.ts +++ b/src/extension/components/SideBarAccountList/types/IProps.ts @@ -11,7 +11,7 @@ interface IProps { isShortForm: boolean; network: INetworkWithTransactionParams; onAccountClick: (id: string) => void; - onRemoveFromGroupClick: (accountID: string) => void; + onAddToGroupClick: (accountID: string) => void; onSort: (items: IAccountWithExtendedProps[]) => void; systemInfo: ISystemInfo | null; } diff --git a/src/extension/enums/StoreNameEnum.ts b/src/extension/enums/StoreNameEnum.ts index cda2c2fc..820aab6f 100644 --- a/src/extension/enums/StoreNameEnum.ts +++ b/src/extension/enums/StoreNameEnum.ts @@ -7,6 +7,7 @@ enum StoreNameEnum { Events = 'events', Messages = 'messages', Layout = 'layout', + MoveGroupModal = 'move-group-modal', Networks = 'networks', Notifications = 'notifications', Passkeys = 'passkeys', diff --git a/src/extension/features/layout/slice.ts b/src/extension/features/layout/slice.ts index f2736305..1490f6f4 100644 --- a/src/extension/features/layout/slice.ts +++ b/src/extension/features/layout/slice.ts @@ -4,6 +4,7 @@ import { createSlice, Draft, PayloadAction, Reducer } from '@reduxjs/toolkit'; import { StoreNameEnum } from '@extension/enums'; // types +import type { IAccountWithExtendedProps } from '@extension/types'; import type { IConfirmModal, IScanQRCodeModal, IState } from './types'; // utils @@ -19,6 +20,12 @@ const slice = createSlice({ ) => { state.confirmModal = action.payload; }, + setMoveAccountGroupModal: ( + state: Draft, + action: PayloadAction + ) => { + state.moveAccountGroupModal = action.payload; + }, setScanQRCodeModal: ( state: Draft, action: PayloadAction @@ -40,6 +47,7 @@ const slice = createSlice({ export const reducer: Reducer = slice.reducer; export const { setConfirmModal, + setMoveAccountGroupModal, setScanQRCodeModal, setSideBar, setWhatsNewModal, diff --git a/src/extension/features/layout/types/IState.ts b/src/extension/features/layout/types/IState.ts index d46ffd25..fb0069d1 100644 --- a/src/extension/features/layout/types/IState.ts +++ b/src/extension/features/layout/types/IState.ts @@ -1,9 +1,11 @@ // types +import type { IAccountWithExtendedProps } from '@extension/types'; import type IConfirmModal from './IConfirmModal'; import type IScanQRCodeModal from './IScanQRCodeModal'; interface IState { confirmModal: IConfirmModal | null; + moveAccountGroupModal: IAccountWithExtendedProps | null; scanQRCodeModal: IScanQRCodeModal | null; sidebar: boolean; whatsNewModal: boolean; diff --git a/src/extension/features/layout/utils/getInitialState.ts b/src/extension/features/layout/utils/getInitialState.ts index afdc282d..e27d606b 100644 --- a/src/extension/features/layout/utils/getInitialState.ts +++ b/src/extension/features/layout/utils/getInitialState.ts @@ -4,6 +4,7 @@ import type { IState } from '../types'; export default function getInitialState(): IState { return { confirmModal: null, + moveAccountGroupModal: null, scanQRCodeModal: null, sidebar: false, whatsNewModal: false, diff --git a/src/extension/features/move-group-modal/index.ts b/src/extension/features/move-group-modal/index.ts new file mode 100644 index 00000000..36c53b16 --- /dev/null +++ b/src/extension/features/move-group-modal/index.ts @@ -0,0 +1,2 @@ +export * from './slice'; +export * from './types'; diff --git a/src/extension/features/move-group-modal/slice.ts b/src/extension/features/move-group-modal/slice.ts new file mode 100644 index 00000000..5ffca648 --- /dev/null +++ b/src/extension/features/move-group-modal/slice.ts @@ -0,0 +1,26 @@ +import { createSlice, Draft, PayloadAction, Reducer } from '@reduxjs/toolkit'; + +// enums +import { StoreNameEnum } from '@extension/enums'; + +// types +import type { IState } from './types'; + +// utils +import getInitialState from './utils/getInitialState'; + +const slice = createSlice({ + initialState: getInitialState(), + name: StoreNameEnum.MoveGroupModal, + reducers: { + closeModal: (state: Draft) => { + state.accountID = null; + }, + openModal: (state: Draft, action: PayloadAction) => { + state.accountID = action.payload; + }, + }, +}); + +export const reducer: Reducer = slice.reducer; +export const { closeModal, openModal } = slice.actions; diff --git a/src/extension/features/move-group-modal/types/IState.ts b/src/extension/features/move-group-modal/types/IState.ts new file mode 100644 index 00000000..3e0a65a6 --- /dev/null +++ b/src/extension/features/move-group-modal/types/IState.ts @@ -0,0 +1,5 @@ +interface IState { + accountID: string | null; +} + +export default IState; diff --git a/src/extension/features/move-group-modal/types/index.ts b/src/extension/features/move-group-modal/types/index.ts new file mode 100644 index 00000000..bf812279 --- /dev/null +++ b/src/extension/features/move-group-modal/types/index.ts @@ -0,0 +1 @@ +export type { default as IState } from './IState'; diff --git a/src/extension/features/move-group-modal/utils/getInitialState/getInitialState.ts b/src/extension/features/move-group-modal/utils/getInitialState/getInitialState.ts new file mode 100644 index 00000000..16525633 --- /dev/null +++ b/src/extension/features/move-group-modal/utils/getInitialState/getInitialState.ts @@ -0,0 +1,8 @@ +// types +import type { IState } from '../../types'; + +export default function getInitialState(): IState { + return { + accountID: null, + }; +} diff --git a/src/extension/features/move-group-modal/utils/getInitialState/index.ts b/src/extension/features/move-group-modal/utils/getInitialState/index.ts new file mode 100644 index 00000000..1f873320 --- /dev/null +++ b/src/extension/features/move-group-modal/utils/getInitialState/index.ts @@ -0,0 +1 @@ +export { default } from './getInitialState'; diff --git a/src/extension/modals/MoveGroupModal/MoveGroupModal.tsx b/src/extension/modals/MoveGroupModal/MoveGroupModal.tsx index 90b38321..6f4a9316 100644 --- a/src/extension/modals/MoveGroupModal/MoveGroupModal.tsx +++ b/src/extension/modals/MoveGroupModal/MoveGroupModal.tsx @@ -47,9 +47,9 @@ import AccountGroupRepository from '@extension/repositories/AccountGroupReposito // selectors import { useSelectAccounts, - useSelectActiveAccount, useSelectAccountsSaving, useSelectAccountGroups, + useSelectMoveGroupModalAccount, } from '@extension/selectors'; // theme @@ -61,18 +61,18 @@ import type { IAccountWithExtendedProps, IAppThunkDispatch, IMainRootState, + IModalProps, } from '@extension/types'; -import type { IProps } from './types'; // utils import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVMAddress'; import ellipseAddress from '@extension/utils/ellipseAddress'; -const MoveGroupModal: FC = ({ isOpen, onClose }) => { +const MoveGroupModal: FC = ({ onClose }) => { const { t } = useTranslation(); const dispatch = useDispatch>(); // selectors - const account = useSelectActiveAccount(); + const account = useSelectMoveGroupModalAccount(); const accounts = useSelectAccounts(); const groups = useSelectAccountGroups(); const saving = useSelectAccountsSaving(); @@ -198,7 +198,7 @@ const MoveGroupModal: FC = ({ isOpen, onClose }) => { return ( { const { t } = useTranslation(); const dispatch = useDispatch>(); - const { - isOpen: isMoveGroupModalOpen, - onClose: onMoveGroupModalClose, - onOpen: onMoveGroupModalOpen, - } = useDisclosure(); const { isOpen: isEditAccountModalOpen, onClose: onEditAccountModalClose, @@ -204,7 +199,8 @@ const AccountPage: FC = () => { }) ); }; - const handleOnMoveGroupClick = () => onMoveGroupModalOpen(); + const handleOnMoveGroupClick = () => + account && openMoveGroupModal(account.id); const handleOnRemoveGroupClick = async () => { let _account: IAccountWithExtendedProps | null; @@ -675,10 +671,6 @@ const AccountPage: FC = () => { <> {account && ( <> - ( + (state) => + !!state.moveGroupModal.accountID + ? state.accounts.items.find( + (value) => value.id === state.moveGroupModal.accountID + ) || null + : null + ); +} diff --git a/src/extension/types/states/IMainRootState.ts b/src/extension/types/states/IMainRootState.ts index 66431efd..15e801d6 100644 --- a/src/extension/types/states/IMainRootState.ts +++ b/src/extension/types/states/IMainRootState.ts @@ -4,6 +4,7 @@ import type { IState as IAddAssetsState } from '@extension/features/add-assets'; import type { IState as IARC0072AssetsState } from '@extension/features/arc0072-assets'; import type { IState as ICredentialLockState } from '@extension/features/credential-lock'; import type { IState as IEventsState } from '@extension/features/events'; +import type { IState as IMoveGroupModalState } from '@extension/features/move-group-modal'; import type { IState as INetworksState } from '@extension/features/networks'; import type { IState as INotificationsState } from '@extension/features/notifications'; import type { IState as IPasskeysState } from '@extension/features/passkeys'; @@ -23,6 +24,7 @@ interface IMainRootState extends IBaseRootState { arc0072Assets: IARC0072AssetsState; credentialLock: ICredentialLockState; events: IEventsState; + moveGroupModal: IMoveGroupModalState; networks: INetworksState; notifications: INotificationsState; passkeys: IPasskeysState; From a09a9e356d925301fc829de2328b96c5c4308998 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 16 Nov 2024 16:57:55 +0000 Subject: [PATCH 22/25] feat: add modal to edit groups --- package.json | 1 + src/extension/apps/main/App.tsx | 2 + src/extension/apps/main/Root.tsx | 8 +- src/extension/components/SideBar/SideBar.tsx | 15 +- src/extension/constants/Dimensions.ts | 4 +- src/extension/enums/StoreNameEnum.ts | 3 +- .../features/accounts/enums/ThunkEnum.ts | 1 + src/extension/features/accounts/slice.ts | 15 + .../features/accounts/thunks/index.ts | 1 + .../accounts/thunks/removeGroupByIDThunk.ts | 60 ++++ src/extension/features/layout/slice.ts | 12 +- src/extension/features/layout/types/IState.ts | 2 - .../features/layout/utils/getInitialState.ts | 1 - .../features/manage-groups-modal/index.ts | 2 + .../features/manage-groups-modal/slice.ts | 26 ++ .../manage-groups-modal/types/IState.ts | 5 + .../manage-groups-modal/types/index.ts | 1 + .../utils/getInitialState/getInitialState.ts | 8 + .../utils/getInitialState/index.ts | 1 + .../ManageGroupsModal/ManageGroupsModal.tsx | 285 ++++++++++++++++++ .../modals/ManageGroupsModal/index.ts | 1 + .../pages/AccountPage/AccountPage.tsx | 6 +- .../pages/CustomNodesPage/CustomNodesPage.tsx | 4 +- .../GeneralSettingsPage.tsx | 4 +- .../SessionsSettingsPage.tsx | 4 +- .../AccountGroupRepository.ts | 13 + src/extension/selectors/index.ts | 1 + .../selectors/manage-groups-modal/index.ts | 1 + .../useSelectManageGroupsModalIsOpen.ts | 10 + src/extension/translations/en.ts | 10 + src/extension/types/states/IMainRootState.ts | 2 + yarn.lock | 33 +- 32 files changed, 481 insertions(+), 61 deletions(-) create mode 100644 src/extension/features/accounts/thunks/removeGroupByIDThunk.ts create mode 100644 src/extension/features/manage-groups-modal/index.ts create mode 100644 src/extension/features/manage-groups-modal/slice.ts create mode 100644 src/extension/features/manage-groups-modal/types/IState.ts create mode 100644 src/extension/features/manage-groups-modal/types/index.ts create mode 100644 src/extension/features/manage-groups-modal/utils/getInitialState/getInitialState.ts create mode 100644 src/extension/features/manage-groups-modal/utils/getInitialState/index.ts create mode 100644 src/extension/modals/ManageGroupsModal/ManageGroupsModal.tsx create mode 100644 src/extension/modals/ManageGroupsModal/index.ts create mode 100644 src/extension/selectors/manage-groups-modal/index.ts create mode 100644 src/extension/selectors/manage-groups-modal/useSelectManageGroupsModalIsOpen.ts diff --git a/package.json b/package.json index b1b312f6..a0dcbc07 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "@reduxjs/toolkit": "^1.9.3", "@stablelib/base64": "^1.0.1", "@stablelib/hex": "^1.0.1", + "@stablelib/random": "^1.0.2", "@stablelib/utf8": "^1.0.1", "algosdk": "^2.7.0", "bignumber.js": "^9.1.1", diff --git a/src/extension/apps/main/App.tsx b/src/extension/apps/main/App.tsx index f283f18b..1562f727 100644 --- a/src/extension/apps/main/App.tsx +++ b/src/extension/apps/main/App.tsx @@ -15,6 +15,7 @@ import { reducer as arc200AssetsReducer } from '@extension/features/arc0200-asse import { reducer as credentialLockReducer } from '@extension/features/credential-lock'; import { reducer as eventsReducer } from '@extension/features/events'; import { reducer as layoutReducer } from '@extension/features/layout'; +import { reducer as manageGroupsModalReducer } from '@extension/features/manage-groups-modal'; import { reducer as messagesReducer } from '@extension/features/messages'; import { reducer as moveGroupModalReducer } from '@extension/features/move-group-modal'; import { reducer as networksReducer } from '@extension/features/networks'; @@ -52,6 +53,7 @@ const App: FC = ({ credentialLock: credentialLockReducer, events: eventsReducer, layout: layoutReducer, + manageGroupsModal: manageGroupsModalReducer, messages: messagesReducer, moveGroupModal: moveGroupModalReducer, networks: networksReducer, diff --git a/src/extension/apps/main/Root.tsx b/src/extension/apps/main/Root.tsx index 2d97fbfd..daa57d0d 100644 --- a/src/extension/apps/main/Root.tsx +++ b/src/extension/apps/main/Root.tsx @@ -12,10 +12,11 @@ import { startPollingForAccountsThunk } from '@extension/features/accounts'; import { fetchARC0072AssetsFromStorageThunk } from '@extension/features/arc0072-assets'; import { fetchARC0200AssetsFromStorageThunk } from '@extension/features/arc0200-assets'; import { - setConfirmModal, + openConfirmModal, setScanQRCodeModal, setWhatsNewModal, } from '@extension/features/layout'; +import { closeModal as closeManageGroupsModal } from '@extension/features/manage-groups-modal'; import { closeModal as closeMoveGroupModal } from '@extension/features/move-group-modal'; import { startPollingForTransactionsParamsThunk } from '@extension/features/networks'; import { setShowingConfetti } from '@extension/features/notifications'; @@ -42,6 +43,7 @@ import ARC0300KeyRegistrationTransactionSendEventModal from '@extension/modals/A import ConfirmModal from '@extension/modals/ConfirmModal'; import CredentialLockModal from '@extension/modals/CredentialLockModal'; import EnableModal from '@extension/modals/EnableModal'; +import ManageGroupsModal from '@extension/modals/ManageGroupsModal'; import MoveGroupModal from '@extension/modals/MoveGroupModal'; import ReKeyAccountModal from '@extension/modals/ReKeyAccountModal'; import RemoveAssetsModal from '@extension/modals/RemoveAssetsModal'; @@ -71,8 +73,9 @@ const Root: FC = ({ i18n }) => { const whatsNewInfo = useSelectSystemWhatsNewInfo(); // handlers const handleAddAssetsModalClose = () => dispatch(resetAddAsset()); - const handleConfirmClose = () => dispatch(setConfirmModal(null)); + const handleConfirmClose = () => dispatch(openConfirmModal(null)); const handleConfettiComplete = () => dispatch(setShowingConfetti(false)); + const handleManageGroupsModalClose = () => dispatch(closeManageGroupsModal()); const handleMoveGroupModalClose = () => dispatch(closeMoveGroupModal()); const handleReKeyAccountModalClose = () => dispatch(resetReKeyAccount()); const handleRemoveAssetsModalClose = () => dispatch(resetRemoveAssets()); @@ -129,6 +132,7 @@ const Root: FC = ({ i18n }) => { {/*action modals*/} + diff --git a/src/extension/components/SideBar/SideBar.tsx b/src/extension/components/SideBar/SideBar.tsx index 7d92744f..1fb09b60 100644 --- a/src/extension/components/SideBar/SideBar.tsx +++ b/src/extension/components/SideBar/SideBar.tsx @@ -9,6 +9,7 @@ import { IoAddCircleOutline, IoChevronBack, IoChevronForward, + IoFolderOutline, IoScanOutline, IoSendOutline, IoSettingsOutline, @@ -50,9 +51,10 @@ import { updateAccountsThunk, } from '@extension/features/accounts'; import { - setConfirmModal, + openConfirmModal, setScanQRCodeModal, } from '@extension/features/layout'; +import { openModal as openManageGroupsModal } from '@extension/features/manage-groups-modal'; import { openModal as openMoveGroupModal } from '@extension/features/move-group-modal'; import { initialize as initializeSendAssets } from '@extension/features/send-assets'; @@ -146,6 +148,7 @@ const SideBar: FC = () => { dispatch(openMoveGroupModal(accountID)); const handleOnGroupSort = (items: IAccountGroup[]) => dispatch(saveAccountGroupsThunk(items)); + const handleOnManageGroupsClick = () => dispatch(openManageGroupsModal()); const handleOnRemoveFromGroupClick = (accountID: string) => { const account = accounts.find((value) => value.id === accountID) || null; let group: IAccountGroup | null; @@ -160,7 +163,7 @@ const SideBar: FC = () => { } dispatch( - setConfirmModal({ + openConfirmModal({ description: t('captions.removedFromGroupConfirm', { account: account.name || @@ -345,6 +348,14 @@ const SideBar: FC = () => { onClick={handleAddAccountClick} /> + {/*manage groups*/} + ('labels.manageGroups')} + onClick={handleOnManageGroupsClick} + /> + {/*settings*/} { state.saving = false; }); + /** remove group by id **/ + builder.addCase(removeGroupByIDThunk.fulfilled, (state: IState, action) => { + if (action.payload) { + state.groups = state.groups.filter(({ id }) => id !== action.payload); + state.items = state.items.map((value) => ({ + ...value, + ...(!!value.groupID && + value.groupID === action.payload && { + groupID: null, + groupIndex: null, + }), + })); + } + }); /** remove standard asset holdings **/ builder.addCase( removeStandardAssetHoldingsThunk.fulfilled, diff --git a/src/extension/features/accounts/thunks/index.ts b/src/extension/features/accounts/thunks/index.ts index 54a554c2..7496c414 100644 --- a/src/extension/features/accounts/thunks/index.ts +++ b/src/extension/features/accounts/thunks/index.ts @@ -5,6 +5,7 @@ export { default as fetchAccountsFromStorageThunk } from './fetchAccountsFromSto export { default as removeAccountByIdThunk } from './removeAccountByIdThunk'; export { default as removeARC0200AssetHoldingsThunk } from './removeARC0200AssetHoldingsThunk'; export { default as removeFromGroupThunk } from './removeFromGroupThunk'; +export { default as removeGroupByIDThunk } from './removeGroupByIDThunk'; export { default as removeStandardAssetHoldingsThunk } from './removeStandardAssetHoldingsThunk'; export { default as saveAccountDetailsThunk } from './saveAccountDetailsThunk'; export { default as saveAccountGroupsThunk } from './saveAccountGroupsThunk'; diff --git a/src/extension/features/accounts/thunks/removeGroupByIDThunk.ts b/src/extension/features/accounts/thunks/removeGroupByIDThunk.ts new file mode 100644 index 00000000..b190d7c0 --- /dev/null +++ b/src/extension/features/accounts/thunks/removeGroupByIDThunk.ts @@ -0,0 +1,60 @@ +import { type AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; + +// enums +import { ThunkEnum } from '../enums'; + +// repositories +import AccountGroupRepository from '@extension/repositories/AccountGroupRepository'; + +// types +import type { + IAccount, + IBaseAsyncThunkConfig, + IMainRootState, +} from '@extension/types'; +import AccountRepository from '@extension/repositories/AccountRepository'; + +const removeGroupByIDThunk: AsyncThunk< + string | null, // return + string, // args + IBaseAsyncThunkConfig +> = createAsyncThunk< + string | null, + string, + IBaseAsyncThunkConfig +>(ThunkEnum.RemoveGroupByID, async (id, { getState }) => { + const accounts = getState().accounts.items; + const logger = getState().system.logger; + const groups = getState().accounts.groups; + const group = groups.find((value) => value.id === id) || null; + let groupAccounts: IAccount[]; + + if (!group) { + logger.debug( + `${ThunkEnum.RemoveGroupByID}: group "${id}" does not exist, ignoring` + ); + + return null; + } + + groupAccounts = accounts.filter(({ groupID }) => groupID === group.id); + + await new AccountGroupRepository().removeByID(group.id); + + // if there are group accounts, remove the group ids and indexes + if (groupAccounts.length > 0) { + await new AccountRepository().saveMany( + groupAccounts.map((value) => ({ + ...value, + groupID: null, + groupIndex: null, + })) + ); + } + + logger.debug(`${ThunkEnum.RemoveGroupByID}: removed group "${id}"`); + + return id; +}); + +export default removeGroupByIDThunk; diff --git a/src/extension/features/layout/slice.ts b/src/extension/features/layout/slice.ts index 1490f6f4..3980f75c 100644 --- a/src/extension/features/layout/slice.ts +++ b/src/extension/features/layout/slice.ts @@ -4,7 +4,6 @@ import { createSlice, Draft, PayloadAction, Reducer } from '@reduxjs/toolkit'; import { StoreNameEnum } from '@extension/enums'; // types -import type { IAccountWithExtendedProps } from '@extension/types'; import type { IConfirmModal, IScanQRCodeModal, IState } from './types'; // utils @@ -14,18 +13,12 @@ const slice = createSlice({ initialState: getInitialState(), name: StoreNameEnum.Layout, reducers: { - setConfirmModal: ( + openConfirmModal: ( state: Draft, action: PayloadAction ) => { state.confirmModal = action.payload; }, - setMoveAccountGroupModal: ( - state: Draft, - action: PayloadAction - ) => { - state.moveAccountGroupModal = action.payload; - }, setScanQRCodeModal: ( state: Draft, action: PayloadAction @@ -46,8 +39,7 @@ const slice = createSlice({ export const reducer: Reducer = slice.reducer; export const { - setConfirmModal, - setMoveAccountGroupModal, + openConfirmModal, setScanQRCodeModal, setSideBar, setWhatsNewModal, diff --git a/src/extension/features/layout/types/IState.ts b/src/extension/features/layout/types/IState.ts index fb0069d1..d46ffd25 100644 --- a/src/extension/features/layout/types/IState.ts +++ b/src/extension/features/layout/types/IState.ts @@ -1,11 +1,9 @@ // types -import type { IAccountWithExtendedProps } from '@extension/types'; import type IConfirmModal from './IConfirmModal'; import type IScanQRCodeModal from './IScanQRCodeModal'; interface IState { confirmModal: IConfirmModal | null; - moveAccountGroupModal: IAccountWithExtendedProps | null; scanQRCodeModal: IScanQRCodeModal | null; sidebar: boolean; whatsNewModal: boolean; diff --git a/src/extension/features/layout/utils/getInitialState.ts b/src/extension/features/layout/utils/getInitialState.ts index e27d606b..afdc282d 100644 --- a/src/extension/features/layout/utils/getInitialState.ts +++ b/src/extension/features/layout/utils/getInitialState.ts @@ -4,7 +4,6 @@ import type { IState } from '../types'; export default function getInitialState(): IState { return { confirmModal: null, - moveAccountGroupModal: null, scanQRCodeModal: null, sidebar: false, whatsNewModal: false, diff --git a/src/extension/features/manage-groups-modal/index.ts b/src/extension/features/manage-groups-modal/index.ts new file mode 100644 index 00000000..36c53b16 --- /dev/null +++ b/src/extension/features/manage-groups-modal/index.ts @@ -0,0 +1,2 @@ +export * from './slice'; +export * from './types'; diff --git a/src/extension/features/manage-groups-modal/slice.ts b/src/extension/features/manage-groups-modal/slice.ts new file mode 100644 index 00000000..9c506efa --- /dev/null +++ b/src/extension/features/manage-groups-modal/slice.ts @@ -0,0 +1,26 @@ +import { createSlice, Draft, Reducer } from '@reduxjs/toolkit'; + +// enums +import { StoreNameEnum } from '@extension/enums'; + +// types +import type { IState } from './types'; + +// utils +import getInitialState from './utils/getInitialState'; + +const slice = createSlice({ + initialState: getInitialState(), + name: StoreNameEnum.ManageGroupsModal, + reducers: { + closeModal: (state: Draft) => { + state.isOpen = false; + }, + openModal: (state: Draft) => { + state.isOpen = true; + }, + }, +}); + +export const reducer: Reducer = slice.reducer; +export const { closeModal, openModal } = slice.actions; diff --git a/src/extension/features/manage-groups-modal/types/IState.ts b/src/extension/features/manage-groups-modal/types/IState.ts new file mode 100644 index 00000000..0184e6f2 --- /dev/null +++ b/src/extension/features/manage-groups-modal/types/IState.ts @@ -0,0 +1,5 @@ +interface IState { + isOpen: boolean; +} + +export default IState; diff --git a/src/extension/features/manage-groups-modal/types/index.ts b/src/extension/features/manage-groups-modal/types/index.ts new file mode 100644 index 00000000..bf812279 --- /dev/null +++ b/src/extension/features/manage-groups-modal/types/index.ts @@ -0,0 +1 @@ +export type { default as IState } from './IState'; diff --git a/src/extension/features/manage-groups-modal/utils/getInitialState/getInitialState.ts b/src/extension/features/manage-groups-modal/utils/getInitialState/getInitialState.ts new file mode 100644 index 00000000..37735d0e --- /dev/null +++ b/src/extension/features/manage-groups-modal/utils/getInitialState/getInitialState.ts @@ -0,0 +1,8 @@ +// types +import type { IState } from '../../types'; + +export default function getInitialState(): IState { + return { + isOpen: false, + }; +} diff --git a/src/extension/features/manage-groups-modal/utils/getInitialState/index.ts b/src/extension/features/manage-groups-modal/utils/getInitialState/index.ts new file mode 100644 index 00000000..1f873320 --- /dev/null +++ b/src/extension/features/manage-groups-modal/utils/getInitialState/index.ts @@ -0,0 +1 @@ +export { default } from './getInitialState'; diff --git a/src/extension/modals/ManageGroupsModal/ManageGroupsModal.tsx b/src/extension/modals/ManageGroupsModal/ManageGroupsModal.tsx new file mode 100644 index 00000000..fa1d0fd7 --- /dev/null +++ b/src/extension/modals/ManageGroupsModal/ManageGroupsModal.tsx @@ -0,0 +1,285 @@ +import { + Heading, + HStack, + Icon, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Text, + Tooltip, + VStack, +} from '@chakra-ui/react'; +import { randomString } from '@stablelib/random'; +import React, { type FC, KeyboardEvent, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { IoFolderOutline, IoTrashOutline } from 'react-icons/io5'; +import { useDispatch } from 'react-redux'; + +// components +import Button from '@extension/components/Button'; +import GenericInput from '@extension/components/GenericInput'; +import IconButton from '@extension/components/IconButton'; +import ModalSubHeading from '@extension/components/ModalSubHeading'; +import ScrollableContainer from '@extension/components/ScrollableContainer'; + +// constants +import { + ACCOUNT_GROUP_NAME_BYTE_LIMIT, + BODY_BACKGROUND_COLOR, + DEFAULT_GAP, +} from '@extension/constants'; + +// features +import { + removeGroupByIDThunk, + saveAccountGroupsThunk, +} from '@extension/features/accounts'; +import { openConfirmModal } from '@extension/features/layout'; + +// hooks +import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; +import useGenericInput from '@extension/hooks/useGenericInput'; +import useSubTextColor from '@extension/hooks/useSubTextColor'; + +// repositories +import AccountGroupRepository from '@extension/repositories/AccountGroupRepository'; + +// selectors +import { + useSelectAccounts, + useSelectAccountsSaving, + useSelectAccountGroups, + useSelectManageGroupsModalIsOpen, +} from '@extension/selectors'; + +// theme +import { theme } from '@extension/theme'; + +// types +import type { + IAppThunkDispatch, + IMainRootState, + IModalProps, +} from '@extension/types'; + +// utils +import calculateIconSize from '@extension/utils/calculateIconSize'; + +const ManageGroupsModal: FC = ({ onClose }) => { + const { t } = useTranslation(); + const dispatch = useDispatch>(); + // selectors + const accounts = useSelectAccounts(); + const isOpen = useSelectManageGroupsModalIsOpen(); + const groups = useSelectAccountGroups(); + const saving = useSelectAccountsSaving(); + // hooks + const defaultTextColor = useDefaultTextColor(); + const { + charactersRemaining: nameCharactersRemaining, + error: nameError, + label: nameLabel, + onBlur: nameOnBlur, + onChange: nameOnChange, + required: isNameRequired, + reset: resetName, + value: nameValue, + validate: validateName, + } = useGenericInput({ + characterLimit: ACCOUNT_GROUP_NAME_BYTE_LIMIT, + label: t('labels.name'), + }); + const subTextColor = useSubTextColor(); + // memo + const _context = useMemo(() => randomString(9), []); + // handlers + const handleOnAddSubmit = async () => { + if (nameValue.length <= 0 || !!validateName(nameValue)) { + return; + } + + // add the new group + await dispatch( + saveAccountGroupsThunk([ + AccountGroupRepository.initializeDefaultAccountGroup(nameValue), + ]) + ).unwrap(); + + // reset input + resetName(); + }; + const handleCancelClick = () => handleClose(); + const handleClose = () => { + // reset inputs + resetName(); + // close + onClose && onClose(); + }; + const handleOnKeyUp = async (event: KeyboardEvent) => { + if (event.key === 'Enter') { + await handleOnAddSubmit(); + } + }; + const handleOnRemoveClick = (id: string) => async () => { + const group = groups.find((value) => value.id === id) || null; + let numberOfAccounts: number; + + if (!group) { + return; + } + + numberOfAccounts = AccountGroupRepository.numberOfAccountsInGroup( + id, + accounts + ); + + // for groups with no accounts, just remove without warning + if (numberOfAccounts <= 0) { + dispatch(removeGroupByIDThunk(id)); + + return; + } + + dispatch( + openConfirmModal({ + description: t('captions.removeGroupConfirm', { + group: group.name, + numberOfAccounts, + }), + onConfirm: () => dispatch(removeGroupByIDThunk(id)), + title: t('headings.removeGroup'), + warningText: t('captions.removeGroupConfirmWarning'), + }) + ); + }; + // renders + const renderGroupItems = () => { + if (groups.length <= 0) { + return ( + + + {t('captions.noGroupsAvailable')} + + + ); + } + + return ( + + {groups.map((value) => ( + + {/*icon*/} + + + {/*name*/} + + {`${value.name} (${AccountGroupRepository.numberOfAccountsInGroup( + value.id, + accounts + )})`} + + + {/*remove button*/} + ('labels.remove')}> + ('ariaLabels.deleteIcon')} + icon={IoTrashOutline} + onClick={handleOnRemoveClick(value.id)} + size="sm" + variant="ghost" + /> + + + ))} + + ); + }; + + return ( + + + {/*header*/} + + + {t('headings.manageGroups')} + + + + {/*body*/} + + + ('headings.addGroup')} /> + {/*add group*/} + ('placeholders.groupName')} + type="text" + validate={validateName} + value={nameValue} + /> + + ('headings.removeGroups')} /> + + {/*remove groups*/} + {renderGroupItems()} + + + + {/*footer*/} + + {/*cancel button*/} + + + + + ); +}; + +export default ManageGroupsModal; diff --git a/src/extension/modals/ManageGroupsModal/index.ts b/src/extension/modals/ManageGroupsModal/index.ts new file mode 100644 index 00000000..f3352d1b --- /dev/null +++ b/src/extension/modals/ManageGroupsModal/index.ts @@ -0,0 +1 @@ +export { default } from './ManageGroupsModal'; diff --git a/src/extension/pages/AccountPage/AccountPage.tsx b/src/extension/pages/AccountPage/AccountPage.tsx index c9951777..01884a9c 100644 --- a/src/extension/pages/AccountPage/AccountPage.tsx +++ b/src/extension/pages/AccountPage/AccountPage.tsx @@ -64,7 +64,7 @@ import { saveActiveAccountDetails, updateAccountsThunk, } from '@extension/features/accounts'; -import { setConfirmModal, setWhatsNewModal } from '@extension/features/layout'; +import { openConfirmModal, setWhatsNewModal } from '@extension/features/layout'; import { openModal as openMoveGroupModal } from '@extension/features/move-group-modal'; import { create as createNotification } from '@extension/features/notifications'; import { updateTransactionParamsForSelectedNetworkThunk } from '@extension/features/networks'; @@ -200,7 +200,7 @@ const AccountPage: FC = () => { ); }; const handleOnMoveGroupClick = () => - account && openMoveGroupModal(account.id); + account && dispatch(openMoveGroupModal(account.id)); const handleOnRemoveGroupClick = async () => { let _account: IAccountWithExtendedProps | null; @@ -252,7 +252,7 @@ const AccountPage: FC = () => { const handleRemoveAccountClick = () => { if (account) { dispatch( - setConfirmModal({ + openConfirmModal({ description: t('captions.removeAccount', { address: ellipseAddress( convertPublicKeyToAVMAddress( diff --git a/src/extension/pages/CustomNodesPage/CustomNodesPage.tsx b/src/extension/pages/CustomNodesPage/CustomNodesPage.tsx index 7b305ded..1589f367 100644 --- a/src/extension/pages/CustomNodesPage/CustomNodesPage.tsx +++ b/src/extension/pages/CustomNodesPage/CustomNodesPage.tsx @@ -16,7 +16,7 @@ import ScrollableContainer from '@extension/components/ScrollableContainer'; import { DEFAULT_GAP } from '@extension/constants'; // features -import { setConfirmModal } from '@extension/features/layout'; +import { openConfirmModal } from '@extension/features/layout'; import { removeCustomNodeThunk } from '@extension/features/networks'; import { create as createNotification } from '@extension/features/notifications'; import { saveToStorageThunk as saveSettingsToStorageThunk } from '@extension/features/settings'; @@ -125,7 +125,7 @@ const CustomNodesPage: FC = () => { } dispatch( - setConfirmModal({ + openConfirmModal({ description: t('captions.removeCustomNodeConfirm', { name: item.name, }), diff --git a/src/extension/pages/GeneralSettingsPage/GeneralSettingsPage.tsx b/src/extension/pages/GeneralSettingsPage/GeneralSettingsPage.tsx index c0776400..0e5357e9 100644 --- a/src/extension/pages/GeneralSettingsPage/GeneralSettingsPage.tsx +++ b/src/extension/pages/GeneralSettingsPage/GeneralSettingsPage.tsx @@ -13,7 +13,7 @@ import SettingsSubHeading from '@extension/components/SettingsSubHeading'; import { DEFAULT_GAP } from '@extension/constants'; // features -import { setConfirmModal } from '@extension/features/layout'; +import { openConfirmModal } from '@extension/features/layout'; import { sendFactoryResetThunk } from '@extension/features/messages'; import { saveToStorageThunk as saveSettingsToStorageThunk } from '@extension/features/settings'; @@ -59,7 +59,7 @@ const GeneralSettingsPage: FC = () => { // handlers const handleClearAllDataClick = () => dispatch( - setConfirmModal({ + openConfirmModal({ description: t('captions.factoryResetModal'), onConfirm: () => dispatch(sendFactoryResetThunk()), // dispatch an event to the background title: t('headings.factoryReset'), diff --git a/src/extension/pages/SessionsSettingsPage/SessionsSettingsPage.tsx b/src/extension/pages/SessionsSettingsPage/SessionsSettingsPage.tsx index 114acd16..ea2e66a2 100644 --- a/src/extension/pages/SessionsSettingsPage/SessionsSettingsPage.tsx +++ b/src/extension/pages/SessionsSettingsPage/SessionsSettingsPage.tsx @@ -17,7 +17,7 @@ import SettingsSessionItem, { import { DEFAULT_GAP } from '@extension/constants'; // features -import { setConfirmModal } from '@extension/features/layout'; +import { openConfirmModal } from '@extension/features/layout'; import { removeAllFromStorageThunk as removeAllSessionsFromStorageThunk, removeByIdFromStorageThunk as removeSessionByIdFromStorageThunk, @@ -62,7 +62,7 @@ const SessionsSettingsPage: FC = () => { setSession(sessions.find((value) => value.id === id) || null); const handleDisconnectAllSessionsClick = () => dispatch( - setConfirmModal({ + openConfirmModal({ description: t('captions.disconnectAllSessions'), onConfirm: () => dispatch(removeAllSessionsFromStorageThunk()), title: t('headings.disconnectAllSessions'), diff --git a/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts b/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts index ea94a019..f39743d8 100644 --- a/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts +++ b/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts @@ -82,6 +82,19 @@ export default class AccountGroupRepository extends BaseRepository { return items.map(this._sanitize); } + /** + * Removes a group by its ID. + * @param {string} id - the group ID. + * @public + */ + public async removeByID(id: string): Promise { + const items = await this.fetchAll(); + + await this._save({ + [ACCOUNT_GROUPS_ITEM_KEY]: items.filter((value) => value.id !== id), + }); + } + /** * Saves the account group to storage. * @param {IAccountGroup} value - The account group to upsert. diff --git a/src/extension/selectors/index.ts b/src/extension/selectors/index.ts index 300e45dd..7c186fc8 100644 --- a/src/extension/selectors/index.ts +++ b/src/extension/selectors/index.ts @@ -5,6 +5,7 @@ export * from './arc-0200-assets'; export * from './credential-lock'; export * from './events'; export * from './layout'; +export * from './manage-groups-modal'; export * from './misc'; export * from './move-group-modal'; export * from './networks'; diff --git a/src/extension/selectors/manage-groups-modal/index.ts b/src/extension/selectors/manage-groups-modal/index.ts new file mode 100644 index 00000000..b599c12f --- /dev/null +++ b/src/extension/selectors/manage-groups-modal/index.ts @@ -0,0 +1 @@ +export { default as useSelectManageGroupsModalIsOpen } from './useSelectManageGroupsModalIsOpen'; diff --git a/src/extension/selectors/manage-groups-modal/useSelectManageGroupsModalIsOpen.ts b/src/extension/selectors/manage-groups-modal/useSelectManageGroupsModalIsOpen.ts new file mode 100644 index 00000000..20ca9e70 --- /dev/null +++ b/src/extension/selectors/manage-groups-modal/useSelectManageGroupsModalIsOpen.ts @@ -0,0 +1,10 @@ +import { useSelector } from 'react-redux'; + +// types +import type { IMainRootState } from '@extension/types'; + +export default function useSelectManageGroupsModalIsOpen(): boolean { + return useSelector( + (state) => state.manageGroupsModal.isOpen + ); +} diff --git a/src/extension/translations/en.ts b/src/extension/translations/en.ts index bdb0a017..920d1a9e 100644 --- a/src/extension/translations/en.ts +++ b/src/extension/translations/en.ts @@ -6,6 +6,7 @@ import { IResourceLanguage } from '@extension/types'; const translation: IResourceLanguage = { ariaLabels: { + deleteIcon: 'A trash can icon.', forwardArrow: 'Forward arrow "->".', informationIcon: 'An "i" icon for information.', plusIcon: 'Plus "+" icon.', @@ -28,6 +29,7 @@ const translation: IResourceLanguage = { create: 'Create', disconnectAllSessions: 'Disconnect All Sessions', dismiss: 'Dismiss', + done: 'Done', encrypt: 'Encrypt', getStarted: 'Get Started', hide: 'Hide', @@ -257,6 +259,10 @@ const translation: IResourceLanguage = { removedFromGroupConfirm: 'Are you sure you want to remove account "{{account}}" from "{{group}}" group?', removedCustomNode: 'The custom node {{name}} has been removed.', + removeGroupConfirm: + 'This group contains {{numberOfAccounts}} accounts. Are you sure you want to remove "{{group}}"?', + removeGroupConfirmWarning: + 'This action will ONLY remove the group your accounts will remain.', removePasskey: 'You are about to remove the passkey "{{name}}". This action will re-enable password authentication.', removePasskeyInstruction1: @@ -385,6 +391,7 @@ const translation: IResourceLanguage = { importAccountViaQRCode: 'Import An Account Via A QR Code', importAccountViaSeedPhrase: 'Import An Account Via Seed Phrase', indexerDetails: 'Indexer Details', + manageGroups: 'Manage Groups', nameYourAccount: 'Name your account', network: 'Network', networks: 'Networks', @@ -416,6 +423,8 @@ const translation: IResourceLanguage = { removedCustomNode: 'Removed Custom Node', removedFromGroupConfirm: 'Remove From Group', removedGroup: 'Removed Group', + removeGroup: 'Remove Group', + removeGroups: 'Remove Groups', removePasskey: 'Remove Passkey', scanQrCode: 'Scan QR Code(s)', selectAccount: 'Select Account', @@ -579,6 +588,7 @@ const translation: IResourceLanguage = { makePrimary: 'Make Primary', manage: 'Manage', managerAccount: 'Manager Account', + manageGroups: 'Manage Groups', max: 'Max', message: 'Message', moreInformation: 'More Information', diff --git a/src/extension/types/states/IMainRootState.ts b/src/extension/types/states/IMainRootState.ts index 15e801d6..3e95586a 100644 --- a/src/extension/types/states/IMainRootState.ts +++ b/src/extension/types/states/IMainRootState.ts @@ -4,6 +4,7 @@ import type { IState as IAddAssetsState } from '@extension/features/add-assets'; import type { IState as IARC0072AssetsState } from '@extension/features/arc0072-assets'; import type { IState as ICredentialLockState } from '@extension/features/credential-lock'; import type { IState as IEventsState } from '@extension/features/events'; +import type { IState as IManageGroupsModalState } from '@extension/features/manage-groups-modal'; import type { IState as IMoveGroupModalState } from '@extension/features/move-group-modal'; import type { IState as INetworksState } from '@extension/features/networks'; import type { IState as INotificationsState } from '@extension/features/notifications'; @@ -24,6 +25,7 @@ interface IMainRootState extends IBaseRootState { arc0072Assets: IARC0072AssetsState; credentialLock: ICredentialLockState; events: IEventsState; + manageGroupsModal: IManageGroupsModalState; moveGroupModal: IMoveGroupModalState; networks: INetworksState; notifications: INotificationsState; diff --git a/yarn.lock b/yarn.lock index 12fe4422..e414b877 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4410,7 +4410,7 @@ atomic-sleep@^1.0.0: resolved "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz" integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== -axios@^1.6.2, axios@^1.7.4: +axios@^1.7.4: version "1.7.5" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.5.tgz#21eed340eb5daf47d29b6e002424b3e88c8c54b1" integrity sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw== @@ -6675,11 +6675,6 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: node-domexception "^1.0.0" web-streams-polyfill "^3.0.3" -fflate@^0.4.8: - version "0.4.8" - resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" - integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== - figures@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz" @@ -10699,27 +10694,6 @@ postcss@8.4.31, postcss@^8.4.21: picocolors "^1.0.0" source-map-js "^1.0.2" -posthog-js@^1.130.1: - version "1.130.1" - resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.130.1.tgz#e8d037043f801d438f785f441843cce7d8af7ec3" - integrity sha512-BC283kxeJnVIeAxn7ZPHf5sCTA6oXs4uvo9fdGAsbKwwfmF9g09rnJOOaoF95J/auf8HT4YB6Vt2KytqtJD44w== - dependencies: - fflate "^0.4.8" - preact "^10.19.3" - -posthog-node@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/posthog-node/-/posthog-node-4.0.1.tgz#eb8b6cdf68c3fdd0dc2b75e8aab2e0ec3727fb2a" - integrity sha512-rtqm2h22QxLGBrW2bLYzbRhliIrqgZ0k+gF0LkQ1SNdeD06YE5eilV0MxZppFSxC8TfH0+B0cWCuebEnreIDgQ== - dependencies: - axios "^1.6.2" - rusha "^0.8.14" - -preact@^10.19.3: - version "10.21.0" - resolved "https://registry.yarnpkg.com/preact/-/preact-10.21.0.tgz#5b0335c873a1724deb66e517830db4fd310c24f6" - integrity sha512-aQAIxtzWEwH8ou+OovWVSVNlFImL7xUCwJX3YMqA3U8iKCNC34999fFOnWjYNsylgfPgMexpbk7WYOLtKr/mxg== - prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" @@ -11458,11 +11432,6 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rusha@^0.8.14: - version "0.8.14" - resolved "https://registry.yarnpkg.com/rusha/-/rusha-0.8.14.tgz#a977d0de9428406138b7bb90d3de5dcd024e2f68" - integrity sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA== - rxjs@^7.0.0: version "7.8.0" resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz" From 6bcee7515a59f68a89f234061bcb0962016ce500 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 16 Nov 2024 19:09:45 +0000 Subject: [PATCH 23/25] feat: add editable text component and allow editing group names --- .../components/ActionItem/ActionItem.tsx | 2 +- .../components/EditableText/EditableText.tsx | 226 ++++++++++++++++++ .../components/EditableText/index.ts | 1 + .../components/EditableText/types/IProps.ts | 13 + .../components/EditableText/types/index.ts | 1 + .../hooks/useGenericInput/useGenericInput.ts | 4 +- .../ManageGroupsModal/ManageGroupsModal.tsx | 85 +++++-- src/extension/translations/en.ts | 10 +- 8 files changed, 320 insertions(+), 22 deletions(-) create mode 100644 src/extension/components/EditableText/EditableText.tsx create mode 100644 src/extension/components/EditableText/index.ts create mode 100644 src/extension/components/EditableText/types/IProps.ts create mode 100644 src/extension/components/EditableText/types/index.ts diff --git a/src/extension/components/ActionItem/ActionItem.tsx b/src/extension/components/ActionItem/ActionItem.tsx index e20df2a0..d3ec73dd 100644 --- a/src/extension/components/ActionItem/ActionItem.tsx +++ b/src/extension/components/ActionItem/ActionItem.tsx @@ -64,7 +64,7 @@ const ActionItem: FC = ({ w="full" > {/*icon*/} - + {/*content*/} diff --git a/src/extension/components/EditableText/EditableText.tsx b/src/extension/components/EditableText/EditableText.tsx new file mode 100644 index 00000000..3384ed09 --- /dev/null +++ b/src/extension/components/EditableText/EditableText.tsx @@ -0,0 +1,226 @@ +import { + Box, + HStack, + Input, + Skeleton, + Text, + type TextProps, + VStack, +} from '@chakra-ui/react'; +import { faker } from '@faker-js/faker'; +import React, { + ChangeEvent, + FC, + KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { IoCheckmarkOutline, IoCloseOutline } from 'react-icons/io5'; + +// components +import IconButton from '@extension/components/IconButton'; + +// constants +import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; + +// hooks +import useButtonHoverBackgroundColor from '@extension/hooks/useButtonHoverBackgroundColor'; +import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; +import usePrimaryColor from '@extension/hooks/usePrimaryColor'; +import useSubTextColor from '@extension/hooks/useSubTextColor'; +import useTextBackgroundColor from '@extension/hooks/useTextBackgroundColor'; + +// theme +import { theme } from '@extension/theme'; + +// types +import type { IProps } from './types'; + +const EditableText: FC> = ({ + characterLimit, + isEditing = false, + isLoading = false, + onCancel, + onSubmit, + placeholder, + value, + ...textProps +}) => { + const { t } = useTranslation(); + const inputRef = useRef(null); + // hooks + const buttonHoverBackgroundColor = useButtonHoverBackgroundColor(); + const defaultTextColor = useDefaultTextColor(); + const primaryColor = usePrimaryColor(); + const subTextColor = useSubTextColor(); + const textBackgroundColor = useTextBackgroundColor(); + // memos + const loadingText = useMemo( + () => faker.random.alphaNumeric(12).toUpperCase(), + [] + ); + const charactersRemaining = useMemo( + () => + characterLimit + ? characterLimit - new TextEncoder().encode(value).byteLength + : null, + [value] + ); + // state + const [_charactersRemaining, setCharactersRemaining] = useState< + number | null + >(charactersRemaining); + const [_value, setValue] = useState(value); + // handlers + const handleCancelClick = () => handleClose(); + const handleClose = () => { + setCharactersRemaining(charactersRemaining); + setValue(value); + onCancel(); + }; + const handleOnChange = (event: ChangeEvent) => { + let byteLength: number; + + // update the characters remaining + if (characterLimit) { + byteLength = new TextEncoder().encode(event.target.value).byteLength; + + setCharactersRemaining(characterLimit - byteLength); + } + + setValue(event.target.value); + }; + const handleOnKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + handleSubmitClick(); + } + }; + const handleSubmitClick = () => { + if ( + (typeof _charactersRemaining === 'number' && _charactersRemaining < 0) || + _value.length <= 0 + ) { + return; + } + + onSubmit(_value); + }; + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + useEffect(() => setValue(value), [value]); + + if (isLoading) { + return ( + + + {loadingText} + + + ); + } + + if (!isEditing) { + return {value}; + } + + return ( + + {/*/input*/} + + + {/*controls*/} + + {/*characters remaining*/} + {typeof _charactersRemaining === 'number' && ( + + = 0 ? subTextColor : 'red.300'} + fontSize="xs" + textAlign="center" + w="full" + > + {t('captions.charactersRemaining', { + amount: _charactersRemaining, + })} + + + )} + + + {/*submit*/} + + ('ariaLabels.checkIcon')} + bg={textBackgroundColor} + icon={IoCheckmarkOutline} + onClick={handleSubmitClick} + size="sm" + type="submit" + variant="ghost" + /> + + + {/*cancel*/} + + ('ariaLabels.crossIcon')} + bg={textBackgroundColor} + icon={IoCloseOutline} + onClick={handleCancelClick} + size="sm" + variant="ghost" + /> + + + + + ); +}; + +export default EditableText; diff --git a/src/extension/components/EditableText/index.ts b/src/extension/components/EditableText/index.ts new file mode 100644 index 00000000..62ce3fe1 --- /dev/null +++ b/src/extension/components/EditableText/index.ts @@ -0,0 +1 @@ +export { default } from './EditableText'; diff --git a/src/extension/components/EditableText/types/IProps.ts b/src/extension/components/EditableText/types/IProps.ts new file mode 100644 index 00000000..e8dc6495 --- /dev/null +++ b/src/extension/components/EditableText/types/IProps.ts @@ -0,0 +1,13 @@ +interface IProps { + characterLimit?: number; + color?: string; + fontSize?: string; + isEditing?: boolean; + isLoading?: boolean; + onCancel: () => void; + onSubmit: (value: string) => void; + placeholder?: string; + value: string; +} + +export default IProps; diff --git a/src/extension/components/EditableText/types/index.ts b/src/extension/components/EditableText/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/components/EditableText/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/hooks/useGenericInput/useGenericInput.ts b/src/extension/hooks/useGenericInput/useGenericInput.ts index 825f1c6a..56ea27ce 100644 --- a/src/extension/hooks/useGenericInput/useGenericInput.ts +++ b/src/extension/hooks/useGenericInput/useGenericInput.ts @@ -31,7 +31,7 @@ export default function useGenericInput< let byteLength: number; // update the characters remaining - if (characterLimit) { + if (typeof characterLimit === 'number') { byteLength = new TextEncoder().encode(event.target.value).byteLength; setCharactersRemaining(characterLimit - byteLength); @@ -72,7 +72,7 @@ export default function useGenericInput< setValue, validate: _validate, value, - ...(charactersRemaining && { + ...(typeof charactersRemaining === 'number' && { charactersRemaining, }), }; diff --git a/src/extension/modals/ManageGroupsModal/ManageGroupsModal.tsx b/src/extension/modals/ManageGroupsModal/ManageGroupsModal.tsx index fa1d0fd7..874fea79 100644 --- a/src/extension/modals/ManageGroupsModal/ManageGroupsModal.tsx +++ b/src/extension/modals/ManageGroupsModal/ManageGroupsModal.tsx @@ -12,13 +12,18 @@ import { VStack, } from '@chakra-ui/react'; import { randomString } from '@stablelib/random'; -import React, { type FC, KeyboardEvent, useMemo } from 'react'; +import React, { type FC, KeyboardEvent, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { IoFolderOutline, IoTrashOutline } from 'react-icons/io5'; +import { + IoFolderOutline, + IoPencilOutline, + IoTrashOutline, +} from 'react-icons/io5'; import { useDispatch } from 'react-redux'; // components import Button from '@extension/components/Button'; +import EditableText from '@extension/components/EditableText'; import GenericInput from '@extension/components/GenericInput'; import IconButton from '@extension/components/IconButton'; import ModalSubHeading from '@extension/components/ModalSubHeading'; @@ -29,6 +34,7 @@ import { ACCOUNT_GROUP_NAME_BYTE_LIMIT, BODY_BACKGROUND_COLOR, DEFAULT_GAP, + INPUT_HEIGHT, } from '@extension/constants'; // features @@ -94,6 +100,8 @@ const ManageGroupsModal: FC = ({ onClose }) => { const subTextColor = useSubTextColor(); // memo const _context = useMemo(() => randomString(9), []); + // states + const [editingGroupID, setEditingGroupID] = useState(null); // handlers const handleOnAddSubmit = async () => { if (nameValue.length <= 0 || !!validateName(nameValue)) { @@ -117,6 +125,26 @@ const ManageGroupsModal: FC = ({ onClose }) => { // close onClose && onClose(); }; + const handleOnEditCancel = () => setEditingGroupID(null); + const handleOnEditClick = (id: string) => () => setEditingGroupID(id); + const handleOnEditSubmit = (id: string) => (value: string) => { + const group = groups.find((_value) => _value.id === id) || null; + + setEditingGroupID(null); + + if (!group || value === group.name || value.length <= 0) { + return; + } + + dispatch( + saveAccountGroupsThunk([ + { + ...group, + name: value, + }, + ]) + ); + }; const handleOnKeyUp = async (event: KeyboardEvent) => { if (event.key === 'Enter') { await handleOnAddSubmit(); @@ -172,6 +200,7 @@ const ManageGroupsModal: FC = ({ onClose }) => { = ({ onClose }) => { w="full" > {/*icon*/} - + {/*name*/} - - {`${value.name} (${AccountGroupRepository.numberOfAccountsInGroup( - value.id, - accounts - )})`} - + + + {`(${AccountGroupRepository.numberOfAccountsInGroup( + value.id, + accounts + )})`} + + + + + + {/*edit button*/} + ('labels.editGroup')}> + ('ariaLabels.pencilIcon')} + icon={IoPencilOutline} + onClick={handleOnEditClick(value.id)} + size="sm" + variant="ghost" + /> + {/*remove button*/} ('labels.remove')}> @@ -258,7 +311,7 @@ const ManageGroupsModal: FC = ({ onClose }) => { value={nameValue} /> - ('headings.removeGroups')} /> + ('headings.editGroups')} /> {/*remove groups*/} {renderGroupItems()} diff --git a/src/extension/translations/en.ts b/src/extension/translations/en.ts index 920d1a9e..f69ac570 100644 --- a/src/extension/translations/en.ts +++ b/src/extension/translations/en.ts @@ -6,10 +6,13 @@ import { IResourceLanguage } from '@extension/types'; const translation: IResourceLanguage = { ariaLabels: { + checkIcon: 'A checkmark icon.', + crossIcon: 'A cross icon.', deleteIcon: 'A trash can icon.', - forwardArrow: 'Forward arrow "->".', + forwardArrow: 'A forward arrow.', informationIcon: 'An "i" icon for information.', - plusIcon: 'Plus "+" icon.', + pencilIcon: 'A pencil icon.', + plusIcon: 'A plus icon.', }, buttons: { add: 'Add', @@ -379,6 +382,7 @@ const translation: IResourceLanguage = { developer: 'Developer', disconnectAllSessions: 'Disconnect All Sessions', editAccount: 'Edit Account', + editGroups: 'Edit Groups', enterAnAddress: 'Enter an address', enterYourSeedPhrase: 'Enter your seed phrase', experimental: 'Experimental', @@ -424,7 +428,6 @@ const translation: IResourceLanguage = { removedFromGroupConfirm: 'Remove From Group', removedGroup: 'Removed Group', removeGroup: 'Remove Group', - removeGroups: 'Remove Groups', removePasskey: 'Remove Passkey', scanQrCode: 'Scan QR Code(s)', selectAccount: 'Select Account', @@ -559,6 +562,7 @@ const translation: IResourceLanguage = { disabled: 'Disabled', disconnect: 'Disconnect', editAccount: 'Edit Account', + editGroup: 'Edit Group', enableCredentialsLock: 'Enable Credential Lock?', enabled: 'Enabled', experimental: 'Experimental', From ff8b67f0fd314f4ea33eb764673e41bf914312c3 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 16 Nov 2024 19:22:44 +0000 Subject: [PATCH 24/25] chore: fix lint and type check errors --- .github/workflows/pull_request_checks.yml | 2 +- package.json | 2 +- src/extension/components/SideBarGroupList/types/index.ts | 2 -- .../move-group-modal/useSelectMoveGroupModalAccount.ts | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pull_request_checks.yml b/.github/workflows/pull_request_checks.yml index 9274314b..75fdacc5 100644 --- a/.github/workflows/pull_request_checks.yml +++ b/.github/workflows/pull_request_checks.yml @@ -71,7 +71,7 @@ jobs: - name: "šŸ”§ Setup" uses: ./.github/actions/use-dependencies - name: "šŸ” Type Check" - run: yarn types:check + run: yarn check:types test: name: "Test" diff --git a/package.json b/package.json index a0dcbc07..aeb80836 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "build:dapp-example": "cross-env TS_NODE_PROJECT=\"webpack/tsconfig.webpack.json\" webpack --config webpack/webpack.config.ts --config-name dapp-example --env environment=production", "build:edge": "cross-env TS_NODE_PROJECT=\"webpack/tsconfig.webpack.json\" webpack --config webpack/webpack.config.ts --config-name extension-scripts --config-name extension-apps --env environment=production --env target=edge", "build:firefox": "cross-env TS_NODE_PROJECT=\"webpack/tsconfig.webpack.json\" webpack --config webpack/webpack.config.ts --config-name extension-scripts --config-name extension-apps --env environment=production --env target=firefox", + "check:types": "tsc --noEmit", "install:chrome": "./scripts/install_chrome.sh", "install:firefox": "./scripts/install_firefox.sh", "lint": "eslint . --ext .ts --ext .tsx --ext .js", @@ -51,7 +52,6 @@ "start:firefox": "concurrently --names \"DAPP,EXTENSION\" -c \"blue.bold,magenta.bold\" \"yarn start:dapp-example\" \"yarn start:extension --env target=firefox\"", "test": "jest", "test:coverage": "jest --coverage", - "types:check": "tsc --noEmit", "validate:firefox": "addons-linter .firefox_build/" }, "devDependencies": { diff --git a/src/extension/components/SideBarGroupList/types/index.ts b/src/extension/components/SideBarGroupList/types/index.ts index 0b803bcd..f404deed 100644 --- a/src/extension/components/SideBarGroupList/types/index.ts +++ b/src/extension/components/SideBarGroupList/types/index.ts @@ -1,3 +1 @@ -export type { default as IGroupItemProps } from './IGroupItemProps'; -export type { default as IAccountItemProps } from './IAccountItemProps'; export type { default as IProps } from './IProps'; diff --git a/src/extension/selectors/move-group-modal/useSelectMoveGroupModalAccount.ts b/src/extension/selectors/move-group-modal/useSelectMoveGroupModalAccount.ts index 031e156f..6d1338a3 100644 --- a/src/extension/selectors/move-group-modal/useSelectMoveGroupModalAccount.ts +++ b/src/extension/selectors/move-group-modal/useSelectMoveGroupModalAccount.ts @@ -9,7 +9,7 @@ import type { export default function useSelectMoveGroupModalAccount(): IAccountWithExtendedProps | null { return useSelector( (state) => - !!state.moveGroupModal.accountID + state.moveGroupModal.accountID ? state.accounts.items.find( (value) => value.id === state.moveGroupModal.accountID ) || null From 818262ca9679404e0c79c22727fbc082ea7d6689 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 16 Nov 2024 19:23:57 +0000 Subject: [PATCH 25/25] build: change kieran contributor address --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aeb80836..49e07d83 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "author": { "name": "Kieran O'Neill", - "email": "kieran@agoralabs.sh", + "email": "kieran@kibis.is", "url": "https://github.com/kieranroneill" }, "license": "AGPL-3.0-or-later",