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();
}