Skip to content

Commit d1a4a70

Browse files
authored
tFeat/wallet not found storybook tests (#583)
* feat: show dedicated not-found state for unknown wallet ids Add WalletNotFound, a reusable component built on ErrorState that echoes the missing wallet id and links back to the wallets dashboard. Wire it into WalletDetail so visiting an unknown wallet id (testnet or mainnet) no longer falls back to the generic error state, and cover it with Vitest/Testing Library tests plus a manual QA checklist. * feat: expand NetworkBadge Storybook stories and coverage Add DarkMode, InWalletRow, and CompactSize stories to NetworkBadge so Storybook exercises the badge in the dark-mode and in-context layouts it actually ships in, plus matching Vitest coverage for those fixtures and a manual QA checklist. * feat: add Storybook stories for EmptyState Add EmptyState.stories.tsx covering the real call sites (no wallets, no wallet data, filtered-to-zero, custom icon, no action, long content, dark mode) since this shared primitive had no Storybook coverage. Extend the Vitest suite with icon-suppression, long-text, re-render, and dark-mode edge cases, plus a manual QA checklist. * test: add WalletTable navigation edge case coverage Add WalletTable.navigation-edge-cases.test.tsx covering gaps left by the existing keyboard/responsive/copy-UX suites: unhandled keys are no-ops, Enter/Space navigate correctly from non-boundary rows, only one row shows the focus ring at a time, the table survives the wallets list shrinking while a row is focused, and every card/row links to its own wallet. Includes a manual QA checklist.
1 parent 8670155 commit d1a4a70

12 files changed

Lines changed: 617 additions & 9 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# EmptyState Storybook stories — what was implemented
2+
3+
`src/components/ui/EmptyState.tsx` had no Storybook coverage even though it's
4+
reused across the wallets, wallet-detail, and transactions views. Added
5+
`EmptyState.stories.tsx` with 8 stories mirroring the real call sites in the
6+
app:
7+
8+
- **Default / NoWallets / NoWalletData** — match the exact copy used on the
9+
wallets dashboard (`src/app/wallet/page.tsx`) and wallet detail view.
10+
- **NoFilteredResults** — zero-results-after-filtering scenario.
11+
- **CustomIcon / NoAction / LongContent** — prop-shape edge cases (custom
12+
icon, no CTA, long text wrapping).
13+
- **DarkMode** — verifies contrast in a `.dark` wrapper.
14+
15+
Also extended `src/test/components/ui/EmptyState.test.tsx` with edge cases
16+
that weren't covered yet: custom icon suppresses the default SVG, long
17+
text doesn't get clipped, the action handler isn't re-bound/double-fired
18+
across re-renders, and dark-mode rendering.
19+
20+
## Manual checklist
21+
- [ ] `npm run storybook`, open `UI/EmptyState` — confirm all 8 stories
22+
render, action buttons log a click in the Actions panel.
23+
- [ ] Compare `NoWallets` story visually against `/dashboard/wallets` with an
24+
empty wallet list — copy and layout should match.
25+
- [ ] Resize to a narrow (375px) viewport on `LongContent` — text wraps
26+
cleanly, action button stays reachable without horizontal scroll.
27+
- [ ] Run `npx vitest run EmptyState` — all EmptyState test files pass.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# NetworkBadge Storybook stories — what was implemented
2+
3+
`NetworkBadge.stories.tsx` already existed (Mainnet, Testnet, InvalidFallback,
4+
WithCustomClass, AllVariants). This change extends it with the scenarios that
5+
were still missing:
6+
7+
- **DarkMode** — renders the badge inside a `.dark` wrapper to visually verify
8+
the `dark:` contrast classes actually used on the wallet detail/table pages.
9+
- **InWalletRow** — shows the badge next to a truncated address, matching how
10+
it's actually composed inside `WalletTable` / `WalletDetail`, as a quick
11+
visual regression check for that layout.
12+
- **CompactSize** — a dense `className` override, guarding against label
13+
clipping when the badge is squeezed into tight table cells.
14+
15+
`NetworkBadge.stories.test.tsx` (new) exercises the same fixtures with
16+
Vitest/Testing Library so the behavior behind the new stories is also covered
17+
by the automated suite, not just visually in Storybook.
18+
19+
## Manual checklist
20+
- [ ] `npm run storybook`, open `Wallet/NetworkBadge` — confirm all 8 stories
21+
render without errors, including the new DarkMode/InWalletRow/CompactSize.
22+
- [ ] Toggle Storybook's background/theme toolbar on `DarkMode` — badge text
23+
stays readable (WCAG AA) against the dark background.
24+
- [ ] Resize the Storybook viewport to a narrow mobile width — `InWalletRow`
25+
doesn't overflow or wrap awkwardly.
26+
- [ ] Run `npx vitest run NetworkBadge` — all NetworkBadge test files pass.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Manual checklist: wallet not found
2+
3+
## What changed
4+
- Added `WalletNotFound` (`src/components/wallet/WalletNotFound.tsx`), a dedicated
5+
not-found state that reuses `ErrorState` and adds the failing wallet id plus a
6+
link back to `/dashboard/wallets`.
7+
- `WalletDetail` now renders `WalletNotFound` instead of a generic `ErrorState`
8+
when `useWalletBalance` reports a not-found error, for both testnet and
9+
mainnet wallets.
10+
- Added `WalletNotFound.test.tsx` covering rendering, the id-specific
11+
description, the back link, and the `role="status"` live region.
12+
13+
## Manual checklist
14+
- [ ] Visit `/dashboard/wallets/does-not-exist` — see "Wallet not found" with
15+
the id echoed back and a "← Back to wallets" link.
16+
- [ ] Click the back link — lands on `/dashboard/wallets`.
17+
- [ ] Visit a real wallet id — detail view loads normally (no regression).
18+
- [ ] Repeat both above at a narrow (375px) viewport — layout stays centered
19+
and readable, link remains tappable.
20+
- [ ] Confirm the same behavior for a testnet-network wallet id that doesn't
21+
exist (no network-specific branching bug).
22+
- [ ] Screen reader: not-found message is announced once via the `status`
23+
live region without requiring focus.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# WalletTable component tests — what was implemented
2+
3+
`WalletTable` already had extensive coverage across seven existing test files
4+
(rendering, keyboard navigation, responsive layout, copy-to-clipboard UX,
5+
accessibility, and integration with `NetworkBadge`/`StatusIndicator`). This
6+
change adds `WalletTable.navigation-edge-cases.test.tsx`, focused on gaps not
7+
exercised by the existing suite:
8+
9+
- Unhandled keys (`Escape`, letter keys) are no-ops — no navigation, no focus
10+
change.
11+
- `Enter`/`Space` navigate to the correct wallet id on non-first/last rows,
12+
not just the first row.
13+
- Exactly one row carries the keyboard focus ring at a time.
14+
- The table doesn't throw when the `wallets` prop shrinks while a row near
15+
the end of the (now-removed) range was focused — a regression-prone case
16+
for the `rowRefs` array.
17+
- The `sr-only` table caption is present for screen readers.
18+
- Every mobile card links to its own wallet's detail page.
19+
- Mounting the table alone never triggers a router navigation.
20+
21+
## Manual checklist
22+
- [ ] On `/dashboard/wallets`, tab into the table and press `Escape` — focus
23+
stays put, nothing navigates.
24+
- [ ] Arrow through rows on desktop — only the active row shows a focus ring.
25+
- [ ] Filter/remove a wallet while a row near the end of the list is focused
26+
— no console error, table re-renders cleanly.
27+
- [ ] On a narrow (375px) viewport, tap a mobile wallet card — navigates to
28+
that wallet's detail page, not another one.
29+
- [ ] Run `npx vitest run WalletTable` — all WalletTable test files pass.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { EmptyState } from "./EmptyState";
3+
4+
/**
5+
* EmptyState is the generic "nothing to show yet" placeholder reused across
6+
* dashboard pages (wallets, transactions, etc.) whenever a data-fetching
7+
* view has no items to render but no error either.
8+
*/
9+
const meta = {
10+
title: "UI/EmptyState",
11+
component: EmptyState,
12+
tags: ["autodocs"],
13+
parameters: {
14+
layout: "padded",
15+
docs: {
16+
description: {
17+
component:
18+
"Empty state shown when a list or detail view has successfully loaded but has no data to display.",
19+
},
20+
},
21+
},
22+
argTypes: {
23+
icon: { description: "Optional custom icon; defaults to a generic outline icon" },
24+
title: { control: "text", description: "Short heading for the empty state" },
25+
description: {
26+
control: "text",
27+
description: "Explains why there's nothing here / what to do next",
28+
},
29+
action: { control: "object", description: "Optional call-to-action button" },
30+
},
31+
} satisfies Meta<typeof EmptyState>;
32+
33+
export default meta;
34+
type Story = StoryObj<typeof meta>;
35+
36+
/** Default empty state with the built-in icon and no action. */
37+
export const Default: Story = {
38+
args: {
39+
title: "No data yet",
40+
description: "There's nothing to show here right now.",
41+
},
42+
};
43+
44+
/** No wallets added — the state used on the wallets dashboard. */
45+
export const NoWallets: Story = {
46+
args: {
47+
title: "No wallets found",
48+
description:
49+
"You haven't added any wallets to monitor yet. Add your first wallet to start tracking.",
50+
action: {
51+
label: "Add Wallet",
52+
onClick: () => console.log("Add wallet clicked"),
53+
},
54+
},
55+
};
56+
57+
/** No wallet data available for a single wallet detail view. */
58+
export const NoWalletData: Story = {
59+
args: {
60+
title: "No wallet data",
61+
description: "This wallet has no data to display yet.",
62+
},
63+
};
64+
65+
/** Filtered list with zero matches. */
66+
export const NoFilteredResults: Story = {
67+
args: {
68+
title: "No matching wallets",
69+
description:
70+
"No wallets match the selected network filter. Try switching between testnet and mainnet.",
71+
action: {
72+
label: "Clear Filter",
73+
onClick: () => console.log("Clear filter clicked"),
74+
},
75+
},
76+
};
77+
78+
/** Custom icon overriding the default outline icon. */
79+
export const CustomIcon: Story = {
80+
args: {
81+
title: "No transactions",
82+
description: "Transactions will appear here once this wallet is active.",
83+
icon: (
84+
<svg
85+
className="h-10 w-10 text-blue-400"
86+
fill="none"
87+
viewBox="0 0 24 24"
88+
stroke="currentColor"
89+
strokeWidth={1.5}
90+
>
91+
<path
92+
strokeLinecap="round"
93+
strokeLinejoin="round"
94+
d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5"
95+
/>
96+
</svg>
97+
),
98+
},
99+
};
100+
101+
/** No action button — purely informational. */
102+
export const NoAction: Story = {
103+
args: {
104+
title: "Nothing to see here",
105+
description: "This section is intentionally empty for now.",
106+
},
107+
};
108+
109+
/** Long title/description to check wrapping and max-width behavior. */
110+
export const LongContent: Story = {
111+
args: {
112+
title: "No wallets match your current search and filter combination",
113+
description:
114+
"Try broadening your search terms, clearing the network filter, or removing the status filter to see more results. If you believe this is an error, contact support.",
115+
action: {
116+
label: "Reset all filters",
117+
onClick: () => console.log("Reset filters clicked"),
118+
},
119+
},
120+
};
121+
122+
/** Rendered inside a dark-mode wrapper to verify contrast. */
123+
export const DarkMode: Story = {
124+
args: {
125+
title: "No wallets found",
126+
description: "Add your first wallet to start tracking.",
127+
action: { label: "Add Wallet", onClick: () => console.log("Add wallet") },
128+
},
129+
parameters: { backgrounds: { default: "dark" } },
130+
decorators: [
131+
(Story) => (
132+
<div className="dark bg-zinc-950 p-6">
133+
<Story />
134+
</div>
135+
),
136+
],
137+
};

src/components/wallet/NetworkBadge.stories.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,37 @@ export const AllVariants: Story = {
5151
</div>
5252
),
5353
};
54+
55+
/** Rendered in dark mode to verify the dark: contrast variants used on real pages. */
56+
export const DarkMode: Story = {
57+
args: { network: "testnet" },
58+
parameters: { backgrounds: { default: "dark" } },
59+
decorators: [
60+
(Story) => (
61+
<div className="dark bg-zinc-950 p-6">
62+
<Story />
63+
</div>
64+
),
65+
],
66+
};
67+
68+
/**
69+
* How the badge looks inline with the other wallet metadata it's usually
70+
* paired with — a quick visual regression check for the wallet table/detail
71+
* layouts rather than the badge in isolation.
72+
*/
73+
export const InWalletRow: Story = {
74+
render: () => (
75+
<div className="flex items-center gap-3 rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
76+
<code className="font-mono text-sm text-zinc-700 dark:text-zinc-300">
77+
GBZX...MADI
78+
</code>
79+
<NetworkBadge network="mainnet" />
80+
</div>
81+
),
82+
};
83+
84+
/** Long custom className to confirm text/badge sizing doesn't overflow oddly. */
85+
export const CompactSize: Story = {
86+
args: { network: "mainnet", className: "text-[10px] px-2 py-0" },
87+
};

