From 49dd11be5604bf10991b226e214fa87892174172 Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Fri, 16 Oct 2026 16:22:19 +0100 Subject: [PATCH] Errors now show as toasts in the bottom right corner --- web/CODE_CONVENTIONS.md | 4 ++ web/README.md | 5 +- web/src/App.tsx | 38 +++++++------ web/src/components/ToastList.tsx | 37 +++++++++++++ .../createOpportunityAnnotations.ts | 15 ++--- web/src/primitives/createToasts.ts | 55 +++++++++++++++++++ 6 files changed, 125 insertions(+), 29 deletions(-) create mode 100644 web/src/components/ToastList.tsx create mode 100644 web/src/primitives/createToasts.ts diff --git a/web/CODE_CONVENTIONS.md b/web/CODE_CONVENTIONS.md index 277f67b..e1e8b68 100644 --- a/web/CODE_CONVENTIONS.md +++ b/web/CODE_CONVENTIONS.md @@ -299,6 +299,10 @@ backend. quotes and apostrophes are both fine. - Errors are shown where the user is looking, with `role="alert"`, and can be retried or dismissed; they're never only logged to the console. +- An action that failed (a mark that wasn't saved) is shown as a toast in + the bottom right corner (`createToasts`, `ToastList`), which goes after + 15 seconds or when it's dismissed. A load that failed is shown in place of what didn't + load, with a way to retry, and as a toast too. ## Security diff --git a/web/README.md b/web/README.md index 0ef168f..8c10975 100644 --- a/web/README.md +++ b/web/README.md @@ -81,10 +81,13 @@ token when it first needs it, and a new one if a request is refused for it. - `src/primitives/createLiveUpdates.ts` – applies the backend's events - `src/primitives/createOpportunityAnnotations.ts` – changing marks and hiding (applied at once, undone if the save fails) +- `src/primitives/createToasts.ts` – the toasts errors are shown in, bottom + right, for 15 seconds or until dismissed - `src/lib/filterOpportunities.ts` – search and filter logic - `src/lib/opportunities.ts` – merging changed opportunities into the list - `src/components/` – FilterPanel, ClassificationPicker, OpportunityTable, - MarkMenu, ThemeToggle; `styles.ts` holds shared Tailwind class strings + MarkMenu, ThemeToggle, ToastList; `styles.ts` holds shared Tailwind class + strings Theme colours and the font are set in the `@theme` block of `src/index.css`. diff --git a/web/src/App.tsx b/web/src/App.tsx index ac6594c..93df902 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,24 +1,27 @@ import type { JSX } from 'solid-js'; -import { createMemo, createSignal, Match, Show, Switch } from 'solid-js'; +import { createEffect, createMemo, createSignal, Match, Switch } from 'solid-js'; import { FilterPanel } from '@/components/FilterPanel'; import { OpportunityTable } from '@/components/OpportunityTable'; -import { buttonPrimary, linkButton } from '@/components/styles'; +import { buttonPrimary } from '@/components/styles'; import { ThemeToggle } from '@/components/ThemeToggle'; +import { ToastList } from '@/components/ToastList'; import type { OpportunityFilters } from '@/lib/filterOpportunities'; import { emptyFilters, filterOpportunities, statusOptions } from '@/lib/filterOpportunities'; import { createLiveUpdates } from '@/primitives/createLiveUpdates'; import { createClassifications, createOpportunities } from '@/primitives/createOpportunities'; import { createOpportunityAnnotations } from '@/primitives/createOpportunityAnnotations'; import { createTheme } from '@/primitives/createTheme'; +import { createToasts } from '@/primitives/createToasts'; const statePanel = 'rounded-[10px] border border-line bg-surface px-6 py-12 text-center'; -/** The page: the filters, any save error, and the table of opportunities. */ +/** The page: the filters, the table of opportunities, and toasts for errors. */ export function App(): JSX.Element { + const toasts = createToasts(); const opportunities = createOpportunities(); const classifications = createClassifications(); - const annotations = createOpportunityAnnotations(opportunities.items); + const annotations = createOpportunityAnnotations(opportunities.items, toasts.show); createLiveUpdates({ opportunities, classifications, annotations }); const theme = createTheme(); const [filters, setFilters] = createSignal(emptyFilters()); @@ -32,6 +35,17 @@ export function App(): JSX.Element { setFilters(emptyFilters()); }; + // A failed load is shown where the list would be, with a retry, and as a + // toast, so it's seen wherever the user is looking. + createEffect(() => { + const err = opportunities.error(); + if (err) toasts.show(`Opportunities didn't load: ${err.message}`); + }); + createEffect(() => { + const err = classifications.error(); + if (err) toasts.show(`Classifications didn't load: ${err.message}`); + }); + return (
@@ -52,20 +66,6 @@ export function App(): JSX.Element { total={opportunities.items().length} /> - - {(message) => ( - - )} - -
}> @@ -94,6 +94,8 @@ export function App(): JSX.Element {
+ +
); } diff --git a/web/src/components/ToastList.tsx b/web/src/components/ToastList.tsx new file mode 100644 index 0000000..dcdda29 --- /dev/null +++ b/web/src/components/ToastList.tsx @@ -0,0 +1,37 @@ +import type { JSX } from 'solid-js'; +import { For } from 'solid-js'; + +import type { Toast } from '@/primitives/createToasts'; +import { linkButton } from './styles'; + +interface Props { + toasts: Toast[]; + onDismiss: (id: number) => void; +} + +/** The toasts, stacked in the bottom right corner with the newest lowest. */ +export function ToastList(props: Props): JSX.Element { + return ( + // The stack lets clicks through to the page; only the toasts take them. +
+ + {(toast) => ( + + )} + +
+ ); +} diff --git a/web/src/primitives/createOpportunityAnnotations.ts b/web/src/primitives/createOpportunityAnnotations.ts index c458236..135ec36 100644 --- a/web/src/primitives/createOpportunityAnnotations.ts +++ b/web/src/primitives/createOpportunityAnnotations.ts @@ -1,4 +1,4 @@ -import { createEffect, createSignal } from 'solid-js'; +import { createEffect } from 'solid-js'; import { createStore, produce, reconcile } from 'solid-js/store'; import { api } from '@/api/client'; @@ -26,21 +26,19 @@ export interface OpportunityAnnotations { * client's still being saved is left alone, so the event can't undo it. */ applyMarks: (marks: OpportunityMarks) => void; - /** The last save failure; cleared by dismissError or the next successful save. */ - error: () => string | null; - dismissError: () => void; } /** * The user's marks on the opportunities, starting from the marks the - * opportunities were loaded with. Each change is saved to the backend. + * opportunities were loaded with. Each change is saved to the backend; a + * change that fails is undone, and onError is given a message saying so. */ export function createOpportunityAnnotations( opportunities: () => readonly Opportunity[], + onError: (message: string) => void, ): OpportunityAnnotations { const [byUuid, setByUuid] = createStore({}); const [saving, setSaving] = createStore>({}); - const [error, setError] = createSignal(null); // A (re)load of the opportunities brings the marks the backend has. createEffect(() => { @@ -66,10 +64,9 @@ export function createOpportunityAnnotations( setSaving(uuid, (count) => (count ?? 0) + 1); try { await api.setMark(uuid, change.mark, change.isSet); - setError(null); } catch (err) { change.undo(); - setError(`${failure}: ${errorText(err)}`); + onError(`${failure}: ${errorText(err)}`); } finally { setSaving( produce((counts) => { @@ -125,7 +122,5 @@ export function createOpportunityAnnotations( applyMarks: (marks) => { if (!saving[marks.uuid]) setByUuid(marks.uuid, annotationFrom(marks)); }, - error, - dismissError: () => setError(null), }; } diff --git a/web/src/primitives/createToasts.ts b/web/src/primitives/createToasts.ts new file mode 100644 index 0000000..579dd96 --- /dev/null +++ b/web/src/primitives/createToasts.ts @@ -0,0 +1,55 @@ +import { createSignal, onCleanup } from 'solid-js'; + +/** The most toasts shown at once: showing another drops the oldest. */ +const MAX_TOASTS = 5; + +/** How long a toast is shown before it goes by itself, in milliseconds. */ +const TOAST_DURATION = 15_000; + +/** A message shown as a toast for a while, or until it's dismissed. */ +export interface Toast { + id: number; + message: string; +} + +/** The toasts on screen, and the actions that show and dismiss them. */ +export interface Toasts { + /** The toasts, oldest first. */ + items: () => Toast[]; + /** Shows message as a new toast, after any already shown, for 15 seconds. */ + show: (message: string) => void; + dismiss: (id: number) => void; +} + +/** + * Toasts, such as errors from actions that failed. Each goes by itself + * after 15 seconds, or sooner when it's dismissed. + */ +export function createToasts(): Toasts { + const [items, setItems] = createSignal([]); + const timers = new Map>(); + let nextId = 1; + onCleanup(() => { + for (const timer of timers.values()) clearTimeout(timer); + }); + + const dismiss = (id: number): void => { + clearTimeout(timers.get(id)); + timers.delete(id); + setItems((toasts) => toasts.filter((toast) => toast.id !== id)); + }; + + return { + items, + show: (message) => { + const toast = { id: nextId, message }; + nextId += 1; + timers.set( + toast.id, + setTimeout(() => dismiss(toast.id), TOAST_DURATION), + ); + setItems((toasts) => [...toasts, toast].slice(-MAX_TOASTS)); + }, + dismiss, + }; +}