From 6106f4c3d7403aecba9c748a1b6947ed43ef8806 Mon Sep 17 00:00:00 2001 From: divine Date: Tue, 30 Jun 2026 07:46:58 +0100 Subject: [PATCH 1/2] fix for issue #303 --- .../documentation/getting-started.md | 58 ++- src/pages/Vaults.tsx | 349 +++++++++++++----- src/pages/__tests__/Vaults.test.tsx | 292 ++++++++++++++- 3 files changed, 581 insertions(+), 118 deletions(-) diff --git a/design-system/documentation/getting-started.md b/design-system/documentation/getting-started.md index a26cabf..aa5ce18 100644 --- a/design-system/documentation/getting-started.md +++ b/design-system/documentation/getting-started.md @@ -7,14 +7,14 @@ tokens and where contributors should add or validate new tokens. Design tokens live in `design-system/tokens/`: -| Token file | Runtime surface | Notes | -| --- | --- | --- | -| `colors.json` | CSS variables in `src/index.css` such as `--bg`, `--surface`, `--text`, `--muted`, `--accent`, `--success`, `--warning`, and chart variables used by analytics views. | Use semantic names in components instead of hard-coded colors. | -| `typography.json` | Typography CSS variables and classes in `src/index.css`, plus `src/utils/typography.ts`. | Components should use the `Text` component or `classifyTypography()` roles where possible. | -| `spacing.json` | Spacing, container, touch-target, and breakpoint CSS variables in `src/index.css`; breakpoint details are documented in `documentation/breakpoints.md`. | Prefer `--spacing-*`, `--container-*`, and breakpoint tokens over one-off values. | -| `borders.json` | Radius, border-width, and semantic border CSS variables in `src/index.css`. | Use `--radius-*`, `--border-width-*`, and semantic border variables for cards, fields, buttons, and modals. | -| `shadows.json` | Elevation language for raised surfaces and overlays. | Match existing component surfaces before adding a new shadow. | -| `motion.json` | JS motion constants in `src/utils/motion.ts` and reduced-motion guidance in `documentation/breakpoints.md`. | Use the exported `duration`, `ease`, and standard transitions for Framer Motion flows. | +| Token file | Runtime surface | Notes | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `colors.json` | CSS variables in `src/index.css` such as `--bg`, `--surface`, `--text`, `--muted`, `--accent`, `--success`, `--warning`, and chart variables used by analytics views. | Use semantic names in components instead of hard-coded colors. | +| `typography.json` | Typography CSS variables and classes in `src/index.css`, plus `src/utils/typography.ts`. | Components should use the `Text` component or `classifyTypography()` roles where possible. | +| `spacing.json` | Spacing, container, touch-target, and breakpoint CSS variables in `src/index.css`; breakpoint details are documented in `documentation/breakpoints.md`. | Prefer `--spacing-*`, `--container-*`, and breakpoint tokens over one-off values. | +| `borders.json` | Radius, border-width, and semantic border CSS variables in `src/index.css`. | Use `--radius-*`, `--border-width-*`, and semantic border variables for cards, fields, buttons, and modals. | +| `shadows.json` | Elevation language for raised surfaces and overlays. | Match existing component surfaces before adding a new shadow. | +| `motion.json` | JS motion constants in `src/utils/motion.ts` and reduced-motion guidance in `documentation/breakpoints.md`. | Use the exported `duration`, `ease`, and standard transitions for Framer Motion flows. | ## Consuming Tokens In Components @@ -97,12 +97,12 @@ import { AddressDisplay } from '../components/AddressDisplay'; ### Props -| Prop | Type | Default | Description | -| --- | --- | --- | --- | -| `address` | `string` | — | Full Stellar address to display. | -| `network` | `'TESTNET' \| 'PUBLIC' \| null` | `undefined` | When provided, renders an explorer link to `stellar.expert`. Omit or pass `null` to hide. | -| `chars` | `number` | `6` | Characters to keep at the start of the truncated display. | -| `tailChars` | `number` | `4` | Characters to keep at the end of the truncated display. | +| Prop | Type | Default | Description | +| ----------- | ------------------------------- | ----------- | ----------------------------------------------------------------------------------------- | +| `address` | `string` | — | Full Stellar address to display. | +| `network` | `'TESTNET' \| 'PUBLIC' \| null` | `undefined` | When provided, renders an explorer link to `stellar.expert`. Omit or pass `null` to hide. | +| `chars` | `number` | `6` | Characters to keep at the start of the truncated display. | +| `tailChars` | `number` | `4` | Characters to keep at the end of the truncated display. | ### Accessibility @@ -121,3 +121,33 @@ import { AddressDisplay } from '../components/AddressDisplay'; so the explorer link points to the correct network. - The component uses `var(--success)`, `var(--muted)`, and `var(--accent)` CSS variables; it inherits correctly in both light and dark themes. + +## Vaults Page View Toggle + +The Vaults page (`src/pages/Vaults.tsx`) includes a list/grid view toggle that allows users to switch between a compact row-based list view and a rich card-based grid view. + +### Implementation + +- **Toggle Component**: An accessible radio group with `role="radiogroup"` and individual buttons with `role="radio"` and `aria-checked` attributes +- **Persistence**: View preference is stored in `localStorage` under the key `vaults-view-preference` and survives page reloads +- **Default View**: Defaults to list view when no preference exists +- **Grid Layout**: Uses CSS Grid with `gridTemplateColumns: repeat(auto-fill, minmax(280px, 1fr))` for responsive card layout +- **Card Component**: Grid view reuses the existing `VaultCard` component (`src/components/VaultCard.tsx`) which displays progress bars, countdown timers, and status badges + +### Token Usage + +The toggle buttons use design system tokens for consistent styling: + +- `var(--surface)` for the toggle container background +- `var(--border)` for borders +- `var(--radius)` for rounded corners +- `var(--accent)` for the active button background +- `var(--bg)` for active button text +- `var(--muted)` for inactive button text + +### Accessibility + +- Toggle buttons are keyboard-accessible via the radio group +- Active state is announced via `aria-checked` and `aria-pressed` attributes +- The radio group has an `aria-label="View mode"` for screen readers +- View preference persists across sessions, respecting user choices diff --git a/src/pages/Vaults.tsx b/src/pages/Vaults.tsx index f50a1e9..ca72c61 100644 --- a/src/pages/Vaults.tsx +++ b/src/pages/Vaults.tsx @@ -1,16 +1,41 @@ -import { useState, useEffect, useRef, useCallback } from 'react' -import { Link, MemoryRouter } from 'react-router-dom' -import { Text } from '../components/Text' -import { StatusChip } from '../components/StatusChip' -import type { VaultStatus, Vault } from '../types/vault' -import { listVaults } from '../services/vaultService' +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link, MemoryRouter } from "react-router-dom"; +import { StatusChip } from "../components/StatusChip"; +import { Text } from "../components/Text"; +import VaultCard from "../components/VaultCard"; +import { listVaults } from "../services/vaultService"; +import type { Vault } from "../types/vault"; -// VaultStatus is imported from '../types/vault' for use in the JSX below. -// The Vault type is imported from '../types/vault' via vaultService. -// Local MOCK_VAULTS removed — listVaults() in vaultService is the single source. -type _VaultStatus = VaultStatus; // suppress unused-import lint +const STORAGE_KEY = "vaults-view-preference"; +const DEFAULT_VIEW: "list" | "grid" = "list"; -const DEFAULT_FETCH = () => listVaults() +function getViewPreference(): "list" | "grid" { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "list" || stored === "grid") return stored; + } catch { + // localStorage may be disabled + } + return DEFAULT_VIEW; +} + +function setViewPreference(view: "list" | "grid") { + try { + localStorage.setItem(STORAGE_KEY, view); + } catch { + // localStorage may be disabled + } +} + +function calculateProgressPct(vault: Vault): number { + if (!vault.milestones || vault.milestones.length === 0) return 0; + const validated = vault.milestones.filter( + (m) => m.status === "validated", + ).length; + return Math.round((validated / vault.milestones.length) * 100); +} + +const DEFAULT_FETCH = () => listVaults(); function Skeleton() { return ( @@ -18,119 +43,269 @@ function Skeleton() { data-testid="skeleton" style={{ height: 72, - background: 'var(--surface, #1e293b)', - border: '1px solid var(--border, #334155)', - borderRadius: 'var(--radius, 8px)', - animation: 'pulse 1.5s ease-in-out infinite', + background: "var(--surface, #1e293b)", + border: "1px solid var(--border, #334155)", + borderRadius: "var(--radius, 8px)", + animation: "pulse 1.5s ease-in-out infinite", }} /> - ) + ); } interface VaultsInnerProps { - fetchVaults?: () => Promise + fetchVaults?: () => Promise; } function VaultsInner({ fetchVaults = DEFAULT_FETCH }: VaultsInnerProps) { - const [vaults, setVaults] = useState([]) - const [status, setStatus] = useState<'loading' | 'empty' | 'data' | 'error'>('loading') - const [retryCount, setRetryCount] = useState(0) + const [vaults, setVaults] = useState([]); + const [status, setStatus] = useState<"loading" | "empty" | "data" | "error">( + "loading", + ); + const [retryCount, setRetryCount] = useState(0); + const [viewMode, setViewMode] = useState<"list" | "grid">(getViewPreference); // Use a ref so changing the fetchVaults prop identity doesn't re-trigger the effect - const fetchRef = useRef(fetchVaults) - fetchRef.current = fetchVaults + const fetchRef = useRef(fetchVaults); + fetchRef.current = fetchVaults; useEffect(() => { - let cancelled = false - setStatus('loading') - fetchRef.current() + let cancelled = false; + setStatus("loading"); + fetchRef + .current() .then((data) => { - if (cancelled) return - setVaults(data) - setStatus(data.length === 0 ? 'empty' : 'data') + if (cancelled) return; + setVaults(data); + setStatus(data.length === 0 ? "empty" : "data"); }) .catch(() => { - if (!cancelled) setStatus('error') - }) - return () => { cancelled = true } - }, [retryCount]) // only re-run on explicit retry + if (!cancelled) setStatus("error"); + }); + return () => { + cancelled = true; + }; + }, [retryCount]); // only re-run on explicit retry - const retry = useCallback(() => setRetryCount((c) => c + 1), []) + const retry = useCallback(() => setRetryCount((c) => c + 1), []); + + const handleViewChange = useCallback((newView: "list" | "grid") => { + setViewMode(newView); + setViewPreference(newView); + }, []); return (
-
+
- Your Vaults - + + Your Vaults + + View and manage your productivity vaults.
- - + Create Vault - +
+
+ + +
+ + + Create Vault + +
- {status === 'loading' && ( -
- + {status === "loading" && ( +
+ + +
)} - {status === 'empty' && ( -
- You don’t have any vaults yet. + {status === "empty" && ( +
+ + You don’t have any vaults yet. + Create your first vault
)} - {status === 'error' && ( -
- Failed to load vaults. + {status === "error" && ( +
+ + Failed to load vaults. +
)} - {status === 'data' && ( -
- {vaults.map((vault) => ( - + {viewMode === "list" && ( +
-
-
- {vault.name} - - Deadline: {new Date(vault.deadline).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })} - -
-
- - {vault.amount.toLocaleString()} {vault.currency} - - -
-
- - ))} -
+ {vaults.map((vault) => ( + +
+
+ + {vault.name} + + + Deadline:{" "} + {new Date(vault.deadline).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + })} + +
+
+ + {vault.amount.toLocaleString()} {vault.currency} + + +
+
+ + ))} +
+ )} + {viewMode === "grid" && ( +
+ {vaults.map((vault) => ( + + ))} +
+ )} + )}
- ) + ); } // Default export wraps with MemoryRouter for standalone usage; @@ -142,5 +317,5 @@ export default function Vaults({ fetchVaults }: VaultsInnerProps = {}) { - ) + ); } diff --git a/src/pages/__tests__/Vaults.test.tsx b/src/pages/__tests__/Vaults.test.tsx index 3d1f6b8..c1a60d8 100644 --- a/src/pages/__tests__/Vaults.test.tsx +++ b/src/pages/__tests__/Vaults.test.tsx @@ -1,42 +1,78 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { vi } from 'vitest'; -import Vaults from '../../pages/Vaults'; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; +import Vaults from "../../pages/Vaults"; // Helper to mock fetch function const mockSuccess = (data: T) => vi.fn().mockResolvedValue(data); -const mockFailure = (message = 'Network error') => vi.fn().mockRejectedValue(new Error(message)); +const mockFailure = (message = "Network error") => + vi.fn().mockRejectedValue(new Error(message)); -describe('Vaults page states', () => { - test('shows loading skeletons initially', async () => { +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value.toString(); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; +})(); + +Object.defineProperty(window, "localStorage", { value: localStorageMock }); + +describe("Vaults page states", () => { + beforeEach(() => { + localStorageMock.clear(); + }); + + test("shows loading skeletons initially", async () => { render(); // Skeletons should be present immediately - const skeletons = screen.getAllByTestId('skeleton'); + const skeletons = screen.getAllByTestId("skeleton"); expect(skeletons.length).toBeGreaterThanOrEqual(3); // Wait for loading to finish (no data) - await waitFor(() => expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument()); + await waitFor(() => + expect(screen.queryByTestId("skeleton")).not.toBeInTheDocument(), + ); }); - test('shows empty state when no vaults', async () => { + test("shows empty state when no vaults", async () => { render(); - await waitFor(() => screen.getByText(/You don’t have any vaults yet./i)); - expect(screen.getByRole('link', { name: /Create your first vault/i })).toBeInTheDocument(); + await waitFor(() => screen.getByText(/You don't have any vaults yet./i)); + expect( + screen.getByRole("link", { name: /Create your first vault/i }), + ).toBeInTheDocument(); }); - test('shows data state when vaults exist', async () => { + test("shows data state when vaults exist", async () => { const mockData = [ - { id: '1', name: 'Test Vault', amount: 1000, currency: 'USDC', status: 'active' as any, deadline: '2025-01-01T00:00:00Z' }, + { + id: "1", + name: "Test Vault", + amount: 1000, + currency: "USDC", + status: "active" as any, + deadline: "2025-01-01T00:00:00Z", + milestones: [], + }, ]; render(); - await waitFor(() => screen.getByText('Test Vault')); + await waitFor(() => screen.getByText("Test Vault")); expect(screen.getByText(/Test Vault/i)).toBeInTheDocument(); }); - test('shows error state and can retry', async () => { + test("shows error state and can retry", async () => { const fetchMock = mockFailure(); render(); await waitFor(() => screen.getByText(/Failed to load vaults./i)); - const retryBtn = screen.getByRole('button', { name: /Retry/i }); + const retryBtn = screen.getByRole("button", { name: /Retry/i }); expect(retryBtn).toBeInTheDocument(); // Mock success on retry fetchMock.mockImplementationOnce(() => Promise.resolve([])); @@ -44,3 +80,225 @@ describe('Vaults page states', () => { await waitFor(() => screen.getByText(/You don’t have any vaults yet./i)); }); }); + +describe("Vaults view toggle", () => { + beforeEach(() => { + localStorageMock.clear(); + }); + + test("defaults to list view when no preference exists", async () => { + const mockData = [ + { + id: "1", + name: "Test Vault", + amount: 1000, + currency: "USDC", + status: "active" as any, + deadline: "2025-01-01T00:00:00Z", + milestones: [], + }, + ]; + render(); + await waitFor(() => screen.getByText("Test Vault")); + + const listButton = screen.getByRole("radio", { name: "List" }); + const gridButton = screen.getByRole("radio", { name: "Grid" }); + + expect(listButton).toHaveAttribute("aria-checked", "true"); + expect(gridButton).toHaveAttribute("aria-checked", "false"); + }); + + test("switches to grid view when grid button is clicked", async () => { + const mockData = [ + { + id: "1", + name: "Test Vault", + amount: 1000, + currency: "USDC", + status: "active" as any, + deadline: "2025-01-01T00:00:00Z", + milestones: [], + }, + ]; + render(); + await waitFor(() => screen.getByText("Test Vault")); + + const gridButton = screen.getByRole("radio", { name: "Grid" }); + userEvent.click(gridButton); + + await waitFor(() => + expect(gridButton).toHaveAttribute("aria-checked", "true"), + ); + await waitFor(() => + expect(screen.getByRole("radio", { name: "List" })).toHaveAttribute( + "aria-checked", + "false", + ), + ); + }); + + test("switches back to list view when list button is clicked", async () => { + const mockData = [ + { + id: "1", + name: "Test Vault", + amount: 1000, + currency: "USDC", + status: "active" as any, + deadline: "2025-01-01T00:00:00Z", + milestones: [], + }, + ]; + render(); + await waitFor(() => screen.getByText("Test Vault")); + + const gridButton = screen.getByRole("radio", { name: "Grid" }); + userEvent.click(gridButton); + + const listButton = screen.getByRole("radio", { name: "List" }); + userEvent.click(listButton); + + await waitFor(() => + expect(listButton).toHaveAttribute("aria-checked", "true"), + ); + await waitFor(() => + expect(gridButton).toHaveAttribute("aria-checked", "false"), + ); + }); + + test("persists grid view preference to localStorage", async () => { + const mockData = [ + { + id: "1", + name: "Test Vault", + amount: 1000, + currency: "USDC", + status: "active" as any, + deadline: "2025-01-01T00:00:00Z", + milestones: [], + }, + ]; + render(); + await waitFor(() => screen.getByText("Test Vault")); + + const gridButton = screen.getByRole("radio", { name: "Grid" }); + userEvent.click(gridButton); + + await waitFor(() => + expect(gridButton).toHaveAttribute("aria-checked", "true"), + ); + expect(localStorageMock.getItem("vaults-view-preference")).toBe("grid"); + }); + + test("persists list view preference to localStorage", async () => { + const mockData = [ + { + id: "1", + name: "Test Vault", + amount: 1000, + currency: "USDC", + status: "active" as any, + deadline: "2025-01-01T00:00:00Z", + milestones: [], + }, + ]; + render(); + await waitFor(() => screen.getByText("Test Vault")); + + const gridButton = screen.getByRole("radio", { name: "Grid" }); + userEvent.click(gridButton); + + const listButton = screen.getByRole("radio", { name: "List" }); + userEvent.click(listButton); + + await waitFor(() => + expect(listButton).toHaveAttribute("aria-checked", "true"), + ); + expect(localStorageMock.getItem("vaults-view-preference")).toBe("list"); + }); + + test("loads grid view from localStorage preference", async () => { + localStorageMock.setItem("vaults-view-preference", "grid"); + + const mockData = [ + { + id: "1", + name: "Test Vault", + amount: 1000, + currency: "USDC", + status: "active" as any, + deadline: "2025-01-01T00:00:00Z", + milestones: [], + }, + ]; + render(); + await waitFor(() => screen.getByText("Test Vault")); + + const gridButton = screen.getByRole("radio", { name: "Grid" }); + const listButton = screen.getByRole("radio", { name: "List" }); + + expect(gridButton).toHaveAttribute("aria-checked", "true"); + expect(listButton).toHaveAttribute("aria-checked", "false"); + }); + + test("shows empty state in both views", async () => { + render(); + await waitFor(() => screen.getByText(/You don’t have any vaults yet./i)); + + // Toggle buttons should still be present + expect(screen.getByRole("radio", { name: "List" })).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Grid" })).toBeInTheDocument(); + }); + + test("renders vaults VaultCard in grid view", async () => { + const mockData = [ + { + id: "1", + name: "Test Vault", + amount: 1000, + currency: "USDC", + status: "active" as any, + deadline: "2025-01-01T00:00:00Z", + milestones: [], + }, + ]; + render(); + await waitFor(() => screen.getByText("Test Vault")); + + const gridButton = screen.getByRole("radio", { name: "Grid" }); + userEvent.click(gridButton); + + // VaultCard should be rendered with progress bar + await waitFor(() => screen.getByLabelText(/Test Vault progress/i)); + }); + + test("handles localStorage errors gracefully", async () => { + // Mock localStorage to throw error + const originalGetItem = localStorageMock.getItem; + localStorageMock.getItem = vi.fn(() => { + throw new Error("localStorage error"); + }); + + const mockData = [ + { + id: "1", + name: "Test Vault", + amount: 1000, + currency: "USDC", + status: "active" as any, + deadline: "2025-01-01T00:00:00Z", + milestones: [], + }, + ]; + render(); + + // Should still render with default list view + await waitFor(() => screen.getByText("Test Vault")); + expect(screen.getByRole("radio", { name: "List" })).toHaveAttribute( + "aria-checked", + "true", + ); + + localStorageMock.getItem = originalGetItem; + }); +}); From f11ba6446712529337f6c23b7d3f96621a8666ac Mon Sep 17 00:00:00 2001 From: divine Date: Tue, 30 Jun 2026 08:25:36 +0100 Subject: [PATCH 2/2] fix: improve router wrapping to avoid nested MemoryRouter in production --- src/pages/Vaults.tsx | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/pages/Vaults.tsx b/src/pages/Vaults.tsx index ca72c61..27ee69e 100644 --- a/src/pages/Vaults.tsx +++ b/src/pages/Vaults.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Link, MemoryRouter } from "react-router-dom"; +import { Link, MemoryRouter, useInRouterContext } from "react-router-dom"; import { StatusChip } from "../components/StatusChip"; import { Text } from "../components/Text"; import VaultCard from "../components/VaultCard"; @@ -310,12 +310,21 @@ function VaultsInner({ fetchVaults = DEFAULT_FETCH }: VaultsInnerProps) { // Default export wraps with MemoryRouter for standalone usage; // tests that need router control can wrap themselves. -export default function Vaults({ fetchVaults }: VaultsInnerProps = {}) { - // If we're already inside a Router (detected by trying), use VaultsInner directly. - // We always wrap in MemoryRouter here so the component is self-contained. +// Default export checks for router context to avoid nested router issues in production +export default function Vaults(props: VaultsInnerProps) { return ( - - - + + + ); } + +function RouterSafeWrapper({ children }: { children: React.ReactNode }) { + try { + const inRouter = useInRouterContext(); + if (inRouter) return <>{children}; + } catch { + // If hook throws outside of context + } + return {children}; +}