From 57c59747c4e2ae325739e6c4b32727bfd9eacef9 Mon Sep 17 00:00:00 2001 From: Max Strasinsky <98811342+mstrasinskis@users.noreply.github.com> Date: Sun, 15 Dec 2024 15:08:59 +0100 Subject: [PATCH] Confirm following from banner (#6004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Motivation If there are inactive neurons, the user should be able to reset their voting-power-refreshed timestamp. In this PR, we add a ["Confirm Following" button](https://github.com/dfinity/nns-dapp/pull/5990) to the modal that displays the user’s inactive neurons. # Changes - Add the `ConfirmFollowingButton` to the modal. - Close the modal when all inactive neurons are refreshed. # Tests - Added. # Todos - [ ] Add entry to changelog (if necessary). Not necessary. --- .../neurons/LosingRewardNeuronsModal.svelte | 20 +++-- .../actions/ConfirmFollowingButton.spec.ts | 2 - .../neurons/LosingRewardNeuronsModal.spec.ts | 50 ++++++++++++- .../neurons/LosingRewardsBanner.spec.ts | 73 ++++++++++++++++++- .../LosingRewardNeuronsModal.page-object.ts | 9 +++ 5 files changed, 142 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/modals/neurons/LosingRewardNeuronsModal.svelte b/frontend/src/lib/modals/neurons/LosingRewardNeuronsModal.svelte index 6a1a0f69e2a..4013fd10e31 100644 --- a/frontend/src/lib/modals/neurons/LosingRewardNeuronsModal.svelte +++ b/frontend/src/lib/modals/neurons/LosingRewardNeuronsModal.svelte @@ -12,16 +12,23 @@ import { buildNeuronUrl } from "$lib/utils/navigation.utils"; import { OWN_CANISTER_ID_TEXT } from "$lib/constants/canister-ids.constants"; import type { NeuronInfo } from "@dfinity/nns"; + import ConfirmFollowingButton from "$lib/components/neuron-detail/actions/ConfirmFollowingButton.svelte"; const dispatcher = createEventDispatcher<{ nnsClose: void }>(); // Load KnownNeurons which are used in the FollowNnsTopicSections onMount(() => listKnownNeurons()); - const confirm = () => { - // TBD - }; const close = () => dispatcher("nnsClose"); + const onConfirmed = ({ + detail: { successCount, totalCount }, + }: { + detail: { successCount: number; totalCount: number }; + }) => { + if (successCount === totalCount) { + close(); + } + }; const navigateToNeuronDetail = async (neuron: NeuronInfo) => { close(); @@ -32,6 +39,9 @@ }) ); }; + + let neuronIds: bigint[]; + $: neuronIds = $soonLosingRewardNeuronsStore.map((neuron) => neuron.neuronId); @@ -63,9 +73,7 @@ - + diff --git a/frontend/src/tests/lib/components/neuron-detail/actions/ConfirmFollowingButton.spec.ts b/frontend/src/tests/lib/components/neuron-detail/actions/ConfirmFollowingButton.spec.ts index 30823adec8b..961fbf92c6c 100644 --- a/frontend/src/tests/lib/components/neuron-detail/actions/ConfirmFollowingButton.spec.ts +++ b/frontend/src/tests/lib/components/neuron-detail/actions/ConfirmFollowingButton.spec.ts @@ -5,7 +5,6 @@ import { mockIdentity, resetIdentity } from "$tests/mocks/auth.store.mock"; import { mockFullNeuron, mockNeuron } from "$tests/mocks/neurons.mock"; import { ConfirmFollowingButtonPo } from "$tests/page-objects/ConfirmFollowingButton.page-object"; import { JestPageObjectElement } from "$tests/page-objects/jest.page-object"; -import { allowLoggingInOneTestForDebugging } from "$tests/utils/console.test-utils"; import { runResolvedPromises } from "$tests/utils/timers.test-utils"; import { busyStore } from "@dfinity/gix-components"; import { nonNullish } from "@dfinity/utils"; @@ -30,7 +29,6 @@ describe("ConfirmFollowingButton", () => { beforeEach(() => { resetIdentity(); - allowLoggingInOneTestForDebugging(); neuronsStore.pushNeurons({ neurons, certified: true }); spyQueryNeurons = vi.spyOn(api, "queryNeurons").mockResolvedValue(neurons); diff --git a/frontend/src/tests/lib/components/neurons/LosingRewardNeuronsModal.spec.ts b/frontend/src/tests/lib/components/neurons/LosingRewardNeuronsModal.spec.ts index 4dd1fb10b9c..b77102ca71c 100644 --- a/frontend/src/tests/lib/components/neurons/LosingRewardNeuronsModal.spec.ts +++ b/frontend/src/tests/lib/components/neurons/LosingRewardNeuronsModal.spec.ts @@ -25,6 +25,7 @@ describe("LosingRewardNeuronsModal", () => { fullNeuron: { ...mockFullNeuron, votingPowerRefreshedTimestampSeconds: BigInt(nowSeconds), + controller: mockIdentity.getPrincipal().toText(), }, }; const in10DaysLosingRewardsNeuron = { @@ -35,6 +36,7 @@ describe("LosingRewardNeuronsModal", () => { votingPowerRefreshedTimestampSeconds: BigInt( nowSeconds - SECONDS_IN_HALF_YEAR + 10 * SECONDS_IN_DAY ), + controller: mockIdentity.getPrincipal().toText(), }, }; const losingRewardsNeuron = { @@ -45,8 +47,22 @@ describe("LosingRewardNeuronsModal", () => { votingPowerRefreshedTimestampSeconds: BigInt( nowSeconds - SECONDS_IN_HALF_YEAR ), + controller: mockIdentity.getPrincipal().toText(), }, }; + const neurons = [ + activeNeuron, + in10DaysLosingRewardsNeuron, + losingRewardsNeuron, + ]; + const refreshedNeurons = neurons.map((neuron) => ({ + ...neuron, + fullNeuron: { + ...neuron.fullNeuron, + votingPowerRefreshedTimestampSeconds: BigInt(nowSeconds), + }, + })); + let spyRefreshVotingPower; const renderComponent = ({ onClose }: { onClose?: () => void } = {}) => { const { container, component } = render(LosingRewardNeuronsModal); @@ -75,11 +91,15 @@ describe("LosingRewardNeuronsModal", () => { }); vi.spyOn(governanceApi, "queryKnownNeurons").mockResolvedValue([]); + vi.spyOn(governanceApi, "queryNeurons").mockResolvedValue(refreshedNeurons); + spyRefreshVotingPower = vi + .spyOn(governanceApi, "refreshVotingPower") + .mockResolvedValue(); }); it("should not display active neurons", async () => { neuronsStore.setNeurons({ - neurons: [activeNeuron, in10DaysLosingRewardsNeuron, losingRewardsNeuron], + neurons, certified: true, }); const po = await renderComponent(); @@ -96,7 +116,7 @@ describe("LosingRewardNeuronsModal", () => { it("should dispatch on close", async () => { neuronsStore.setNeurons({ - neurons: [activeNeuron, in10DaysLosingRewardsNeuron, losingRewardsNeuron], + neurons, certified: true, }); const onClose = vi.fn(); @@ -109,6 +129,30 @@ describe("LosingRewardNeuronsModal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it("should confirm following", async () => { + neuronsStore.setNeurons({ + neurons: [in10DaysLosingRewardsNeuron, losingRewardsNeuron], + certified: true, + }); + const po = await renderComponent({}); + + expect((await po.getNnsLosingRewardsNeuronCardPos()).length).toEqual(2); + + await po.clickConfirmFollowing(); + await runResolvedPromises(); + expect((await po.getNnsLosingRewardsNeuronCardPos()).length).toEqual(0); + + expect(spyRefreshVotingPower).toHaveBeenCalledTimes(2); + expect(spyRefreshVotingPower).toHaveBeenCalledWith({ + identity: mockIdentity, + neuronId: in10DaysLosingRewardsNeuron.neuronId, + }); + expect(spyRefreshVotingPower).toHaveBeenCalledWith({ + identity: mockIdentity, + neuronId: losingRewardsNeuron.neuronId, + }); + }); + it("should navigate to the neuron details", async () => { neuronsStore.setNeurons({ neurons: [losingRewardsNeuron], @@ -143,7 +187,7 @@ describe("LosingRewardNeuronsModal", () => { .spyOn(governanceApi, "queryKnownNeurons") .mockResolvedValue([]); neuronsStore.setNeurons({ - neurons: [activeNeuron, in10DaysLosingRewardsNeuron, losingRewardsNeuron], + neurons, certified: true, }); diff --git a/frontend/src/tests/lib/components/neurons/LosingRewardsBanner.spec.ts b/frontend/src/tests/lib/components/neurons/LosingRewardsBanner.spec.ts index 16411d5e0d8..c2db0f960c0 100644 --- a/frontend/src/tests/lib/components/neurons/LosingRewardsBanner.spec.ts +++ b/frontend/src/tests/lib/components/neurons/LosingRewardsBanner.spec.ts @@ -1,39 +1,60 @@ +import * as governanceApi from "$lib/api/governance.api"; import LosingRewardsBanner from "$lib/components/neurons/LosingRewardsBanner.svelte"; import { SECONDS_IN_DAY, SECONDS_IN_HALF_YEAR } from "$lib/constants/constants"; 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 { LosingRewardsBannerPo } from "$tests/page-objects/LosingRewardsBanner.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"; describe("LosingRewardsBanner", () => { const nowSeconds = nowInSeconds(); const activeNeuron = { ...mockNeuron, + neuronId: 0n, fullNeuron: { ...mockFullNeuron, votingPowerRefreshedTimestampSeconds: BigInt(nowSeconds), + controller: mockIdentity.getPrincipal().toText(), }, }; const in10DaysLosingRewardsNeuron = { ...mockNeuron, + neuronId: 1n, fullNeuron: { ...mockFullNeuron, votingPowerRefreshedTimestampSeconds: BigInt( nowSeconds - SECONDS_IN_HALF_YEAR + 10 * SECONDS_IN_DAY ), + controller: mockIdentity.getPrincipal().toText(), }, }; const losingRewardsNeuron = { ...mockNeuron, + neuronId: 2n, fullNeuron: { ...mockFullNeuron, votingPowerRefreshedTimestampSeconds: BigInt( nowSeconds - SECONDS_IN_HALF_YEAR ), + controller: mockIdentity.getPrincipal().toText(), }, }; + const neurons = [ + activeNeuron, + in10DaysLosingRewardsNeuron, + losingRewardsNeuron, + ]; + const refreshedNeurons = neurons.map((neuron) => ({ + ...neuron, + fullNeuron: { + ...neuron.fullNeuron, + votingPowerRefreshedTimestampSeconds: BigInt(nowSeconds), + }, + })); const renderComponent = () => { const { container } = render(LosingRewardsBanner); @@ -41,9 +62,14 @@ describe("LosingRewardsBanner", () => { }; beforeEach(() => { + resetIdentity(); vi.useFakeTimers({ now: nowSeconds * 1000, }); + + vi.spyOn(governanceApi, "queryKnownNeurons").mockResolvedValue([]); + vi.spyOn(governanceApi, "queryNeurons").mockResolvedValue(refreshedNeurons); + vi.spyOn(governanceApi, "refreshVotingPower").mockResolvedValue(); }); it("should not display banner when all neurons are active", async () => { @@ -77,7 +103,7 @@ describe("LosingRewardsBanner", () => { it("displays losing rewards title ", async () => { neuronsStore.setNeurons({ - neurons: [activeNeuron, in10DaysLosingRewardsNeuron, losingRewardsNeuron], + neurons, certified: true, }); const po = await renderComponent(); @@ -109,4 +135,49 @@ describe("LosingRewardsBanner", () => { await po.clickConfirm(); expect(await po.getLosingRewardNeuronsModalPo().isPresent()).toEqual(true); }); + + it("should close modal when all refreshed", async () => { + const spyRefreshVotingPower = vi + .spyOn(governanceApi, "refreshVotingPower") + .mockResolvedValue(); + neuronsStore.setNeurons({ + neurons: [losingRewardsNeuron], + certified: true, + }); + const po = await renderComponent(); + + expect(await po.getLosingRewardNeuronsModalPo().isPresent()).toEqual(false); + await po.clickConfirm(); + expect(await po.getLosingRewardNeuronsModalPo().isPresent()).toEqual(true); + + await po.getLosingRewardNeuronsModalPo().clickConfirmFollowing(); + await runResolvedPromises(); + + vi.advanceTimersToNextFrame(); + expect(spyRefreshVotingPower).toBeCalledTimes(1); + expect(await po.getLosingRewardNeuronsModalPo().isPresent()).toEqual(false); + }); + + it("should not close the modal on error", async () => { + const spyConsoleError = vi.spyOn(console, "error").mockReturnValue(); + const spyRefreshVotingPower = vi + .spyOn(governanceApi, "refreshVotingPower") + .mockRejectedValueOnce(new Error()); + + neuronsStore.setNeurons({ + neurons: [losingRewardsNeuron], + certified: true, + }); + const po = await renderComponent(); + + await po.clickConfirm(); + await runResolvedPromises(); + await po.getLosingRewardNeuronsModalPo().clickConfirmFollowing(); + await runResolvedPromises(); + vi.advanceTimersToNextFrame(); + + expect(spyRefreshVotingPower).toBeCalledTimes(1); + expect(spyConsoleError).toBeCalledTimes(1); + 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 index de6815c4b1b..68838651dc3 100644 --- a/frontend/src/tests/page-objects/LosingRewardNeuronsModal.page-object.ts +++ b/frontend/src/tests/page-objects/LosingRewardNeuronsModal.page-object.ts @@ -1,5 +1,6 @@ import { ModalPo } from "$tests/page-objects/Modal.page-object"; import type { PageObjectElement } from "$tests/types/page-object.types"; +import { ConfirmFollowingButtonPo } from "./ConfirmFollowingButton.page-object"; import { NnsLosingRewardsNeuronCardPo } from "./NnsLosingRewardsNeuronCard.page-object"; export class LosingRewardNeuronsModalPo extends ModalPo { @@ -15,6 +16,14 @@ export class LosingRewardNeuronsModalPo extends ModalPo { return NnsLosingRewardsNeuronCardPo.allUnder(this.root); } + getConfirmFollowingButtonPo(): ConfirmFollowingButtonPo { + return ConfirmFollowingButtonPo.under(this.root); + } + + async clickConfirmFollowing(): Promise { + return this.getConfirmFollowingButtonPo().click(); + } + async clickCancel(): Promise { return this.getButton("cancel-button").click(); }