diff --git a/frontend/src/lib/components/accounts/IcrcWalletPage.svelte b/frontend/src/lib/components/accounts/IcrcWalletPage.svelte index 219df9d49f2..cf330465983 100644 --- a/frontend/src/lib/components/accounts/IcrcWalletPage.svelte +++ b/frontend/src/lib/components/accounts/IcrcWalletPage.svelte @@ -35,7 +35,7 @@ import type { Universe } from "$lib/types/universe"; import { selectableUniversesStore } from "$lib/derived/selectable-universes.derived"; - export let testId: string; + export let testId: string = "icrc-wallet-page"; export let accountIdentifier: string | undefined | null = undefined; export let ledgerCanisterId: Principal | undefined; export let indexCanisterId: Principal | undefined; diff --git a/frontend/src/lib/components/accounts/ImportTokenForm.svelte b/frontend/src/lib/components/accounts/ImportTokenForm.svelte index 77e45e5875f..95694ef388d 100644 --- a/frontend/src/lib/components/accounts/ImportTokenForm.svelte +++ b/frontend/src/lib/components/accounts/ImportTokenForm.svelte @@ -10,11 +10,14 @@ export let ledgerCanisterId: Principal | undefined = undefined; export let indexCanisterId: Principal | undefined = undefined; + export let addIndexCanisterMode: boolean = false; const dispatch = createEventDispatcher(); let isSubmitDisabled = true; - $: isSubmitDisabled = isNullish(ledgerCanisterId); + $: isSubmitDisabled = addIndexCanisterMode + ? isNullish(indexCanisterId) + : isNullish(ledgerCanisterId); @@ -26,6 +29,7 @@ placeholderLabelKey="import_token.placeholder" name="ledger-canister-id" testId="ledger-canister-id" + disabled={addIndexCanisterMode} > {$i18n.import_token.ledger_label} - +

