diff --git a/frontend/src/lib/components/portfolio/SkeletonTokensCard.svelte b/frontend/src/lib/components/portfolio/SkeletonTokensCard.svelte index d2fdb71cec9..7a0c088e374 100644 --- a/frontend/src/lib/components/portfolio/SkeletonTokensCard.svelte +++ b/frontend/src/lib/components/portfolio/SkeletonTokensCard.svelte @@ -1,8 +1,10 @@ - +
diff --git a/frontend/src/lib/pages/Portfolio.svelte b/frontend/src/lib/pages/Portfolio.svelte index 15edeaa9906..02f5eea2a1a 100644 --- a/frontend/src/lib/pages/Portfolio.svelte +++ b/frontend/src/lib/pages/Portfolio.svelte @@ -3,6 +3,7 @@ import LoginCard from "$lib/components/portfolio/LoginCard.svelte"; import NoHeldTokensCard from "$lib/components/portfolio/NoHeldTokensCard.svelte"; import NoStakedTokensCard from "$lib/components/portfolio/NoStakedTokensCard.svelte"; + import SkeletonTokensCard from "$lib/components/portfolio/SkeletonTokensCard.svelte"; import StakedTokensCard from "$lib/components/portfolio/StakedTokensCard.svelte"; import TotalAssetsCard from "$lib/components/portfolio/TotalAssetsCard.svelte"; import { authSignedInStore } from "$lib/derived/auth.derived"; @@ -16,7 +17,7 @@ import { getTotalBalanceInUsd } from "$lib/utils/token.utils"; import { TokenAmountV2, isNullish } from "@dfinity/utils"; - export let userTokens: UserToken[]; + export let userTokens: UserToken[] = []; export let tableProjects: TableProject[]; let totalTokensBalanceInUsd: number; @@ -48,16 +49,53 @@ ? totalTokensBalanceInUsd + totalStakedInUsd : undefined; - let showNoHeldTokensCard: boolean; - $: showNoHeldTokensCard = $authSignedInStore && totalTokensBalanceInUsd === 0; + let areHeldTokensLoading: boolean; + $: areHeldTokensLoading = userTokens.some( + (token) => token.balance === "loading" + ); - let showNoStakedTokensCard: boolean; - $: showNoStakedTokensCard = $authSignedInStore && totalStakedInUsd === 0; + // Determines the display state of the held tokens card + // - 'full': Shows card with data (or when user is not signed in, visitor data) + // - 'loading': Shows skeleton while data is being fetched + // - 'empty': Shows empty state when user has no tokens + type TokensCardType = "empty" | "skeleton" | "full"; + + let heldTokensCard: TokensCardType; + $: heldTokensCard = !$authSignedInStore + ? "full" + : areHeldTokensLoading + ? "skeleton" + : totalTokensBalanceInUsd === 0 + ? "empty" + : "full"; + + let areStakedTokensLoading: boolean; + $: areStakedTokensLoading = tableProjects.some( + (project) => project.isStakeLoading + ); - // The Card should display a Primary Action when it is the only available option. - // This occurs when there are tokens but no stake. + // Determines the display state of the staked tokens card + // Similar logic to heldTokensCard but for staked tokens + let stakedTokensCard: TokensCardType; + $: stakedTokensCard = !$authSignedInStore + ? "full" + : areStakedTokensLoading + ? "skeleton" + : totalStakedInUsd === 0 + ? "empty" + : "full"; + + // Controls whether the staked tokens card should show a primary action + // Primary action is shown when there are tokens but no stakes + // This helps guide users to stake their tokens when possible let hasNoStakedTokensCardAPrimaryAction: boolean; - $: hasNoStakedTokensCardAPrimaryAction = !showNoHeldTokensCard; + $: hasNoStakedTokensCardAPrimaryAction = + stakedTokensCard === "empty" && heldTokensCard === "full"; + + // Global loading state that tracks if either held or staked tokens are loading + // TotalAssetsCard will show this if either held or staked are loading + let isSomethingLoading: boolean; + $: isSomethingLoading = areHeldTokensLoading || areStakedTokensLoading; let topHeldTokens: UserTokenData[]; $: topHeldTokens = getTopHeldTokens({ @@ -80,10 +118,13 @@
- {#if showNoHeldTokensCard} + {#if heldTokensCard === "skeleton"} + + {:else if heldTokensCard === "empty"} {:else} {/if} - {#if showNoStakedTokensCard} + + {#if stakedTokensCard === "skeleton"} + + {:else if stakedTokensCard === "empty"} {:else} { expect(await heldTokensCardPo.getInfoRow().isPresent()).toBe(false); expect(await stakedTokensCardPo.getInfoRow().isPresent()).toBe(false); }); + + it("should not show any loading state", async () => { + const po = renderPage({ + tableProjects: mockTableProjects, + userTokens: mockTokens, + }); + + expect(await po.getTotalAssetsCardPo().hasSpinner()).toEqual(false); + expect(await po.getHeldTokensSkeletonCard().isPresent()).toEqual(false); + expect(await po.getStakedTokensSkeletonCard().isPresent()).toEqual(false); + }); }); describe("when logged in", () => { @@ -455,6 +467,20 @@ describe("Portfolio page", () => { "$0.00" ); }); + + it("should not display a primary action when the staked tokens card loads before the held tokens card", async () => { + const loadingToken = createUserTokenLoading({}); + + const po = renderPage({ + userTokens: [loadingToken], + tableProjects: [], + }); + + expect(await po.getNoStakedTokensCarPo().isPresent()).toEqual(true); + expect(await po.getNoStakedTokensCarPo().hasPrimaryAction()).toEqual( + false + ); + }); }); describe("TotalAssetsCard", () => { @@ -514,5 +540,84 @@ describe("Portfolio page", () => { ).toBe(true); }); }); + + describe("Loading States", () => { + const loadingToken = createUserTokenLoading({}); + const loadingProject: TableProject = { + ...mockTableProject, + isStakeLoading: true, + }; + + const loadedToken = createUserToken({ + balanceInUsd: 100, + universeId: principal(1), + }); + + const loadedProject: TableProject = { + ...mockTableProject, + stakeInUsd: 100, + isStakeLoading: false, + }; + + it("should show the inital loading state - both tokens and projects loading", async () => { + const po = renderPage({ + userTokens: [loadingToken], + tableProjects: [loadingProject], + }); + + expect(await po.getTotalAssetsCardPo().hasSpinner()).toEqual(true); + expect(await po.getHeldTokensSkeletonCard().isPresent()).toEqual(true); + expect(await po.getStakedTokensSkeletonCard().isPresent()).toEqual( + true + ); + expect(await po.getHeldTokensCardPo().isPresent()).toEqual(false); + expect(await po.getStakedTokensCardPo().isPresent()).toEqual(false); + }); + + it("should show a partial loading state - tokens loaded, projects still loading", async () => { + const po = renderPage({ + userTokens: [loadedToken], + tableProjects: [loadingProject], + }); + + expect(await po.getTotalAssetsCardPo().hasSpinner()).toEqual(true); + expect(await po.getHeldTokensSkeletonCard().isPresent()).toEqual(false); + expect(await po.getStakedTokensSkeletonCard().isPresent()).toEqual( + true + ); + expect(await po.getHeldTokensCardPo().isPresent()).toEqual(true); + expect(await po.getStakedTokensCardPo().isPresent()).toEqual(false); + }); + + it("should show a partial loading state - projects loaded, tokens still loading", async () => { + const po = renderPage({ + userTokens: [loadingToken], + tableProjects: [loadedProject], + }); + + expect(await po.getTotalAssetsCardPo().hasSpinner()).toEqual(true); + expect(await po.getHeldTokensSkeletonCard().isPresent()).toEqual(true); + expect(await po.getStakedTokensSkeletonCard().isPresent()).toEqual( + false + ); + expect(await po.getHeldTokensCardPo().isPresent()).toEqual(false); + expect(await po.getStakedTokensCardPo().isPresent()).toEqual(true); + }); + + it("should show a fully loaded state - both tokens and projects loaded", async () => { + const po = renderPage({ + userTokens: [loadedToken], + tableProjects: [loadedProject], + }); + + expect(await po.getTotalAssetsCardPo().hasSpinner()).toEqual(false); + expect(await po.getHeldTokensSkeletonCard().isPresent()).toEqual(false); + expect(await po.getStakedTokensSkeletonCard().isPresent()).toEqual( + false + ); + expect(await po.getHeldTokensCardPo().isPresent()).toEqual(true); + expect(await po.getStakedTokensCardPo().isPresent()).toEqual(true); + }); + }); }); }); diff --git a/frontend/src/tests/page-objects/PortfolioPage.page-object.ts b/frontend/src/tests/page-objects/PortfolioPage.page-object.ts index b11b46c4269..b2cbc0e0ce3 100644 --- a/frontend/src/tests/page-objects/PortfolioPage.page-object.ts +++ b/frontend/src/tests/page-objects/PortfolioPage.page-object.ts @@ -35,4 +35,12 @@ export class PortfolioPagePo extends BasePageObject { getStakedTokensCardPo(): StakedTokensCardPo { return StakedTokensCardPo.under(this.root); } + + getHeldTokensSkeletonCard(): PageObjectElement { + return this.getElement("held-tokens-skeleton-card"); + } + + getStakedTokensSkeletonCard(): PageObjectElement { + return this.getElement("staked-tokens-skeleton-card"); + } }