Errors now show as toasts in the bottom right corner

This commit is contained in:
bwilliams committed 2026-10-16 16:22:19 +01:00
1 parent 115ee29a85
commit 49dd11be56
6 files changed
+125 -29

No files matched your search

+4
View File
@@ -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
+4 -1
View File
@@ -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`.
+20 -18
View File
@@ -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<OpportunityFilters>(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 (
<div class="mx-auto max-w-[1440px] px-3 pt-4 pb-12 sm:px-6 sm:pt-7 sm:pb-16">
<header class="mb-5 flex items-center justify-between gap-4">
@@ -52,20 +66,6 @@ export function App(): JSX.Element {
total={opportunities.items().length}
/>
<Show when={annotations.error()}>
{(message) => (
<div
class="mb-4 flex items-center justify-between gap-4 rounded-md bg-danger-soft px-3.5 py-2.5 text-danger"
role="alert"
>
<span>{message()}</span>
<button type="button" class={linkButton} onClick={annotations.dismissError}>
Dismiss
</button>
</div>
)}
</Show>
<main>
<Switch fallback={<OpportunityTable opportunities={rows()} annotations={annotations} />}>
<Match when={opportunities.isLoading() && opportunities.items().length === 0}>
@@ -94,6 +94,8 @@ export function App(): JSX.Element {
</Match>
</Switch>
</main>
<ToastList toasts={toasts.items()} onDismiss={toasts.dismiss} />
</div>
);
}
+37
View File
@@ -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.
<div class="pointer-events-none fixed right-4 bottom-4 z-50 flex w-[min(24rem,calc(100vw-2rem))] flex-col gap-2">
<For each={props.toasts}>
{(toast) => (
<div
class="pointer-events-auto flex items-start justify-between gap-4 rounded-md border border-line bg-danger-soft px-3.5 py-2.5 text-danger shadow-[0_8px_24px_rgb(0_0_0/0.18)]"
role="alert"
>
<span>{toast.message}</span>
<button
type="button"
class={linkButton}
aria-label={`Dismiss: ${toast.message}`}
onClick={() => props.onDismiss(toast.id)}
>
Dismiss
</button>
</div>
)}
</For>
</div>
);
}
@@ -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<AnnotationMap>({});
const [saving, setSaving] = createStore<Record<string, number>>({});
const [error, setError] = createSignal<string | null>(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),
};
}
+55
View File
@@ -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<Toast[]>([]);
const timers = new Map<number, ReturnType<typeof setTimeout>>();
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,
};
}