From 5d13f412623db91b78f1c9aa02a761fa22191e87 Mon Sep 17 00:00:00 2001 From: Yusef Habib Date: Thu, 23 Jan 2025 09:31:37 +0100 Subject: [PATCH] feat(portfolio): implement page loading states (#6216) # Motivation Loading all the information for the Portfolio page may take some time. We want to display loading indicators to enhance the user experience. https://github.com/user-attachments/assets/807d6d1f-8742-4ec0-a642-409eef735e8f # Changes - Logic to decide the type of card to display: `empty`, `skeleton`, or `full`, depending on the data. - Provide boolean to the total assets card if any content is loading. # Tests - Added unit test for the transition between not signed in, loading and loaded - Manually tested in [devenv](https://qsgjb-riaaa-aaaaa-aaaga-cai.yhabib-ingress.devenv.dfinity.network/) # Todos - [ ] Add entry to changelog (if necessary). Not necessary --- .../portfolio/SkeletonTokensCard.svelte | 4 +- frontend/src/lib/pages/Portfolio.svelte | 64 +++++++++-- .../src/tests/lib/pages/Portfolio.spec.ts | 105 ++++++++++++++++++ .../page-objects/PortfolioPage.page-object.ts | 8 ++ 4 files changed, 170 insertions(+), 11 deletions(-) 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"); + } }