Skip to content
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

feat(portfolio): implement page loading states #6216

Merged
merged 8 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from 7 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
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
yhabib marked this conversation as resolved.
Show resolved Hide resolved
? "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
111 changes: 111 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,90 @@ 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 loadedToken = createUserToken({
balanceInUsd: 100,
universeId: principal(1),
});

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 () => {
// Test Case 3:
yhabib marked this conversation as resolved.
Show resolved Hide resolved
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],
yhabib marked this conversation as resolved.
Show resolved Hide resolved
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);
});
});
});
});
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");
}
}
Loading