diff --git a/frontend/src/lib/assets/question-mark.svg b/frontend/src/lib/assets/question-mark.svg index ce048bc98c7..3b6c2a8acb2 100644 --- a/frontend/src/lib/assets/question-mark.svg +++ b/frontend/src/lib/assets/question-mark.svg @@ -1,4 +1,3 @@ - - - ? + + diff --git a/frontend/src/lib/components/accounts/IcrcWalletPage.svelte b/frontend/src/lib/components/accounts/IcrcWalletPage.svelte index cf330465983..e32fe5258cd 100644 --- a/frontend/src/lib/components/accounts/IcrcWalletPage.svelte +++ b/frontend/src/lib/components/accounts/IcrcWalletPage.svelte @@ -21,7 +21,7 @@ } from "$lib/utils/accounts.utils"; import { replacePlaceholders } from "$lib/utils/i18n.utils"; import { IconDots, Island, Spinner } from "@dfinity/gix-components"; - import type { Principal } from "@dfinity/principal"; + import { Principal } from "@dfinity/principal"; import { TokenAmountV2, isNullish, nonNullish } from "@dfinity/utils"; import type { Writable } from "svelte/store"; import { ENABLE_IMPORT_TOKEN } from "$lib/stores/feature-flags.store"; @@ -29,7 +29,6 @@ import WalletMorePopover from "./WalletMorePopover.svelte"; import { isImportedToken as checkImportedToken } from "$lib/utils/imported-tokens.utils"; import { importedTokensStore } from "$lib/stores/imported-tokens.store"; - import { startBusy, stopBusy } from "$lib/stores/busy.store"; import { removeImportedTokens } from "$lib/services/imported-tokens.services"; import ImportTokenRemoveConfirmation from "./ImportTokenRemoveConfirmation.svelte"; import type { Universe } from "$lib/types/universe"; @@ -173,26 +172,9 @@ // Just for type safety. This should never happen. if (isNullish(ledgerCanisterId)) return; - startBusy({ - initiator: "import-token-removing", - labelKey: "import_token.removing", - }); - - try { - const importedTokens = $importedTokensStore.importedTokens ?? []; - const { success } = await removeImportedTokens({ - tokensToRemove: importedTokens.filter( - ({ ledgerCanisterId: id }) => - id.toText() === ledgerCanisterId?.toText() - ), - importedTokens, - }); - - if (success) { - goto(AppPath.Tokens); - } - } finally { - stopBusy("import-token-removing"); + const { success } = await removeImportedTokens(ledgerCanisterId); + if (success) { + goto(AppPath.Tokens); } }; @@ -273,6 +255,7 @@ {#if removeImportedTokenConfirmationVisible && nonNullish(universe)} (removeImportedTokenConfirmationVisible = false)} on:nnsConfirm={removeImportedToken} diff --git a/frontend/src/lib/components/accounts/ImportTokenRemoveConfirmation.svelte b/frontend/src/lib/components/accounts/ImportTokenRemoveConfirmation.svelte index 1882d322e8f..373669a49f2 100644 --- a/frontend/src/lib/components/accounts/ImportTokenRemoveConfirmation.svelte +++ b/frontend/src/lib/components/accounts/ImportTokenRemoveConfirmation.svelte @@ -5,8 +5,10 @@ import { nonNullish } from "@dfinity/utils"; import ConfirmationModal from "$lib/modals/common/ConfirmationModal.svelte"; import { Tag } from "@dfinity/gix-components"; + import type { Principal } from "@dfinity/principal"; - export let universe: Universe | undefined; + export let universe: Universe | undefined = undefined; + export let ledgerCanisterId: Principal;

{$i18n.import_token.remove_confirmation_header}