src/components/wallet/WalletDetail.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import ReceiveWalletModal from "@/components/wallet/ReceiveWalletModal";
2424
import { SendWalletModal } from "@/components/wallet/SendWalletModal";
2525
import { StatusIndicator } from "@/components/wallet/StatusIndicator";
2626
import { WalletActivityFeed } from "@/components/wallet/WalletActivityFeed";
27+
import { WalletNotFound } from "@/components/wallet/WalletNotFound";
2728
import { useAnalyticsTracking } from "@/hooks/useAnalyticsTracking";
2829
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
2930
import { useWalletBalance } from "@/hooks/useWalletBalance";
@@ -165,17 +166,14 @@ export function WalletDetail({ id }: WalletDetailProps) {
165166
}
166167

167168
if (error && !wallet) {
169+
if (isNotFound) {
170+
return <WalletNotFound walletId={id} />;
171+
}
168172
return (
169173
<ErrorState
170-
title={isNotFound ? "Wallet not found" : "Failed to load wallet"}
171-
description={
172-
isNotFound
173-
? "No wallet exists for this ID. It may have been removed or the link is invalid."
174-
: `${error}. Check your connection and try again.`
175-
}
176-
retry={
177-
isNotFound ? undefined : { label: "Try Again", onRetry: refresh }
178-
}
174+
title="Failed to load wallet"
175+
description={`${error}. Check your connection and try again.`}
176+
retry={{ label: "Try Again", onRetry: refresh }}
179177
/>
180178
);
181179
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
import { WalletNotFound } from "./WalletNotFound";
4+
5+
describe("WalletNotFound", () => {
6+
it("renders the not found title", () => {
7+
render(<WalletNotFound />);
8+
expect(screen.getByText("Wallet not found")).toBeInTheDocument();
9+
});
10+
11+
it("shows a generic description when no wallet id is given", () => {
12+
render(<WalletNotFound />);
13+
expect(
14+
screen.getByText(
15+
"No wallet exists for this ID. It may have been removed or the link is invalid.",
16+
),
17+
).toBeInTheDocument();
18+
});
19+
20+
it("includes the wallet id in the description when provided", () => {
21+
render(<WalletNotFound walletId="w-does-not-exist" />);
22+
expect(
23+
screen.getByText(/No wallet exists for id "w-does-not-exist"/),
24+
).toBeInTheDocument();
25+
});
26+
27+
it("renders a link back to the wallets dashboard by default", () => {
28+
render(<WalletNotFound />);
29+
const link = screen.getByRole("link", { name: /back to wallets/i });
30+
expect(link).toBeInTheDocument();
31+
expect(link).toHaveAttribute("href", "/dashboard/wallets");
32+
});
33+
34+
it("supports a custom back link destination and label", () => {
35+
render(<WalletNotFound backHref="/dashboard" backLabel="Back to dashboard" />);
36+
const link = screen.getByRole("link", { name: /back to dashboard/i });
37+
expect(link).toHaveAttribute("href", "/dashboard");
38+
});
39+
40+
it("announces the not-found state to assistive technology", () => {
41+
const { container } = render(<WalletNotFound />);
42+
const status = container.querySelector('[role="status"]');
43+
expect(status).toBeInTheDocument();
44+
expect(status).toHaveAttribute("aria-live", "polite");
45+
});
46+
47+
it("does not render a retry button since retrying a bad id cannot succeed", () => {
48+
render(<WalletNotFound />);
49+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
50+
});
51+
});

0 commit comments

Comments
 (0)