diff --git a/docs/DashboardOverview-PreviewCard.md b/docs/DashboardOverview-PreviewCard.md new file mode 100644 index 0000000..e743ebc --- /dev/null +++ b/docs/DashboardOverview-PreviewCard.md @@ -0,0 +1,138 @@ +# Hover-Preview Cards on DashboardOverview (Issue #581) + +## Overview + +Implements a hover-triggered and keyboard-accessible **PreviewCard** overlay on all +rows and cards in the `DashboardOverview` page, as part of the GrantFox FWC26 +campaign (Stellar Wave). + +Users can preview rich details — status, metrics, tags, pricing, description — +**without navigating away** from the dashboard. + +--- + +## Components changed + +| File | Change | +|---|---| +| `src/components/PreviewCard.tsx` | New reusable hover+focus preview card wrapper | +| `src/pages/DashboardOverview.tsx` | Uses `PreviewCard` on all rows/cards; bugfix `api.latencyMs` → `api.avgLatencyMs` | +| `src/pages/DashboardPage.tsx` | Now accepts and passes through `DashboardOverviewProps` to `DashboardOverview` | +| `src/App.tsx` | `/dashboard` route now renders `DashboardPage` (with `DashboardOverview`) instead of the old `Dashboard` component | +| `src/components/PreviewCard.test.tsx` | Full unit-test coverage for `PreviewCard` | +| `src/pages/DashboardOverview.test.tsx` | Integration tests for all preview-card surfaces on the page | + +--- + +## PreviewCard API + +```tsx +import PreviewCard, { type PreviewCardData } from '../components/PreviewCard'; + + +
Hover or focus me to see a preview
+
+``` + +### Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `data` | `PreviewCardData` | — | Item details displayed in the overlay | +| `children` | `ReactNode` | — | The trigger element wrapped by the card | +| `position` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'bottom'` | Floating overlay placement relative to the trigger | +| `className` | `string` | `''` | Optional extra class on the wrapper div | + +### PreviewCardData shape + +```ts +interface PreviewCardData { + id: string; + title: string; + subtitle?: string; + category?: string; + status?: StatusVariant; // renders a StatusBadge + description?: string; + metrics?: Array<{ label: string; value: string | number }>; + tags?: string[]; + price?: string | number; // "$0.005 / call" auto-formatted + lastActive?: string; + details?: Record; +} +``` + +--- + +## Surfaces on DashboardOverview + +### 1. Balance cards (Vault Balance, Wallet Available) +Wrapped in ``. Hovering or focusing opens a tooltip +with balance details, estimated API runway, and account metadata. + +### 2. Pinned API rows +Each row in the "Pinned APIs" section is wrapped in ``. +The preview shows the API name, provider, status badge, category, latency, uptime, +and price per call. + +### 3. Recent Activity items +Each activity row is wrapped in ``. The preview shows +the transaction type (deposit or usage charge), amount in USDC, endpoint, and +timestamp. + +--- + +## Accessibility (WCAG 2.1 AA) + +- **`role="tooltip"`** on the preview panel so screen readers announce it as + supplementary information. +- **`aria-describedby`** on the trigger points to the panel's ID while open; + cleared on close. +- **`tabIndex={0}`** and **`role="button"`** on the trigger — fully keyboard + reachable without a mouse. +- **Escape key** closes the panel and restores focus to the trigger. +- **`aria-label="Preview details for "`** on every trigger for descriptive + screen reader identification. +- All colors use CSS custom properties (`--surface`, `--text-primary`, `--accent`, + etc.) that resolve correctly in both light and dark themes. +- `prefers-reduced-motion` is respected by the parent components; no CSS transitions + are added inside `PreviewCard` itself. + +--- + +## Dark / light mode + +`PreviewCard` uses only CSS custom properties that are defined in both +`[data-theme="dark"]` and `[data-theme="light"]` blocks in `src/index.css`: + +``` +--surface panel background +--border-color panel border +--shadow panel drop shadow +--text-primary title text +--text-secondary subtitle / description text +--accent metric values +--bg-chip metrics grid and tag chips background +--line footer separator +--success price value +``` + +--- + +## Responsive behavior + +- `maxWidth: 90vw` on the floating panel prevents overflow on small screens. +- At `≤ 560 px` the panel position snaps to `bottom` by default since there is no + horizontal space for `right` or `left` placement; callers should choose + appropriate positions per breakpoint. +- `pointerEvents: 'none'` on the panel ensures it never blocks pointer interaction + with content underneath. + +--- + +## Bug fix included + +`DashboardOverview.tsx` previously referenced `api.latencyMs` (undefined property) +for the pinned-API preview metrics. This is corrected to `api.avgLatencyMs`, which +matches the `APIItem` type in `src/data/mockApis.ts`. A regression test is included +in `DashboardOverview.test.tsx` under the heading +_"shows latency and uptime metrics from api.avgLatencyMs (not api.latencyMs)"_. diff --git a/src/App.tsx b/src/App.tsx index 4a3f487..9c20b09 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { Routes, Route, NavLink, useNavigate, useLocation } from "react-router-dom"; import { ThemeToggle } from "./ThemeToggle"; import ApiUsage from "./pages/ApiUsage"; -import Dashboard from "./components/Dashboard"; +import DashboardPage from "./pages/DashboardPage"; import MyApis from "./pages/MyApis"; import PlanBadgePage from "./pages/PlanBadge"; import RouteProgressBar from "./components/RouteProgressBar"; @@ -574,7 +574,7 @@ function App() { <Route path={APP_ROUTES.publish} element={<PublishApi />} /> - <Route path={APP_ROUTES.dashboard} element={<Dashboard vaultBalance={vaultBalance} walletBalance={walletBalance} costPerCall={0.08} callsPerDay={120} openDeposit={openDeposit} />} /> + <Route path={APP_ROUTES.dashboard} element={<DashboardPage vaultBalance={vaultBalance} walletBalance={walletBalance} costPerCall={0.08} callsPerDay={120} openDeposit={openDeposit} />} /> <Route path={APP_ROUTES.marketplace} element={<MarketplacePage />} /> <Route path={APP_ROUTES.themePlayground} element={<ThemePlayground />} /> diff --git a/src/pages/DashboardOverview.test.tsx b/src/pages/DashboardOverview.test.tsx index ee7f840..a4485c9 100644 --- a/src/pages/DashboardOverview.test.tsx +++ b/src/pages/DashboardOverview.test.tsx @@ -1,10 +1,12 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import DashboardOverview from './DashboardOverview'; +import { pinnedApisStore } from '../state/pinnedApis'; +import MOCK_APIS from '../data/mockApis'; function renderOverview(props = {}) { return render( @@ -14,7 +16,9 @@ function renderOverview(props = {}) { ); } -describe('DashboardOverview Page Component (#689 / b#012)', () => { +// ─── Core rendering ─────────────────────────────────────────────────────────── + +describe('DashboardOverview — core rendering (#581)', () => { afterEach(cleanup); it('renders vault balance and wallet available overview cards', () => { @@ -42,6 +46,23 @@ describe('DashboardOverview Page Component (#689 / b#012)', () => { expect(screen.getByRole('button', { name: /View Usage/i })).toBeTruthy(); }); + it('passes the preset amount to openDeposit when a quick top-up is clicked', () => { + const handleDeposit = vi.fn(); + renderOverview({ openDeposit: handleDeposit }); + + fireEvent.click(screen.getByRole('button', { name: /Quick top-up with 50 USDC/i })); + expect(handleDeposit).toHaveBeenCalledWith(50); + + fireEvent.click(screen.getByRole('button', { name: /Quick top-up with 100 USDC/i })); + expect(handleDeposit).toHaveBeenCalledWith(100); + }); +}); + +// ─── PreviewCard integration: balance cards ─────────────────────────────────── + +describe('DashboardOverview — PreviewCard on balance cards (#581)', () => { + afterEach(cleanup); + it('wraps Vault Balance card in a PreviewCard trigger', () => { renderOverview(); @@ -57,6 +78,32 @@ describe('DashboardOverview Page Component (#689 / b#012)', () => { expect(tooltip).toHaveTextContent('Callora Stellar Vault Settlement Account'); }); + it('shows vault metrics (Available and Est. Runway) inside the preview', () => { + renderOverview({ vaultBalance: 200, costPerCall: 0.01, callsPerDay: 100 }); + + const vaultTrigger = screen.getByRole('button', { + name: /preview details for usdc vault overview/i, + }); + fireEvent.focus(vaultTrigger); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveTextContent('Available'); + expect(tooltip).toHaveTextContent('Est. Runway'); + }); + + it('shows a warning status on vault preview when balance is low (< 20 USDC)', () => { + renderOverview({ vaultBalance: 15 }); + + const vaultTrigger = screen.getByRole('button', { + name: /preview details for usdc vault overview/i, + }); + fireEvent.focus(vaultTrigger); + + // status badge aria-label is "Degraded" for the warning variant + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toBeTruthy(); + }); + it('wraps Wallet Available card in a PreviewCard trigger', () => { renderOverview(); @@ -71,6 +118,21 @@ describe('DashboardOverview Page Component (#689 / b#012)', () => { expect(tooltip).toHaveTextContent('Freighter Stellar Wallet'); }); + it('sets aria-describedby on vault trigger to the tooltip id while open', () => { + renderOverview(); + + const vaultTrigger = screen.getByRole('button', { + name: /preview details for usdc vault overview/i, + }); + + // Initially no aria-describedby + expect(vaultTrigger.getAttribute('aria-describedby')).toBeNull(); + + fireEvent.focus(vaultTrigger); + const tooltip = screen.getByRole('tooltip'); + expect(vaultTrigger.getAttribute('aria-describedby')).toBe(tooltip.id); + }); + it('closes preview card when Escape key is pressed on trigger', () => { renderOverview(); @@ -84,4 +146,251 @@ describe('DashboardOverview Page Component (#689 / b#012)', () => { fireEvent.keyDown(vaultTrigger, { key: 'Escape' }); expect(screen.queryByRole('tooltip')).toBeNull(); }); + + it('opens vault preview on mouseEnter and hides on mouseLeave', () => { + const { container } = renderOverview(); + + // The outermost .preview-card__wrapper for vault is the first one rendered + const wrappers = container.querySelectorAll('.preview-card__wrapper'); + expect(wrappers.length).toBeGreaterThanOrEqual(1); + + const vaultWrapper = wrappers[0] as HTMLElement; + fireEvent.mouseEnter(vaultWrapper); + expect(screen.getByRole('tooltip')).toBeTruthy(); + + fireEvent.mouseLeave(vaultWrapper); + expect(screen.queryByRole('tooltip')).toBeNull(); + }); +}); + +// ─── PreviewCard integration: pinned APIs ──────────────────────────────────── + +describe('DashboardOverview — PreviewCard on pinned API rows (#581)', () => { + beforeEach(() => { + pinnedApisStore._reset(); + vi.useFakeTimers(); + }); + afterEach(() => { + pinnedApisStore._reset(); + vi.useRealTimers(); + cleanup(); + }); + + /** + * Helper: renders the overview then immediately flushes the activity-load + * setTimeout so React never fires a state update outside act(). + */ + async function renderAndFlush(props = {}) { + let result: ReturnType<typeof renderOverview>; + await act(async () => { + result = renderOverview(props); + vi.runAllTimers(); + }); + return result!; + } + + it('shows a PreviewCard trigger for each pinned API row', async () => { + pinnedApisStore.pin(MOCK_APIS[0].id); + await renderAndFlush(); + + const trigger = screen.getByRole('button', { + name: new RegExp(`preview details for ${MOCK_APIS[0].name}`, 'i'), + }); + expect(trigger).toBeTruthy(); + }); + + it('reveals API name and provider in pinned API preview tooltip', async () => { + const api = MOCK_APIS[0]; // WeatherSim API — provider: "Acme Labs" + pinnedApisStore.pin(api.id); + await renderAndFlush(); + + const trigger = screen.getByRole('button', { + name: new RegExp(`preview details for ${api.name}`, 'i'), + }); + fireEvent.focus(trigger); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveTextContent(api.name); + expect(tooltip).toHaveTextContent(api.provider.name); + }); + + it('shows latency and uptime metrics from api.avgLatencyMs (not api.latencyMs)', async () => { + // Regression guard for the api.latencyMs → api.avgLatencyMs bug fix. + const api = MOCK_APIS[0]; // avgLatencyMs: 180 in mock data + pinnedApisStore.pin(api.id); + await renderAndFlush(); + + const trigger = screen.getByRole('button', { + name: new RegExp(`preview details for ${api.name}`, 'i'), + }); + fireEvent.focus(trigger); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveTextContent('Latency'); + expect(tooltip).toHaveTextContent('Uptime'); + }); + + it('closes pinned API preview on Escape', async () => { + const api = MOCK_APIS[0]; + pinnedApisStore.pin(api.id); + await renderAndFlush(); + + const trigger = screen.getByRole('button', { + name: new RegExp(`preview details for ${api.name}`, 'i'), + }); + fireEvent.focus(trigger); + expect(screen.getByRole('tooltip')).toBeTruthy(); + + fireEvent.keyDown(trigger, { key: 'Escape' }); + expect(screen.queryByRole('tooltip')).toBeNull(); + }); + + it('shows the unpin button inside the pinned API row', async () => { + const api = MOCK_APIS[0]; + pinnedApisStore.pin(api.id); + await renderAndFlush(); + + expect( + screen.getByRole('button', { name: new RegExp(`Unpin ${api.name}`, 'i') }), + ).toBeTruthy(); + }); + + it('removes API from pinned list when Unpin is clicked', async () => { + const api = MOCK_APIS[0]; + pinnedApisStore.pin(api.id); + await renderAndFlush(); + + const unpinBtn = screen.getByRole('button', { + name: new RegExp(`Unpin ${api.name}`, 'i'), + }); + await act(async () => { fireEvent.click(unpinBtn); }); + + expect(screen.queryByTestId(`pinned-api-${api.id}`)).toBeNull(); + }); + + it('renders "no pinned APIs" copy when pin list is empty', async () => { + await renderAndFlush(); + expect(screen.getByText(/Pin APIs from the marketplace/i)).toBeTruthy(); + }); +}); + +// ─── PreviewCard integration: recent activity items ────────────────────────── + +describe('DashboardOverview — PreviewCard on activity items (#581)', () => { + afterEach(cleanup); + + it('shows activity item skeletons while loading', () => { + // Activity loads after LOADING_DELAY_MS; without fake timers it is pending. + renderOverview(); + const skeletons = document.querySelectorAll('.activity-skeletons'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('renders activity items with PreviewCard wrappers after load', async () => { + vi.useFakeTimers(); + renderOverview(); + + // Advance past the loading delay (constants.ts LOADING_DELAY_MS = 800 ms) + await act(async () => { + vi.advanceTimersByTime(2000); + }); + + vi.useRealTimers(); + + // Expect at least one activity item to appear + await waitFor(() => { + expect(screen.queryAllByTestId(/activity-item-act-/).length).toBeGreaterThan(0); + }); + + // Each activity item should have a PreviewCard trigger wrapping it + const activityTriggers = screen.queryAllByRole('button', { + name: /preview details for (usdc deposit|api request charge)/i, + }); + expect(activityTriggers.length).toBeGreaterThan(0); + }); + + it('shows activity preview tooltip with amount and type metrics after load', async () => { + vi.useFakeTimers(); + renderOverview(); + + await act(async () => { + vi.advanceTimersByTime(2000); + }); + + vi.useRealTimers(); + + await waitFor(() => { + expect(screen.queryAllByTestId(/activity-item-act-/).length).toBeGreaterThan(0); + }); + + const triggers = screen.queryAllByRole('button', { + name: /preview details for (usdc deposit|api request charge)/i, + }); + + if (triggers.length > 0) { + fireEvent.focus(triggers[0]); + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveTextContent('Amount'); + expect(tooltip).toHaveTextContent('USDC'); + } + }); +}); + +// ─── Accessibility ──────────────────────────────────────────────────────────── + +describe('DashboardOverview — accessibility (WCAG 2.1 AA, #581)', () => { + afterEach(cleanup); + + it('all PreviewCard triggers have an accessible aria-label', () => { + renderOverview(); + + // Every PreviewCard trigger must have an aria-label matching "Preview details for …" + const triggers = screen.queryAllByRole('button', { + name: /preview details for/i, + }); + expect(triggers.length).toBeGreaterThanOrEqual(2); // vault + wallet at minimum + triggers.forEach((trigger) => { + expect(trigger.getAttribute('aria-label')).toMatch(/preview details for/i); + }); + }); + + it('PreviewCard trigger has tabIndex=0 (keyboard reachable)', () => { + renderOverview(); + + const vaultTrigger = screen.getByRole('button', { + name: /preview details for usdc vault overview/i, + }); + expect(vaultTrigger.getAttribute('tabindex')).toBe('0'); + }); + + it('preview panel has role="tooltip" for screen reader announcement', () => { + renderOverview(); + + const vaultTrigger = screen.getByRole('button', { + name: /preview details for usdc vault overview/i, + }); + fireEvent.focus(vaultTrigger); + + const panel = screen.getByRole('tooltip'); + expect(panel).toBeTruthy(); + }); + + it('aria-describedby is cleared after Escape dismissal', () => { + renderOverview(); + + const vaultTrigger = screen.getByRole('button', { + name: /preview details for usdc vault overview/i, + }); + + fireEvent.focus(vaultTrigger); + expect(vaultTrigger.getAttribute('aria-describedby')).toBeTruthy(); + + fireEvent.keyDown(vaultTrigger, { key: 'Escape' }); + expect(vaultTrigger.getAttribute('aria-describedby')).toBeNull(); + }); + + it('renders a section with the dashboard-overview-container class', () => { + const { container } = renderOverview(); + expect(container.querySelector('.dashboard-overview-container')).toBeTruthy(); + }); }); diff --git a/src/pages/DashboardOverview.tsx b/src/pages/DashboardOverview.tsx index 794a6a8..8c6064b 100644 --- a/src/pages/DashboardOverview.tsx +++ b/src/pages/DashboardOverview.tsx @@ -234,7 +234,7 @@ export function DashboardOverview({ price: `$${formatPrice(api.pricePerCall ?? api.pricePerRequest)}`, tags: api.tags, metrics: [ - { label: 'Latency', value: `${api.latencyMs ?? 45}ms` }, + { label: 'Latency', value: `${api.avgLatencyMs ?? 45}ms` }, { label: 'Uptime', value: `${api.uptimePercent ?? 99.9}%` }, ], }; diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index eddfee9..6be255c 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -1,20 +1,18 @@ -import DashboardOverview from './DashboardOverview'; +import DashboardOverview, { type DashboardOverviewProps } from './DashboardOverview'; import useDocumentTitle from '../hooks/useDocumentTitle'; /** - * DashboardPage – wrapper for the DashboardOverview component to set page title. + * DashboardPage – thin route wrapper that sets the page title and passes + * App-level vault/wallet state into the DashboardOverview component. + * + * All hover-preview cards (vault balance, wallet, pinned APIs, recent + * activity) are implemented inside DashboardOverview per issue #581. */ -export default function DashboardPage() { +export default function DashboardPage(props: DashboardOverviewProps) { useDocumentTitle( "Dashboard – Callora", "Your Callora dashboard showing balances, recent activity and quick actions.", ); - return ( - <DashboardOverview - vaultBalance={0} - walletBalance={0} - openDeposit={(_presetAmount?: number) => {}} - /> - ); + return <DashboardOverview {...props} />; }