Skip to content

Commit 110a583

Browse files
committed
feat(pay-card): add a Balance screen to the devtool
The linked-wallet total has no UI that shows the number itself, so a wrong total cannot be told apart from a wrong formatter. The devtool shows what the calculation returned. - Opening the screen requests the wallets: the queries are skipped until then, so the tool does not fetch a cardholder's wallets to render a list of flags. - The total is rendered raw, in the counter-value currency's smallest unit. Formatting it would hide the unit bug this screen exists to catch. - A partial total says so. A wallet the rates could not price is left out of the sum rather than counted as zero, which otherwise reads as a complete total. - Pricing needs the app's rates and currency settings, so the host passes its resolver in, the same way the card visual gets one.
1 parent 8a4a864 commit 110a583

15 files changed

Lines changed: 276 additions & 13 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@devtools/pay-card": minor
3+
"@devtools/bindings": minor
4+
"live-mobile": patch
5+
---
6+
7+
Add a "Balance" screen to the Card / Pay devtool.
8+
9+
- Opening it requests the card-linked wallets and shows the total the balance calculation returned, unformatted, in the counter-value currency's smallest unit.
10+
- A refresh button refetches them.
11+
- Says when the total is partial, so a wallet the rates could not price does not read as a complete total.
12+
- Pricing needs the app's rates and currency settings, so the host passes `resolveCounterValue` in; without one the wallets are never requested.

