diff --git a/frontend/src/lib/components/tokens/TokensTable/TokensTable.svelte b/frontend/src/lib/components/tokens/TokensTable/TokensTable.svelte index 0715d712b18..feb160a78db 100644 --- a/frontend/src/lib/components/tokens/TokensTable/TokensTable.svelte +++ b/frontend/src/lib/components/tokens/TokensTable/TokensTable.svelte @@ -4,23 +4,48 @@ import TokenTitleCell from "$lib/components/tokens/TokensTable/TokenTitleCell.svelte"; import ResponsiveTable from "$lib/components/ui/ResponsiveTable.svelte"; import { i18n } from "$lib/stores/i18n"; + import { importedTokensStore } from "$lib/stores/imported-tokens.store"; import type { TokensTableColumn, UserToken } from "$lib/types/tokens-page"; + import type { TokensTableOrder } from "$lib/types/tokens-page"; + import { + compareTokensAlphabetically, + compareTokensByBalance, + } from "$lib/utils/tokens-table.utils"; export let userTokensData: Array; export let firstColumnHeader: string; + export let order: TokensTableOrder = []; - const columns: TokensTableColumn[] = [ + let enableSorting: boolean; + $: enableSorting = order.length > 0; + + let importedTokenIds: Set = new Set(); + $: importedTokenIds = new Set( + ($importedTokensStore.importedTokens ?? []).map(({ ledgerCanisterId }) => + ledgerCanisterId.toText() + ) + ); + + let columns: TokensTableColumn[]; + + $: columns = [ { + id: "title", title: firstColumnHeader, cellComponent: TokenTitleCell, alignment: "left", templateColumns: ["1fr"], + comparator: enableSorting ? compareTokensAlphabetically : undefined, }, { + id: "balance", title: $i18n.tokens.balance_header, cellComponent: TokenBalanceCell, alignment: "right", templateColumns: ["max-content"], + comparator: enableSorting + ? compareTokensByBalance({ importedTokenIds }) + : undefined, }, { title: "", @@ -35,6 +60,7 @@ testId="tokens-table-component" tableData={userTokensData} {columns} + bind:order on:nnsAction > diff --git a/frontend/src/tests/lib/components/tokens/TokensTable.spec.ts b/frontend/src/tests/lib/components/tokens/TokensTable.spec.ts index 45c2b0c5c12..6634cc6e02e 100644 --- a/frontend/src/tests/lib/components/tokens/TokensTable.spec.ts +++ b/frontend/src/tests/lib/components/tokens/TokensTable.spec.ts @@ -4,6 +4,7 @@ import { AppPath } from "$lib/constants/routes.constants"; import { overrideFeatureFlagsStore } from "$lib/stores/feature-flags.store"; import { importedTokensStore } from "$lib/stores/imported-tokens.store"; import { ActionType } from "$lib/types/actions"; +import type { TokensTableOrder } from "$lib/types/tokens-page"; import { UserTokenAction, type UserTokenData, @@ -20,8 +21,10 @@ import { TokensTablePo } from "$tests/page-objects/TokensTable.page-object"; import { JestPageObjectElement } from "$tests/page-objects/jest.page-object"; import { createActionEvent } from "$tests/utils/actions.test-utils"; import { render } from "$tests/utils/svelte.test-utils"; +import { runResolvedPromises } from "$tests/utils/timers.test-utils"; import { ICPToken, TokenAmount } from "@dfinity/utils"; import { waitFor } from "@testing-library/svelte"; +import { get, writable, type Writable } from "svelte/store"; import type { Mock } from "vitest"; describe("TokensTable", () => { @@ -29,18 +32,36 @@ describe("TokensTable", () => { userTokensData, firstColumnHeader, onAction, + orderStore, + order, }: { userTokensData: Array; firstColumnHeader?: string; onAction?: Mock; + orderStore?: Writable; + order?: TokensTableOrder; }) => { - const { container } = render(TokensTable, { - props: { userTokensData, firstColumnHeader }, + const { container, component } = render(TokensTable, { + props: { + userTokensData, + firstColumnHeader, + order: order ?? get(orderStore), + }, events: { nnsAction: onAction, }, }); + if (orderStore) { + component.$$.update = () => { + orderStore.set(component.$$.ctx[component.$$.props["order"]]); + }; + } + + orderStore?.subscribe((order) => { + component.$set({ order }); + }); + return TokensTablePo.under(new JestPageObjectElement(container)); }; @@ -445,4 +466,168 @@ describe("TokensTable", () => { expect(await row1Po.hasImportedTokenTag()).toBe(false); expect(await row2Po.hasImportedTokenTag()).toBe(true); }); + + describe("Sorting", () => { + const tokenIcp = createUserToken({ + universeId: OWN_CANISTER_ID, + title: "Internet Computer", + }); + const tokenA = createUserToken({ + universeId: principal(0), + title: "A", + }); + const tokenB = createUserToken({ + universeId: principal(1), + title: "B", + }); + + const getProjectNames = async (po) => + Promise.all((await po.getRows()).map((row) => row.getProjectName())); + + it("should not allow sorting without order", async () => { + const po = renderTable({ + userTokensData: [tokenIcp, tokenA], + }); + + expect(await po.getColumnHeaderWithArrow()).toBe(undefined); + }); + + it("should allow sorting when order is specified", async () => { + const po = renderTable({ + userTokensData: [tokenIcp, tokenA], + order: [ + { + columnId: "balance", + }, + ], + }); + + expect(await po.getColumnHeaderWithArrow()).toBe("Balance"); + }); + + it("should change order based on order prop", async () => { + const tokensTableOrderStore: Writable = writable([ + { + columnId: "balance", + }, + ]); + const po = renderTable({ + userTokensData: [tokenIcp, tokenA], + orderStore: tokensTableOrderStore, + }); + + expect(await getProjectNames(po)).toEqual(["Internet Computer", "A"]); + + tokensTableOrderStore.set([ + { + columnId: "title", + }, + ]); + await runResolvedPromises(); + + expect(await getProjectNames(po)).toEqual(["A", "Internet Computer"]); + }); + + it("should change order store based on clicked header", async () => { + const tokensTableOrderStore: Writable = writable([ + { + columnId: "balance", + }, + { + columnId: "title", + }, + ]); + const firstColumnHeader = "Projects"; + const po = renderTable({ + firstColumnHeader, + userTokensData: [tokenIcp, tokenA], + orderStore: tokensTableOrderStore, + }); + + expect(get(tokensTableOrderStore)).toEqual([ + { + columnId: "balance", + }, + { + columnId: "title", + }, + ]); + + await po.clickColumnHeader(firstColumnHeader); + + expect(get(tokensTableOrderStore)).toEqual([ + { + columnId: "title", + }, + { + columnId: "balance", + }, + ]); + + await po.clickColumnHeader("Balance"); + + expect(get(tokensTableOrderStore)).toEqual([ + { + columnId: "balance", + }, + { + columnId: "title", + }, + ]); + + await po.clickColumnHeader("Balance"); + + expect(get(tokensTableOrderStore)).toEqual([ + { + columnId: "balance", + reversed: true, + }, + { + columnId: "title", + }, + ]); + }); + + it("should order imported tokens without balance before other tokens without balance", async () => { + const po = renderTable({ + userTokensData: [tokenIcp, tokenA, tokenB], + order: [ + { + columnId: "balance", + }, + { + columnId: "title", + }, + ], + }); + + // If B is not an imported token, it comes after A. + expect(await getProjectNames(po)).toEqual([ + "Internet Computer", + "A", + "B", + ]); + + // Make B an imported token. + importedTokensStore.set({ + importedTokens: [ + { + ledgerCanisterId: tokenB.universeId, + indexCanisterId: undefined, + }, + ], + certified: true, + }); + + await runResolvedPromises(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // If B is an imported token, it comes before A. + expect(await getProjectNames(po)).toEqual([ + "Internet Computer", + "B", + "A", + ]); + }); + }); });