Skip to content

Commit

Permalink
feat(frontend): Do not show all-zero state in Hero by default (#3354)
Browse files Browse the repository at this point in the history
# 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>
  • Loading branch information
AntonioVentilii and github-actions[bot] authored Nov 5, 2024
1 parent 09a3f68 commit bec3ace
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 10 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { getContext } from 'svelte';
import { anyBalanceNonZero } from '$lib/derived/balances.derived';
import { allBalancesZero } from '$lib/derived/balances.derived';
import { combinedDerivedSortedNetworkTokensUi } from '$lib/derived/network-tokens.derived';
import { HERO_CONTEXT_KEY, type HeroContext } from '$lib/stores/hero.store';
import { i18n } from '$lib/stores/i18n.store';
Expand All @@ -24,6 +24,6 @@
{/if}
</output>
<span class="max-w-48 text-xl font-medium text-brand-secondary-alt sm:max-w-none">
{$anyBalanceNonZero ? $i18n.hero.text.available_balance : $i18n.hero.text.top_up}
{$allBalancesZero ? $i18n.hero.text.top_up : $i18n.hero.text.available_balance}
</span>
</span>
6 changes: 3 additions & 3 deletions src/frontend/src/lib/components/hero/Actions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import Receive from '$lib/components/receive/Receive.svelte';
import Send from '$lib/components/send/Send.svelte';
import HeroButtonGroup from '$lib/components/ui/HeroButtonGroup.svelte';
import { anyBalanceNonZero } from '$lib/derived/balances.derived';
import { allBalancesZero } from '$lib/derived/balances.derived';
import {
networkEthereum,
networkICP,
Expand All @@ -43,8 +43,8 @@
let isTransactionsPage = false;
$: isTransactionsPage = isRouteTransactions($page);
let sendAction = false;
$: sendAction = $anyBalanceNonZero || isTransactionsPage;
let sendAction = true;
$: sendAction = !$allBalancesZero || isTransactionsPage;
</script>

<div role="toolbar" class="flex w-full justify-center pt-10">
Expand Down
10 changes: 8 additions & 2 deletions src/frontend/src/lib/components/hero/HeroContent.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -45,7 +49,9 @@
});
$: loading.set(
isRouteTransactions($page) ? isNullish(pageTokenUi?.balance) : !$exchangeInitialized
isRouteTransactions($page)
? isNullish(pageTokenUi?.balance)
: !$exchangeInitialized || $noPositiveBalanceAndNotAllBalancesZero
);
let isTransactionsPage = false;
Expand Down
22 changes: 21 additions & 1 deletion src/frontend/src/lib/derived/balances.derived.ts
Original file line number Diff line number Diff line change
@@ -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<OptionBalance> = derived(
[balancesStore, token],
([$balanceStore, $token]) => (nonNullish($token) ? $balanceStore?.[$token.id]?.data : undefined)
);

// TODO: Create tests for this store
export const balanceZero: Readable<boolean> = derived(
[balancesStore, token],
([$balanceStore, $token]) =>
Expand All @@ -19,6 +22,23 @@ export const balanceZero: Readable<boolean> = derived(
$balanceStore[$token.id]?.data.isZero() === true
);

// TODO: Create tests for this store
export const anyBalanceNonZero: Readable<boolean> = derived([balancesStore], ([$balanceStore]) =>
checkAnyNonZeroBalance($balanceStore)
);

// TODO: Create tests for this store
export const allBalancesZero: Readable<boolean> = derived(
[balancesStore, enabledNetworkTokens],
([$balancesStore, $enabledNetworkTokens]) =>
checkAllBalancesZero({
$balancesStore: $balancesStore,
minLength: $enabledNetworkTokens.length
})
);

// TODO: Create tests for this store
export const noPositiveBalanceAndNotAllBalancesZero: Readable<boolean> = derived(
[anyBalanceNonZero, allBalancesZero],
([$anyBalanceNonZero, $allBalancesZero]) => !$anyBalanceNonZero && !$allBalancesZero
);
1 change: 1 addition & 0 deletions src/frontend/src/lib/derived/network-tokens.derived.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Token[]> = derived(
[enabledTokens, selectedNetwork, pseudoNetworkChainFusion],
filterTokensForSelectedNetwork
Expand Down
28 changes: 28 additions & 0 deletions src/frontend/src/lib/utils/balances.utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,38 @@
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<BalancesData>): boolean =>
nonNullish($balancesStore) &&
Object.getOwnPropertySymbols($balancesStore).some(
(tokenId) => !($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<BalancesData>;
minLength: number;
}): boolean =>
nonNullish($balancesStore) &&
Object.getOwnPropertySymbols($balancesStore).length >= Math.max(minLength, 1) &&
Object.getOwnPropertySymbols($balancesStore).every((tokenId) => {
const balance: Option<BalancesData> = $balancesStore[tokenId as TokenId];

return balance === null || (balance?.data?.isZero() ?? false) || balance?.data === null;
});
94 changes: 93 additions & 1 deletion src/frontend/src/tests/lib/utils/balances.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<BalancesData> = {
[Symbol('token1')]: { data: bn1 },
[Symbol('token2')]: { data: ZERO }
} as unknown as CertifiedStoreData<BalancesData>;

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<BalancesData>;

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<BalancesData>;

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<BalancesData>;

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<BalancesData>;

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<BalancesData>;

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<BalancesData>;

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<BalancesData>;

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<BalancesData>;

expect(checkAllBalancesZero({ $balancesStore: mockBalancesStore, minLength: 3 })).toBe(true);
});
});
9 changes: 8 additions & 1 deletion src/frontend/src/tests/utils/derived.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -51,6 +56,8 @@ import type { Readable, Unsubscriber } from 'svelte/store';
import type { MockInstance } from 'vitest';

const derivedList: Record<string, Readable<unknown>> = {
allBalancesZero,
anyBalanceNonZero,
authIdentity,
authNotSignedIn,
authSignedIn,
Expand Down

0 comments on commit bec3ace

Please sign in to comment.