- {#if nonNullish(universe)}{/if} + {#if nonNullish(universe)} + + {:else} + {ledgerCanisterId.toText()} + {/if} {$i18n.import_token.imported_token}

diff --git a/frontend/src/lib/components/tokens/TokensTable/TokenActionsCell.svelte b/frontend/src/lib/components/tokens/TokensTable/TokenActionsCell.svelte index 4f19fc9e284..fb386169995 100644 --- a/frontend/src/lib/components/tokens/TokensTable/TokenActionsCell.svelte +++ b/frontend/src/lib/components/tokens/TokensTable/TokenActionsCell.svelte @@ -1,29 +1,42 @@ {#if nonNullish(userToken)} diff --git a/frontend/src/lib/components/tokens/TokensTable/TokenBalanceCell.svelte b/frontend/src/lib/components/tokens/TokensTable/TokenBalanceCell.svelte index 47e828b8cf9..d58293bb304 100644 --- a/frontend/src/lib/components/tokens/TokensTable/TokenBalanceCell.svelte +++ b/frontend/src/lib/components/tokens/TokensTable/TokenBalanceCell.svelte @@ -1,17 +1,27 @@ -{#if rowData.balance === "loading"} +{#if isUserTokenLoading(rowData)} -{:else} +{:else if isUserTokenData(rowData)} +{:else} + -/- {/if} diff --git a/frontend/src/lib/components/tokens/TokensTable/actions/GoToDashboardButton.svelte b/frontend/src/lib/components/tokens/TokensTable/actions/GoToDashboardButton.svelte new file mode 100644 index 00000000000..003a5a0a1cb --- /dev/null +++ b/frontend/src/lib/components/tokens/TokensTable/actions/GoToDashboardButton.svelte @@ -0,0 +1,12 @@ + + + diff --git a/frontend/src/lib/components/tokens/TokensTable/actions/GoToDetailIcon.svelte b/frontend/src/lib/components/tokens/TokensTable/actions/GoToDetailIcon.svelte index 68ab88a9780..56e5e9d4da0 100644 --- a/frontend/src/lib/components/tokens/TokensTable/actions/GoToDetailIcon.svelte +++ b/frontend/src/lib/components/tokens/TokensTable/actions/GoToDetailIcon.svelte @@ -1,9 +1,9 @@ diff --git a/frontend/src/lib/components/tokens/TokensTable/actions/ReceiveButton.svelte b/frontend/src/lib/components/tokens/TokensTable/actions/ReceiveButton.svelte index d2c8846b8aa..370a708b057 100644 --- a/frontend/src/lib/components/tokens/TokensTable/actions/ReceiveButton.svelte +++ b/frontend/src/lib/components/tokens/TokensTable/actions/ReceiveButton.svelte @@ -1,20 +1,25 @@ - +{#if isUserTokenData(userToken)} + +{/if} diff --git a/frontend/src/lib/components/tokens/TokensTable/actions/RemoveButton.svelte b/frontend/src/lib/components/tokens/TokensTable/actions/RemoveButton.svelte new file mode 100644 index 00000000000..42a1f1a23c8 --- /dev/null +++ b/frontend/src/lib/components/tokens/TokensTable/actions/RemoveButton.svelte @@ -0,0 +1,27 @@ + + + + + diff --git a/frontend/src/lib/components/tokens/TokensTable/actions/SendButton.svelte b/frontend/src/lib/components/tokens/TokensTable/actions/SendButton.svelte index ccb85be8782..641b22e28c0 100644 --- a/frontend/src/lib/components/tokens/TokensTable/actions/SendButton.svelte +++ b/frontend/src/lib/components/tokens/TokensTable/actions/SendButton.svelte @@ -1,20 +1,25 @@ - +{#if isUserTokenData(userToken)} + +{/if} diff --git a/frontend/src/lib/derived/tokens-list-user.derived.ts b/frontend/src/lib/derived/tokens-list-user.derived.ts index 661ac4c2900..9b6c7360767 100644 --- a/frontend/src/lib/derived/tokens-list-user.derived.ts +++ b/frontend/src/lib/derived/tokens-list-user.derived.ts @@ -1,4 +1,5 @@ import { OWN_CANISTER_ID_TEXT } from "$lib/constants/canister-ids.constants"; +import { failedImportedTokenLedgerIdsStore } from "$lib/stores/imported-tokens.store"; import type { IcrcTokenMetadata } from "$lib/types/icrc"; import { UserTokenAction, @@ -8,6 +9,7 @@ import { import { sumAccounts } from "$lib/utils/accounts.utils"; import { buildAccountsUrl, buildWalletUrl } from "$lib/utils/navigation.utils"; import { isUniverseNns } from "$lib/utils/universe.utils"; +import { toUserTokenFailed } from "$lib/utils/user-token.utils"; import { TokenAmountV2, isNullish } from "@dfinity/utils"; import { derived, type Readable } from "svelte/store"; import type { UniversesAccounts } from "./accounts-list.derived"; @@ -75,16 +77,24 @@ export const tokensListUserStore = derived< Readable, Readable, Readable>, + Readable>, ], UserToken[] >( - [tokensListBaseStore, universesAccountsStore, tokensByUniverseIdStore], - ([tokensList, accounts, tokensByUniverse]) => - tokensList.map((baseTokenData) => + [ + tokensListBaseStore, + universesAccountsStore, + tokensByUniverseIdStore, + failedImportedTokenLedgerIdsStore, + ], + ([tokensList, accounts, tokensByUniverse, failedImportedTokenLedgerIds]) => [ + ...tokensList.map((baseTokenData) => convertToUserTokenData({ baseTokenData, accounts, tokensByUniverse, }) - ) + ), + ...failedImportedTokenLedgerIds.map(toUserTokenFailed), + ] ); diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index adbd29d3350..355217b288e 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -1060,6 +1060,7 @@ "import_button": "Import", "link_to_dashboard": "https://dashboard.internetcomputer.org/canister/$canisterId", "add_index_canister": "Add Index Canister", - "add_index_description": "Transaction history is not available. To see this token’s transaction history in the NNS dapp, you need to provide an index canister. Note: not all tokens have index canisters." + "add_index_description": "Transaction history is not available. To see this token’s transaction history in the NNS dapp, you need to provide an index canister. Note: not all tokens have index canisters.", + "failed_tooltip": "The NNS dapp couldn’t load your token. Please try again later, or contact the developers." } } diff --git a/frontend/src/lib/services/imported-tokens.services.ts b/frontend/src/lib/services/imported-tokens.services.ts index 704f8d0cefa..5c6a596d87f 100644 --- a/frontend/src/lib/services/imported-tokens.services.ts +++ b/frontend/src/lib/services/imported-tokens.services.ts @@ -10,7 +10,11 @@ import type { ImportedTokens } from "$lib/canisters/nns-dapp/nns-dapp.types"; import { MAX_IMPORTED_TOKENS } from "$lib/constants/imported-tokens.constants"; import { FORCE_CALL_STRATEGY } from "$lib/constants/mockable.constants"; import { getAuthenticatedIdentity } from "$lib/services/auth.services"; -import { importedTokensStore } from "$lib/stores/imported-tokens.store"; +import { startBusy, stopBusy } from "$lib/stores/busy.store"; +import { + failedImportedTokenLedgerIdsStore, + importedTokensStore, +} from "$lib/stores/imported-tokens.store"; import { toastsError, toastsSuccess } from "$lib/stores/toasts.store"; import type { ImportedTokenData } from "$lib/types/imported-tokens"; import { notForceCallStrategy } from "$lib/utils/env.utils"; @@ -20,6 +24,7 @@ import { } from "$lib/utils/imported-tokens.utils"; import type { Principal } from "@dfinity/principal"; import { isNullish } from "@dfinity/utils"; +import { get } from "svelte/store"; import { queryAndUpdate } from "./utils.services"; /** Load imported tokens from the `nns-dapp` backend and update the `importedTokensStore` store. @@ -33,11 +38,22 @@ export const loadImportedTokens = async ({ return queryAndUpdate({ request: (options) => getImportedTokens(options), strategy: FORCE_CALL_STRATEGY, - onLoad: ({ response: { imported_tokens: importedTokens }, certified }) => + onLoad: ({ response: { imported_tokens: importedTokens }, certified }) => { importedTokensStore.set({ importedTokens: importedTokens.map(toImportedTokenData), certified, - }), + }); + /* + The failed imported token store needs to be reset to remove the failed table rows, + but this should only happen after the imported tokens are fully loaded. + + If we remove record A from the failed imported token store before that, it will still exist + in the imported tokens store, triggering the loading of account data for record A; + However, if we update the imported tokens store in the next step (removing record A), + the error handling will treat this token as a `not imported token` and display all errors. + */ + failedImportedTokenLedgerIdsStore.reset(); + }, onError: ({ error: err, certified }) => { console.error(err); @@ -58,6 +74,8 @@ export const loadImportedTokens = async ({ // Explicitly handle only UPDATE errors importedTokensStore.reset(); + // Also reset the failed imported token store to remove the failed table rows. + failedImportedTokenLedgerIdsStore.reset(); toastsError({ labelKey: "error__imported_tokens.load_imported_tokens", @@ -172,35 +190,38 @@ export const addIndexCanister = async ({ * - Displays a success toast if the operation is successful. * - Displays an error toast if the operation fails. */ -export const removeImportedTokens = async ({ - tokensToRemove, - importedTokens, -}: { - tokensToRemove: ImportedTokenData[]; - importedTokens: ImportedTokenData[]; -}): Promise<{ success: boolean }> => { - // Compare imported tokens by their ledgerCanisterId because they should be unique. - const ledgerIdsToRemove = new Set( - tokensToRemove.map(({ ledgerCanisterId }) => ledgerCanisterId.toText()) - ); - const tokens = importedTokens.filter( - ({ ledgerCanisterId }) => !ledgerIdsToRemove.has(ledgerCanisterId.toText()) - ); - const { err } = await saveImportedToken({ tokens }); - - if (isNullish(err)) { - await loadImportedTokens(); - toastsSuccess({ - labelKey: "tokens.remove_imported_token_success", +export const removeImportedTokens = async ( + ledgerCanisterId: Principal +): Promise<{ success: boolean }> => { + try { + startBusy({ + initiator: "import-token-removing", + labelKey: "import_token.removing", }); - return { success: true }; - } + const remainingTokens = ( + get(importedTokensStore).importedTokens ?? [] + ).filter( + ({ ledgerCanisterId: id }) => id.toText() !== ledgerCanisterId.toText() + ); + const { err } = await saveImportedToken({ tokens: remainingTokens }); + + if (isNullish(err)) { + await loadImportedTokens(); + toastsSuccess({ + labelKey: "tokens.remove_imported_token_success", + }); - toastsError({ - labelKey: "error__imported_tokens.remove_imported_token", - err, - }); + return { success: true }; + } - return { success: false }; + toastsError({ + labelKey: "error__imported_tokens.remove_imported_token", + err, + }); + + return { success: false }; + } finally { + stopBusy("import-token-removing"); + } }; diff --git a/frontend/src/lib/types/actions.ts b/frontend/src/lib/types/actions.ts index 8affbd2e649..9019185dc8f 100644 --- a/frontend/src/lib/types/actions.ts +++ b/frontend/src/lib/types/actions.ts @@ -3,6 +3,7 @@ import type { UserTokenData } from "./tokens-page"; export enum ActionType { Send = "send", Receive = "receive", + Remove = "remove", } export type Action = { diff --git a/frontend/src/lib/types/i18n.d.ts b/frontend/src/lib/types/i18n.d.ts index b4b44a28101..d67d69155be 100644 --- a/frontend/src/lib/types/i18n.d.ts +++ b/frontend/src/lib/types/i18n.d.ts @@ -1120,6 +1120,7 @@ interface I18nImport_token { link_to_dashboard: string; add_index_canister: string; add_index_description: string; + failed_tooltip: string; } interface I18nNeuron_state { diff --git a/frontend/src/lib/types/tokens-page.ts b/frontend/src/lib/types/tokens-page.ts index fac663eee0b..4a41ff36fe6 100644 --- a/frontend/src/lib/types/tokens-page.ts +++ b/frontend/src/lib/types/tokens-page.ts @@ -3,6 +3,7 @@ * * - `UserTokenBase` is not used directly, but it is used to create the `UserTokenLoading` and `UserTokenData` types. * - `UserTokenLoading` is to render a loading row in the tokens table. Used when either balance or token are not present. + * - `UserTokenFailed` is to render a failed token row in the tokens table. Used when an error occurred while fetching the imported token data. * - `UserTokenData` is used to render a row in the tokens table. Used when both balance and token are present. * - `UserTokenAction` is a list of actions supported by the tokens page and hardcoded in the TokensTable. * - `UserToken` is the union of `UserTokenLoading` and `UserTokenData`. @@ -16,6 +17,8 @@ export enum UserTokenAction { Send = "send", GoToDetail = "goToDetail", Receive = "receive", + GoToDashboard = "goToDashboard", + Remove = "remove", } export type UserTokenBase = { @@ -40,6 +43,12 @@ export type UserTokenLoading = UserTokenBase & { domKey: string; }; +export type UserTokenFailed = Omit & { + balance: "failed"; + domKey: string; + actions: UserTokenAction[]; +}; + export type UserTokenData = UserTokenBase & { balance: TokenAmountV2 | UnavailableTokenAmount; // Identifier of the account related to the row (only if the row represents one account, not multiple) @@ -49,7 +58,8 @@ export type UserTokenData = UserTokenBase & { fee: TokenAmountV2; rowHref: string; domKey: string; + actions: UserTokenAction[]; }; -export type UserToken = UserTokenLoading | UserTokenData; +export type UserToken = UserTokenLoading | UserTokenFailed | UserTokenData; export type TokensTableColumn = ResponsiveTableColumn; diff --git a/frontend/src/lib/utils/tokens-table.utils.ts b/frontend/src/lib/utils/tokens-table.utils.ts index 45e4e2c8f75..88ecfa0b343 100644 --- a/frontend/src/lib/utils/tokens-table.utils.ts +++ b/frontend/src/lib/utils/tokens-table.utils.ts @@ -7,6 +7,7 @@ import { mergeComparators, } from "$lib/utils/responsive-table.utils"; import { TokenAmountV2 } from "@dfinity/utils"; +import { isUserTokenFailed } from "./user-token.utils"; const getTokenBalanceOrZero = (token: UserToken) => token.balance instanceof TokenAmountV2 ? token.balance.toUlps() : 0n; @@ -15,6 +16,10 @@ export const compareTokensIcpFirst = createDescendingComparator( (token: UserToken) => token.universeId.toText() === OWN_CANISTER_ID_TEXT ); +export const compareFailedTokensLast = createAscendingComparator( + (token: UserToken) => isUserTokenFailed(token) +); + export const compareTokensWithBalanceOrImportedFirst = ({ importedTokenIds, }: { @@ -50,6 +55,7 @@ export const compareTokensForTokensTable = ({ mergeComparators([ compareTokensIcpFirst, compareTokensWithBalanceOrImportedFirst({ importedTokenIds }), + compareFailedTokensLast, compareTokensByImportance, compareTokensAlphabetically, ]); diff --git a/frontend/src/lib/utils/user-token.utils.ts b/frontend/src/lib/utils/user-token.utils.ts index 08379d7f9d7..f351b0bab0e 100644 --- a/frontend/src/lib/utils/user-token.utils.ts +++ b/frontend/src/lib/utils/user-token.utils.ts @@ -1,7 +1,38 @@ -import type { UserTokenData, UserTokenLoading } from "$lib/types/tokens-page"; +import UNKNOWN_LOGO from "$lib/assets/question-mark.svg"; +import { + UserTokenAction, + type UserTokenData, + type UserTokenFailed, + type UserTokenLoading, +} from "$lib/types/tokens-page"; +import { Principal } from "@dfinity/principal"; + +export const isUserTokenLoading = ( + userToken: UserTokenData | UserTokenLoading | UserTokenFailed +): userToken is UserTokenLoading => { + return userToken.balance === "loading"; +}; + +export const isUserTokenFailed = ( + userToken: UserTokenData | UserTokenLoading | UserTokenFailed +): userToken is UserTokenFailed => { + return userToken.balance === "failed"; +}; export const isUserTokenData = ( - userToken: UserTokenData | UserTokenLoading + userToken: UserTokenData | UserTokenLoading | UserTokenFailed ): userToken is UserTokenData => { - return userToken.balance !== "loading"; + return !isUserTokenLoading(userToken) && !isUserTokenFailed(userToken); }; + +export const toUserTokenFailed = ( + ledgerCanisterIdText: string +): UserTokenFailed => ({ + universeId: Principal.fromText(ledgerCanisterIdText), + // Title will be used for sorting. + title: ledgerCanisterIdText, + logo: UNKNOWN_LOGO, + balance: "failed", + domKey: ledgerCanisterIdText, + actions: [UserTokenAction.GoToDashboard, UserTokenAction.Remove], +}); diff --git a/frontend/src/routes/(app)/(nns)/tokens/+page.svelte b/frontend/src/routes/(app)/(nns)/tokens/+page.svelte index 2de932f2a0b..23c21f9eaa9 100644 --- a/frontend/src/routes/(app)/(nns)/tokens/+page.svelte +++ b/frontend/src/routes/(app)/(nns)/tokens/+page.svelte @@ -28,7 +28,11 @@ import type { Account } from "$lib/types/account"; import { ActionType, type Action } from "$lib/types/actions"; import type { CkBTCAdditionalCanisters } from "$lib/types/ckbtc-canisters"; - import type { UserToken, UserTokenData } from "$lib/types/tokens-page"; + import type { + UserToken, + UserTokenData, + UserTokenFailed, + } from "$lib/types/tokens-page"; import type { Universe, UniverseCanisterIdText } from "$lib/types/universe"; import { isIcrcTokenUniverse, @@ -41,6 +45,9 @@ import { onMount } from "svelte"; import { compareTokensForTokensTable } from "$lib/utils/tokens-table.utils"; import { importedTokensStore } from "$lib/stores/imported-tokens.store"; + import ImportTokenRemoveConfirmation from "$lib/components/accounts/ImportTokenRemoveConfirmation.svelte"; + import { isUserTokenData } from "$lib/utils/user-token.utils"; + import { removeImportedTokens } from "$lib/services/imported-tokens.services"; onMount(() => { loadCkBTCTokens(); @@ -147,11 +154,12 @@ | "ckbtc-send" | "icrc-send" | "icrc-receive" - | "ckbtc-receive"; + | "ckbtc-receive" + | "imported-remove"; let modal: | { type: ModalType; - data: UserTokenData; + data: UserTokenData | UserTokenFailed; } | undefined; const closeModal = () => { @@ -166,10 +174,11 @@ let account: Account | undefined; $: account = - modal && - $universesAccountsStore[modal.data.universeId.toText()].find( - ({ type }) => type === "main" - ); + modal && isUserTokenData(modal.data) + ? $universesAccountsStore[modal.data.universeId.toText()].find( + ({ type }) => type === "main" + ) + : undefined; const handleAction = ({ detail }: { detail: Action }) => { if (detail.type === ActionType.Send) { @@ -196,6 +205,16 @@ modal = { type: "icrc-receive", data: detail.data }; } } + if (detail.type === ActionType.Remove) { + modal = { type: "imported-remove", data: detail.data }; + } + }; + + const removeImportedToken = async () => { + // Just for type safety. This should never happen. + if (nonNullish(modal?.data.universeId)) { + await removeImportedTokens(modal.data.universeId); + } }; let importedTokenIds: Set = new Set(); @@ -222,63 +241,73 @@ /> {/if} - {#if modal?.type === "sns-send"} - - {/if} + {#if nonNullish(modal) && isUserTokenData(modal.data)} + {#if modal.type === "sns-send"} + + {/if} - {#if modal?.type === "ckbtc-send"} - - {/if} + {#if modal.type === "ckbtc-send"} + + {/if} - {#if modal?.type === "icrc-send"} - - {/if} + {#if modal.type === "icrc-send"} + + {/if} - {#if modal?.type === "ckbtc-receive" && nonNullish(ckBTCCanisters) && nonNullish(account)} - + {#if modal.type === "ckbtc-receive" && nonNullish(ckBTCCanisters) && nonNullish(account)} + + {/if} + + {#if modal.type === "icrc-receive" && nonNullish(account)} + + {/if} {/if} - {#if modal?.type === "icrc-receive" && nonNullish(account)} - {/if} diff --git a/frontend/src/tests/lib/components/accounts/ImportTokenRemoveConfirmation.spec.ts b/frontend/src/tests/lib/components/accounts/ImportTokenRemoveConfirmation.spec.ts index 69082edae60..83c32441d04 100644 --- a/frontend/src/tests/lib/components/accounts/ImportTokenRemoveConfirmation.spec.ts +++ b/frontend/src/tests/lib/components/accounts/ImportTokenRemoveConfirmation.spec.ts @@ -1,12 +1,27 @@ +import * as importedTokensApi from "$lib/api/imported-tokens.api"; import ImportTokenRemoveConfirmation from "$lib/components/accounts/ImportTokenRemoveConfirmation.svelte"; +import { AppPath } from "$lib/constants/routes.constants"; +import { pageStore } from "$lib/derived/page.derived"; +import { icrcAccountsStore } from "$lib/stores/icrc-accounts.store"; +import { importedTokensStore } from "$lib/stores/imported-tokens.store"; +import { tokensStore } from "$lib/stores/tokens.store"; import type { Universe } from "$lib/types/universe"; +import { page } from "$mocks/$app/stores"; +import { mockIdentity, resetIdentity } from "$tests/mocks/auth.store.mock"; +import { mockIcrcMainAccount } from "$tests/mocks/icrc-accounts.mock"; import { principal } from "$tests/mocks/sns-projects.mock"; +import { mockUniversesTokens } from "$tests/mocks/tokens.mock"; import { ImportTokenRemoveConfirmationPo } from "$tests/page-objects/ImportTokenRemoveConfirmation.page-object"; import { JestPageObjectElement } from "$tests/page-objects/jest.page-object"; import { render } from "$tests/utils/svelte.test-utils"; +import { runResolvedPromises } from "$tests/utils/timers.test-utils"; +import { busyStore } from "@dfinity/gix-components"; +import type { Principal } from "@dfinity/principal"; +import { get } from "svelte/store"; describe("ImportTokenRemoveConfirmation", () => { - const ledgerCanisterId = principal(1); + const ledgerCanisterId = principal(0); + const ledgerCanisterId2 = principal(1); const tokenLogo = "data:image/svg+xml;base64,PHN2ZyB3..."; const tokenName = "ckTest"; const mockUniverse: Universe = { @@ -14,15 +29,19 @@ describe("ImportTokenRemoveConfirmation", () => { title: tokenName, logo: tokenLogo, }; - const renderComponent = () => { + const renderComponent = ( + props: { + ledgerCanisterId: Principal; + universe: Universe; + } = { + ledgerCanisterId: ledgerCanisterId, + universe: mockUniverse, + } + ) => { const { container, component } = render(ImportTokenRemoveConfirmation, { - props: { - universe: mockUniverse, - }, + props, }); - const nnsConfirm = vi.fn(); - component.$on("nnsConfirm", nnsConfirm); const nnsClose = vi.fn(); component.$on("nnsClose", nnsClose); @@ -30,13 +49,49 @@ describe("ImportTokenRemoveConfirmation", () => { po: ImportTokenRemoveConfirmationPo.under( new JestPageObjectElement(container) ), - nnsConfirm, nnsClose, }; }; beforeEach(() => { + vi.clearAllMocks(); + vi.clearAllTimers(); vi.restoreAllMocks(); + resetIdentity(); + busyStore.resetForTesting(); + page.mock({ + data: { universe: ledgerCanisterId.toText() }, + routeId: AppPath.Wallet, + }); + tokensStore.setTokens(mockUniversesTokens); + icrcAccountsStore.set({ + accounts: { + accounts: [mockIcrcMainAccount], + certified: true, + }, + ledgerCanisterId, + }); + + importedTokensStore.set({ + importedTokens: [ + { + ledgerCanisterId, + indexCanisterId: undefined, + }, + { + ledgerCanisterId: ledgerCanisterId2, + indexCanisterId: undefined, + }, + ], + certified: true, + }); + icrcAccountsStore.set({ + accounts: { + accounts: [mockIcrcMainAccount], + certified: true, + }, + ledgerCanisterId, + }); }); it("should render token logo", async () => { @@ -50,14 +105,131 @@ describe("ImportTokenRemoveConfirmation", () => { }); it("should dispatch events", async () => { - const { po, nnsClose, nnsConfirm } = renderComponent(); + const { po, nnsClose } = renderComponent(); expect(nnsClose).not.toBeCalled(); await po.clickNo(); expect(nnsClose).toBeCalledTimes(1); - expect(nnsConfirm).not.toBeCalled(); + nnsClose.mockClear(); + }); + + it("should work w/o universe", async () => { + const { po, nnsClose } = renderComponent({ + ledgerCanisterId, + universe: undefined, + }); + expect(await po.getUniverseSummaryPo().isPresent()).toEqual(false); + expect(nnsClose).not.toBeCalled(); + await po.clickNo(); + expect(nnsClose).toBeCalledTimes(1); + }); + + it("should remove imported tokens", async () => { + let resolveSetImportedTokens; + const spyOnSetImportedTokens = vi + .spyOn(importedTokensApi, "setImportedTokens") + .mockImplementation( + () => + new Promise((resolve) => (resolveSetImportedTokens = resolve)) + ); + const spyOnGetImportedTokens = vi + .spyOn(importedTokensApi, "getImportedTokens") + .mockResolvedValue({ + imported_tokens: [ + { + ledger_canister_id: ledgerCanisterId2, + index_canister_id: [], + }, + ], + }); + + const { po, nnsClose } = await renderComponent(); + + expect(get(pageStore).path).toEqual(AppPath.Wallet); + expect(nnsClose).toBeCalledTimes(0); + expect(get(busyStore)).toEqual([]); + await po.clickYes(); + expect(get(importedTokensStore).importedTokens).toEqual([ + { + ledgerCanisterId, + indexCanisterId: undefined, + }, + { + ledgerCanisterId: ledgerCanisterId2, + indexCanisterId: undefined, + }, + ]); + expect(get(busyStore)).toEqual([ + { + initiator: "import-token-removing", + text: "Removing imported token...", + }, + ]); + + resolveSetImportedTokens(); + await runResolvedPromises(); + + // The token should be removed. + expect(spyOnSetImportedTokens).toBeCalledTimes(1); + expect(spyOnSetImportedTokens).toHaveBeenCalledWith({ + identity: mockIdentity, + importedTokens: [ + { + ledger_canister_id: ledgerCanisterId2, + index_canister_id: [], + }, + ], + }); + expect(spyOnGetImportedTokens).toBeCalledTimes(2); + + expect(get(busyStore)).toEqual([]); + expect(get(pageStore).path).toEqual(AppPath.Tokens); + expect(get(importedTokensStore).importedTokens).toEqual([ + { + ledgerCanisterId: ledgerCanisterId2, + indexCanisterId: undefined, + }, + ]); + expect(nnsClose).toBeCalledTimes(1); + }); + + it("should stay on the same page when removal is unsuccessful", async () => { + vi.spyOn(console, "error").mockReturnValue(); + const spyOnSetImportedTokens = vi + .spyOn(importedTokensApi, "setImportedTokens") + .mockRejectedValue(new Error()); + const spyOnGetImportedTokens = vi.spyOn( + importedTokensApi, + "getImportedTokens" + ); + const { po } = await renderComponent(); + expect(get(importedTokensStore).importedTokens).toEqual([ + { + ledgerCanisterId, + indexCanisterId: undefined, + }, + { + ledgerCanisterId: ledgerCanisterId2, + indexCanisterId: undefined, + }, + ]); await po.clickYes(); - expect(nnsConfirm).toBeCalledTimes(1); + await runResolvedPromises(); + expect(spyOnSetImportedTokens).toBeCalledTimes(1); + // should stay on wallet page + expect(get(pageStore).path).toEqual(AppPath.Wallet); + // without data change + expect(spyOnGetImportedTokens).toBeCalledTimes(0); + expect(get(importedTokensStore).importedTokens).toEqual([ + { + ledgerCanisterId, + indexCanisterId: undefined, + }, + { + ledgerCanisterId: ledgerCanisterId2, + indexCanisterId: undefined, + }, + ]); }); }); diff --git a/frontend/src/tests/lib/pages/IcrcWallet.spec.ts b/frontend/src/tests/lib/pages/IcrcWallet.spec.ts index 9cbd8a4e10f..e3f5c8c947c 100644 --- a/frontend/src/tests/lib/pages/IcrcWallet.spec.ts +++ b/frontend/src/tests/lib/pages/IcrcWallet.spec.ts @@ -529,23 +529,15 @@ describe("IcrcWallet", () => { }); it("should remove imported tokens", async () => { - let resolveSetImportedTokens; - const spyOnSetImportedTokens = vi - .spyOn(importedTokensApi, "setImportedTokens") - .mockImplementation( - () => - new Promise((resolve) => (resolveSetImportedTokens = resolve)) - ); - const spyOnGetImportedTokens = vi - .spyOn(importedTokensApi, "getImportedTokens") - .mockResolvedValue({ - imported_tokens: [ - { - ledger_canister_id: ledgerCanisterId2, - index_canister_id: [], - }, - ], - }); + vi.spyOn(importedTokensApi, "setImportedTokens").mockResolvedValue(); + vi.spyOn(importedTokensApi, "getImportedTokens").mockResolvedValue({ + imported_tokens: [ + { + ledger_canister_id: ledgerCanisterId2, + index_canister_id: [], + }, + ], + }); const po = await renderWallet({}); const morePopoverPo = po.getWalletMorePopoverPo(); @@ -559,13 +551,8 @@ describe("IcrcWallet", () => { await morePopoverPo.getRemoveButtonPo().click(); await runResolvedPromises(); - expect(get(pageStore).path).toEqual(AppPath.Wallet); - expect(get(busyStore)).toEqual([]); - // Confirm the removal. expect(await confirmationPo.isPresent()).toBe(true); - await confirmationPo.clickYes(); - expect(get(importedTokensStore).importedTokens).toEqual([ { ledgerCanisterId, @@ -576,80 +563,10 @@ describe("IcrcWallet", () => { indexCanisterId: undefined, }, ]); - expect(get(busyStore)).toEqual([ - { - initiator: "import-token-removing", - text: "Removing imported token...", - }, - ]); - - resolveSetImportedTokens(); - await runResolvedPromises(); - - // The token should be removed. - expect(spyOnSetImportedTokens).toBeCalledTimes(1); - expect(spyOnSetImportedTokens).toHaveBeenCalledWith({ - identity: mockIdentity, - importedTokens: [ - { - ledger_canister_id: ledgerCanisterId2, - index_canister_id: [], - }, - ], - }); - expect(spyOnGetImportedTokens).toBeCalledTimes(2); - - expect(get(busyStore)).toEqual([]); - expect(get(pageStore).path).toEqual(AppPath.Tokens); - expect(get(importedTokensStore).importedTokens).toEqual([ - { - ledgerCanisterId: ledgerCanisterId2, - indexCanisterId: undefined, - }, - ]); - }); - - it("should stay on the same page when removal is unsuccessful", async () => { - vi.spyOn(console, "error").mockReturnValue(); - const spyOnSetImportedTokens = vi - .spyOn(importedTokensApi, "setImportedTokens") - .mockRejectedValue(new Error()); - const spyOnGetImportedTokens = vi.spyOn( - importedTokensApi, - "getImportedTokens" - ); - - expect(get(importedTokensStore).importedTokens).toEqual([ - { - ledgerCanisterId, - indexCanisterId: undefined, - }, - { - ledgerCanisterId: ledgerCanisterId2, - indexCanisterId: undefined, - }, - ]); - - const po = await renderWallet({}); - const morePopoverPo = po.getWalletMorePopoverPo(); - const confirmationPo = po.getImportTokenRemoveConfirmationPo(); - - await po.getMoreButton().click(); - await runResolvedPromises(); - await morePopoverPo.getRemoveButtonPo().click(); - await runResolvedPromises(); await confirmationPo.clickYes(); await runResolvedPromises(); - expect(spyOnSetImportedTokens).toBeCalledTimes(1); - // should stay on wallet page - expect(get(pageStore).path).toEqual(AppPath.Wallet); - // without data change - expect(spyOnGetImportedTokens).toBeCalledTimes(0); + expect(get(importedTokensStore).importedTokens).toEqual([ - { - ledgerCanisterId, - indexCanisterId: undefined, - }, { ledgerCanisterId: ledgerCanisterId2, indexCanisterId: undefined, diff --git a/frontend/src/tests/lib/services/imported-tokens.services.spec.ts b/frontend/src/tests/lib/services/imported-tokens.services.spec.ts index 6618e416af1..4ec221cc780 100644 --- a/frontend/src/tests/lib/services/imported-tokens.services.spec.ts +++ b/frontend/src/tests/lib/services/imported-tokens.services.spec.ts @@ -15,7 +15,11 @@ import * as toastsStore from "$lib/stores/toasts.store"; import type { ImportedTokenData } from "$lib/types/imported-tokens"; import { mockIdentity, resetIdentity } from "$tests/mocks/auth.store.mock"; import { principal } from "$tests/mocks/sns-projects.mock"; -import { toastsStore as toastsStoreEntry } from "@dfinity/gix-components"; +import { runResolvedPromises } from "$tests/utils/timers.test-utils"; +import { + busyStore, + toastsStore as toastsStoreEntry, +} from "@dfinity/gix-components"; import * as dfinityUtils from "@dfinity/utils"; import { get } from "svelte/store"; @@ -43,6 +47,7 @@ describe("imported-tokens-services", () => { resetIdentity(); importedTokensStore.reset(); toastsStoreEntry.reset(); + busyStore.resetForTesting(); vi.spyOn(console, "error").mockReturnValue(); vi.spyOn(dfinityUtils, "createAgent").mockReturnValue(undefined); }); @@ -271,12 +276,15 @@ describe("imported-tokens-services", () => { const spySetImportedTokens = vi .spyOn(importedTokensApi, "setImportedTokens") .mockResolvedValue(undefined); - expect(spySetImportedTokens).toBeCalledTimes(0); - - const { success } = await removeImportedTokens({ - tokensToRemove: [importedTokenDataA], + importedTokensStore.set({ importedTokens: [importedTokenDataA, importedTokenDataB], + certified: true, }); + expect(spySetImportedTokens).toBeCalledTimes(0); + + const { success } = await removeImportedTokens( + importedTokenDataA.ledgerCanisterId + ); expect(success).toEqual(true); expect(spySetImportedTokens).toBeCalledTimes(1); @@ -286,23 +294,36 @@ describe("imported-tokens-services", () => { }); }); - it("should remove multiple tokens", async () => { - const spySetImportedTokens = vi + it("should display busy store", async () => { + let resolveSetImportedTokens; + const spyOnSetImportedTokens = vi .spyOn(importedTokensApi, "setImportedTokens") - .mockResolvedValue(undefined); - expect(spySetImportedTokens).toBeCalledTimes(0); - - const { success } = await removeImportedTokens({ - tokensToRemove: [importedTokenDataA, importedTokenDataB], + .mockImplementation( + () => + new Promise((resolve) => (resolveSetImportedTokens = resolve)) + ); + importedTokensStore.set({ importedTokens: [importedTokenDataA, importedTokenDataB], + certified: true, }); + expect(spyOnSetImportedTokens).toBeCalledTimes(0); + expect(get(busyStore)).toEqual([]); - expect(success).toEqual(true); - expect(spySetImportedTokens).toBeCalledTimes(1); - expect(spySetImportedTokens).toBeCalledWith({ - identity: mockIdentity, - importedTokens: [], - }); + removeImportedTokens(importedTokenDataA.ledgerCanisterId); + await runResolvedPromises(); + + expect(spyOnSetImportedTokens).toBeCalledTimes(1); + expect(get(busyStore)).toEqual([ + { + initiator: "import-token-removing", + text: "Removing imported token...", + }, + ]); + + resolveSetImportedTokens(); + await runResolvedPromises(); + + expect(get(busyStore)).toEqual([]); }); it("should update the store", async () => { @@ -324,10 +345,7 @@ describe("imported-tokens-services", () => { certified: true, }); - await removeImportedTokens({ - tokensToRemove: [importedTokenDataA], - importedTokens: [importedTokenDataA, importedTokenDataB], - }); + await removeImportedTokens(importedTokenDataA.ledgerCanisterId); expect(spyGetImportedTokens).toBeCalledTimes(2); expect(get(importedTokensStore)).toEqual({ @@ -344,12 +362,12 @@ describe("imported-tokens-services", () => { vi.spyOn(importedTokensApi, "getImportedTokens").mockResolvedValue({ imported_tokens: [importedTokenB], }); - expect(spyToastSuccess).not.toBeCalled(); - - await removeImportedTokens({ - tokensToRemove: [importedTokenDataA], + importedTokensStore.set({ importedTokens: [importedTokenDataA, importedTokenDataB], + certified: true, }); + expect(spyToastSuccess).not.toBeCalled(); + await removeImportedTokens(importedTokenDataA.ledgerCanisterId); expect(spyToastSuccess).toBeCalledTimes(1); expect(spyToastSuccess).toBeCalledWith({ @@ -362,12 +380,14 @@ describe("imported-tokens-services", () => { vi.spyOn(importedTokensApi, "setImportedTokens").mockRejectedValue( testError ); - expect(spyToastError).not.toBeCalled(); - - const { success } = await removeImportedTokens({ - tokensToRemove: [importedTokenDataA], + importedTokensStore.set({ importedTokens: [importedTokenDataA, importedTokenDataB], + certified: true, }); + expect(spyToastError).not.toBeCalled(); + const { success } = await removeImportedTokens( + importedTokenDataA.ledgerCanisterId + ); expect(success).toEqual(false); expect(spyToastError).toBeCalledTimes(1); diff --git a/frontend/src/tests/page-objects/ImportTokenRemoveConfirmation.page-object.ts b/frontend/src/tests/page-objects/ImportTokenRemoveConfirmation.page-object.ts index a24f225acd7..3566ebf3a2b 100644 --- a/frontend/src/tests/page-objects/ImportTokenRemoveConfirmation.page-object.ts +++ b/frontend/src/tests/page-objects/ImportTokenRemoveConfirmation.page-object.ts @@ -14,4 +14,8 @@ export class ImportTokenRemoveConfirmationPo extends ConfirmationModalPo { getUniverseSummaryPo(): UniverseSummaryPo { return UniverseSummaryPo.under(this.root); } + + getLedgerCanisterIdText(): Promise { + return this.getText("ledger-canister-id"); + } }