From bec3ace272dac9aaa227883ff90565abd1f3913a Mon Sep 17 00:00:00 2001
From: Antonio Ventilii <32648800+AntonioVentilii@users.noreply.github.com>
Date: Tue, 5 Nov 2024 16:49:56 +0100
Subject: [PATCH] feat(frontend): Do not show all-zero state in Hero by default
(#3354)
# Motivation
We have 3 different states for the Hero:
A. **Loading**: initial state when the user join.
B. **Balance**: state that is shown when the user has at least some
token.
C. **No-balance**: state that is shown when all the tokens have zero
balance or cannot be loaded.
The idea is to have only two transitions: from A to B, or from A to C,
with no middle glitch.
That means that:
- for state B, the code shall wait for AT LEAST one positive token
balance;
- for state C, the code shall wait for all tokens to be zero balance (or
not available).
So, we create a new derived that monitors all tokens balance to be zero,
once all of them are loaded. This is based not only on checking for
nullish or zero values, but even to have a minimum number of tokens
loaded in the balance store.
# Changes
- Create store to check for all balances to be zero.
- Include condition in the loading context for the Hero: after the
exchange is initialized, it can stop loading IF either any token has
positive balance OR all tokens have zero balance.
- Substitute condition for the Send Button in the Hero: always visible,
hidden only when all balances are Zero.
- Same for the alternative info string below the USD balance.
# Tests
I provided some tests for the new util. However, for the stores/derived,
I shall do a separate PR, since it requires to mock the `page` store
too, and it is not quite trivial.
https://github.com/user-attachments/assets/89da6086-04a6-41a2-889f-e5db74333f5d
https://github.com/user-attachments/assets/3598a006-ed0d-45fd-9e0c-9cbb8ac7ddbd
---------
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
---
.../exchange/ExchangeBalance.svelte | 4 +-
.../src/lib/components/hero/Actions.svelte | 6 +-
.../lib/components/hero/HeroContent.svelte | 10 +-
.../src/lib/derived/balances.derived.ts | 22 ++++-
.../src/lib/derived/network-tokens.derived.ts | 1 +
src/frontend/src/lib/utils/balances.utils.ts | 28 ++++++
.../tests/lib/utils/balances.utils.spec.ts | 94 ++++++++++++++++++-
src/frontend/src/tests/utils/derived.utils.ts | 9 +-
8 files changed, 164 insertions(+), 10 deletions(-)
diff --git a/src/frontend/src/lib/components/exchange/ExchangeBalance.svelte b/src/frontend/src/lib/components/exchange/ExchangeBalance.svelte
index 0c633ac7f5..bce74c5315 100644
--- a/src/frontend/src/lib/components/exchange/ExchangeBalance.svelte
+++ b/src/frontend/src/lib/components/exchange/ExchangeBalance.svelte
@@ -1,6 +1,6 @@
diff --git a/src/frontend/src/lib/components/hero/HeroContent.svelte b/src/frontend/src/lib/components/hero/HeroContent.svelte
index 6538761ae2..c44bba688c 100644
--- a/src/frontend/src/lib/components/hero/HeroContent.svelte
+++ b/src/frontend/src/lib/components/hero/HeroContent.svelte
@@ -14,7 +14,11 @@
import TokenLogo from '$lib/components/tokens/TokenLogo.svelte';
import SkeletonLogo from '$lib/components/ui/SkeletonLogo.svelte';
import { SLIDE_PARAMS } from '$lib/constants/transition.constants';
- import { balance, balanceZero } from '$lib/derived/balances.derived';
+ import {
+ balance,
+ balanceZero,
+ noPositiveBalanceAndNotAllBalancesZero
+ } from '$lib/derived/balances.derived';
import { exchangeInitialized, exchanges } from '$lib/derived/exchange.derived';
import { networkBitcoin, networkEthereum, networkICP } from '$lib/derived/network.derived';
import { pageToken } from '$lib/derived/page-token.derived';
@@ -45,7 +49,9 @@
});
$: loading.set(
- isRouteTransactions($page) ? isNullish(pageTokenUi?.balance) : !$exchangeInitialized
+ isRouteTransactions($page)
+ ? isNullish(pageTokenUi?.balance)
+ : !$exchangeInitialized || $noPositiveBalanceAndNotAllBalancesZero
);
let isTransactionsPage = false;
diff --git a/src/frontend/src/lib/derived/balances.derived.ts b/src/frontend/src/lib/derived/balances.derived.ts
index a9f413fcf3..c4da929b6a 100644
--- a/src/frontend/src/lib/derived/balances.derived.ts
+++ b/src/frontend/src/lib/derived/balances.derived.ts
@@ -1,15 +1,18 @@
+import { enabledNetworkTokens } from '$lib/derived/network-tokens.derived';
import { balancesStore } from '$lib/stores/balances.store';
import { token } from '$lib/stores/token.store';
import type { OptionBalance } from '$lib/types/balance';
-import { checkAnyNonZeroBalance } from '$lib/utils/balances.utils';
+import { checkAllBalancesZero, checkAnyNonZeroBalance } from '$lib/utils/balances.utils';
import { nonNullish } from '@dfinity/utils';
import { derived, type Readable } from 'svelte/store';
+// TODO: Create tests for this store
export const balance: Readable = derived(
[balancesStore, token],
([$balanceStore, $token]) => (nonNullish($token) ? $balanceStore?.[$token.id]?.data : undefined)
);
+// TODO: Create tests for this store
export const balanceZero: Readable = derived(
[balancesStore, token],
([$balanceStore, $token]) =>
@@ -19,6 +22,23 @@ export const balanceZero: Readable = derived(
$balanceStore[$token.id]?.data.isZero() === true
);
+// TODO: Create tests for this store
export const anyBalanceNonZero: Readable = derived([balancesStore], ([$balanceStore]) =>
checkAnyNonZeroBalance($balanceStore)
);
+
+// TODO: Create tests for this store
+export const allBalancesZero: Readable = derived(
+ [balancesStore, enabledNetworkTokens],
+ ([$balancesStore, $enabledNetworkTokens]) =>
+ checkAllBalancesZero({
+ $balancesStore: $balancesStore,
+ minLength: $enabledNetworkTokens.length
+ })
+);
+
+// TODO: Create tests for this store
+export const noPositiveBalanceAndNotAllBalancesZero: Readable = derived(
+ [anyBalanceNonZero, allBalancesZero],
+ ([$anyBalanceNonZero, $allBalancesZero]) => !$anyBalanceNonZero && !$allBalancesZero
+);
diff --git a/src/frontend/src/lib/derived/network-tokens.derived.ts b/src/frontend/src/lib/derived/network-tokens.derived.ts
index 592fbe729a..ea0e63bcfb 100644
--- a/src/frontend/src/lib/derived/network-tokens.derived.ts
+++ b/src/frontend/src/lib/derived/network-tokens.derived.ts
@@ -10,6 +10,7 @@ import { derived, type Readable } from 'svelte/store';
/**
* All user-enabled tokens matching the selected network or chain fusion.
*/
+// TODO: Create tests for this store
export const enabledNetworkTokens: Readable = derived(
[enabledTokens, selectedNetwork, pseudoNetworkChainFusion],
filterTokensForSelectedNetwork
diff --git a/src/frontend/src/lib/utils/balances.utils.ts b/src/frontend/src/lib/utils/balances.utils.ts
index 9141462abe..7b7edbdabe 100644
--- a/src/frontend/src/lib/utils/balances.utils.ts
+++ b/src/frontend/src/lib/utils/balances.utils.ts
@@ -1,6 +1,7 @@
import { type BalancesData } from '$lib/stores/balances.store';
import type { CertifiedStoreData } from '$lib/stores/certified.store';
import type { TokenId } from '$lib/types/token';
+import type { Option } from '$lib/types/utils';
import { nonNullish } from '@dfinity/utils';
export const checkAnyNonZeroBalance = ($balancesStore: CertifiedStoreData): boolean =>
@@ -8,3 +9,30 @@ export const checkAnyNonZeroBalance = ($balancesStore: CertifiedStoreData !($balancesStore[tokenId as TokenId]?.data?.isZero() ?? true)
);
+
+/**
+ * Check if all balances are zero.
+ *
+ * It requires a minimum length of the balance data to be considered valid.
+ * This is to avoid false positives when, for example, the list of tokens is still loading,
+ * and the number of tokens in the balance store is not the same as the number of tokens in the UI.
+ *
+ * @param $balancesStore - Certified store of balances.
+ * @param minLength - Minimum length of the store to be considered valid.
+ * @returns `true` if all balances are zero and the conditions are met, `false` otherwise.
+ */
+
+export const checkAllBalancesZero = ({
+ $balancesStore,
+ minLength
+}: {
+ $balancesStore: CertifiedStoreData;
+ minLength: number;
+}): boolean =>
+ nonNullish($balancesStore) &&
+ Object.getOwnPropertySymbols($balancesStore).length >= Math.max(minLength, 1) &&
+ Object.getOwnPropertySymbols($balancesStore).every((tokenId) => {
+ const balance: Option = $balancesStore[tokenId as TokenId];
+
+ return balance === null || (balance?.data?.isZero() ?? false) || balance?.data === null;
+ });
diff --git a/src/frontend/src/tests/lib/utils/balances.utils.spec.ts b/src/frontend/src/tests/lib/utils/balances.utils.spec.ts
index 6fed121b2b..ef96246c87 100644
--- a/src/frontend/src/tests/lib/utils/balances.utils.spec.ts
+++ b/src/frontend/src/tests/lib/utils/balances.utils.spec.ts
@@ -1,7 +1,7 @@
import { ZERO } from '$lib/constants/app.constants';
import type { BalancesData } from '$lib/stores/balances.store';
import type { CertifiedStoreData } from '$lib/stores/certified.store';
-import { checkAnyNonZeroBalance } from '$lib/utils/balances.utils';
+import { checkAllBalancesZero, checkAnyNonZeroBalance } from '$lib/utils/balances.utils';
import { bn1 } from '$tests/mocks/balances.mock';
describe('checkAnyNonZeroBalance', () => {
@@ -58,3 +58,95 @@ describe('checkAnyNonZeroBalance', () => {
expect(checkAnyNonZeroBalance(undefined)).toBe(false);
});
});
+
+describe('checkAllBalancesZero', () => {
+ it('should return false if there is at least one non-zero balance', () => {
+ const mockBalancesStore: CertifiedStoreData = {
+ [Symbol('token1')]: { data: bn1 },
+ [Symbol('token2')]: { data: ZERO }
+ } as unknown as CertifiedStoreData;
+
+ expect(checkAllBalancesZero({ $balancesStore: mockBalancesStore, minLength: 1 })).toBe(false);
+ });
+
+ it('should return false if there is at least one non-zero balance and one nullish balance', () => {
+ const mockBalancesStore = {
+ [Symbol('token1')]: { data: bn1 },
+ [Symbol('token2')]: undefined
+ } as unknown as CertifiedStoreData;
+
+ expect(checkAllBalancesZero({ $balancesStore: mockBalancesStore, minLength: 1 })).toBe(false);
+ });
+
+ it('should return false if there is at least one zero balance and one undefined balance', () => {
+ const mockBalancesStore = {
+ [Symbol('token1')]: { data: ZERO },
+ [Symbol('token2')]: undefined
+ } as unknown as CertifiedStoreData;
+
+ expect(checkAllBalancesZero({ $balancesStore: mockBalancesStore, minLength: 1 })).toBe(false);
+ });
+
+ it('should return true if there is at least one zero balance and one null balance', () => {
+ const mockBalancesStore = {
+ [Symbol('token1')]: { data: ZERO },
+ [Symbol('token2')]: null
+ } as unknown as CertifiedStoreData;
+
+ expect(checkAllBalancesZero({ $balancesStore: mockBalancesStore, minLength: 1 })).toBe(true);
+ });
+
+ it('should return true if all balances are zero', () => {
+ const mockBalancesStore = {
+ [Symbol('token1')]: { data: ZERO },
+ [Symbol('token2')]: { data: ZERO }
+ } as unknown as CertifiedStoreData;
+
+ expect(checkAllBalancesZero({ $balancesStore: mockBalancesStore, minLength: 1 })).toBe(true);
+ });
+
+ it('should return false if balances data are nullish', () => {
+ const mockBalancesStore = {
+ [Symbol('token1')]: { data: null },
+ [Symbol('token2')]: { data: undefined }
+ } as unknown as CertifiedStoreData;
+
+ expect(checkAllBalancesZero({ $balancesStore: mockBalancesStore, minLength: 1 })).toBe(false);
+ });
+
+ it('should return false if balances are nullish', () => {
+ const mockBalancesStore = {
+ [Symbol('token1')]: null,
+ [Symbol('token2')]: undefined
+ } as unknown as CertifiedStoreData;
+
+ expect(checkAllBalancesZero({ $balancesStore: mockBalancesStore, minLength: 1 })).toBe(false);
+ });
+
+ it('should return false if store is empty and minimum length is 0', () => {
+ expect(checkAllBalancesZero({ $balancesStore: {}, minLength: 0 })).toBe(false);
+ });
+
+ it('should return false if store is empty and minimum length is 1', () => {
+ expect(checkAllBalancesZero({ $balancesStore: {}, minLength: 1 })).toBe(false);
+ });
+
+ it('should return false if minimum length is not met', () => {
+ const mockBalancesStore = {
+ [Symbol('token1')]: { data: ZERO },
+ [Symbol('token2')]: { data: ZERO }
+ } as unknown as CertifiedStoreData;
+
+ expect(checkAllBalancesZero({ $balancesStore: mockBalancesStore, minLength: 3 })).toBe(false);
+ });
+
+ it('should return true if minimum length is met', () => {
+ const mockBalancesStore = {
+ [Symbol('token1')]: { data: ZERO },
+ [Symbol('token2')]: { data: ZERO },
+ [Symbol('token3')]: { data: ZERO }
+ } as unknown as CertifiedStoreData;
+
+ expect(checkAllBalancesZero({ $balancesStore: mockBalancesStore, minLength: 3 })).toBe(true);
+ });
+});
diff --git a/src/frontend/src/tests/utils/derived.utils.ts b/src/frontend/src/tests/utils/derived.utils.ts
index 237c6ef8ae..505a4b076b 100644
--- a/src/frontend/src/tests/utils/derived.utils.ts
+++ b/src/frontend/src/tests/utils/derived.utils.ts
@@ -8,7 +8,12 @@ import {
ethAddressNotLoaded
} from '$lib/derived/address.derived';
import { authIdentity, authNotSignedIn, authSignedIn } from '$lib/derived/auth.derived';
-import { balance, balanceZero } from '$lib/derived/balances.derived';
+import {
+ allBalancesZero,
+ anyBalanceNonZero,
+ balance,
+ balanceZero
+} from '$lib/derived/balances.derived';
import { isBusy } from '$lib/derived/busy.derived';
import { exchangeInitialized, exchanges } from '$lib/derived/exchange.derived';
import { userHasPouhCredential } from '$lib/derived/has-pouh-credential';
@@ -51,6 +56,8 @@ import type { Readable, Unsubscriber } from 'svelte/store';
import type { MockInstance } from 'vitest';
const derivedList: Record> = {
+ allBalancesZero,
+ anyBalanceNonZero,
authIdentity,
authNotSignedIn,
authSignedIn,