Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
15 changes: 15 additions & 0 deletions .changeset/pay-card-devtools-balance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@devtools/pay-card": minor
"@devtools/bindings": minor
"live-mobile": patch
---

Add a "Balance" screen to the Card / Pay devtool.

- Opening it requests the card-linked wallets and shows the total the balance calculation returned, unformatted, in the counter-value currency's smallest unit.
- Lists every wallet with every field the calculation saw, including the provider's own unmapped `currency` and `network` ids, so a currency-mapping gap can be read off the screen.
- A wallet with no match or no rate says which, so it does not read as a zero.
- Names the endpoint that failed and prints what it answered, rather than reporting that something failed.
- A refresh button refetches them.
- Both devtool entries sit in one "Debug" section, matching the sections around them.
- Pricing needs the app's rates and currency settings, so the host passes `resolveCounterValue` in; without one the wallets are never requested.
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ describe("DevToolsScreen", () => {
it("mounts DevTools with the configured tools and stack screen options padded by the bottom inset", () => {
render(withBottomInset(<DevToolsScreen />));

expect(devToolsSpy).toHaveBeenCalledTimes(1);
const props = devToolsSpy.mock.calls[0][0];
// The last render, not the first: the tool props read queries that settle after mount.
expect(devToolsSpy).toHaveBeenCalled();
const props = devToolsSpy.mock.lastCall![0];

expect(props.config).toEqual([
{ id: "feature-flags", config: { marker: "ff-props" } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import {
useEnvDevToolProps,
} from "@devtools/bindings";
import type { DevToolsConfig } from "@devtools/shell";
import { usePayCardWalletCounterValue } from "LLM/features/PayTab/hooks/usePayCardWalletCounterValue";
import { useDevToolsRelay } from "./useDevToolsRelay";

export function useDevToolsScreenViewModel() {
const featureFlagsProps = useFeatureFlagsToolProps();
const payCardToolProps = usePayCardToolProps({ platform: "native" });
const resolveCounterValue = usePayCardWalletCounterValue();
const payCardToolProps = usePayCardToolProps({ platform: "native", resolveCounterValue });
const envToolProps = useEnvDevToolProps();
const { theme } = useTheme();
const { bottom } = useSafeAreaInsets();
Expand Down
6 changes: 4 additions & 2 deletions devtools/bindings/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@devtools/env": "workspace:*",
"@devtools/registry": "workspace:*",
"@domain/api-card-management": "workspace:*",
"@features/flow-pay-card-wallets": "workspace:*",
"@features/flow-pay-feature-tour": "workspace:*",
"@features/platform-feature-flags": "workspace:*",
"@shared/env": "workspace:*",
Expand All @@ -35,6 +36,7 @@
"devDependencies": {
"@ledgerhq/test-quarantine": "workspace:*",
"@reduxjs/toolkit": "catalog:",
"@shared/api-services": "workspace:*",
"@swc/core": "catalog:",
"@swc/jest": "catalog:",
"@testing-library/dom": "catalog:",
Expand All @@ -44,10 +46,10 @@
"@types/react": "catalog:",
"jest": "catalog:",
"jest-environment-jsdom": "catalog:",
"jest-sonar": "0.2.16",
"react": "catalog:",
"react-dom": "catalog:",
"react-redux": "catalog:",
"typescript": "catalog:",
"jest-sonar": "0.2.16"
"typescript": "catalog:"
}
}
8 changes: 7 additions & 1 deletion devtools/bindings/src/usePayCardToolProps.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
payCardFeatureTourSlice,
markPayCardFeatureTourSeen,
} from "@features/flow-pay-feature-tour/state";
import { cardApi } from "@shared/api-services";
import { usePayCardToolProps } from "./usePayCardToolProps";

/**
Expand Down Expand Up @@ -45,8 +46,13 @@ function buildStore() {
reducer: {
featureFlags: featureFlagsReducer,
payCardFeatureTour: payCardFeatureTourSlice.reducer,
// The tool reads the Card endpoints, so its api has to be part of the store under test.
[cardApi.reducerPath]: cardApi.reducer,
},
middleware: gdm => gdm().concat(createFeatureFlagsMiddleware({ resolutionConfig: {} })),
middleware: gdm =>
gdm()
.concat(createFeatureFlagsMiddleware({ resolutionConfig: {} }))
.concat(cardApi.middleware),
});
}

Expand Down
68 changes: 66 additions & 2 deletions devtools/bindings/src/usePayCardToolProps.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLazyGetCardStatusQuery } from "@domain/api-card-management";
import {
useGetCardLinkedWalletsQuery,
useGetInternalWalletsQuery,
useLazyGetCardStatusQuery,
} from "@domain/api-card-management";
import {
useCardLinkedWallets,
type ResolveWalletCounterValue,
} from "@features/flow-pay-card-wallets";
import { useDispatch, useSelector } from "react-redux";
import { setOverride } from "@shared/feature-flags";
import { changes, getEnv, setEnvUnsafe, type EnvName } from "@shared/env";
Expand All @@ -19,6 +27,11 @@ type PayCardProbe = PayCardToolProps["interaction"]["probes"][number];
export type UsePayCardToolPropsOptions = {
/** Pass `"native"` on mobile to include the `walletPay` onboarding step. */
readonly platform?: "web" | "native";
/**
* Prices one card-linked wallet. Pricing needs the app's rates and currency settings, so the host
* owns it; without one the tool reports no balance rather than a wrong zero.
*/
readonly resolveCounterValue?: ResolveWalletCounterValue;
};

const LEADING_ONBOARDING_STEPS: readonly OnboardingStep[] = [
Expand Down Expand Up @@ -61,6 +74,9 @@ function initialSteps(platform: "web" | "native"): readonly OnboardingStep[] {
: [...LEADING_ONBOARDING_STEPS, PURCHASE_STEP];
}

/** Never called: the wallet queries are skipped whenever the host omits its own resolver. */
const NO_COUNTER_VALUE: ResolveWalletCounterValue = () => null;

/** Reads what an endpoint answered, whatever shape the failure arrives in. */
function describeError(error: unknown): string {
if (error === undefined || error === null) return "";
Expand Down Expand Up @@ -172,15 +188,63 @@ export function usePayCardToolProps(options: UsePayCardToolPropsOptions = {}): P

const interaction = useMemo(() => ({ probes: [cardStatusProbe] }), [cardStatusProbe]);

// The wallets are read when the balance screen opens, not when the tool mounts.
const [walletsRequested, setWalletsRequested] = useState(false);
const { resolveCounterValue } = options;

const linkedWallets = useCardLinkedWallets({
resolveCounterValue: resolveCounterValue ?? NO_COUNTER_VALUE,
skip: !walletsRequested || !resolveCounterValue,
});

const loadWallets = useCallback(() => setWalletsRequested(true), []);

const { refetch: refetchWallets } = linkedWallets;
const refreshWallets = useCallback(() => {
setWalletsRequested(true);
refetchWallets();
}, [refetchWallets]);

// `useCardLinkedWallets` reports only that something failed. Reading the same cache entries again
// costs no request and gives the tool what each endpoint actually answered.
const skipWallets = !walletsRequested || !resolveCounterValue;
const { error: linkedError } = useGetCardLinkedWalletsQuery(undefined, { skip: skipWallets });
const { error: internalError } = useGetInternalWalletsQuery(undefined, { skip: skipWallets });

const errors = useMemo(
() =>
[
{ endpoint: "GET /v1/wallet/internal/card_linked", error: linkedError },
{ endpoint: "GET /v1/wallet/internal", error: internalError },
]
.filter(({ error }) => error !== undefined)
.map(({ endpoint, error }) => ({ endpoint, detail: describeError(error) })),
[linkedError, internalError],
);

const balance = useMemo(
() => ({
total: linkedWallets.total,
isPartialTotal: linkedWallets.isPartialTotal,
wallets: linkedWallets.wallets,
isFetching: linkedWallets.isFetching,
errors,
load: loadWallets,
refresh: refreshWallets,
}),
[linkedWallets, errors, loadWallets, refreshWallets],
);

return useMemo(
() => ({
flags,
onboarding,
interaction,
balance,
hasSeenFeatureTour,
resetPayCardFeatureTourSeen: resetFeatureTour,
env,
}),
[flags, onboarding, interaction, hasSeenFeatureTour, resetFeatureTour, env],
[flags, onboarding, interaction, balance, hasSeenFeatureTour, resetFeatureTour, env],
);
}
112 changes: 112 additions & 0 deletions devtools/pay-card/src/components/Balance/Balance.native.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { ScrollView } from "react-native";
import { Box, Button, Divider, IconButton, Text } from "@ledgerhq/lumen-ui-rnative";
import { Refresh } from "@ledgerhq/lumen-ui-rnative/symbols";
import type { PayCardBalanceProps, PayCardBalanceWallet } from "../../types";

export interface BalanceScreenProps extends PayCardBalanceProps {
readonly onBack: () => void;
}

const CONTAINER_LX = { gap: "s16", padding: "s16" } as const;
const HEADER_LX = {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
} as const;
const BLOCK_LX = { gap: "s4" } as const;
const FIELD_LX = { flexDirection: "row", gap: "s8" } as const;

function Field({ label, value }: { readonly label: string; readonly value: string }) {
return (
<Box lx={FIELD_LX}>
<Text typography="body3" lx={{ color: "muted" }}>
{label}
</Text>
<Text typography="body3" lx={{ color: "base" }}>
{value}
</Text>
</Box>
);
}

function Wallet({ wallet }: { readonly wallet: PayCardBalanceWallet }) {
return (
<Box lx={BLOCK_LX}>
<Text typography="body2">{`${wallet.priority}. ${wallet.currency} / ${wallet.network}`}</Text>
{/* The provider's own ids, unmapped: what a currency mapping would have to be keyed on. */}
<Field label="currency" value={wallet.currency} />
<Field label="network" value={wallet.network} />
<Field label="balance" value={wallet.balance ?? "null — no internal wallet matched"} />
<Field
label="counterValue"
value={
wallet.counterValue === null
? "null — no currency matched this ticker, or no rate for it"
: String(wallet.counterValue)
}
/>
<Field label="id" value={wallet.id} />
<Field label="address" value={wallet.address} />
</Box>
);
}

export function BalanceScreen({
total,
isPartialTotal,
wallets,
isFetching,
errors,
onBack,
refresh,
}: BalanceScreenProps) {
return (
<ScrollView>
<Box lx={CONTAINER_LX}>
<Box lx={HEADER_LX}>
<Button appearance="gray" size="sm" onPress={onBack}>
Back
</Button>
<IconButton
icon={Refresh}
appearance="no-background"
size="sm"
loading={isFetching}
onPress={refresh}
accessibilityLabel="Refresh"
/>
</Box>

<Box lx={BLOCK_LX}>
<Text typography="body3" lx={{ color: "muted" }}>
Total amount
</Text>
{/* Always stringified: an absent total has to read as `undefined`, not as a blank. */}
<Text typography="heading4SemiBold" lx={{ color: "base" }}>
{String(total)}
</Text>
<Field label="isPartialTotal" value={String(isPartialTotal)} />
<Field label="wallets" value={String(wallets.length)} />
</Box>

{errors.map(({ endpoint, detail }) => (
<Box key={endpoint} lx={BLOCK_LX}>
<Text typography="body2" lx={{ color: "error" }}>
{endpoint}
</Text>
<Text typography="body3" lx={{ color: "error" }}>
{detail}
</Text>
</Box>
))}

{wallets.map(wallet => (
<Box key={wallet.id} lx={BLOCK_LX}>
<Divider />
<Wallet wallet={wallet} />
</Box>
))}
</Box>
</ScrollView>
);
}
10 changes: 10 additions & 0 deletions devtools/pay-card/src/components/Balance/Balance.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { PayCardBalanceProps } from "../../types";

export interface BalanceScreenProps extends PayCardBalanceProps {
readonly onBack: () => void;
}

/** Native-only for now, like the interaction screen it sits next to. */
export function BalanceScreen(_props: BalanceScreenProps) {
return null;
}
Loading
Loading