From a390ef518ace8b38634377e8a4bc0d5c360ecbe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lloren=C3=A7?= Date: Fri, 17 Mar 2023 17:39:10 +0100 Subject: [PATCH 1/5] GIX-1336: Move restoring participation to Project Detail --- .../project-detail/ParticipateButton.svelte | 98 +----- frontend/src/lib/pages/ProjectDetail.svelte | 319 ++++++++++-------- .../project-detail/ParticipateButton.spec.ts | 8 +- .../ProjectStatusSection.spec.ts | 26 +- .../src/tests/lib/pages/ProjectDetail.spec.ts | 102 ++++-- 5 files changed, 281 insertions(+), 272 deletions(-) diff --git a/frontend/src/lib/components/project-detail/ParticipateButton.svelte b/frontend/src/lib/components/project-detail/ParticipateButton.svelte index d6a4cb7c5d0..d1c1e6407c4 100644 --- a/frontend/src/lib/components/project-detail/ParticipateButton.svelte +++ b/frontend/src/lib/components/project-detail/ParticipateButton.svelte @@ -16,32 +16,19 @@ import Tooltip from "$lib/components/ui/Tooltip.svelte"; import SignInGuard from "$lib/components/common/SignInGuard.svelte"; import type { Principal } from "@dfinity/principal"; - import { isNullish, nonNullish } from "@dfinity/utils"; + import { nonNullish } from "@dfinity/utils"; import { snsTicketsStore } from "$lib/stores/sns-tickets.store"; - import { - cancelPollGetOpenTicket, - hidePollingToast, - restoreSnsSaleParticipation, - } from "$lib/services/sns-sale.services"; - import { isSignedIn } from "$lib/utils/auth.utils"; - import { authStore } from "$lib/stores/auth.store"; - import { - getCommitmentE8s, - hasOpenTicketInProcess, - } from "$lib/utils/sns.utils"; + import { hasOpenTicketInProcess } from "$lib/utils/sns.utils"; import type { TicketStatus } from "$lib/types/sale"; - import type { SaleStep } from "$lib/types/sale"; - import SaleInProgressModal from "$lib/modals/sns/sale/SaleInProgressModal.svelte"; import SpinnerText from "$lib/components/ui/SpinnerText.svelte"; - const { store: projectDetailStore, reload } = - getContext(PROJECT_DETAIL_CONTEXT_KEY); + const { store: projectDetailStore } = getContext( + PROJECT_DETAIL_CONTEXT_KEY + ); let lifecycle: number; - let swapCanisterId: Principal; $: ({ swap: { lifecycle }, - swapCanisterId, } = $projectDetailStore.summary ?? ({ @@ -58,19 +45,12 @@ swapCommitment: $projectDetailStore.swapCommitment, }); - let userCommitment: undefined | bigint; - $: userCommitment = - // swapCommitment=null - not initialized yet - $projectDetailStore.swapCommitment === null - ? undefined - : getCommitmentE8s($projectDetailStore.swapCommitment) ?? BigInt(0); - let rootCanisterId: Principal | undefined; $: rootCanisterId = nonNullish($projectDetailStore?.summary?.rootCanisterId) ? $projectDetailStore?.summary?.rootCanisterId : undefined; - // busy if open ticket is available or not requested + // TODO: Receive this as props let status: TicketStatus = "unknown"; $: ({ status } = hasOpenTicketInProcess({ rootCanisterId, @@ -80,72 +60,10 @@ let busy = true; $: busy = status !== "none"; - // Flag to avoid second getOpenTicket call on same page navigation - let loadingTicketRootCanisterId: string | undefined; - - let progressStep: SaleStep | undefined = undefined; - - const updateTicket = async (swapCanisterId: Principal) => { - // Avoid second call for the same rootCanisterId - if ( - rootCanisterId === undefined || - loadingTicketRootCanisterId === rootCanisterId.toText() - ) { - return; - } - if (isNullish(userCommitment)) { - // Typescript guard, user commitment cannot be undefined here - return; - } - loadingTicketRootCanisterId = rootCanisterId.toText(); - - const updateProgress = (step: SaleStep) => (progressStep = step); - - await restoreSnsSaleParticipation({ - rootCanisterId, - userCommitment, - swapCanisterId, - postprocess: reload, - updateProgress, - }); - }; - - // skip ticket update if - // - the sns is not open - // - the user is not sign in - // - user commitment information is not loaded - // - project swap canister id is not loaded, needed for the ticket call - $: if ( - lifecycle === SnsSwapLifecycle.Open && - isSignedIn($authStore.identity) && - nonNullish(userCommitment) && - nonNullish(swapCanisterId) - ) { - updateTicket(swapCanisterId); - } - let userHasParticipatedToSwap = false; $: userHasParticipatedToSwap = hasUserParticipatedToSwap({ swapCommitment: $projectDetailStore.swapCommitment, }); - - onDestroy(() => { - if (rootCanisterId === undefined) { - return; - } - - // remove the ticket to stop sale-participation-retry from another pages because of the non-obvious UX - snsTicketsStore.setTicket({ - rootCanisterId, - ticket: undefined, - }); - // TODO: Improve cancellatoin of actions onDestroy - // The polling was triggered by `restoreSnsSaleParticipation` call and needs to be canceled explicitly. - cancelPollGetOpenTicket(); - - // Hide toasts when moving away from the page - hidePollingToast(); - }); {#if lifecycle === SnsSwapLifecycle.Open} @@ -189,10 +107,6 @@ {/if} -{#if status === "open" && nonNullish(progressStep)} - -{/if} - {#if showModal} {/if} diff --git a/frontend/src/lib/pages/ProjectDetail.svelte b/frontend/src/lib/pages/ProjectDetail.svelte index a29472dbcb4..25a8ab79774 100644 --- a/frontend/src/lib/pages/ProjectDetail.svelte +++ b/frontend/src/lib/pages/ProjectDetail.svelte @@ -33,49 +33,33 @@ } from "$lib/services/sns-swap-metrics.services"; import { SnsSwapLifecycle } from "@dfinity/sns"; import { snsTotalSupplyTokenAmountStore } from "$lib/derived/sns/sns-total-supply-token-amount.derived"; + import SaleInProgressModal from "$lib/modals/sns/sale/SaleInProgressModal.svelte"; + import { + cancelPollGetOpenTicket, + hidePollingToast, + restoreSnsSaleParticipation, + } from "$lib/services/sns-sale.services"; + import { snsTicketsStore } from "$lib/stores/sns-tickets.store"; + import { SaleStep } from "$lib/types/sale"; + import { getCommitmentE8s } from "$lib/utils/sns.utils"; export let rootCanisterId: string | undefined | null; - let unsubscribeWatchCommitment: () => void | undefined; - let unsubscribeWatchMetrics: () => void | undefined; - let enableWatchers = false; - $: enableWatchers = - $snsSummariesStore.find( - ({ rootCanisterId: rootCanister }) => - rootCanister?.toText() === rootCanisterId - )?.swap.lifecycle === SnsSwapLifecycle.Open; - - onDestroy(() => { - unsubscribeWatchCommitment?.(); - unsubscribeWatchMetrics?.(); - }); - - $: if (nonNullish(rootCanisterId) && isSignedIn($authStore.identity)) { - loadCommitment({ rootCanisterId, forceFetch: false }); - } - - $: if (nonNullish(rootCanisterId) && enableWatchers) { - unsubscribeWatchCommitment?.(); - unsubscribeWatchCommitment = watchSnsTotalCommitment({ rootCanisterId }); - } - - const reloadSnsMetrics = async ({ forceFetch }: { forceFetch: boolean }) => { - const swapCanisterId = $projectDetailStore?.summary - ?.swapCanisterId as Principal; - - if (isNullish(rootCanisterId) || isNullish(swapCanisterId)) { + const goBack = async (): Promise => { + if (!browser) { return; } - await loadSnsSwapMetrics({ - rootCanisterId: Principal.fromText(rootCanisterId), - swapCanisterId, - forceFetch, - }); + return goto(AppPath.Launchpad, { replaceState: true }); }; + ///////////////////////////////// + // Set up and update the context + ///////////////////////////////// + + // Used to reload the data after a new swap participation const reload = async () => { - if (rootCanisterId === undefined || rootCanisterId === null) { + if (isNullish(rootCanisterId) || isNullish(swapCanisterId)) { // We cannot reload data for an undefined rootCanisterd but we silent the error here because it most probably means that the user has already navigated away of the detail route return; } @@ -83,8 +67,19 @@ await Promise.all([ loadSnsTotalCommitment({ rootCanisterId, strategy: "update" }), loadSnsLifecycle({ rootCanisterId }), - loadCommitment({ rootCanisterId, forceFetch: true }), - reloadSnsMetrics({ forceFetch: true }), + loadSnsSwapCommitment({ + rootCanisterId, + onError: () => { + // Set to not found + $projectDetailStore.swapCommitment = undefined; + }, + forceFetch: true, + }), + loadSnsSwapMetrics({ + forceFetch: true, + rootCanisterId: Principal.fromText(rootCanisterId), + swapCanisterId, + }), ]); }; @@ -93,134 +88,186 @@ swapCommitment: null, totalTokensSupply: null, }); - debugSelectedProjectStore(projectDetailStore); - setContext(PROJECT_DETAIL_CONTEXT_KEY, { store: projectDetailStore, reload, }); - let swapCanisterId: Principal | undefined; - $: if ( - nonNullish(swapCanisterId) && - nonNullish(rootCanisterId) && - enableWatchers - ) { - reloadSnsMetrics({ forceFetch: false }); - unsubscribeWatchMetrics?.(); - - unsubscribeWatchMetrics = watchSnsMetrics({ - rootCanisterId: Principal.fromText(rootCanisterId), - swapCanisterId: swapCanisterId, - }); - } - - const loadCommitment = ({ - rootCanisterId, - forceFetch, - }: { - rootCanisterId: string; - forceFetch: boolean; - }) => - loadSnsSwapCommitment({ - rootCanisterId, - onError: () => { - // Set to not found - $projectDetailStore.swapCommitment = undefined; - goBack(); - }, - forceFetch, - }); - - const goBack = async (): Promise => { - if (!browser) { - return; - } - - return goto(AppPath.Launchpad, { replaceState: true }); - }; - - // TODO: Change to a `let` that is recalculated when the store changes - const setProjectStore = (rootCanisterId: string) => { - // Check project summaries are loaded in store - if ($snsSummariesStore.length === 0) { - return; - } - // Check valid rootCanisterId - try { - if (rootCanisterId !== undefined) { - Principal.fromText(rootCanisterId); - } - } catch (error: unknown) { - // set values as not found - $projectDetailStore.summary = undefined; - $projectDetailStore.swapCommitment = undefined; - $projectDetailStore.totalTokensSupply = undefined; - return; - } - $projectDetailStore.summary = - rootCanisterId !== undefined - ? $snsSummariesStore.find( - ({ rootCanisterId: rootCanister }) => - rootCanister?.toText() === rootCanisterId - ) - : undefined; - - $projectDetailStore.swapCommitment = - rootCanisterId !== undefined - ? $snsSwapCommitmentsStore?.find( - (item) => - item?.swapCommitment?.rootCanisterId?.toText() === rootCanisterId - )?.swapCommitment - : undefined; - - $projectDetailStore.totalTokensSupply = - rootCanisterId !== undefined - ? $snsTotalSupplyTokenAmountStore[rootCanisterId] - : undefined; - }; - /** - * We load all the sns summaries and swap commitments on the global scale of the app. That's why we subscribe to these stores - i.e. each times they change, we can try to find the current root canister id within these data. + * Set up the projectDetailStore that lives in the context. + * + * We load all the sns summaries and swap commitments on the global scale of the app. + * That's why we subscribe to these stores - i.e. each times they change, we can try to find the current root canister id within these data. */ $: $snsSummariesStore, $snsSwapCommitmentsStore, $snsTotalSupplyTokenAmountStore, (async () => { - if (rootCanisterId === undefined || rootCanisterId === null) { + if (isNullish(rootCanisterId)) { await goBack(); return; } - setProjectStore(rootCanisterId); - - // TODO: Understand why this component doesn't subscribe to the store `projectDetailStore`. - // Is it because it's created in this same component? - const summary = $snsSummariesStore.find( + // Check project summaries are loaded in store + if ($snsSummariesStore.length === 0) { + return; + } + // Check valid rootCanisterId + try { + Principal.fromText(rootCanisterId); + } catch (error: unknown) { + // set values as not found + $projectDetailStore.summary = undefined; + $projectDetailStore.swapCommitment = undefined; + $projectDetailStore.totalTokensSupply = undefined; + return; + } + $projectDetailStore.summary = $snsSummariesStore.find( ({ rootCanisterId: rootCanister }) => rootCanister?.toText() === rootCanisterId ); - const newSwapCanisterId = summary?.swapCanisterId; - if (newSwapCanisterId?.toText() !== swapCanisterId?.toText()) { - swapCanisterId = newSwapCanisterId; - } + $projectDetailStore.swapCommitment = $snsSwapCommitmentsStore?.find( + (item) => + item?.swapCommitment?.rootCanisterId?.toText() === rootCanisterId + )?.swapCommitment; + + $projectDetailStore.totalTokensSupply = + $snsTotalSupplyTokenAmountStore[rootCanisterId]; })(); + ///////////////////////////////// + // Set up watchers and load the data in stores + ///////////////////////////////// + $: layoutTitleStore.set($projectDetailStore?.summary?.metadata.name ?? ""); - let notFound: boolean; - $: notFound = $projectDetailStore.summary === undefined; + let enableWatchers = false; + $: enableWatchers = + $projectDetailStore?.summary?.swap.lifecycle === SnsSwapLifecycle.Open; + + let swapCanisterId: Principal | undefined; + $: swapCanisterId = $projectDetailStore.summary?.swapCanisterId; + + $: if (nonNullish(rootCanisterId) && isSignedIn($authStore.identity)) { + loadSnsSwapCommitment({ + rootCanisterId, + onError: () => { + // Set to not found + $projectDetailStore.swapCommitment = undefined; + }, + }); + } + + let unsubscribeWatchCommitment: () => void | undefined; + let unsubscribeWatchMetrics: () => void | undefined; + $: if ( + nonNullish(rootCanisterId) && + enableWatchers && + nonNullish(swapCanisterId) + ) { + unsubscribeWatchCommitment?.(); + unsubscribeWatchCommitment = watchSnsTotalCommitment({ rootCanisterId }); - $: (async () => { + // We load the metrics to have them initially available before setInterval starts + loadSnsSwapMetrics({ + rootCanisterId: Principal.fromText(rootCanisterId), + swapCanisterId, + forceFetch: false, + }); + unsubscribeWatchMetrics?.(); + unsubscribeWatchMetrics = watchSnsMetrics({ + rootCanisterId: Principal.fromText(rootCanisterId), + swapCanisterId: swapCanisterId, + }); + } + + ///////////////////////////////// + // Restore participation + ///////////////////////////////// + + // Flag to avoid second getOpenTicket call on same page navigation + let loadingTicketRootCanisterIdText: string | undefined; + let userCommitment: undefined | bigint; + $: userCommitment = getCommitmentE8s($projectDetailStore.swapCommitment); + let progressStep: SaleStep | undefined = undefined; + $: { + if (nonNullish(progressStep) && progressStep === SaleStep.DONE) { + // Leave some time to the user to see the final step being done + setTimeout(() => { + progressStep = undefined; + }, 1000); + } + } + + // skip ticket update if + // - the sns is not open + // - the user is not sign in + // - user commitment information is not loaded + // - project swap canister id is not loaded, needed for the ticket call + // - no root canister id + // - ticket already in progress for the same root canister id + $: if ( + $projectDetailStore.summary?.swap.lifecycle === SnsSwapLifecycle.Open && + isSignedIn($authStore.identity) && + nonNullish(userCommitment) && + nonNullish(swapCanisterId) && + nonNullish(rootCanisterId) && + loadingTicketRootCanisterIdText !== rootCanisterId + ) { + loadingTicketRootCanisterIdText = rootCanisterId; + + const updateProgress = (step: SaleStep) => (progressStep = step); + + restoreSnsSaleParticipation({ + rootCanisterId: Principal.fromText(rootCanisterId), + userCommitment, + swapCanisterId, + postprocess: reload, + updateProgress, + }); + } + + ///////////////////////////////// + // Clean up and checks + ///////////////////////////////// + + $: { + const notFound = $projectDetailStore.summary === undefined; if (notFound) { toastsError({ labelKey: "error__sns.project_not_found", }); - await goBack(); + goBack(); } - })(); + } + + onDestroy(() => { + unsubscribeWatchCommitment?.(); + unsubscribeWatchMetrics?.(); + if (isNullish(rootCanisterId)) { + return; + } + + try { + // remove the ticket to stop sale-participation-retry from another pages because of the non-obvious UX + snsTicketsStore.setTicket({ + rootCanisterId: Principal.fromText(rootCanisterId), + ticket: undefined, + }); + } catch (error: unknown) { + // ignore error + // it can happen if the rootCanisterId is not valid + } + + // TODO: Improve cancellatoin of actions onDestroy + // The polling was triggered by `restoreSnsSaleParticipation` call and needs to be canceled explicitly. + cancelPollGetOpenTicket(); + + // Hide toasts when moving away from the page + hidePollingToast(); + });
@@ -240,6 +287,10 @@
+{#if nonNullish(progressStep)} + +{/if} +