From 0ac558e5846cb22d9419b36aba9805ea1d817e27 Mon Sep 17 00:00:00 2001 From: David de Kloet <122978264+dskloetd@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:08:12 +0100 Subject: [PATCH 1/7] Add banner to show total USD value (#5932) # Motivation At the top of both the tokens page and the neurons page we want to show a banner with the total value in USD of the listed assets. This PR adds a first version of that banner component, not yet used on any page. image image Switching the USD/ICP values and error state will be added in subsequence PRs. # Changes 1. Add banner component. # Tests 1. Added unit tests. # Todos - [ ] Add entry to changelog (if necessary). not necessary --------- Co-authored-by: Yusef Habib --- .../lib/components/ui/UsdValueBanner.svelte | 133 ++++++++++++++++++ frontend/src/lib/i18n/en.json | 3 +- frontend/src/lib/types/i18n.d.ts | 1 + .../lib/components/ui/UsdValueBanner.spec.ts | 87 ++++++++++++ .../UsdValueBanner.page-object.ts | 27 ++++ 5 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/components/ui/UsdValueBanner.svelte create mode 100644 frontend/src/tests/lib/components/ui/UsdValueBanner.spec.ts create mode 100644 frontend/src/tests/page-objects/UsdValueBanner.page-object.ts diff --git a/frontend/src/lib/components/ui/UsdValueBanner.svelte b/frontend/src/lib/components/ui/UsdValueBanner.svelte new file mode 100644 index 00000000000..93e7ceaca97 --- /dev/null +++ b/frontend/src/lib/components/ui/UsdValueBanner.svelte @@ -0,0 +1,133 @@ + + +
+
+ +
+
+
+

+ ${usdAmountFormatted} +

+
+ {icpAmountFormatted} + {$i18n.core.icp} +
+
+
+ {$i18n.auth.ic_logo} + + 1 {$i18n.core.icp} = ${icpPriceFormatted} + + +
+ 1 {$i18n.core.icp} = ${icpPriceFormatted} +
+
+ {$i18n.accounts.token_price_source} +
+
+
+
+
+ + diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 87df953b95a..8e66bcc7207 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -262,7 +262,8 @@ "received_amount": "Received Amount", "received_amount_notice": "Received Amount *", "transaction_time": "Transaction Time", - "transaction_time_seconds": "Seconds" + "transaction_time_seconds": "Seconds", + "token_price_source": "Token prices are provided by ICPSwap." }, "neuron_types": { "seed": "Seed", diff --git a/frontend/src/lib/types/i18n.d.ts b/frontend/src/lib/types/i18n.d.ts index f42521a95ff..118b637ae20 100644 --- a/frontend/src/lib/types/i18n.d.ts +++ b/frontend/src/lib/types/i18n.d.ts @@ -274,6 +274,7 @@ interface I18nAccounts { received_amount_notice: string; transaction_time: string; transaction_time_seconds: string; + token_price_source: string; } interface I18nNeuron_types { diff --git a/frontend/src/tests/lib/components/ui/UsdValueBanner.spec.ts b/frontend/src/tests/lib/components/ui/UsdValueBanner.spec.ts new file mode 100644 index 00000000000..55621f22b31 --- /dev/null +++ b/frontend/src/tests/lib/components/ui/UsdValueBanner.spec.ts @@ -0,0 +1,87 @@ +import UsdValueBanner from "$lib/components/ui/UsdValueBanner.svelte"; +import { CKUSDC_UNIVERSE_CANISTER_ID } from "$lib/constants/ckusdc-canister-ids.constants"; +import { icpSwapTickersStore } from "$lib/stores/icp-swap.store"; +import { mockIcpSwapTicker } from "$tests/mocks/icp-swap.mock"; +import { UsdValueBannerPo } from "$tests/page-objects/UsdValueBanner.page-object"; +import { JestPageObjectElement } from "$tests/page-objects/jest.page-object"; +import { render } from "$tests/utils/svelte.test-utils"; + +describe("UsdValueBanner", () => { + const renderComponent = (usdAmount: number) => { + const { container } = render(UsdValueBanner, { usdAmount }); + return UsdValueBannerPo.under(new JestPageObjectElement(container)); + }; + + const setIcpPrice = (icpPrice: number) => { + icpSwapTickersStore.set([ + { + ...mockIcpSwapTicker, + base_id: CKUSDC_UNIVERSE_CANISTER_ID.toText(), + last_price: String(icpPrice), + }, + ]); + }; + + it("should display the USD amount", async () => { + const usdAmount = 50; + const po = renderComponent(usdAmount); + + expect(await po.getPrimaryAmount()).toEqual("$50.00"); + }); + + it("should display the USD amount as absent", async () => { + const usdAmount = undefined; + const po = renderComponent(usdAmount); + + expect(await po.getPrimaryAmount()).toEqual("$-/-"); + }); + + it("should display the ICP amount", async () => { + const usdAmount = 50; + const icpPrice = 10; + + setIcpPrice(icpPrice); + + const po = renderComponent(usdAmount); + + expect(await po.getSecondaryAmount()).toEqual("5.00 ICP"); + }); + + it("should display the ICP amount as absent without exchange rates", async () => { + const usdAmount = 50; + const po = renderComponent(usdAmount); + + expect(await po.getSecondaryAmount()).toEqual("-/- ICP"); + }); + + it("should display the ICP price", async () => { + const usdAmount = 50; + const icpPrice = 10; + + setIcpPrice(icpPrice); + + const po = renderComponent(usdAmount); + + expect(await po.getIcpPrice()).toEqual("10.00"); + }); + + it("should display the ICP price as absent without exchange rates", async () => { + const usdAmount = 50; + const po = renderComponent(usdAmount); + + expect(await po.getIcpPrice()).toEqual("-/-"); + }); + + it("should display the ICP price in the tooltip", async () => { + const usdAmount = 50; + const icpPrice = 10; + + setIcpPrice(icpPrice); + + const po = renderComponent(usdAmount); + + expect(await po.getTooltipIconPo().getTooltipText()).toEqual( + "1 ICP = $10.00 Token prices are provided by ICPSwap." + ); + }); +}); diff --git a/frontend/src/tests/page-objects/UsdValueBanner.page-object.ts b/frontend/src/tests/page-objects/UsdValueBanner.page-object.ts new file mode 100644 index 00000000000..8de2abf4a6c --- /dev/null +++ b/frontend/src/tests/page-objects/UsdValueBanner.page-object.ts @@ -0,0 +1,27 @@ +import { TooltipIconPo } from "$tests/page-objects/TooltipIcon.page-object"; +import { BasePageObject } from "$tests/page-objects/base.page-object"; +import type { PageObjectElement } from "$tests/types/page-object.types"; + +export class UsdValueBannerPo extends BasePageObject { + private static readonly TID = "usd-value-banner-component"; + + static under(element: PageObjectElement): UsdValueBannerPo { + return new UsdValueBannerPo(element.byTestId(UsdValueBannerPo.TID)); + } + + getTooltipIconPo(): TooltipIconPo { + return TooltipIconPo.under(this.root); + } + + getPrimaryAmount(): Promise { + return this.getText("primary-amount"); + } + + getSecondaryAmount(): Promise { + return this.getText("secondary-amount"); + } + + getIcpPrice(): Promise { + return this.getText("icp-price"); + } +} From 6ac105b3c9f712d9342efa8275dc102b4893477f Mon Sep 17 00:00:00 2001 From: Max Strasinsky <98811342+mstrasinskis@users.noreply.github.com> Date: Thu, 5 Dec 2024 17:45:13 +0100 Subject: [PATCH 2/7] Losing rewards modal (#5923) # Motivation Users should not only be notified about neurons that are losing rewards but also be able to refresh all these neurons to keep them active. To achieve this, the Confirm Following modal was created. It displays all inactive neurons along with their followings. Out of scope: - Refresh api call. - The gaps in the Following component need to be adjusted to match the new design. - Tags status. - Page reload after navigation to the neuron details page. # Changes - New LosingRewardNeuronsModal component. - Should fetch known neurons, to display them in the following neurons component. - Display the modal from the LosingRewardsBanner. # Tests - Unit tests updated. - Tested manually in a separate branch. | Dark | Light | |--------|--------| | image | image | | image | image | # Todos - [ ] Add entry to changelog (if necessary). Not necessary. --------- Co-authored-by: gix-bot --- .../neurons/LosingRewardsBanner.svelte | 14 +- frontend/src/lib/i18n/en.json | 4 + .../neurons/LosingRewardNeuronsModal.svelte | 79 +++++++++++ frontend/src/lib/types/i18n.d.ts | 4 + .../neurons/LosingRewardNeuronsModal.spec.ts | 124 ++++++++++++++++++ .../neurons/LosingRewardsBanner.spec.ts | 18 ++- .../LosingRewardNeuronsModal.page-object.ts | 21 +++ .../LosingRewardsBanner.page-object.ts | 7 +- .../NnsLosingRewardsNeuronCard.page-object.ts | 4 + 9 files changed, 266 insertions(+), 9 deletions(-) create mode 100644 frontend/src/lib/modals/neurons/LosingRewardNeuronsModal.svelte create mode 100644 frontend/src/tests/lib/components/neurons/LosingRewardNeuronsModal.spec.ts create mode 100644 frontend/src/tests/page-objects/LosingRewardNeuronsModal.page-object.ts diff --git a/frontend/src/lib/components/neurons/LosingRewardsBanner.svelte b/frontend/src/lib/components/neurons/LosingRewardsBanner.svelte index 8945a93e899..6f2375b6187 100644 --- a/frontend/src/lib/components/neurons/LosingRewardsBanner.svelte +++ b/frontend/src/lib/components/neurons/LosingRewardsBanner.svelte @@ -14,6 +14,7 @@ import { START_REDUCING_VOTING_POWER_AFTER_SECONDS } from "$lib/constants/neurons.constants"; import { secondsToDissolveDelayDuration } from "$lib/utils/date.utils"; import TestIdWrapper from "$lib/components/common/TestIdWrapper.svelte"; + import LosingRewardNeuronsModal from "$lib/modals/neurons/LosingRewardNeuronsModal.svelte"; // The neurons in the store are sorted by the time they will lose rewards. let mostInactiveNeuron: NeuronInfo | undefined; @@ -35,9 +36,7 @@ ), }); - const onConfirm = () => { - // TODO: Display the modal - }; + let isModalVisible = false; @@ -47,10 +46,17 @@
-
{/if} + + {#if isModalVisible} + (isModalVisible = false)} /> + {/if}
diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 8e66bcc7207..72082e594b9 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -373,6 +373,10 @@ }, "losing_rewards_modal": { "goto_neuron": "Go to neuron details", + "title": "Review your following for neurons", + "description": "ICP neurons that are inactive for $period start losing voting rewards. In order to avoid losing rewards, vote manually, edit or confirm your following.", + "label": "Neurons", + "confirm": "Confirm Following", "no_following": "This neuron has no following configured." }, "new_followee": { diff --git a/frontend/src/lib/modals/neurons/LosingRewardNeuronsModal.svelte b/frontend/src/lib/modals/neurons/LosingRewardNeuronsModal.svelte new file mode 100644 index 00000000000..0db7bf75a0b --- /dev/null +++ b/frontend/src/lib/modals/neurons/LosingRewardNeuronsModal.svelte @@ -0,0 +1,79 @@ + + + + + {$i18n.losing_rewards_modal.title} + + +
+

+ {replacePlaceholders($i18n.losing_rewards_modal.description, { + $period: secondsToDissolveDelayDuration( + BigInt(START_REDUCING_VOTING_POWER_AFTER_SECONDS) + ), + })} +

+ +

{$i18n.losing_rewards_modal.label}

+
    + {#each $soonLosingRewardNeuronsStore as neuron (neuron.neuronId)} +
  • + +
  • + {/each} +
+
+ + +
+
+
+ + diff --git a/frontend/src/lib/types/i18n.d.ts b/frontend/src/lib/types/i18n.d.ts index 118b637ae20..e259aa33f17 100644 --- a/frontend/src/lib/types/i18n.d.ts +++ b/frontend/src/lib/types/i18n.d.ts @@ -389,6 +389,10 @@ interface I18nLosing_rewards_banner { interface I18nLosing_rewards_modal { goto_neuron: string; + title: string; + description: string; + label: string; + confirm: string; no_following: string; } diff --git a/frontend/src/tests/lib/components/neurons/LosingRewardNeuronsModal.spec.ts b/frontend/src/tests/lib/components/neurons/LosingRewardNeuronsModal.spec.ts new file mode 100644 index 00000000000..ce8ec6b825e --- /dev/null +++ b/frontend/src/tests/lib/components/neurons/LosingRewardNeuronsModal.spec.ts @@ -0,0 +1,124 @@ +import { clearCache } from "$lib/api-services/governance.api-service"; +import * as governanceApi from "$lib/api/governance.api"; +import { SECONDS_IN_DAY, SECONDS_IN_HALF_YEAR } from "$lib/constants/constants"; +import LosingRewardNeuronsModal from "$lib/modals/neurons/LosingRewardNeuronsModal.svelte"; +import { neuronsStore } from "$lib/stores/neurons.store"; +import { nowInSeconds } from "$lib/utils/date.utils"; +import { mockIdentity, resetIdentity } from "$tests/mocks/auth.store.mock"; +import { mockFullNeuron, mockNeuron } from "$tests/mocks/neurons.mock"; +import { LosingRewardNeuronsModalPo } from "$tests/page-objects/LosingRewardNeuronsModal.page-object"; +import { JestPageObjectElement } from "$tests/page-objects/jest.page-object"; +import { runResolvedPromises } from "$tests/utils/timers.test-utils"; +import { nonNullish } from "@dfinity/utils"; +import { render } from "@testing-library/svelte"; + +describe("LosingRewardNeuronsModal", () => { + const nowSeconds = nowInSeconds(); + const activeNeuron = { + ...mockNeuron, + neuronId: 0n, + fullNeuron: { + ...mockFullNeuron, + votingPowerRefreshedTimestampSeconds: BigInt(nowSeconds), + }, + }; + const in10DaysLosingRewardsNeuron = { + ...mockNeuron, + neuronId: 1n, + fullNeuron: { + ...mockFullNeuron, + votingPowerRefreshedTimestampSeconds: BigInt( + nowSeconds - SECONDS_IN_HALF_YEAR + 10 * SECONDS_IN_DAY + ), + }, + }; + const losingRewardsNeuron = { + ...mockNeuron, + neuronId: 2n, + fullNeuron: { + ...mockFullNeuron, + votingPowerRefreshedTimestampSeconds: BigInt( + nowSeconds - SECONDS_IN_HALF_YEAR + ), + }, + }; + + const renderComponent = ({ onClose }: { onClose?: () => void } = {}) => { + const { container, component } = render(LosingRewardNeuronsModal); + + if (nonNullish(onClose)) { + component.$on("nnsClose", onClose); + } + + return LosingRewardNeuronsModalPo.under( + new JestPageObjectElement(container) + ); + }; + + beforeEach(() => { + resetIdentity(); + // Remove known neurons from the cache. + clearCache(); + + vi.useFakeTimers({ + now: nowSeconds * 1000, + }); + + vi.spyOn(governanceApi, "queryKnownNeurons").mockResolvedValue([]); + }); + + it("should not display active neurons", async () => { + neuronsStore.setNeurons({ + neurons: [activeNeuron, in10DaysLosingRewardsNeuron, losingRewardsNeuron], + certified: true, + }); + const po = await renderComponent(); + const cards = await po.getNnsLosingRewardsNeuronCardPos(); + + expect(cards.length).toEqual(2); + expect(await cards[0].getNeuronId()).toEqual( + `${losingRewardsNeuron.neuronId}` + ); + expect(await cards[1].getNeuronId()).toEqual( + `${in10DaysLosingRewardsNeuron.neuronId}` + ); + }); + + it("should dispatch on close", async () => { + neuronsStore.setNeurons({ + neurons: [activeNeuron, in10DaysLosingRewardsNeuron, losingRewardsNeuron], + certified: true, + }); + const onClose = vi.fn(); + const po = await renderComponent({ + onClose, + }); + + expect(onClose).toHaveBeenCalledTimes(0); + await po.clickCancel(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("should fetch known neurons", async () => { + const queryKnownNeuronsSpy = vi + .spyOn(governanceApi, "queryKnownNeurons") + .mockResolvedValue([]); + neuronsStore.setNeurons({ + neurons: [activeNeuron, in10DaysLosingRewardsNeuron, losingRewardsNeuron], + certified: true, + }); + + expect(queryKnownNeuronsSpy).toHaveBeenCalledTimes(0); + await renderComponent(); + await runResolvedPromises(); + expect(queryKnownNeuronsSpy).toHaveBeenCalledTimes(2); + expect(queryKnownNeuronsSpy).toHaveBeenCalledWith({ + certified: true, + identity: mockIdentity, + }); + expect(queryKnownNeuronsSpy).toHaveBeenCalledWith({ + certified: false, + identity: mockIdentity, + }); + }); +}); diff --git a/frontend/src/tests/lib/components/neurons/LosingRewardsBanner.spec.ts b/frontend/src/tests/lib/components/neurons/LosingRewardsBanner.spec.ts index ece6923d8af..124e4c460c9 100644 --- a/frontend/src/tests/lib/components/neurons/LosingRewardsBanner.spec.ts +++ b/frontend/src/tests/lib/components/neurons/LosingRewardsBanner.spec.ts @@ -5,9 +5,7 @@ import { nowInSeconds } from "$lib/utils/date.utils"; import { mockFullNeuron, mockNeuron } from "$tests/mocks/neurons.mock"; import { LosingRewardsBannerPo } from "$tests/page-objects/LosingRewardsBanner.page-object"; import { JestPageObjectElement } from "$tests/page-objects/jest.page-object"; -import { runResolvedPromises } from "$tests/utils/timers.test-utils"; -import { render } from "@testing-library/svelte"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "$tests/utils/svelte.test-utils"; describe("LosingRewardsBanner", () => { const nowSeconds = nowInSeconds(); @@ -94,9 +92,21 @@ describe("LosingRewardsBanner", () => { certified: true, }); const po = await renderComponent(); - await runResolvedPromises(); + expect(await po.getText()).toBe( "ICP neurons that are inactive for 6 months start losing voting rewards. In order to avoid losing rewards, vote manually, edit or confirm your following." ); }); + + it("should open losing reward neurons modal", async () => { + neuronsStore.setNeurons({ + neurons: [activeNeuron, in10DaysLosingRewardsNeuron], + certified: true, + }); + const po = await renderComponent(); + + expect(await po.getLosingRewardNeuronsModalPo().isPresent()).toEqual(false); + await po.clickConfirm(); + expect(await po.getLosingRewardNeuronsModalPo().isPresent()).toEqual(true); + }); }); diff --git a/frontend/src/tests/page-objects/LosingRewardNeuronsModal.page-object.ts b/frontend/src/tests/page-objects/LosingRewardNeuronsModal.page-object.ts new file mode 100644 index 00000000000..de6815c4b1b --- /dev/null +++ b/frontend/src/tests/page-objects/LosingRewardNeuronsModal.page-object.ts @@ -0,0 +1,21 @@ +import { ModalPo } from "$tests/page-objects/Modal.page-object"; +import type { PageObjectElement } from "$tests/types/page-object.types"; +import { NnsLosingRewardsNeuronCardPo } from "./NnsLosingRewardsNeuronCard.page-object"; + +export class LosingRewardNeuronsModalPo extends ModalPo { + static readonly TID = "losing-reward-neurons-modal-component"; + + static under(element: PageObjectElement): LosingRewardNeuronsModalPo { + return new LosingRewardNeuronsModalPo( + element.byTestId(LosingRewardNeuronsModalPo.TID) + ); + } + + getNnsLosingRewardsNeuronCardPos(): Promise { + return NnsLosingRewardsNeuronCardPo.allUnder(this.root); + } + + async clickCancel(): Promise { + return this.getButton("cancel-button").click(); + } +} diff --git a/frontend/src/tests/page-objects/LosingRewardsBanner.page-object.ts b/frontend/src/tests/page-objects/LosingRewardsBanner.page-object.ts index 78681470bc4..a6044b5027c 100644 --- a/frontend/src/tests/page-objects/LosingRewardsBanner.page-object.ts +++ b/frontend/src/tests/page-objects/LosingRewardsBanner.page-object.ts @@ -1,6 +1,7 @@ import type { PageObjectElement } from "$tests/types/page-object.types"; import { BannerPo } from "./Banner.page-object"; import { BasePageObject } from "./base.page-object"; +import { LosingRewardNeuronsModalPo } from "./LosingRewardNeuronsModal.page-object"; export class LosingRewardsBannerPo extends BasePageObject { private static readonly TID = "losing-rewards-banner-component"; @@ -15,6 +16,10 @@ export class LosingRewardsBannerPo extends BasePageObject { return BannerPo.under(this.root); } + getLosingRewardNeuronsModalPo(): LosingRewardNeuronsModalPo { + return LosingRewardNeuronsModalPo.under(this.root); + } + async isVisible(): Promise { return this.getBannerPo().isPresent(); } @@ -28,6 +33,6 @@ export class LosingRewardsBannerPo extends BasePageObject { } async clickConfirm(): Promise { - // TBD + return this.getButton("confirm-button").click(); } } diff --git a/frontend/src/tests/page-objects/NnsLosingRewardsNeuronCard.page-object.ts b/frontend/src/tests/page-objects/NnsLosingRewardsNeuronCard.page-object.ts index 945f2443c72..998c2eb81e2 100644 --- a/frontend/src/tests/page-objects/NnsLosingRewardsNeuronCard.page-object.ts +++ b/frontend/src/tests/page-objects/NnsLosingRewardsNeuronCard.page-object.ts @@ -26,4 +26,8 @@ export class NnsLosingRewardsNeuronCardPo extends CardPo { async hasNoFollowingMessage(): Promise { return this.isPresent("no-following"); } + + async getNeuronId(): Promise { + return this.getElement("neuron-id").getText(); + } } From ab07bacb0c7dd61095d71c9f87db4aaa5224c44f Mon Sep 17 00:00:00 2001 From: jasonz-dfinity <133917836+jasonz-dfinity@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:33:58 -0800 Subject: [PATCH 3/7] Update changelog after release (#5935) # Motivation I forgot to update the changelog after the [release in August](https://dashboard.internetcomputer.org/proposal/132124) # Changes Add a new entry for the upgrade proposal and move change entries into it # Todos - [x] Add entry to changelog (if necessary). --- CHANGELOG-Sns_Aggregator.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG-Sns_Aggregator.md b/CHANGELOG-Sns_Aggregator.md index 354b45aa4a3..faa8b23bf18 100644 --- a/CHANGELOG-Sns_Aggregator.md +++ b/CHANGELOG-Sns_Aggregator.md @@ -18,6 +18,8 @@ The SNS Aggregator is released through proposals in the Network Nervous System. ### Security +## [Proposal 132124](https://dashboard.internetcomputer.org/proposal/132124) +### Security - Decoding quota of 10,000 in the `http_request` method. ## [Proposal 129614](https://dashboard.internetcomputer.org/proposal/129614) From 7aadce5e1db9f7a9ceb5b88252c02c55b5f3516f Mon Sep 17 00:00:00 2001 From: Max Strasinsky <98811342+mstrasinskis@users.noreply.github.com> Date: Fri, 6 Dec 2024 11:15:07 +0100 Subject: [PATCH 4/7] Tags style danger (#5922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Motivation For inactive neurons, we display tags like “Missing Rewards” next to them. To make these tags more noticeable, they should use the “Error” style. # Changes - Set the intent of the Tag component based on the NeuronTagData.status. # Tests - To simplify the code, the NeuronTagPo extends now the TagPo. - Added tests for the NeuronsTag component, as it now includes some logic. image # Todos - [ ] Add entry to changelog (if necessary). Not necessary. --------- Co-authored-by: gix-bot --- .../src/lib/components/ui/NeuronTag.svelte | 5 ++- .../tests/lib/components/ui/NeuronTag.spec.ts | 37 +++++++++++++++++++ .../page-objects/NeuronTag.page-object.ts | 16 +++----- .../src/tests/page-objects/Tag.page-object.ts | 8 ++++ 4 files changed, 55 insertions(+), 11 deletions(-) create mode 100644 frontend/src/tests/lib/components/ui/NeuronTag.spec.ts diff --git a/frontend/src/lib/components/ui/NeuronTag.svelte b/frontend/src/lib/components/ui/NeuronTag.svelte index 7ce8fdb22f4..f6778fbfbc4 100644 --- a/frontend/src/lib/components/ui/NeuronTag.svelte +++ b/frontend/src/lib/components/ui/NeuronTag.svelte @@ -4,6 +4,9 @@ export let tag: NeuronTagData; export let size: "medium" | "large" = "medium"; + + let intent: "error" | "info"; + $: intent = tag.status === "danger" ? "error" : "info"; -{tag.text} +{tag.text} diff --git a/frontend/src/tests/lib/components/ui/NeuronTag.spec.ts b/frontend/src/tests/lib/components/ui/NeuronTag.spec.ts new file mode 100644 index 00000000000..e5aa0475042 --- /dev/null +++ b/frontend/src/tests/lib/components/ui/NeuronTag.spec.ts @@ -0,0 +1,37 @@ +import NeuronTag from "$lib/components/ui/NeuronTag.svelte"; +import { NeuronTagPo } from "$tests/page-objects/NeuronTag.page-object"; +import { JestPageObjectElement } from "$tests/page-objects/jest.page-object"; +import { render } from "@testing-library/svelte"; + +describe("NeuronTag", () => { + const renderComponent = (props) => { + const { container } = render(NeuronTag, props); + return NeuronTagPo.under(new JestPageObjectElement(container)); + }; + + it("should render default tag", async () => { + const po = renderComponent({ + props: { tag: { text: "test" } }, + }); + + expect(await po.getText()).toEqual("test"); + expect(await po.isIntentError()).toEqual(false); + expect(await po.isSizeLarge()).toEqual(false); + }); + + it("should render in danger status", async () => { + const po = renderComponent({ + props: { tag: { text: "test", status: "danger" } }, + }); + + expect(await po.isIntentError()).toEqual(true); + }); + + it("should render large tag", async () => { + const po = renderComponent({ + props: { tag: { text: "test" }, size: "large" }, + }); + + expect(await po.isSizeLarge()).toEqual(true); + }); +}); diff --git a/frontend/src/tests/page-objects/NeuronTag.page-object.ts b/frontend/src/tests/page-objects/NeuronTag.page-object.ts index 3d84c459c27..7a6fdd4d15b 100644 --- a/frontend/src/tests/page-objects/NeuronTag.page-object.ts +++ b/frontend/src/tests/page-objects/NeuronTag.page-object.ts @@ -1,20 +1,16 @@ -import { BasePageObject } from "$tests/page-objects/base.page-object"; import type { PageObjectElement } from "$tests/types/page-object.types"; +import { TagPo } from "./Tag.page-object"; -export class NeuronTagPo extends BasePageObject { - private static readonly TID = "neuron-tag-component"; +export class NeuronTagPo extends TagPo { + private static readonly NeuronTagTID = "neuron-tag-component"; - private constructor(root: PageObjectElement) { - super(root); + static under(element: PageObjectElement): NeuronTagPo { + return new NeuronTagPo(element.byTestId(NeuronTagPo.NeuronTagTID)); } static async allUnder(element: PageObjectElement): Promise { - return Array.from(await element.allByTestId(NeuronTagPo.TID)).map( + return Array.from(await element.allByTestId(NeuronTagPo.NeuronTagTID)).map( (el) => new NeuronTagPo(el) ); } - - async isStatusDanger(): Promise { - return (await this.root.getClasses()).includes("error"); - } } diff --git a/frontend/src/tests/page-objects/Tag.page-object.ts b/frontend/src/tests/page-objects/Tag.page-object.ts index 2dca73fa8ba..2d32a8ade8c 100644 --- a/frontend/src/tests/page-objects/Tag.page-object.ts +++ b/frontend/src/tests/page-objects/Tag.page-object.ts @@ -9,4 +9,12 @@ export class TagPo extends BasePageObject { (el) => new TagPo(el) ); } + + async isIntentError(): Promise { + return (await this.root.getClasses()).includes("error"); + } + + async isSizeLarge(): Promise { + return (await this.root.getClasses()).includes("tag--large"); + } } From a575197190daabdcad4eaabf7d116222ee264edf Mon Sep 17 00:00:00 2001 From: Yusef Habib Date: Fri, 6 Dec 2024 11:31:47 +0100 Subject: [PATCH 5/7] refactor: add explicit type annotation for transactionDirection (#5938) # Motivation TypeScript was inferring a string type for `transactionDirection` when it should specifically be either "credit" or "debit". Adding an explicit type union improves type safety and prevents potential bugs from assigning invalid values. # Changes - Added literal type union `"credit" | "debit"` to `transactionDirection` variable # Tests Not necessary # Todos - [ ] Add entry to changelog (if necessary). Not necessary --- frontend/src/lib/utils/icp-transactions.utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/utils/icp-transactions.utils.ts b/frontend/src/lib/utils/icp-transactions.utils.ts index 6b4400f0942..568c5e52c52 100644 --- a/frontend/src/lib/utils/icp-transactions.utils.ts +++ b/frontend/src/lib/utils/icp-transactions.utils.ts @@ -160,7 +160,9 @@ export const mapIcpTransactionToReport = ({ const { to, from, amount, fee } = txInfo; const isSelfTransaction = isToSelf(transaction.transaction); const isReceive = isSelfTransaction || from !== accountIdentifier; - const transactionDirection = isReceive ? "credit" : "debit"; + const transactionDirection: "credit" | "debit" = isReceive + ? "credit" + : "debit"; const useFee = !isReceive; const feeApplied = useFee && fee ? fee : 0n; From 3654e6b647918c0284c59440771c80ba54bf698e Mon Sep 17 00:00:00 2001 From: Yusef Habib Date: Fri, 6 Dec 2024 11:59:37 +0100 Subject: [PATCH 6/7] fix: error handling for getTransactionInformation (#5937) # Motivation `getTransactionInformation` was throwing an error while its consumers were expecting an `undefined` and they will handle the error. # Changes - `getTransactionInformation` should return `undefined` if the transaction type is unknown. # Tests - New unit tests for `mapIcpTransactionToUi` check for a toast message when an error is thrown. # Todos - [ ] Add entry to changelog (if necessary). Not necessary. --- .../src/lib/utils/icp-transactions.utils.ts | 17 ++++---- .../lib/utils/icp-transactions.utils.spec.ts | 39 ++++++++++++++++++- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/utils/icp-transactions.utils.ts b/frontend/src/lib/utils/icp-transactions.utils.ts index 568c5e52c52..2ce7798ec16 100644 --- a/frontend/src/lib/utils/icp-transactions.utils.ts +++ b/frontend/src/lib/utils/icp-transactions.utils.ts @@ -17,6 +17,7 @@ import { ICPToken, TokenAmountV2, fromNullable, + isNullish, nonNullish, } from "@dfinity/utils"; import { transactionName } from "./transactions.utils"; @@ -122,9 +123,7 @@ const getTransactionInformation = ( data = operation.Transfer; } // Edge case, a transaction will have either "Approve", "Burn", "Mint" or "Transfer" data. - if (data === undefined) { - throw new Error(`Unknown transaction type ${JSON.stringify(operation)}`); - } + if (isNullish(data)) return undefined; return { from: "from" in data ? data.from : undefined, @@ -149,11 +148,11 @@ export const mapIcpTransactionToReport = ({ swapCanisterAccounts: Set; }) => { const txInfo = getTransactionInformation(transaction.transaction.operation); - if (txInfo === undefined) { + if (isNullish(txInfo)) { throw new Error( - `Unknown transaction type ${ + `Unknown transaction type "${ Object.keys(transaction.transaction.operation)[0] - }` + }"` ); } @@ -214,11 +213,11 @@ export const mapIcpTransactionToUi = ({ }): UiTransaction | undefined => { try { const txInfo = getTransactionInformation(transaction.transaction.operation); - if (txInfo === undefined) { + if (isNullish(txInfo)) { throw new Error( - `Unknown transaction type ${ + `Unknown transaction type "${ Object.keys(transaction.transaction.operation)[0] - }` + }"` ); } const isReceive = diff --git a/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts b/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts index 17fa64361a4..b3762aadc52 100644 --- a/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts +++ b/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts @@ -3,6 +3,7 @@ import { TOP_UP_CANISTER_MEMO, } from "$lib/constants/api.constants"; import { NANO_SECONDS_IN_MILLISECOND } from "$lib/constants/constants"; +import * as toastsStore from "$lib/stores/toasts.store"; import { type UiTransaction } from "$lib/types/transaction"; import { mapIcpTransactionToReport, @@ -102,7 +103,7 @@ describe("icp-transactions.utils", () => { neuronAccounts: new Set(), swapCanisterAccounts: new Set(), }) - ).toThrowError('Unknown transaction type {"Unknown":{}}'); + ).toThrowError('Unknown transaction type "Unknown"'); }); it("should return transaction information", () => { @@ -291,6 +292,14 @@ describe("icp-transactions.utils", () => { }); describe("mapIcpTransactionToUi", () => { + let spyToastError; + + beforeEach(() => { + spyToastError = vi + .spyOn(toastsStore, "toastsError") + .mockImplementation(vi.fn()); + }); + it("maps stake neuron transaction", () => { const transaction = createTransaction({ operation: defaultTransferOperation, @@ -475,6 +484,34 @@ describe("icp-transactions.utils", () => { ).toEqual(expectedUiTransaction); }); + it("should show a toaster if no transaction information is found", () => { + const transaction = createTransaction({ + operation: { + // @ts-expect-error: Even though it is not possible our implementations handles it. + Unknown: {}, + }, + }); + + expect(spyToastError).toBeCalledTimes(0); + + mapIcpTransactionToUi({ + transaction, + accountIdentifier: from, + toSelfTransaction: false, + neuronAccounts: new Set([to]), + swapCanisterAccounts: new Set(), + i18n: en, + }); + expect(spyToastError).toBeCalledTimes(1); + expect(spyToastError).toBeCalledWith({ + err: new Error('Unknown transaction type "Unknown"'), + labelKey: "error.transaction_data", + substitutions: { + $txId: "1234", + }, + }); + }); + describe("maps timestamps", () => { const createdDate = new Date("2023-01-01T00:12:00.000Z"); const blockDate = new Date("2023-01-01T00:34:00.000Z"); From d516f88447a2ee5b0d10f566e473543575a555b8 Mon Sep 17 00:00:00 2001 From: Yusef Habib Date: Fri, 6 Dec 2024 12:12:39 +0100 Subject: [PATCH 7/7] 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, + }); + } +}