Skip to content

Commit

Permalink
feat(portfolio): implement page loading states (#6216)
Browse files Browse the repository at this point in the history
# 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
  • Loading branch information
yhabib authored Jan 23, 2025
1 parent 1b9ab52 commit 5d13f41
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 11 deletions.
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<script lang="ts">
import Card from "$lib/components/portfolio/Card.svelte";
export let testId: string;
</script>

<Card testId="skeleton-tokens-card">
<Card {testId}>
<div class="wrapper">
<div class="header">
<div class="header-wrapper">
Expand Down
64 changes: 54 additions & 10 deletions frontend/src/lib/pages/Portfolio.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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({
Expand All @@ -80,10 +118,13 @@
<TotalAssetsCard
usdAmount={totalUsdAmount}
hasUnpricedTokens={hasUnpricedTokensOrStake}
isLoading={isSomethingLoading}
/>
</div>
<div class="content">
{#if showNoHeldTokensCard}
{#if heldTokensCard === "skeleton"}
<SkeletonTokensCard testId="held-tokens-skeleton-card" />
{:else if heldTokensCard === "empty"}
<NoHeldTokensCard />
{:else}
<HeldTokensCard
Expand All @@ -92,7 +133,10 @@
numberOfTopStakedTokens={topStakedTokens.length}
/>
{/if}
{#if showNoStakedTokensCard}

{#if stakedTokensCard === "skeleton"}
<SkeletonTokensCard testId="staked-tokens-skeleton-card" />
{:else if stakedTokensCard === "empty"}
<NoStakedTokensCard primaryCard={hasNoStakedTokensCardAPrimaryAction} />
{:else}
<StakedTokensCard
Expand Down
105 changes: 105 additions & 0 deletions frontend/src/tests/lib/pages/Portfolio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ckTESTBTCTokenBase,
createIcpUserToken,
createUserToken,
createUserTokenLoading,
} from "$tests/mocks/tokens-page.mock";
import { PortfolioPagePo } from "$tests/page-objects/PortfolioPage.page-object";
import { JestPageObjectElement } from "$tests/page-objects/jest.page-object";
Expand Down Expand Up @@ -113,6 +114,17 @@ describe("Portfolio page", () => {
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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
});
});
8 changes: 8 additions & 0 deletions frontend/src/tests/page-objects/PortfolioPage.page-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}

0 comments on commit 5d13f41

Please sign in to comment.