Skip to content

feat(launchpad2): sns fav projects services #7137

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/src/lib/constants/sns.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ export const WATCH_SALE_STATE_EVERY_MILLISECONDS = 10_000;
* ref: https://github.com/dfinity/nns-dapp/blob/38c66bafd1130be921fe9b27296b9d8e5338b6ff/rs/sns_aggregator/src/upstream.rs#L19
*/
export const AGGREGATOR_METRICS_TIME_WINDOW_SECONDS = 2 * 30 * 24 * 3600;

// Should be in sync with the backend:
// https://github.com/dfinity/nns-dapp/blob/645f1bf762fed70230ed8779b5dbd92cc35ac023/rs/backend/src/accounts_store.rs#L35
export const MAX_SNS_FAV_PROJECTS = 20;
8 changes: 7 additions & 1 deletion frontend/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,10 @@
"create_sns_proposal_title": "Create Service Nervous System (SNS)",
"create_sns_proposal_vote": "Vote"
},
"fav_projects": {
"adding": "Adding project to favorites",
"removing": "Removing project from favorites"
},
"sns_project_detail": {
"swap_proposal": "Swap Proposal",
"link_to_dashboard": "ICP Dashboard",
Expand Down Expand Up @@ -1062,7 +1066,9 @@
"invalid_canister_id": "Importing the token was unsuccessful because \"$canisterId\" is not a valid canister ID. Please verify the ID and retry."
},
"error__fav_projects": {
"too_many": "You can't add more than $limit favorite projects."
"too_many": "You can't add more than $limit favorite projects.",
"adding_error": "There was an error while adding the project to favorites.",
"removing_error": "There was an error while removing the project from favorites."
},
"error__sns": {
"undefined_project": "The requested project is invalid or throws an error.",
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/lib/services/app.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { loadImportedTokens } from "$lib/services/imported-tokens.services";
import { loadNetworkEconomicsParameters } from "$lib/services/network-economics.services";
import { loadNnsTotalVotingPower } from "$lib/services/nns-total-voting-power.service";
import { loadSnsProjects } from "$lib/services/public/sns.services";
import { loadSnsFavProjects } from "$lib/services/sns.fav-projects.services";
import { ENABLE_LAUNCHPAD_REDESIGN } from "$lib/stores/feature-flags.store";
import { get } from "svelte/store";

export const initAppPrivateData = async (): Promise<void> => {
const initNetworkEconomicsParameters: Promise<void>[] = [
Expand All @@ -15,9 +18,9 @@ export const initAppPrivateData = async (): Promise<void> => {
// Reload the SNS projects even if they were loaded.
// Get latest data and create wrapper caches for the logged in identity.
const initSns: Promise<void>[] = [loadSnsProjects()];
// Load imported tokens
const initImportedTokens: Promise<void>[] = [
const initNnsDappUserData = (): Promise<void>[] => [
loadImportedTokens({ ignoreAccountNotFoundError: true }),
...(get(ENABLE_LAUNCHPAD_REDESIGN) ? [loadSnsFavProjects()] : []),
];
const initGovernanceMetrics: Promise<void>[] = [loadGovernanceMetrics()];
/**
Expand All @@ -27,7 +30,7 @@ export const initAppPrivateData = async (): Promise<void> => {
Promise.all(initNetworkEconomicsParameters),
Promise.all(initGovernanceMetrics),
Promise.all(initNns),
Promise.all(initImportedTokens),
Promise.all(initNnsDappUserData()),
Promise.all(initSns),
]);

Expand Down
145 changes: 145 additions & 0 deletions frontend/src/lib/services/sns.fav-projects.services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { getFavProjects, setFavProjects } from "$lib/api/fav-projects.api";
import { AccountNotFoundError } from "$lib/canisters/nns-dapp/nns-dapp.errors";
import type { FavProjects } from "$lib/canisters/nns-dapp/nns-dapp.types";
import { FORCE_CALL_STRATEGY } from "$lib/constants/mockable.constants";
import { getAuthenticatedIdentity } from "$lib/services/auth.services";
import { queryAndUpdate } from "$lib/services/utils.services";
import { startBusy, stopBusy } from "$lib/stores/busy.store";
import { snsFavProjectsStore } from "$lib/stores/sns-fav-projects.store";
import { toastsError } from "$lib/stores/toasts.store";
import { isLastCall } from "$lib/utils/env.utils";
import {
fromSnsFavProject,
toSnsFavProject,
} from "$lib/utils/sns-fav-projects.utils";
import type { Principal } from "@dfinity/principal";
import { isNullish } from "@dfinity/utils";
import { get } from "svelte/store";

export const loadSnsFavProjects = async () => {
return queryAndUpdate<FavProjects, unknown>({
request: (options) => getFavProjects(options),
strategy: FORCE_CALL_STRATEGY,
onLoad: ({ response: { fav_projects: favProjects }, certified }) => {
snsFavProjectsStore.set({
rootCanisterIds: favProjects.map(fromSnsFavProject),
certified,
});
},
onError: ({ error: err, certified, strategy }) => {
console.error(err);

if (err instanceof AccountNotFoundError) {
// When you log in with a new account for the first time, the account is created in the NNS dapp.
// If you request favorite projects before the account is created, an `AccountNotFound` error will be thrown.
// In this case, we can be sure that the user has no favorite projects.
snsFavProjectsStore.set({
rootCanisterIds: [],
certified,
});
return;
}

if (!isLastCall({ strategy, certified })) {
return;
}

// Explicitly handle only UPDATE errors
// Reset the store to avoid showing stale data.
snsFavProjectsStore.reset();

// Failing to load favorite projects is not a critical error,
// so we just log in console.
},
logMessage: "Get Favorite Projects",
});
};

// Save favorite projects to the nns-dapp backend.
// Returns an error if the operation fails.
const saveSnsFavProject = async ({
projects,
}: {
projects: Principal[];
}): Promise<{ err: Error | undefined }> => {
try {
const identity = await getAuthenticatedIdentity();
await setFavProjects({
identity,
favProjects: projects.map(toSnsFavProject),
});
} catch (err) {
return { err: err as Error };
}

return { err: undefined };
};

/**
* Add new favorite project and reload favorite projects from the `nns-dapp` backend to update the `snsFavProjectsStore`.
* No success toast is shown, as the state will be reflected on the UI immediately.
*/
export const addSnsFavProject = async (
projectToAdd: Principal
): Promise<{ success: boolean }> => {
try {
startBusy({
initiator: "fav-project-adding",
labelKey: "fav_projects.adding",
});

const projects = [
...(get(snsFavProjectsStore).rootCanisterIds ?? []),
projectToAdd,
];
const { err } = await saveSnsFavProject({ projects });

if (isNullish(err)) {
// Update the store.
await loadSnsFavProjects();
return { success: true };
}

toastsError({
labelKey: "error__fav_projects.adding_error",
});

return { success: false };
} finally {
stopBusy("fav-project-adding");
}
};

/**
* Remove favorite project and reload favorite projects from the `nns-dapp` backend to update the `snsFavProjectsStore`.
* No success toast is shown, as the state will be reflected on the UI immediately.
*/
export const removeSnsFavProject = async (
rootCanisterId: Principal
): Promise<{ success: boolean }> => {
try {
startBusy({
initiator: "fav-project-removing",
labelKey: "fav_projects.removing",
});

const remainingProjects = (
get(snsFavProjectsStore).rootCanisterIds ?? []
).filter((id) => id.toText() !== rootCanisterId.toText());
const { err } = await saveSnsFavProject({ projects: remainingProjects });

if (isNullish(err)) {
snsFavProjectsStore.remove(rootCanisterId);

return { success: true };
}

toastsError({
labelKey: "error__fav_projects.removing_error",
});

return { success: false };
} finally {
stopBusy("fav-project-removing");
}
};
2 changes: 2 additions & 0 deletions frontend/src/lib/stores/busy.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export type BusyStateInitiatorType =
| "refresh-voting-power"
| "change-neuron-visibility"
| "reporting-neurons"
| "fav-project-adding"
| "fav-project-removing"
| "reporting-transactions";

export interface BusyState {
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/lib/stores/sns-fav-projects.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Principal } from "@dfinity/principal";
import { writable } from "svelte/store";

export interface SnsFavProjectsStoreData {
rootCanisterIds: Principal[] | undefined;
certified: boolean | undefined;
}

/**
* A store that contains user favorite projects
*/
const initSnsFavProjectsStore = () => {
const { update, subscribe, set } = writable<SnsFavProjectsStoreData>({
rootCanisterIds: undefined,
certified: undefined,
});

return {
subscribe,

set({
rootCanisterIds,
certified,
}: {
rootCanisterIds: Principal[];
certified: boolean;
}) {
set({
rootCanisterIds,
certified,
});
},

remove(rootCanisterId: Principal) {
update(({ rootCanisterIds, certified }) => ({
rootCanisterIds: rootCanisterIds?.filter(
(id) => id.toText() !== rootCanisterId.toText()
),
certified,
}));
},

reset() {
set({
rootCanisterIds: undefined,
certified: undefined,
});
},
};
};

export const snsFavProjectsStore = initSnsFavProjectsStore();
8 changes: 8 additions & 0 deletions frontend/src/lib/types/i18n.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,11 @@ interface I18nLaunchpad_cards {
create_sns_proposal_vote: string;
}

interface I18nFav_projects {
adding: string;
removing: string;
}

interface I18nSns_project_detail {
swap_proposal: string;
link_to_dashboard: string;
Expand Down Expand Up @@ -1111,6 +1116,8 @@ interface I18nError__imported_tokens {

interface I18nError__fav_projects {
too_many: string;
adding_error: string;
removing_error: string;
}

interface I18nError__sns {
Expand Down Expand Up @@ -1684,6 +1691,7 @@ interface I18n {
sns_launchpad: I18nSns_launchpad;
launchpad: I18nLaunchpad;
launchpad_cards: I18nLaunchpad_cards;
fav_projects: I18nFav_projects;
sns_project_detail: I18nSns_project_detail;
sns_sale: I18nSns_sale;
sns_neuron_detail: I18nSns_neuron_detail;
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/lib/utils/sns-fav-projects.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { FavProject } from "$lib/canisters/nns-dapp/nns-dapp.types";
import type { Principal } from "@dfinity/principal";

export const toSnsFavProject = (rootCanisterId: Principal): FavProject => ({
root_canister_id: rootCanisterId,
});

export const fromSnsFavProject = ({
root_canister_id,
}: FavProject): Principal => root_canister_id;
43 changes: 43 additions & 0 deletions frontend/src/tests/lib/services/app.services.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { clearSnsAggregatorCache } from "$lib/api-services/sns-aggregator.api-service";
import * as agent from "$lib/api/agent.api";
import * as favProjectsApi from "$lib/api/fav-projects.api";
import * as governanceApi from "$lib/api/governance.api";
import * as aggregatorApi from "$lib/api/sns-aggregator.api";
import { NNSDappCanister } from "$lib/canisters/nns-dapp/nns-dapp.canister";
Expand All @@ -19,6 +20,8 @@ import type { HttpAgent } from "@dfinity/agent";
import { toastsStore } from "@dfinity/gix-components";
import { LedgerCanister } from "@dfinity/ledger-icp";
import { get } from "svelte/store";

import { overrideFeatureFlagsStore } from "$lib/stores/feature-flags.store";
import { mock } from "vitest-mock-extended";

vi.mock("$lib/api/sns-aggregator.api");
Expand Down Expand Up @@ -148,6 +151,46 @@ describe("app-services", () => {
});
});

it("should load fav projects", async () => {
overrideFeatureFlagsStore.setFlag("ENABLE_LAUNCHPAD_REDESIGN", true);
const spyGetFavProjects = vi
.spyOn(favProjectsApi, "getFavProjects")
.mockResolvedValue({
fav_projects: [],
});

expect(spyGetFavProjects).toBeCalledTimes(0);

initAppPrivateData();
await runResolvedPromises();

expect(spyGetFavProjects).toHaveBeenCalledTimes(2);
expect(spyGetFavProjects).toHaveBeenCalledWith({
certified: false,
identity: mockIdentity,
});
expect(spyGetFavProjects).toHaveBeenCalledWith({
certified: true,
identity: mockIdentity,
});
});

it("should not load fav projects without feature flag", async () => {
overrideFeatureFlagsStore.setFlag("ENABLE_LAUNCHPAD_REDESIGN", false);
const spyGetFavProjects = vi
.spyOn(favProjectsApi, "getFavProjects")
.mockResolvedValue({
fav_projects: [],
});

expect(spyGetFavProjects).toBeCalledTimes(0);

initAppPrivateData();
await runResolvedPromises();

expect(spyGetFavProjects).toHaveBeenCalledTimes(0);
});

it("should load network economics", async () => {
const spyGetNetworkEconomicsParameters = vi
.spyOn(governanceApi, "getNetworkEconomicsParameters")
Expand Down
Loading