Errors now show as toasts in the bottom right corner
This commit is contained in:
1 parent
115ee29a85
commit
49dd11be56
6 files changed
+125
-29
No files matched your search
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user