apps/ledger-live-mobile/src/mvvm/features/DevTools/__integrations__/DevToolsScreen.integration.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ describe("DevToolsScreen", () => {
5959
it("mounts DevTools with the configured tools and stack screen options padded by the bottom inset", () => {
6060
render(withBottomInset(<DevToolsScreen />));
6161

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

6566
expect(props.config).toEqual([
6667
{ id: "feature-flags", config: { marker: "ff-props" } },

apps/ledger-live-mobile/src/mvvm/features/DevTools/screens/DevToolsScreen/useDevToolsScreenViewModel.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import {
99
useEnvDevToolProps,
1010
} from "@devtools/bindings";
1111
import type { DevToolsConfig } from "@devtools/shell";
12+
import { usePayCardWalletCounterValue } from "LLM/features/PayTab/hooks/usePayCardWalletCounterValue";
1213
import { useDevToolsRelay } from "./useDevToolsRelay";
1314

1415
export function useDevToolsScreenViewModel() {
1516
const featureFlagsProps = useFeatureFlagsToolProps();
16-
const payCardToolProps = usePayCardToolProps({ platform: "native" });
17+
const resolveCounterValue = usePayCardWalletCounterValue();
18+
const payCardToolProps = usePayCardToolProps({ platform: "native", resolveCounterValue });
1719
const envToolProps = useEnvDevToolProps();
1820
const { theme } = useTheme();
1921
const { bottom } = useSafeAreaInsets();

devtools/bindings/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@devtools/env": "workspace:*",
2424
"@devtools/registry": "workspace:*",
2525
"@domain/api-card-management": "workspace:*",
26+
"@features/flow-pay-card-wallets": "workspace:*",
2627
"@features/flow-pay-feature-tour": "workspace:*",
2728
"@features/platform-feature-flags": "workspace:*",
2829
"@shared/env": "workspace:*",
@@ -35,6 +36,7 @@
3536
"devDependencies": {
3637
"@ledgerhq/test-quarantine": "workspace:*",
3738
"@reduxjs/toolkit": "catalog:",
39+
"@shared/api-services": "workspace:*",
3840
"@swc/core": "catalog:",
3941
"@swc/jest": "catalog:",
4042
"@testing-library/dom": "catalog:",
@@ -44,10 +46,10 @@
4446
"@types/react": "catalog:",
4547
"jest": "catalog:",
4648
"jest-environment-jsdom": "catalog:",
49+
"jest-sonar": "0.2.16",
4750
"react": "catalog:",
4851
"react-dom": "catalog:",
4952
"react-redux": "catalog:",
50-
"typescript": "catalog:",
51-
"jest-sonar": "0.2.16"
53+
"typescript": "catalog:"
5254
}
5355
}

devtools/bindings/src/usePayCardToolProps.test.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,21 @@ import {
77
payCardFeatureTourSlice,
88
markPayCardFeatureTourSeen,
99
} from "@features/flow-pay-feature-tour/state";
10+
import { cardApi } from "@shared/api-services";
1011
import { usePayCardToolProps } from "./usePayCardToolProps";
1112

1213
function buildStore() {
1314
return configureStore({
1415
reducer: {
1516
featureFlags: featureFlagsReducer,
1617
payCardFeatureTour: payCardFeatureTourSlice.reducer,
18+
// The tool reads the Card endpoints, so its api has to be part of the store under test.
19+
[cardApi.reducerPath]: cardApi.reducer,
1720
},
18-
middleware: gdm => gdm().concat(createFeatureFlagsMiddleware({ resolutionConfig: {} })),
21+
middleware: gdm =>
22+
gdm()
23+
.concat(createFeatureFlagsMiddleware({ resolutionConfig: {} }))
24+
.concat(cardApi.middleware),
1925
});
2026
}
2127

devtools/bindings/src/usePayCardToolProps.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { useCallback, useMemo, useState } from "react";
22
import { useLazyGetCardStatusQuery } from "@domain/api-card-management";
3+
import {
4+
useCardLinkedWallets,
5+
type ResolveWalletCounterValue,
6+
} from "@features/flow-pay-card-wallets";
37
import { useDispatch, useSelector } from "react-redux";
48
import { setOverride } from "@shared/feature-flags";
59
import { useFeature } from "@features/platform-feature-flags";
@@ -16,6 +20,11 @@ type PayCardProbe = PayCardToolProps["interaction"]["probes"][number];
1620
export type UsePayCardToolPropsOptions = {
1721
/** Pass `"native"` on mobile to include the `walletPay` onboarding step. */
1822
readonly platform?: "web" | "native";
23+
/**
24+
* Prices one card-linked wallet. Pricing needs the app's rates and currency settings, so the host
25+
* owns it; without one the tool reports no balance rather than a wrong zero.
26+
*/
27+
readonly resolveCounterValue?: ResolveWalletCounterValue;
1928
};
2029

2130
const LEADING_ONBOARDING_STEPS: readonly OnboardingStep[] = [
@@ -39,6 +48,9 @@ function initialSteps(platform: "web" | "native"): readonly OnboardingStep[] {
3948
: [...LEADING_ONBOARDING_STEPS, PURCHASE_STEP];
4049
}
4150

51+
/** Never called: the wallet queries are skipped whenever the host omits its own resolver. */
52+
const NO_COUNTER_VALUE: ResolveWalletCounterValue = () => null;
53+
4254
/** Reads what an endpoint answered, whatever shape the failure arrives in. */
4355
function describeError(error: unknown): string {
4456
if (error === undefined || error === null) return "";
@@ -133,14 +145,45 @@ export function usePayCardToolProps(options: UsePayCardToolPropsOptions = {}): P
133145

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

148+
// The wallets are read when the balance screen opens, not when the tool mounts.
149+
const [walletsRequested, setWalletsRequested] = useState(false);
150+
const { resolveCounterValue } = options;
151+
152+
const linkedWallets = useCardLinkedWallets({
153+
resolveCounterValue: resolveCounterValue ?? NO_COUNTER_VALUE,
154+
skip: !walletsRequested || !resolveCounterValue,
155+
});
156+
157+
const loadWallets = useCallback(() => setWalletsRequested(true), []);
158+
159+
const { refetch: refetchWallets } = linkedWallets;
160+
const refreshWallets = useCallback(() => {
161+
setWalletsRequested(true);
162+
refetchWallets();
163+
}, [refetchWallets]);
164+
165+
const balance = useMemo(
166+
() => ({
167+
total: linkedWallets.total,
168+
isPartialTotal: linkedWallets.isPartialTotal,
169+
walletCount: linkedWallets.wallets.length,
170+
isFetching: linkedWallets.isFetching,
171+
error: linkedWallets.isError ? "The wallet endpoints answered with an error." : undefined,
172+
load: loadWallets,
173+
refresh: refreshWallets,
174+
}),
175+
[linkedWallets, loadWallets, refreshWallets],
176+
);
177+
136178
return useMemo(
137179
() => ({
138180
flags,
139181
onboarding,
140182
interaction,
183+
balance,
141184
hasSeenFeatureTour,
142185
resetPayCardFeatureTourSeen: resetFeatureTour,
143186
}),
144-
[flags, onboarding, interaction, hasSeenFeatureTour, resetFeatureTour],
187+
[flags, onboarding, interaction, balance, hasSeenFeatureTour, resetFeatureTour],
145188
);
146189
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { ScrollView } from "react-native";
2+
import { Box, Button, IconButton, Text } from "@ledgerhq/lumen-ui-rnative";
3+
import { Refresh } from "@ledgerhq/lumen-ui-rnative/symbols";
4+
import type { PayCardBalanceProps } from "../../types";
5+
6+
export interface BalanceScreenProps extends PayCardBalanceProps {
7+
readonly onBack: () => void;
8+
}
9+
10+
const CONTAINER_LX = { gap: "s12", padding: "s16" } as const;
11+
const HEADER_LX = {
12+
flexDirection: "row",
13+
alignItems: "center",
14+
justifyContent: "space-between",
15+
} as const;
16+
const ROW_LX = { gap: "s4" } as const;
17+
18+
export function BalanceScreen({
19+
total,
20+
isPartialTotal,
21+
walletCount,
22+
isFetching,
23+
error,
24+
onBack,
25+
refresh,
26+
}: BalanceScreenProps) {
27+
return (
28+
<ScrollView>
29+
<Box lx={CONTAINER_LX}>
30+
<Box lx={HEADER_LX}>
31+
<Button appearance="gray" size="sm" onPress={onBack}>
32+
Back
33+
</Button>
34+
<IconButton
35+
icon={Refresh}
36+
appearance="no-background"
37+
size="sm"
38+
loading={isFetching}
39+
onPress={refresh}
40+
accessibilityLabel="Refresh"
41+
/>
42+
</Box>
43+
44+
<Box lx={ROW_LX}>
45+
<Text typography="body3" lx={{ color: "muted" }}>
46+
Total amount
47+
</Text>
48+
<Text typography="heading4SemiBold">{total}</Text>
49+
</Box>
50+
51+
<Text typography="body3" lx={{ color: "muted" }}>
52+
{`${walletCount} linked wallet(s)`}
53+
</Text>
54+
55+
{isPartialTotal ? (
56+
<Text typography="body3" lx={{ color: "warning" }}>
57+
Partial: a wallet could not be priced and is not in the total.
58+
</Text>
59+
) : null}
60+
61+
{error === undefined ? null : (
62+
<Text typography="body3" lx={{ color: "error" }}>
63+
{error}
64+
</Text>
65+
)}
66+
</Box>
67+
</ScrollView>
68+
);
69+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { PayCardBalanceProps } from "../../types";
2+
3+
export interface BalanceScreenProps extends PayCardBalanceProps {
4+
readonly onBack: () => void;
5+
}
6+
7+
/** Native-only for now, like the interaction screen it sits next to. */
8+
export function BalanceScreen(_props: BalanceScreenProps) {
9+
return null;
10+
}

devtools/pay-card/src/pay-card/PayCard.native.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ function buildProps(): PayCardToolProps {
2323
setStepDone: jest.fn(),
2424
},
2525
interaction: { probes: [] },
26+
balance: {
27+
total: 0,
28+
isPartialTotal: false,
29+
walletCount: 0,
30+
isFetching: false,
31+
error: undefined,
32+
load: jest.fn(),
33+
refresh: jest.fn(),
34+
},
2635
hasSeenFeatureTour: false,
2736
resetPayCardFeatureTourSeen: jest.fn(),
2837
};
@@ -107,6 +116,44 @@ describe("PayCard (native)", () => {
107116
expect(screen.getByText('{ "status": "ACTIVE" }')).toBeTruthy();
108117
});
109118

119+
it("requests the linked wallets when the balance screen opens, and shows the total", async () => {
120+
const user = userEvent.setup();
121+
const load = jest.fn();
122+
const props = buildProps();
123+
render(
124+
<PayCard {...props} balance={{ ...props.balance, total: 12540, walletCount: 3, load }} />,
125+
);
126+
127+
await user.press(screen.getByText("Balance"));
128+
129+
expect(load).toHaveBeenCalledTimes(1);
130+
expect(screen.getByText("Total amount")).toBeTruthy();
131+
expect(screen.getByText("12540")).toBeTruthy();
132+
expect(screen.getByText("3 linked wallet(s)")).toBeTruthy();
133+
});
134+
135+
it("refetches the linked wallets from the balance screen", async () => {
136+
const user = userEvent.setup();
137+
const refresh = jest.fn();
138+
const props = buildProps();
139+
render(<PayCard {...props} balance={{ ...props.balance, refresh }} />);
140+
141+
await user.press(screen.getByText("Balance"));
142+
await user.press(screen.getByLabelText("Refresh"));
143+
144+
expect(refresh).toHaveBeenCalledTimes(1);
145+
});
146+
147+
it("says the total is partial rather than letting it read as complete", async () => {
148+
const user = userEvent.setup();
149+
const props = buildProps();
150+
render(<PayCard {...props} balance={{ ...props.balance, isPartialTotal: true }} />);
151+
152+
await user.press(screen.getByText("Balance"));
153+
154+
expect(screen.getByText(/Partial/)).toBeTruthy();
155+
});
156+
110157
it("wires onboarding actions", async () => {
111158
const user = userEvent.setup();
112159
const props = buildProps();

devtools/pay-card/src/pay-card/PayCard.native.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,27 @@ import type { PayCardToolProps } from "../types";
1919
import { Section } from "../components/Section/Section";
2020
import { ToggleRow } from "../components/ToggleRow/ToggleRow";
2121
import { Interaction } from "../components/Interaction/Interaction";
22+
import { BalanceScreen } from "../components/Balance/Balance";
2223

2324
const BUTTON_ROW_STYLE = { flexDirection: "row", flexWrap: "wrap", gap: 8 } as const;
2425

2526
export function PayCard(props: Readonly<PayCardToolProps>) {
26-
const { flags, onboarding, interaction, hasSeenFeatureTour, resetPayCardFeatureTourSeen } = props;
27-
const [showInteraction, setShowInteraction] = useState(false);
27+
const {
28+
flags,
29+
onboarding,
30+
interaction,
31+
balance,
32+
hasSeenFeatureTour,
33+
resetPayCardFeatureTourSeen,
34+
} = props;
35+
const [screen, setScreen] = useState<"tool" | "interaction" | "balance">("tool");
2836

29-
if (showInteraction) {
30-
return <Interaction {...interaction} onBack={() => setShowInteraction(false)} />;
37+
if (screen === "interaction") {
38+
return <Interaction {...interaction} onBack={() => setScreen("tool")} />;
39+
}
40+
41+
if (screen === "balance") {
42+
return <BalanceScreen {...balance} onBack={() => setScreen("tool")} />;
3143
}
3244

3345
return (
@@ -36,7 +48,7 @@ export function PayCard(props: Readonly<PayCardToolProps>) {
3648
<SectionHeaderTitle>Debug</SectionHeaderTitle>
3749
</SectionHeader>
3850

39-
<ListItem onPress={() => setShowInteraction(true)}>
51+
<ListItem onPress={() => setScreen("interaction")}>
4052
<ListItemLeading lx={{ paddingHorizontal: "s16" }}>
4153
<Spot appearance="icon" icon={CreditCard} />
4254
<ListItemContent>
@@ -48,7 +60,12 @@ export function PayCard(props: Readonly<PayCardToolProps>) {
4860
</ListItemTrailing>
4961
</ListItem>
5062

51-
<ListItem>
63+
<ListItem
64+
onPress={() => {
65+
balance.load();
66+
setScreen("balance");
67+
}}
68+
>
5269
<ListItemLeading lx={{ paddingHorizontal: "s16" }}>
5370
<Spot appearance="icon" icon={CoinsCrypto} />
5471
<ListItemContent>

0 commit comments

Comments
 (0)