- + {#if !addIndexCanisterMode} + + {/if}
diff --git a/frontend/src/lib/components/ui/PrincipalInput.svelte b/frontend/src/lib/components/ui/PrincipalInput.svelte index 9c224c1ddd6..8501b53818e 100644 --- a/frontend/src/lib/components/ui/PrincipalInput.svelte +++ b/frontend/src/lib/components/ui/PrincipalInput.svelte @@ -9,6 +9,7 @@ export let principal: Principal | undefined = undefined; export let required: boolean | undefined = undefined; export let testId: string | undefined = undefined; + export let disabled: boolean | undefined = undefined; let address = principal?.toText() ?? ""; $: principal = getPrincipalFromString(address); @@ -26,6 +27,7 @@ {placeholderLabelKey} {name} {testId} + {disabled} bind:value={address} errorMessage={showError ? $i18n.error.principal_not_valid : undefined} on:blur={showErrorIfAny} diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index c27480f3ead..a430630d3d0 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -1045,6 +1045,7 @@ "verifying": "Verifying token details...", "importing": "Importing new token...", "removing": "Removing imported token...", + "updating": "Updating imported token...", "description": "To import a new token to your NNS dapp wallet, you will need to find, and paste the ledger canister id of the token. If you want to see your transaction history, you need to import the token’s index canister.", "ledger_label": "Ledger Canister ID", "index_label_optional": "Index Canister ID (Optional)", @@ -1057,6 +1058,8 @@ "index_label": "Index Canister ID", "index_fallback_label": "Transaction history won’t be displayed.", "import_button": "Import", - "link_to_dashboard": "https://dashboard.internetcomputer.org/canister/$canisterId" + "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 history add an index canister. Note: not all tokens have index canisters." } } diff --git a/frontend/src/lib/modals/accounts/AddIndexCanisterModal.svelte b/frontend/src/lib/modals/accounts/AddIndexCanisterModal.svelte new file mode 100644 index 00000000000..677bc7eeaae --- /dev/null +++ b/frontend/src/lib/modals/accounts/AddIndexCanisterModal.svelte @@ -0,0 +1,69 @@ + + + + {$i18n.import_token.add_index_canister} + + diff --git a/frontend/src/lib/pages/IcrcWallet.svelte b/frontend/src/lib/pages/IcrcWallet.svelte index aab4b441e53..ccb09e5d5ca 100644 --- a/frontend/src/lib/pages/IcrcWallet.svelte +++ b/frontend/src/lib/pages/IcrcWallet.svelte @@ -3,14 +3,20 @@ import IcrcWalletPage from "$lib/components/accounts/IcrcWalletPage.svelte"; import IcrcWalletTransactionsList from "$lib/components/accounts/IcrcWalletTransactionsList.svelte"; import NoTransactions from "$lib/components/accounts/NoTransactions.svelte"; + import { icrcCanistersStore } from "$lib/derived/icrc-canisters.derived"; import { selectedIcrcTokenUniverseIdStore } from "$lib/derived/selected-universe.derived"; import { tokensByUniverseIdStore } from "$lib/derived/tokens.derived"; - import { icrcCanistersStore } from "$lib/derived/icrc-canisters.derived"; + import { importedTokensStore } from "$lib/stores/imported-tokens.store"; import type { CanisterId } from "$lib/types/canister"; import type { IcrcTokenMetadata } from "$lib/types/icrc"; import type { WalletStore } from "$lib/types/wallet.context"; - import { nonNullish } from "@dfinity/utils"; + import { isImportedToken as checkImportedToken } from "$lib/utils/imported-tokens.utils"; + import { Html, IconCanistersPage, IconPlus } from "@dfinity/gix-components"; + import { isNullish, nonNullish } from "@dfinity/utils"; import { writable } from "svelte/store"; + import AddIndexCanisterModal from "$lib/modals/accounts/AddIndexCanisterModal.svelte"; + import { i18n } from "$lib/stores/i18n"; + import TestIdWrapper from "$lib/components/common/TestIdWrapper.svelte"; export let accountIdentifier: string | undefined | null = undefined; @@ -35,41 +41,98 @@ const reloadAccount = async () => await wallet.reloadAccount?.(); const reloadTransactions = () => transactions?.reloadTransactions?.(); + + let isImportedToken = false; + $: isImportedToken = checkImportedToken({ + ledgerCanisterId: $selectedIcrcTokenUniverseIdStore, + importedTokens: $importedTokensStore.importedTokens, + }); + + let showAddIndexCanisterModal = false; - - - {#if nonNullish($selectedAccountStore.account) && nonNullish($selectedIcrcTokenUniverseIdStore) && nonNullish(indexCanisterId)} - - {:else} - - {/if} - - - - {#if nonNullish($selectedAccountStore.account) && nonNullish(token) && nonNullish($selectedIcrcTokenUniverseIdStore)} - - {/if} - - + + + + {#if isImportedToken && isNullish(indexCanisterId)} +
+
+ +
+

+ +

+ +
+ {:else if isNullish($selectedAccountStore.account) || isNullish($selectedIcrcTokenUniverseIdStore) || isNullish(indexCanisterId)} + + {:else} + + {/if} +
+ + + {#if nonNullish($selectedAccountStore.account) && nonNullish(token) && nonNullish($selectedIcrcTokenUniverseIdStore)} + + {/if} + +
+ + {#if showAddIndexCanisterModal && nonNullish($selectedIcrcTokenUniverseIdStore)} + (showAddIndexCanisterModal = false)} + ledgerCanisterId={$selectedIcrcTokenUniverseIdStore} + /> + {/if} +
+ + diff --git a/frontend/src/lib/stores/busy.store.ts b/frontend/src/lib/stores/busy.store.ts index 986f40dff4a..55c8b7fe91b 100644 --- a/frontend/src/lib/stores/busy.store.ts +++ b/frontend/src/lib/stores/busy.store.ts @@ -48,7 +48,8 @@ export type BusyStateInitiatorType = | "reload-receive-account" | "import-token-validation" | "import-token-importing" - | "import-token-removing"; + | "import-token-removing" + | "import-token-updating"; export interface BusyState { initiator: BusyStateInitiatorType; diff --git a/frontend/src/lib/types/i18n.d.ts b/frontend/src/lib/types/i18n.d.ts index 7c929489ced..1350b975283 100644 --- a/frontend/src/lib/types/i18n.d.ts +++ b/frontend/src/lib/types/i18n.d.ts @@ -1104,6 +1104,7 @@ interface I18nImport_token { verifying: string; importing: string; removing: string; + updating: string; description: string; ledger_label: string; index_label_optional: string; @@ -1117,6 +1118,8 @@ interface I18nImport_token { index_fallback_label: string; import_button: string; link_to_dashboard: string; + add_index_canister: string; + add_index_description: string; } interface I18nNeuron_state { diff --git a/frontend/src/tests/lib/components/accounts/ImportTokenForm.spec.ts b/frontend/src/tests/lib/components/accounts/ImportTokenForm.spec.ts index ed1cfca5597..d788dd27984 100644 --- a/frontend/src/tests/lib/components/accounts/ImportTokenForm.spec.ts +++ b/frontend/src/tests/lib/components/accounts/ImportTokenForm.spec.ts @@ -9,6 +9,7 @@ describe("ImportTokenForm", () => { const renderComponent = (props: { ledgerCanisterId: Principal | undefined; indexCanisterId: Principal | undefined; + addIndexCanisterMode?: boolean | undefined; }) => { const { container, component } = render(ImportTokenForm, { props, @@ -49,6 +50,7 @@ describe("ImportTokenForm", () => { principal(0).toText() ); expect(await po.getLedgerCanisterInputPo().isRequired()).toEqual(true); + expect(await po.getLedgerCanisterInputPo().isDisabled()).toEqual(false); }); it("should render index canister id", async () => { @@ -96,6 +98,7 @@ describe("ImportTokenForm", () => { }); expect(await po.getSubmitButtonPo().isDisabled()).toEqual(false); + expect(await po.getSubmitButtonPo().getText()).toEqual("Next"); // Enter an invalid canister id await po @@ -158,4 +161,21 @@ describe("ImportTokenForm", () => { expect(nnsSubmit).toBeCalledTimes(1); expect(nnsClose).not.toHaveBeenCalled(); }); + + it("should display addIndexCanister mode ", async () => { + const { po } = renderComponent({ + ledgerCanisterId: principal(0), + indexCanisterId: undefined, + addIndexCanisterMode: true, + }); + + expect(await po.getLedgerCanisterInputPo().isDisabled()).toEqual(true); + expect(await po.getIndexCanisterInputPo().isRequired()).toEqual(true); + expect((await po.getIndexCanisterInputPo().getText()).trim()).toEqual( + "Index Canister ID" + ); + expect(await po.getSubmitButtonPo().getText()).toEqual( + "Add index canister" + ); + }); }); diff --git a/frontend/src/tests/lib/components/ui/PrincipalInput.spec.ts b/frontend/src/tests/lib/components/ui/PrincipalInput.spec.ts index 32a74bf1b52..b411194c766 100644 --- a/frontend/src/tests/lib/components/ui/PrincipalInput.spec.ts +++ b/frontend/src/tests/lib/components/ui/PrincipalInput.spec.ts @@ -4,7 +4,11 @@ import { render, waitFor } from "@testing-library/svelte"; import PrincipalInputTest from "./PrincipalInputTest.svelte"; describe("PrincipalInput", () => { - const props = { name: "name", placeholderLabelKey: "test.placeholder" }; + const props = { + name: "name", + placeholderLabelKey: "test.placeholder", + disabled: undefined, + }; it("should render an input", () => { const { getByTestId } = render(PrincipalInputTest, { @@ -29,4 +33,25 @@ describe("PrincipalInput", () => { expect(getByText(en.error.principal_not_valid)).toBeInTheDocument() ); }); + + it("should be not disabled by default", async () => { + const { getByTestId } = render(PrincipalInputTest, { + props: { + ...props, + }, + }); + expect(getByTestId("input-ui-element").getAttribute("disabled")).toBeNull(); + }); + + it("should provide disable state", async () => { + const { getByTestId: getByTestId2 } = render(PrincipalInputTest, { + props: { + ...props, + disabled: true, + }, + }); + expect( + getByTestId2("input-ui-element").getAttribute("disabled") + ).not.toBeNull(); + }); }); diff --git a/frontend/src/tests/lib/components/ui/PrincipalInputTest.svelte b/frontend/src/tests/lib/components/ui/PrincipalInputTest.svelte index 79ecf07549b..4fb8f9a670d 100644 --- a/frontend/src/tests/lib/components/ui/PrincipalInputTest.svelte +++ b/frontend/src/tests/lib/components/ui/PrincipalInputTest.svelte @@ -4,8 +4,9 @@ export let placeholderLabelKey: string; export let name: string; + export let disabled: boolean | undefined; let principal: Principal | undefined = undefined; - + diff --git a/frontend/src/tests/lib/pages/IcrcWallet.spec.ts b/frontend/src/tests/lib/pages/IcrcWallet.spec.ts index fbf459a956c..9cbd8a4e10f 100644 --- a/frontend/src/tests/lib/pages/IcrcWallet.spec.ts +++ b/frontend/src/tests/lib/pages/IcrcWallet.spec.ts @@ -115,6 +115,7 @@ describe("IcrcWallet", () => { balancesObserverCallback = undefined; vi.clearAllMocks(); vi.clearAllTimers(); + vi.restoreAllMocks(); tokensStore.reset(); overrideFeatureFlagsStore.reset(); toastsStore.reset(); @@ -490,6 +491,7 @@ describe("IcrcWallet", () => { describe("imported tokens", () => { const ledgerCanisterId = principal(0); const ledgerCanisterId2 = principal(1); + const indexCanisterId = principal(2); beforeEach(() => { page.mock({ @@ -654,5 +656,203 @@ describe("IcrcWallet", () => { }, ]); }); + + describe("index canister addition", () => { + let spyOnGetImportedTokens; + let spyOnSetImportedTokens; + let resolveSetImportedTokens; + + beforeEach(() => { + spyOnGetImportedTokens = vi + .spyOn(importedTokensApi, "getImportedTokens") + .mockResolvedValue({ + imported_tokens: [ + { + ledger_canister_id: ledgerCanisterId, + index_canister_id: [indexCanisterId], + }, + ], + }); + spyOnSetImportedTokens = vi + .spyOn(importedTokensApi, "setImportedTokens") + .mockImplementation( + () => + new Promise( + (resolve) => (resolveSetImportedTokens = resolve) + ) + ); + importedTokensStore.set({ + importedTokens: [ + { + ledgerCanisterId, + indexCanisterId: undefined, + }, + ], + certified: true, + }); + // Needs to pass the index canister validation. + vi.spyOn(icrcIndexApi, "getLedgerId").mockResolvedValue( + ledgerCanisterId + ); + }); + + it('should not display "Add index canister" button when already available', async () => { + importedTokensStore.set({ + importedTokens: [ + { + ledgerCanisterId, + indexCanisterId, + }, + ], + certified: true, + }); + const po = await renderWallet({}); + expect(await po.getAddIndexCanisterButtonPo().isPresent()).toBe(false); + }); + + it('should display "Add index canister" button when no index canister available', async () => { + const po = await renderWallet({}); + expect(await po.getAddIndexCanisterButtonPo().isPresent()).toBe(true); + }); + + it("should add index canister", async () => { + const po = await renderWallet({}); + const addIndexCanisterModalPo = po.getAddIndexCanisterModalPo(); + + expect(await po.getAddIndexCanisterButtonPo().isPresent()).toBe(true); + + // Open the modal. + await po.getAddIndexCanisterButtonPo().click(); + + expect(spyOnSetImportedTokens).toBeCalledTimes(0); + expect(await addIndexCanisterModalPo.isPresent()).toBe(true); + expect(get(busyStore)).toEqual([]); + + await addIndexCanisterModalPo.typeIndexCanisterId( + indexCanisterId.toText() + ); + await addIndexCanisterModalPo.clickAddIndexCanisterButton(); + await runResolvedPromises(); + + expect(get(toastsStore)).toEqual([]); + expect(get(busyStore)).toEqual([ + { + initiator: "import-token-updating", + text: "Updating imported token...", + }, + ]); + expect(spyOnGetImportedTokens).toBeCalledTimes(0); + + resolveSetImportedTokens(); + await runResolvedPromises(); + + expect(get(toastsStore)).toMatchObject([ + { + level: "success", + text: "The token has been successfully updated!", + }, + ]); + expect(spyOnSetImportedTokens).toBeCalledTimes(1); + expect(spyOnSetImportedTokens).toHaveBeenCalledWith({ + identity: mockIdentity, + importedTokens: [ + { + ledger_canister_id: ledgerCanisterId, + index_canister_id: [indexCanisterId], + }, + ], + }); + expect(spyOnGetImportedTokens).toBeCalledTimes(2); + expect(get(busyStore)).toEqual([]); + expect(get(importedTokensStore).importedTokens).toEqual([ + { + ledgerCanisterId, + indexCanisterId, + }, + ]); + + // The add index canister button should not be displayed anymore. + expect(await po.getAddIndexCanisterButtonPo().isPresent()).toBe(false); + }); + + it("should validate index canister before addition", async () => { + // Mock the index canister to belong to a different ledger canister. + const spyOnGetLedgerId = vi + .spyOn(icrcIndexApi, "getLedgerId") + .mockResolvedValue(principal(13)); + + const po = await renderWallet({}); + const addIndexCanisterModalPo = po.getAddIndexCanisterModalPo(); + + expect(await po.getAddIndexCanisterButtonPo().isPresent()).toBe(true); + + await po.getAddIndexCanisterButtonPo().click(); + await addIndexCanisterModalPo.typeIndexCanisterId( + indexCanisterId.toText() + ); + await addIndexCanisterModalPo.clickAddIndexCanisterButton(); + await runResolvedPromises(); + + expect(get(toastsStore)).toMatchObject([ + { + level: "error", + text: "The provided index canister ID does not match the associated ledger canister ID.", + }, + ]); + expect(get(busyStore)).toEqual([]); + expect(spyOnGetLedgerId).toBeCalledTimes(1); + expect(spyOnGetLedgerId).toBeCalledWith({ + certified: false, + identity: mockIdentity, + indexCanisterId, + }); + expect(spyOnSetImportedTokens).toBeCalledTimes(0); + expect(spyOnGetImportedTokens).toBeCalledTimes(0); + expect(get(importedTokensStore).importedTokens).toEqual([ + { + ledgerCanisterId, + indexCanisterId: undefined, + }, + ]); + expect(await po.getAddIndexCanisterButtonPo().isPresent()).toBe(true); + }); + + it("should handle errors", async () => { + vi.spyOn(console, "error").mockReturnValue(); + // mock an error when updating imported tokens + spyOnSetImportedTokens = vi + .spyOn(importedTokensApi, "setImportedTokens") + .mockRejectedValue(new Error("test")); + + const po = await renderWallet({}); + const addIndexCanisterModalPo = po.getAddIndexCanisterModalPo(); + + expect(await po.getAddIndexCanisterButtonPo().isPresent()).toBe(true); + + await po.getAddIndexCanisterButtonPo().click(); + await addIndexCanisterModalPo.typeIndexCanisterId( + indexCanisterId.toText() + ); + await addIndexCanisterModalPo.clickAddIndexCanisterButton(); + await runResolvedPromises(); + + expect(get(toastsStore)).toMatchObject([ + { + level: "error", + text: "There was an unexpected issue while updating the imported token. test", + }, + ]); + expect(get(busyStore)).toEqual([]); + expect(spyOnSetImportedTokens).toBeCalledTimes(1); + expect(spyOnGetImportedTokens).toBeCalledTimes(0); + expect(get(importedTokensStore).importedTokens).toEqual([ + { + ledgerCanisterId, + indexCanisterId: undefined, + }, + ]); + expect(await po.getAddIndexCanisterButtonPo().isPresent()).toBe(true); + }); + }); }); }); diff --git a/frontend/src/tests/page-objects/AddIndexCanisterModal.page-object.ts b/frontend/src/tests/page-objects/AddIndexCanisterModal.page-object.ts new file mode 100644 index 00000000000..b7372ea6f6e --- /dev/null +++ b/frontend/src/tests/page-objects/AddIndexCanisterModal.page-object.ts @@ -0,0 +1,27 @@ +import { ImportTokenFormPo } from "$tests/page-objects/ImportTokenForm.page-object"; +import { ModalPo } from "$tests/page-objects/Modal.page-object"; +import type { PageObjectElement } from "$tests/types/page-object.types"; + +export class AddIndexCanisterModalPo extends ModalPo { + private static readonly TID = "add-index-canister-modal-component"; + + static under(element: PageObjectElement): AddIndexCanisterModalPo { + return new AddIndexCanisterModalPo( + element.byTestId(AddIndexCanisterModalPo.TID) + ); + } + + getImportTokenFormPo(): ImportTokenFormPo { + return ImportTokenFormPo.under(this.root); + } + + typeIndexCanisterId(indexCanisterId: string): Promise { + return this.getImportTokenFormPo() + .getIndexCanisterInputPo() + .typeText(indexCanisterId); + } + + clickAddIndexCanisterButton(): Promise { + return this.getImportTokenFormPo().getSubmitButtonPo().click(); + } +} diff --git a/frontend/src/tests/page-objects/IcrcWallet.page-object.ts b/frontend/src/tests/page-objects/IcrcWallet.page-object.ts index 4809e7e91db..f2baf81a083 100644 --- a/frontend/src/tests/page-objects/IcrcWallet.page-object.ts +++ b/frontend/src/tests/page-objects/IcrcWallet.page-object.ts @@ -7,6 +7,7 @@ import { WalletPageHeaderPo } from "$tests/page-objects/WalletPageHeader.page-ob import { WalletPageHeadingPo } from "$tests/page-objects/WalletPageHeading.page-object"; import { BasePageObject } from "$tests/page-objects/base.page-object"; import type { PageObjectElement } from "$tests/types/page-object.types"; +import { AddIndexCanisterModalPo } from "./AddIndexCanisterModal.page-object"; export class IcrcWalletPo extends BasePageObject { private static readonly TID = "icrc-wallet-component"; @@ -35,6 +36,14 @@ export class IcrcWalletPo extends BasePageObject { return this.getButton("more-button"); } + getAddIndexCanisterButtonPo(): ButtonPo { + return this.getButton("add-index-canister-button"); + } + + getAddIndexCanisterModalPo(): AddIndexCanisterModalPo { + return AddIndexCanisterModalPo.under(this.root); + } + getWalletMorePopoverPo(): WalletMorePopoverPo { return WalletMorePopoverPo.under(this.root); }