From 34b2c33db40d995ca807ffaef5d59c5fe4ec55e1 Mon Sep 17 00:00:00 2001 From: Yusrah Mohammed <203854152+miss-yusrah@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:40:51 +0100 Subject: [PATCH] Improve mobile details, form errors, nav restore, and request IDs. Co-authored-by: Cursor --- dashboard/src/App.tsx | 10 + dashboard/src/components/EventCard.test.tsx | 37 +++- dashboard/src/components/EventCard.tsx | 26 ++- dashboard/src/components/FormField.test.tsx | 51 +++++ dashboard/src/components/FormField.tsx | 136 ++++++++++++ .../NotificationDetailsDrawer.test.tsx | 34 +++ .../components/NotificationDetailsDrawer.tsx | 17 +- .../components/NotificationTimelineView.tsx | 45 ++-- dashboard/src/hooks/useMediaQuery.test.ts | 97 +++++++++ dashboard/src/hooks/useMediaQuery.ts | 43 ++++ dashboard/src/index.css | 200 +++++++++++++++++- .../src/pages/NotificationPreferencesPage.tsx | 63 +++--- dashboard/src/pages/TemplatesPage.tsx | 78 ++++--- listener/src/api/events-server.ts | 16 +- listener/src/middleware/request-id.test.ts | 43 ++++ listener/src/middleware/request-id.ts | 31 +++ listener/src/utils/request-id.test.ts | 81 ++++++- listener/src/utils/request-id.ts | 63 ++++-- 18 files changed, 956 insertions(+), 115 deletions(-) create mode 100644 dashboard/src/components/FormField.test.tsx create mode 100644 dashboard/src/components/FormField.tsx create mode 100644 dashboard/src/hooks/useMediaQuery.test.ts create mode 100644 dashboard/src/hooks/useMediaQuery.ts create mode 100644 listener/src/middleware/request-id.test.ts create mode 100644 listener/src/middleware/request-id.ts diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 6b1a1c7a..0c0bf844 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -17,6 +17,7 @@ import { ThemeToggle } from './components/ThemeToggle'; import { MobileNavDrawer, NAV_ITEMS, type Tab } from './components/MobileNavDrawer'; import { ToastProvider } from './context/ToastContext'; import { useTheme } from './hooks/useTheme'; +import { useIsMobileNav } from './hooks/useMediaQuery'; import { DeliveryHeatmap } from './components/DeliveryHeatmap'; import { useEventStore } from './store/eventStore'; import { SyncStatus } from './components/SyncStatus'; @@ -31,6 +32,7 @@ export function App() { return 'explorer'; }); const [drawerOpen, setDrawerOpen] = useState(false); + const isMobileNav = useIsMobileNav(); const { theme, toggleTheme } = useTheme(); const events = useEventStore((state) => state.events); const tabListRef = useRef(null); @@ -53,6 +55,14 @@ export function App() { return () => window.removeEventListener('hashchange', handleHashChange); }, []); + // Close mobile drawer when the viewport crosses into desktop layout so + // hidden overlay/focus-trap state does not linger after a resize (#681). + useEffect(() => { + if (!isMobileNav && drawerOpen) { + setDrawerOpen(false); + } + }, [isMobileNav, drawerOpen]); + const handleTabKeyDown = useCallback((e: KeyboardEvent) => { const tabs = Array.from( tabListRef.current?.querySelectorAll('[role="tab"]') ?? [], diff --git a/dashboard/src/components/EventCard.test.tsx b/dashboard/src/components/EventCard.test.tsx index 5a1793b1..43fcdd33 100644 --- a/dashboard/src/components/EventCard.test.tsx +++ b/dashboard/src/components/EventCard.test.tsx @@ -1,20 +1,23 @@ -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, screen } from '@testing-library/react'; import { axe, toHaveNoViolations } from 'jest-axe'; import { EventCard } from './EventCard'; import type { BlockchainEvent } from '../types/event'; expect.extend(toHaveNoViolations); +const LONG_HASH = + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + const mockEvent: BlockchainEvent = { - eventId: 'evt-1', + eventId: 'evt-1-very-long-identifier-that-should-not-overflow-the-layout', type: 'TaskCreated', eventName: 'TaskCreated', ledger: 12345, contractAddress: 'GABCDEF1234567890ABCDEF1234567890ABCDEF12', receivedAt: Date.now(), - value: '100', - txHash: 'abcdef1234567890', - topic: [], + value: '{"message":"long payload content that must wrap on narrow viewports without horizontal scroll"}', + txHash: LONG_HASH, + topic: ['topic-with-a-very-long-name-that-needs-wrapping-on-mobile'], } as BlockchainEvent; test('clickable EventCard has no accessibility violations', async () => { @@ -35,4 +38,26 @@ test('activates on Space key, not just Enter', () => { fireEvent.keyDown(card, { key: 'Enter' }); expect(onClick).toHaveBeenCalledTimes(2); -}); \ No newline at end of file +}); + +describe('EventCard mobile detail layout (#680)', () => { + const breakpoints = [375, 390, 414, 600] as const; + + afterEach(() => { + document.documentElement.style.width = ''; + }); + + it.each(breakpoints)('keeps expanded details readable at %spx without full hash overflow', (width) => { + document.documentElement.style.width = `${width}px`; + + const { container } = render(); + + expect(container.querySelector('.event-card--expanded')).toBeTruthy(); + expect(screen.getByTitle(LONG_HASH)).toBeInTheDocument(); + // Shortened display, not the raw 64+ char hash as the only text node + expect(screen.queryByText(LONG_HASH)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /copy tx hash/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /copy contract address/i })).toBeInTheDocument(); + expect(container.querySelector('.event-card__payload')).toHaveTextContent(/long payload content/i); + }); +}); diff --git a/dashboard/src/components/EventCard.tsx b/dashboard/src/components/EventCard.tsx index c48a6138..f7cf08ed 100644 --- a/dashboard/src/components/EventCard.tsx +++ b/dashboard/src/components/EventCard.tsx @@ -104,6 +104,15 @@ function handleActivationKey(onClick: (e: BlockchainEvent) => void, event: Block }; } +function IdValue({ value, label }: { value: string; label: string }) { + return ( +
+ {shortenAddress(value)} + +
+ ); +} + function CompactCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e: BlockchainEvent) => void }) { const displayName = event.eventName ?? event.type; const badgeClass = getEventBadgeClass(event.eventName); @@ -132,7 +141,9 @@ function CompactCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e:
- Value: {event.value} + + Value: {event.value} + {event.txHash && ( Tx: {shortenAddress(event.txHash)} )} @@ -165,13 +176,13 @@ function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e
Contract
-
{event.contractAddress}
+
{event.txHash && (
Tx Hash
-
{event.txHash}
+
)} @@ -182,7 +193,7 @@ function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e
Value
-
{event.value}
+
{event.value}
{event.topic.length > 0 && ( @@ -205,10 +216,7 @@ function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e
Event ID
-
- {event.eventId} - -
+
@@ -235,4 +243,4 @@ export const EventCard = memo(function EventCard({ } return ; -}); \ No newline at end of file +}); diff --git a/dashboard/src/components/FormField.test.tsx b/dashboard/src/components/FormField.test.tsx new file mode 100644 index 00000000..b027cf7f --- /dev/null +++ b/dashboard/src/components/FormField.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import { FormField, FormInput, getFormFieldA11yProps } from './FormField'; + +expect.extend(toHaveNoViolations); + +describe('FormField (#678)', () => { + it('associates the error message with the control for screen readers', () => { + render( + + + , + ); + + const input = screen.getByLabelText('Email address'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAttribute('aria-describedby', 'email-error'); + expect(screen.getByRole('alert')).toHaveTextContent('Email address: enter a valid email.'); + expect(screen.getByRole('alert')).toHaveAttribute('id', 'email-error'); + }); + + it('marks the field invalid without relying on colour alone', () => { + const { container } = render( + + + , + ); + + expect(container.querySelector('.form-field--invalid')).toBeTruthy(); + expect(container.querySelector('[data-invalid="true"]')).toBeTruthy(); + expect(container.querySelector('.form-field__error-icon')).toHaveTextContent('!'); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('exposes a11y props that name the affected field', () => { + expect(getFormFieldA11yProps('name', 'Name: required')).toEqual({ + id: 'name', + 'aria-invalid': true, + 'aria-describedby': 'name-error', + }); + }); + + it('has no accessibility violations when showing an error', async () => { + const { container } = render( + + + , + ); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/dashboard/src/components/FormField.tsx b/dashboard/src/components/FormField.tsx new file mode 100644 index 00000000..05ca97f2 --- /dev/null +++ b/dashboard/src/components/FormField.tsx @@ -0,0 +1,136 @@ +import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react'; + +export interface FormFieldErrorProps { + id: string; + children: ReactNode; +} + +/** Accessible inline validation message associated with a control (#678). */ +export function FormFieldError({ id, children }: FormFieldErrorProps) { + return ( + + ); +} + +export interface FormFieldProps { + id: string; + label: string; + error?: string | null; + hint?: string; + required?: boolean; + children: ReactNode; + className?: string; +} + +/** + * Standard form field wrapper: label + control slot + optional hint/error. + * Errors are wired via aria-describedby / aria-invalid on the control + * (use getFormFieldA11yProps) so messaging is not colour-only (#678). + */ +export function FormField({ + id, + label, + error, + hint, + required, + children, + className, +}: FormFieldProps) { + const errorId = `${id}-error`; + const hintId = `${id}-hint`; + const invalid = Boolean(error); + + return ( +
+ + {children} + {hint && !error ? ( +

+ {hint} +

+ ) : null} + {error ? {error} : null} +
+ ); +} + +/** Aria props to spread onto the associated input/select/textarea. */ +export function getFormFieldA11yProps(id: string, error?: string | null, hint?: string) { + const describedBy: string[] = []; + if (error) describedBy.push(`${id}-error`); + else if (hint) describedBy.push(`${id}-hint`); + + return { + id, + 'aria-invalid': Boolean(error) || undefined, + 'aria-describedby': describedBy.length > 0 ? describedBy.join(' ') : undefined, + } as const; +} + +export type FormInputProps = InputHTMLAttributes & { + fieldId: string; + error?: string | null; + hint?: string; +}; + +export function FormInput({ fieldId, error, hint, className, ...rest }: FormInputProps) { + const a11y = getFormFieldA11yProps(fieldId, error, hint); + return ( + + ); +} + +export type FormTextareaProps = TextareaHTMLAttributes & { + fieldId: string; + error?: string | null; + hint?: string; +}; + +export function FormTextarea({ fieldId, error, hint, className, ...rest }: FormTextareaProps) { + const a11y = getFormFieldA11yProps(fieldId, error, hint); + return ( +