From d516f88447a2ee5b0d10f566e473543575a555b8 Mon Sep 17 00:00:00 2001 From: Yusef Habib Date: Fri, 6 Dec 2024 12:12:39 +0100 Subject: [PATCH] NNS1-3480: new Export ICP Transactions button (#5887) # Motivation To generate a Csv file with transaction information shows in the `/wallet` page of an account. It can be tested [here](https://qsgjb-riaaa-aaaaa-aaaga-cai.yhabib-ingress.devenv.dfinity.network/) # Changes - New component to create and initialize the download of a CSV file containing information about all transactions for all user accounts. # Tests - Component Unit Tests # Todos - [ ] Add entry to changelog (if necessary). Not necessary Prev. PR: #5895 | Next PR: #5926 --- .../header/ExportIcpTransactionsButton.svelte | 239 ++++++++++++++++++ frontend/src/lib/i18n/en.json | 31 ++- frontend/src/lib/types/i18n.d.ts | 25 +- frontend/src/lib/utils/export-to-csv.utils.ts | 6 +- .../ExportIcpTransactionsButton.spec.ts | 211 ++++++++++++++++ ...ExportIcpTransactionsButton.page-object.ts | 17 ++ 6 files changed, 509 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/components/header/ExportIcpTransactionsButton.svelte create mode 100644 frontend/src/tests/lib/components/header/ExportIcpTransactionsButton.spec.ts create mode 100644 frontend/src/tests/page-objects/ExportIcpTransactionsButton.page-object.ts diff --git a/frontend/src/lib/components/header/ExportIcpTransactionsButton.svelte b/frontend/src/lib/components/header/ExportIcpTransactionsButton.svelte new file mode 100644 index 00000000000..0bc06b0e6f6 --- /dev/null +++ b/frontend/src/lib/components/header/ExportIcpTransactionsButton.svelte @@ -0,0 +1,239 @@ + + + + + diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 72082e594b9..ccc4a72533e 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -154,23 +154,34 @@ "account_menu": "Open menu to access logout button", "main_icp_account_id": "Main ICP Account ID", "account_id_tooltip": "You can send ICP both to your principal ID and account ID, however some exchanges or wallets may not support transactions using a principal ID.", - "export_neurons": "Export Neurons Info" + "export_neurons": "Export Neurons Info", + "export_transactions": "Export ICP Transactions" }, "export_csv_neurons": { + "account_id": "Account ID", "account_id_label": "NNS Account Principal ID", - "date_label": "Export Date Time", + "account_name": "Account Name", + "amount": "Amount($tokenSymbol)", + "available_maturity": "Available Maturity", + "balance": "Balance($tokenSymbol)", "controller_id": "Controller Principal ID", - "neuron_id": "Neuron ID", - "project": "Project", - "symbol": "Symbol", + "creation_date": "Creation Date", + "date_label": "Export Date Time", + "dissolve_date": "Dissolve Date", + "dissolve_delay": "Dissolve Delay", + "from": "From", "neuron_account_id": "Neuron Account ID", + "neuron_id": "Neuron ID", + "numer_of_transactions": "Transactions", + "project": "Project Name", "stake": "Stake", - "available_maturity": "Available Maturity", "staked_maturity": "Staked Maturity", - "dissolve_delay": "Dissolve Delay", - "dissolve_date": "Dissolve Date", - "creation_date": "Creation Date", - "state": "State" + "state": "State", + "symbol": "Symbol", + "timestamp": "Date Time", + "to": "To", + "transaction_id": "TX ID", + "transaction_type": "TX Type" }, "export_error": { "csv_generation": "Failed to generate CSV file", diff --git a/frontend/src/lib/types/i18n.d.ts b/frontend/src/lib/types/i18n.d.ts index e259aa33f17..b34e80bef9a 100644 --- a/frontend/src/lib/types/i18n.d.ts +++ b/frontend/src/lib/types/i18n.d.ts @@ -162,23 +162,34 @@ interface I18nHeader { main_icp_account_id: string; account_id_tooltip: string; export_neurons: string; + export_transactions: string; } interface I18nExport_csv_neurons { + account_id: string; account_id_label: string; - date_label: string; + account_name: string; + amount: string; + available_maturity: string; + balance: string; controller_id: string; + creation_date: string; + date_label: string; + dissolve_date: string; + dissolve_delay: string; + from: string; + neuron_account_id: string; neuron_id: string; + numer_of_transactions: string; project: string; - symbol: string; - neuron_account_id: string; stake: string; - available_maturity: string; staked_maturity: string; - dissolve_delay: string; - dissolve_date: string; - creation_date: string; state: string; + symbol: string; + timestamp: string; + to: string; + transaction_id: string; + transaction_type: string; } interface I18nExport_error { diff --git a/frontend/src/lib/utils/export-to-csv.utils.ts b/frontend/src/lib/utils/export-to-csv.utils.ts index 0d18216aae5..9efafae13eb 100644 --- a/frontend/src/lib/utils/export-to-csv.utils.ts +++ b/frontend/src/lib/utils/export-to-csv.utils.ts @@ -5,7 +5,7 @@ type Metadata = { value: string; }; -type Dataset = { +export type CsvDataset = { data: T[]; metadata?: Metadata[]; }; @@ -165,7 +165,7 @@ export const combineDatasetsToCsv = ({ headers, }: { headers: CsvHeader[]; - datasets: Dataset[]; + datasets: CsvDataset[]; }): string => { const csvParts: string[] = []; // A double empty line break requires 3 new lines @@ -208,7 +208,7 @@ export const generateCsvFileToSave = async ({ fileName?: string; description?: string; headers: CsvHeader[]; - datasets: Dataset[]; + datasets: CsvDataset[]; }): Promise => { try { const csvContent = combineDatasetsToCsv({ datasets, headers }); diff --git a/frontend/src/tests/lib/components/header/ExportIcpTransactionsButton.spec.ts b/frontend/src/tests/lib/components/header/ExportIcpTransactionsButton.spec.ts new file mode 100644 index 00000000000..d26663b9d3e --- /dev/null +++ b/frontend/src/tests/lib/components/header/ExportIcpTransactionsButton.spec.ts @@ -0,0 +1,211 @@ +import * as icpIndexApi from "$lib/api/icp-index.api"; +import ExportIcpTransactionsButton from "$lib/components/header/ExportIcpTransactionsButton.svelte"; +import * as toastsStore from "$lib/stores/toasts.store"; +import * as exportToCsv from "$lib/utils/export-to-csv.utils"; +import { resetIdentity, setNoIdentity } from "$tests/mocks/auth.store.mock"; +import { mockAccountsStoreData } from "$tests/mocks/icp-accounts.store.mock"; +import { createTransactionWithId } from "$tests/mocks/icp-transactions.mock"; +import { ExportIcpTransactionsButtonPo } from "$tests/page-objects/ExportIcpTransactionsButton.page-object"; +import { JestPageObjectElement } from "$tests/page-objects/jest.page-object"; +import { setAccountsForTesting } from "$tests/utils/accounts.test-utils"; +import { runResolvedPromises } from "$tests/utils/timers.test-utils"; +import { render } from "@testing-library/svelte"; + +vi.mock("$lib/api/icp-ledger.api"); + +describe("ExportIcpTransactionsButton", () => { + let spyGenerateCsvFileToSave; + let spyToastError; + + beforeEach(() => { + vi.clearAllTimers(); + + spyGenerateCsvFileToSave = vi + .spyOn(exportToCsv, "generateCsvFileToSave") + .mockImplementation(() => Promise.resolve()); + spyToastError = vi.spyOn(toastsStore, "toastsError"); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const mockDate = new Date("2023-10-14T00:00:00Z"); + vi.useFakeTimers(); + vi.setSystemTime(mockDate); + + resetIdentity(); + + setAccountsForTesting({ + ...mockAccountsStoreData, + }); + + const mockTransactions = [ + createTransactionWithId({}), + createTransactionWithId({ + id: 1n, + }), + ]; + + vi.spyOn(icpIndexApi, "getTransactions").mockResolvedValue({ + transactions: mockTransactions, + balance: 0n, + oldestTxId: 1n, + }); + }); + + const renderComponent = ({ onTrigger }: { onTrigger?: () => void } = {}) => { + const { container, component } = render(ExportIcpTransactionsButton); + + const po = ExportIcpTransactionsButtonPo.under({ + element: new JestPageObjectElement(container), + }); + + if (onTrigger) { + component.$on("nnsExportIcpTransactionsCsvTriggered", onTrigger); + } + return po; + }; + + it("should be disabled when there is no identity", async () => { + setNoIdentity(); + const po = renderComponent(); + expect(await po.isDisabled()).toBe(true); + }); + + it("should name the file with the date of the export", async () => { + const po = renderComponent(); + + expect(await po.isDisabled()).toBe(false); + expect(spyGenerateCsvFileToSave).toHaveBeenCalledTimes(0); + + await po.click(); + await runResolvedPromises(); + + const expectedFileName = `icp_transactions_export_20231014`; + expect(spyGenerateCsvFileToSave).toHaveBeenCalledWith( + expect.objectContaining({ + fileName: expectedFileName, + }) + ); + expect(spyGenerateCsvFileToSave).toHaveBeenCalledTimes(1); + }); + + it("should transform transaction data correctly", async () => { + const po = renderComponent(); + + expect(spyGenerateCsvFileToSave).toBeCalledTimes(0); + await po.click(); + await runResolvedPromises(); + + expect(spyGenerateCsvFileToSave).toBeCalledWith( + expect.objectContaining({ + datasets: expect.arrayContaining([ + { + data: expect.arrayContaining([ + { + amount: "-1.0001", + from: "d4685b31b51450508aff0331584df7692a84467b680326f5c5f7d30ae711682f", + id: "1234", + project: "Internet Computer", + symbol: "ICP", + timestamp: "Jan 1, 2023 12:00 AM", + to: "d0654c53339c85e0e5fff46a2d800101bc3d896caef34e1a0597426792ff9f32", + type: "Sent", + }, + ]), + metadata: [ + { + label: "Account ID", + value: + "d4685b31b51450508aff0331584df7692a84467b680326f5c5f7d30ae711682f", + }, + { + label: "Account Name", + value: "Main", + }, + { + label: "Balance(ICP)", + value: "1'234'567.8901", + }, + { + label: "Controller Principal ID", + value: + "xlmdg-vkosz-ceopx-7wtgu-g3xmd-koiyc-awqaq-7modz-zf6r6-364rh-oqe", + }, + { + label: "Transactions", + value: "2", + }, + { + label: "Export Date Time", + value: "Oct 14, 2023 12:00 AM", + }, + ], + }, + ]), + }) + ); + expect(spyGenerateCsvFileToSave).toBeCalledTimes(1); + }); + + it("should dispatch nnsExportIcpTransactionsCsvTriggered event after click to close the menu", async () => { + const onTrigger = vi.fn(); + const po = renderComponent({ onTrigger }); + + expect(onTrigger).toHaveBeenCalledTimes(0); + + await po.click(); + await runResolvedPromises(); + expect(onTrigger).toHaveBeenCalledTimes(1); + }); + + it("should show error toast when file system access fails", async () => { + vi.spyOn(exportToCsv, "generateCsvFileToSave").mockRejectedValueOnce( + new exportToCsv.FileSystemAccessError("File system access denied") + ); + + const po = renderComponent(); + + expect(spyToastError).toBeCalledTimes(0); + + await po.click(); + await runResolvedPromises(); + + expect(spyToastError).toBeCalledWith({ + labelKey: "export_error.file_system_access", + }); + expect(spyToastError).toBeCalledTimes(1); + }); + + it("should show error toast when Csv generation fails", async () => { + vi.spyOn(exportToCsv, "generateCsvFileToSave").mockRejectedValueOnce( + new exportToCsv.CsvGenerationError("Csv generation failed") + ); + + const po = renderComponent(); + + expect(spyToastError).toBeCalledTimes(0); + + await po.click(); + await runResolvedPromises(); + + expect(spyToastError).toBeCalledWith({ + labelKey: "export_error.csv_generation", + }); + expect(spyToastError).toBeCalledTimes(1); + }); + + it("should show error toast when file saving fails", async () => { + vi.spyOn(exportToCsv, "generateCsvFileToSave").mockRejectedValueOnce( + new Error("Something wrong happened") + ); + + const po = renderComponent(); + + expect(spyToastError).toBeCalledTimes(0); + await po.click(); + await runResolvedPromises(); + + expect(spyToastError).toBeCalledWith({ + labelKey: "export_error.neurons", + }); + expect(spyToastError).toBeCalledTimes(1); + }); +}); diff --git a/frontend/src/tests/page-objects/ExportIcpTransactionsButton.page-object.ts b/frontend/src/tests/page-objects/ExportIcpTransactionsButton.page-object.ts new file mode 100644 index 00000000000..9ad60ee8b7a --- /dev/null +++ b/frontend/src/tests/page-objects/ExportIcpTransactionsButton.page-object.ts @@ -0,0 +1,17 @@ +import { ButtonPo } from "$tests/page-objects/Button.page-object"; +import type { PageObjectElement } from "$tests/types/page-object.types"; + +export class ExportIcpTransactionsButtonPo extends ButtonPo { + static readonly TID = "export-icp-transactions-button-component"; + + static under({ + element, + }: { + element: PageObjectElement; + }): ExportIcpTransactionsButtonPo { + return ButtonPo.under({ + element, + testId: ExportIcpTransactionsButtonPo.TID, + }); + } +}