mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Add notification toasts and surface persistence failures instead of failing silently
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { notify, useNotificationStore } from './NotificationStore';
|
||||
|
||||
afterEach(() => useNotificationStore.getState().clear());
|
||||
|
||||
describe('NotificationStore', () => {
|
||||
it('appends a notification carrying its options and returns the id', () => {
|
||||
const id = notify({ kind: 'error', title: 'Storage full', message: 'Free space.' });
|
||||
const list = useNotificationStore.getState().notifications;
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]).toMatchObject({
|
||||
id,
|
||||
kind: 'error',
|
||||
title: 'Storage full',
|
||||
message: 'Free space.',
|
||||
});
|
||||
});
|
||||
|
||||
it('coalesces an identical notification instead of stacking duplicates', () => {
|
||||
const first = notify({ kind: 'error', title: 'Couldn’t save', message: 'A storage error.' });
|
||||
const second = notify({ kind: 'error', title: 'Couldn’t save', message: 'A storage error.' });
|
||||
expect(second).toBe(first);
|
||||
expect(useNotificationStore.getState().notifications).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not coalesce when the content differs', () => {
|
||||
notify({ kind: 'error', title: 'A', message: 'one' });
|
||||
notify({ kind: 'error', title: 'A', message: 'two' });
|
||||
notify({ kind: 'warning', title: 'A', message: 'one' });
|
||||
expect(useNotificationStore.getState().notifications).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('dismiss removes only the matching notification', () => {
|
||||
const a = notify({ kind: 'info', title: 'A', message: 'a' });
|
||||
notify({ kind: 'info', title: 'B', message: 'b' });
|
||||
useNotificationStore.getState().dismiss(a);
|
||||
const list = useNotificationStore.getState().notifications;
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].title).toBe('B');
|
||||
});
|
||||
|
||||
it('clear empties the list', () => {
|
||||
notify({ kind: 'success', title: 'Saved', message: 'Done.' });
|
||||
useNotificationStore.getState().clear();
|
||||
expect(useNotificationStore.getState().notifications).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Notifications — the app's non-blocking feedback channel (toasts).
|
||||
*
|
||||
* The sibling of ConfirmStore: ConfirmStore is the *blocking* layer (the user
|
||||
* must choose before continuing), this is the *non-blocking* one (an outcome is
|
||||
* announced and the user carries on). Per the design language "non-blocking
|
||||
* outcomes are toasts" (docs/architecture/09 → Toasts); per spec §10 a failed
|
||||
* save must *tell the user* rather than lose data silently.
|
||||
*
|
||||
* A notification's `kind` carries Carbon's status meaning (error/warning/
|
||||
* success/info) and drives both colour and urgency. The product distinction —
|
||||
* can the user fix it or not — is encoded in the copy plus the optional
|
||||
* `detail`: a user-fixable failure puts the next step in `message` and omits
|
||||
* `detail`; a failure the user can't fix carries diagnostic `detail` so the
|
||||
* problem can be reported (see services/storage-errors).
|
||||
*
|
||||
* The store holds only the list + add/dismiss; timed auto-dismiss for the
|
||||
* positive kinds lives in the Toaster component, keeping this store pure and
|
||||
* deterministic to test. Callable from non-React code (orchestration) via the
|
||||
* imperative `notify` export, exactly like `confirm`.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
/** Carbon status levels: colour carries meaning (docs/architecture/09). */
|
||||
export type NotificationKind = 'error' | 'warning' | 'success' | 'info';
|
||||
|
||||
export interface NotifyOptions {
|
||||
kind: NotificationKind;
|
||||
/** What happened, in a few words (Carbon: "tell users what stopped"). */
|
||||
title: string;
|
||||
/** One or two sentences: the consequence and, when fixable, the next step. */
|
||||
message: string;
|
||||
/**
|
||||
* Technical diagnostics for a failure the user cannot fix, surfaced so the
|
||||
* issue can be reported and traced. Omit for user-actionable or positive
|
||||
* notifications — there is nothing for the user to report.
|
||||
*/
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface Notification extends NotifyOptions {
|
||||
/** Stable id for React keys and dismissal. */
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** True when two notifications say the same thing — used to coalesce repeats. */
|
||||
function sameContent(a: NotifyOptions, b: NotifyOptions): boolean {
|
||||
return a.kind === b.kind && a.title === b.title && a.message === b.message;
|
||||
}
|
||||
|
||||
export interface NotificationState {
|
||||
notifications: Notification[];
|
||||
/**
|
||||
* Announce a notification; returns its id. An identical one already showing is
|
||||
* coalesced (its id is returned, nothing is appended) so a repeatedly-failing
|
||||
* auto-save can't stack a tower of the same toast.
|
||||
*/
|
||||
notify: (options: NotifyOptions) => string;
|
||||
/** Dismiss one notification by id (the close button, or auto-dismiss). */
|
||||
dismiss: (id: string) => void;
|
||||
/** Remove all notifications. */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationState>((set, get) => ({
|
||||
notifications: [],
|
||||
|
||||
notify: (options) => {
|
||||
const existing = get().notifications.find((n) => sameContent(n, options));
|
||||
if (existing) return existing.id;
|
||||
const id = crypto.randomUUID();
|
||||
set((s) => ({ notifications: [...s.notifications, { ...options, id }] }));
|
||||
return id;
|
||||
},
|
||||
|
||||
dismiss: (id) => set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) })),
|
||||
|
||||
clear: () => set({ notifications: [] }),
|
||||
}));
|
||||
|
||||
/** Imperative entry point for non-React callers (orchestration, services). */
|
||||
export const notify = (options: NotifyOptions): string =>
|
||||
useNotificationStore.getState().notify(options);
|
||||
Reference in New Issue
Block a user