mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add notification toasts and surface persistence failures instead of failing silently
This commit is contained in:
@@ -4,6 +4,7 @@ import { ResizeHandle } from './components/ResizeHandle';
|
|||||||
import { SnippetLibrary } from './components/SnippetLibrary';
|
import { SnippetLibrary } from './components/SnippetLibrary';
|
||||||
import { SpecEditor } from './components/SpecEditor';
|
import { SpecEditor } from './components/SpecEditor';
|
||||||
import { ThemeToggle } from './components/ThemeToggle';
|
import { ThemeToggle } from './components/ThemeToggle';
|
||||||
|
import { Toaster } from './components/Toaster';
|
||||||
import { usePanesStore } from './stores/PanesStore';
|
import { usePanesStore } from './stores/PanesStore';
|
||||||
import styles from './App.module.css';
|
import styles from './App.module.css';
|
||||||
|
|
||||||
@@ -50,6 +51,10 @@ export function App() {
|
|||||||
{/* Global confirmation layer — sits above the (future) feature-modal
|
{/* Global confirmation layer — sits above the (future) feature-modal
|
||||||
shell so a discard-changes prompt can appear over an open modal. */}
|
shell so a discard-changes prompt can appear over an open modal. */}
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
|
{/* Non-blocking notifications (failed saves, etc.) — top-right toasts,
|
||||||
|
layered above the confirm backdrop so a failure stays visible. */}
|
||||||
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
.region {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--space-5);
|
||||||
|
right: var(--space-5);
|
||||||
|
/* Above the confirm backdrop (z 1000) so a failure stays visible and
|
||||||
|
dismissible even with a confirmation open; top-right won't block the
|
||||||
|
centered dialog. */
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: min(24rem, calc(100vw - 2 * var(--space-5)));
|
||||||
|
/* The region is only a positioner; individual toasts re-enable pointer events. */
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--layer-02);
|
||||||
|
/* Design language → Toasts: 1px border in the support colour, square. The
|
||||||
|
accent (per-kind) is set by the modifier classes below. */
|
||||||
|
border: var(--border-width) solid var(--toast-accent);
|
||||||
|
border-left-width: 3px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 2px 12px rgb(0 0 0 / 0.3);
|
||||||
|
animation: toast-in var(--dur-moderate) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
--toast-accent: var(--support-error);
|
||||||
|
}
|
||||||
|
.warning {
|
||||||
|
--toast-accent: var(--support-warning);
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
--toast-accent: var(--support-success);
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
--toast-accent: var(--support-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailSummary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailText {
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
font-family: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--layer-01);
|
||||||
|
border: var(--border-width) solid var(--border);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailMeta {
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
background: var(--layer-01);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Toaster — renders the NotificationStore as a stack of toasts (spec §10,
|
||||||
|
* design language → Toasts). Mounted once at the app root, beside ConfirmDialog.
|
||||||
|
*
|
||||||
|
* The non-blocking counterpart to ConfirmDialog: top-right, stacked newest-last,
|
||||||
|
* each dismissible. Error/warning toasts persist until dismissed (Carbon: a
|
||||||
|
* critical message shouldn't vanish on a timer); success/info auto-dismiss. A
|
||||||
|
* failure that carries diagnostic `detail` exposes it under a collapsed
|
||||||
|
* "Technical details" disclosure — available to report, without shouting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
useNotificationStore,
|
||||||
|
type Notification,
|
||||||
|
type NotificationKind,
|
||||||
|
} from '../stores/NotificationStore';
|
||||||
|
import styles from './Toaster.module.css';
|
||||||
|
|
||||||
|
/** Auto-dismiss delay for the non-critical kinds (ms). Errors/warnings persist. */
|
||||||
|
const AUTO_DISMISS_MS = 6000;
|
||||||
|
|
||||||
|
/** Kinds that fade on their own; errors and warnings wait for the user. */
|
||||||
|
const AUTO_DISMISS: Record<NotificationKind, boolean> = {
|
||||||
|
success: true,
|
||||||
|
info: true,
|
||||||
|
warning: false,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_CLASS: Record<NotificationKind, string> = {
|
||||||
|
error: styles.error,
|
||||||
|
warning: styles.warning,
|
||||||
|
success: styles.success,
|
||||||
|
info: styles.info,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Toast({ notification }: { notification: Notification }) {
|
||||||
|
const dismiss = useNotificationStore((s) => s.dismiss);
|
||||||
|
const { id, kind, title, message, detail } = notification;
|
||||||
|
|
||||||
|
// Positive/informational toasts clear themselves after a beat; the close
|
||||||
|
// button still works for an early dismissal.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!AUTO_DISMISS[kind]) return;
|
||||||
|
const timer = setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [id, kind, dismiss]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.toast} ${KIND_CLASS[kind]}`}
|
||||||
|
// Errors/warnings interrupt assistive tech (assertive); the rest are polite.
|
||||||
|
role={kind === 'error' || kind === 'warning' ? 'alert' : 'status'}
|
||||||
|
>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<p className={styles.title}>{title}</p>
|
||||||
|
<p className={styles.message}>{message}</p>
|
||||||
|
{detail !== undefined && (
|
||||||
|
<details className={styles.detail}>
|
||||||
|
<summary className={styles.detailSummary}>Technical details</summary>
|
||||||
|
<pre className={styles.detailText}>{detail}</pre>
|
||||||
|
<p className={styles.detailMeta}>Astrolabe v{__APP_VERSION__}</p>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.close}
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
onClick={() => dismiss(id)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const notifications = useNotificationStore((s) => s.notifications);
|
||||||
|
if (notifications.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className={styles.region} role="region" aria-label="Notifications">
|
||||||
|
{notifications.map((n) => (
|
||||||
|
<Toast key={n.id} notification={n} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createSnippet, type Snippet } from '@core/snippet';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The persistence subscribers are the subtlest glue in the app: a debounced
|
||||||
|
* auto-save and a reference-diff write-through that decides what to upsert vs.
|
||||||
|
* delete. They run against a mocked IndexedDB adapter so we assert the *wiring*
|
||||||
|
* (when each adapter call fires, with what, and that a rejection surfaces) with
|
||||||
|
* no real database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mock the adapter, but keep the real StorageQuotaError so the error-mapping
|
||||||
|
// path (instanceof check) behaves exactly as in production.
|
||||||
|
vi.mock('../infrastructure/snippet-store', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../infrastructure/snippet-store')>();
|
||||||
|
return { ...actual, saveSnippet: vi.fn(), deleteSnippet: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { saveSnippet, deleteSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
|
||||||
|
import { useNotificationStore } from '../stores/NotificationStore';
|
||||||
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
import { AUTOSAVE_DEBOUNCE_MS, wirePersistence } from './persistence';
|
||||||
|
|
||||||
|
const save = vi.mocked(saveSnippet);
|
||||||
|
const del = vi.mocked(deleteSnippet);
|
||||||
|
|
||||||
|
let teardown: () => void = () => {};
|
||||||
|
|
||||||
|
/** Flush pending microtasks (e.g. a `.catch` on a rejected adapter promise). */
|
||||||
|
const flush = () => Promise.resolve().then(() => Promise.resolve());
|
||||||
|
|
||||||
|
function snippetWith(id: string, spec: string): Snippet {
|
||||||
|
return createSnippet({ id, spec });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
save.mockReset().mockResolvedValue(undefined);
|
||||||
|
del.mockReset().mockResolvedValue(undefined);
|
||||||
|
useNotificationStore.getState().clear();
|
||||||
|
useSnippetStore.getState().reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
teardown();
|
||||||
|
teardown = () => {};
|
||||||
|
vi.clearAllTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wireDraftAutoSave', () => {
|
||||||
|
it('persists a settled, valid edit after the debounce', async () => {
|
||||||
|
useSnippetStore.getState().hydrate([snippetWith('a', '{"a":1}')]);
|
||||||
|
teardown = wirePersistence();
|
||||||
|
save.mockClear();
|
||||||
|
|
||||||
|
useSnippetStore.getState().updateDraft('{"a":2}');
|
||||||
|
expect(save).not.toHaveBeenCalled(); // nothing yet — waiting for the pause
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
|
expect(save.mock.calls[0][0]).toMatchObject({ id: 'a', draftSpec: '{"a":2}' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not persist a half-typed, unparseable buffer', async () => {
|
||||||
|
useSnippetStore.getState().hydrate([snippetWith('a', '{"a":1}')]);
|
||||||
|
teardown = wirePersistence();
|
||||||
|
save.mockClear();
|
||||||
|
|
||||||
|
useSnippetStore.getState().updateDraft('{ "a":');
|
||||||
|
await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wireWriteThrough', () => {
|
||||||
|
it('persists a newly created snippet', () => {
|
||||||
|
useSnippetStore.getState().hydrate([]);
|
||||||
|
teardown = wirePersistence();
|
||||||
|
save.mockClear();
|
||||||
|
|
||||||
|
useSnippetStore.getState().createSnippet({ id: 'n', spec: '{}' });
|
||||||
|
|
||||||
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
|
expect(save.mock.calls[0][0]).toMatchObject({ id: 'n' });
|
||||||
|
expect(del).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a removed snippet without re-saving the survivors', () => {
|
||||||
|
useSnippetStore.getState().hydrate([snippetWith('a', '{}'), snippetWith('b', '{}')]);
|
||||||
|
teardown = wirePersistence();
|
||||||
|
save.mockClear();
|
||||||
|
|
||||||
|
useSnippetStore.getState().removeSnippet('a');
|
||||||
|
|
||||||
|
expect(del).toHaveBeenCalledTimes(1);
|
||||||
|
expect(del).toHaveBeenCalledWith('a');
|
||||||
|
// 'b' kept its object reference through the filter, so it is not rewritten.
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores state changes that do not touch the snippets array', () => {
|
||||||
|
useSnippetStore.getState().hydrate([snippetWith('a', '{}')]);
|
||||||
|
teardown = wirePersistence();
|
||||||
|
save.mockClear();
|
||||||
|
|
||||||
|
useSnippetStore.getState().setEditorView('published');
|
||||||
|
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
expect(del).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a failed save as an error notification instead of losing it silently', async () => {
|
||||||
|
useSnippetStore.getState().hydrate([]);
|
||||||
|
teardown = wirePersistence();
|
||||||
|
save.mockClear();
|
||||||
|
save.mockRejectedValueOnce(new StorageQuotaError());
|
||||||
|
|
||||||
|
useSnippetStore.getState().createSnippet({ id: 'n', spec: '{}' });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const notes = useNotificationStore.getState().notifications;
|
||||||
|
expect(notes).toHaveLength(1);
|
||||||
|
expect(notes[0]).toMatchObject({ kind: 'error', title: 'Storage full' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { deleteSnippet, saveSnippet } from '../infrastructure/snippet-store';
|
import { deleteSnippet, saveSnippet } from '../infrastructure/snippet-store';
|
||||||
|
import { storageErrorNotification } from '../services/storage-errors';
|
||||||
|
import { notify } from '../stores/NotificationStore';
|
||||||
import { useSnippetStore } from '../stores/SnippetStore';
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
|
||||||
/** Delay before a settled edit is committed to the stored draft (spec §03B). */
|
/** Delay before a settled edit is committed to the stored draft (spec §03B). */
|
||||||
@@ -40,12 +42,20 @@ function wireWriteThrough(): Unsubscribe {
|
|||||||
const prev = prevSnippets;
|
const prev = prevSnippets;
|
||||||
prevSnippets = next;
|
prevSnippets = next;
|
||||||
|
|
||||||
|
// A failed write must surface, not vanish — these promises are otherwise
|
||||||
|
// fire-and-forget, so a rejection becomes a toast rather than silent loss
|
||||||
|
// (spec §10 → "told when a save fails rather than losing data silently").
|
||||||
for (const old of prev) {
|
for (const old of prev) {
|
||||||
if (!next.some((n) => n.id === old.id)) void deleteSnippet(old.id);
|
if (!next.some((n) => n.id === old.id)) {
|
||||||
|
deleteSnippet(old.id).catch((err) => notify(storageErrorNotification('delete', err)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const n of next) {
|
for (const n of next) {
|
||||||
const old = prev.find((p) => p.id === n.id);
|
const old = prev.find((p) => p.id === n.id);
|
||||||
if (old !== n) void saveSnippet(n); // new, or a changed reference
|
if (old !== n) {
|
||||||
|
// new, or a changed reference
|
||||||
|
saveSnippet(n).catch((err) => notify(storageErrorNotification('save', err)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
* the UI renders immediately and fills in when hydration completes.
|
* the UI renders immediately and fills in when hydration completes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSnippet } from '@core/snippet';
|
import { createSnippet, type Snippet } from '@core/snippet';
|
||||||
import { loadSnippets, saveSnippet } from '../infrastructure/snippet-store';
|
import { loadSnippets, saveSnippet } from '../infrastructure/snippet-store';
|
||||||
|
import { storageErrorNotification } from '../services/storage-errors';
|
||||||
|
import { notify } from '../stores/NotificationStore';
|
||||||
import { useSnippetStore } from '../stores/SnippetStore';
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
import { wirePersistence } from './persistence';
|
import { wirePersistence } from './persistence';
|
||||||
|
|
||||||
@@ -18,11 +20,31 @@ export async function initApp(): Promise<void> {
|
|||||||
if (started) return; // idempotent — guard against double-invocation (StrictMode)
|
if (started) return; // idempotent — guard against double-invocation (StrictMode)
|
||||||
started = true;
|
started = true;
|
||||||
|
|
||||||
let snippets = await loadSnippets();
|
// If storage can't be opened (private-browsing, blocked storage), don't fail
|
||||||
|
// startup silently: warn, then run this session in memory so the app is still
|
||||||
|
// usable. `storageOk` gates the seed-save below — there's no point trying to
|
||||||
|
// persist a seed into storage we just failed to read.
|
||||||
|
let snippets: Snippet[] = [];
|
||||||
|
let storageOk = true;
|
||||||
|
try {
|
||||||
|
snippets = await loadSnippets();
|
||||||
|
} catch (err) {
|
||||||
|
storageOk = false;
|
||||||
|
notify(storageErrorNotification('load', err));
|
||||||
|
}
|
||||||
|
|
||||||
if (snippets.length === 0) {
|
if (snippets.length === 0) {
|
||||||
const sample = createSnippet();
|
const sample = createSnippet();
|
||||||
await saveSnippet(sample); // ensure the seed survives even before the first edit
|
|
||||||
snippets = [sample];
|
snippets = [sample];
|
||||||
|
if (storageOk) {
|
||||||
|
// Ensure the seed survives even before the first edit; a failure here is
|
||||||
|
// surfaced, not swallowed.
|
||||||
|
try {
|
||||||
|
await saveSnippet(sample);
|
||||||
|
} catch (err) {
|
||||||
|
notify(storageErrorNotification('save', err));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useSnippetStore.getState().hydrate(snippets);
|
useSnippetStore.getState().hydrate(snippets);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { StorageQuotaError } from '../infrastructure/snippet-store';
|
||||||
|
import { storageErrorNotification } from './storage-errors';
|
||||||
|
|
||||||
|
describe('storageErrorNotification', () => {
|
||||||
|
describe('a failure the user can fix (storage full)', () => {
|
||||||
|
it('tells them the step to take and carries no diagnostic detail', () => {
|
||||||
|
const n = storageErrorNotification('save', new StorageQuotaError());
|
||||||
|
expect(n.kind).toBe('error');
|
||||||
|
expect(n.title).toBe('Storage full');
|
||||||
|
expect(n.message.toLowerCase()).toContain('delete');
|
||||||
|
// Nothing to report — the fix is in the user's hands.
|
||||||
|
expect(n.detail).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a failure the user cannot fix', () => {
|
||||||
|
it('explains a load failure and attaches a reportable diagnostic', () => {
|
||||||
|
const n = storageErrorNotification('load', new DOMException('blocked', 'SecurityError'));
|
||||||
|
expect(n.kind).toBe('error');
|
||||||
|
expect(n.title).toBe("Couldn't open your library");
|
||||||
|
expect(n.detail).toBeDefined();
|
||||||
|
// The diagnostic names the operation and the underlying error.
|
||||||
|
expect(n.detail).toContain('load');
|
||||||
|
expect(n.detail).toContain('SecurityError');
|
||||||
|
expect(n.detail).toContain('blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the right title for a failed save vs delete and includes detail', () => {
|
||||||
|
const save = storageErrorNotification('save', new Error('boom'));
|
||||||
|
expect(save.title).toBe("Couldn't save your changes");
|
||||||
|
expect(save.detail).toContain('save failed');
|
||||||
|
expect(save.detail).toContain('boom');
|
||||||
|
|
||||||
|
const del = storageErrorNotification('delete', new Error('boom'));
|
||||||
|
expect(del.title).toBe("Couldn't delete the snippet");
|
||||||
|
expect(del.detail).toContain('delete failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes a non-Error throw without crashing', () => {
|
||||||
|
const n = storageErrorNotification('save', 'weird string');
|
||||||
|
expect(n.detail).toContain('weird string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Translate a persistence failure into a user-facing notification (spec §10 →
|
||||||
|
* "the user is told when a save fails rather than losing data silently").
|
||||||
|
*
|
||||||
|
* Pure and framework-free so the wording is unit-tested directly. The mapping
|
||||||
|
* encodes the product's actionability rule (Carbon: a user action is mandatory
|
||||||
|
* on an error message — but only the user's *own* action belongs in the copy):
|
||||||
|
*
|
||||||
|
* - A failure the user CAN resolve (storage full) → tell them the step to
|
||||||
|
* take, and carry no `detail` (there is nothing for them to report).
|
||||||
|
* - A failure the user CANNOT resolve (storage blocked, an unexpected DB
|
||||||
|
* error) → explain it plainly AND attach diagnostic `detail` (the operation
|
||||||
|
* that failed + the underlying error) so it can be reported and traced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StorageQuotaError } from '../infrastructure/snippet-store';
|
||||||
|
import type { NotifyOptions } from '../stores/NotificationStore';
|
||||||
|
|
||||||
|
/** Which persistence operation failed — shapes both the wording and the trace. */
|
||||||
|
export type StorageOp = 'load' | 'save' | 'delete';
|
||||||
|
|
||||||
|
/** Compact, reportable description of an unexpected error for the `detail` field. */
|
||||||
|
function diagnostic(op: StorageOp, err: unknown): string {
|
||||||
|
const base = `Storage ${op} failed`;
|
||||||
|
if (err instanceof Error) {
|
||||||
|
// Include the error name when it adds signal (DOMException subtypes, etc.).
|
||||||
|
return err.name && err.name !== 'Error'
|
||||||
|
? `${base}: ${err.name}: ${err.message}`
|
||||||
|
: `${base}: ${err.message}`;
|
||||||
|
}
|
||||||
|
return `${base}: ${String(err)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a thrown persistence error to the notification to show for it. `op` is the
|
||||||
|
* operation that failed; `err` is whatever the adapter threw.
|
||||||
|
*/
|
||||||
|
export function storageErrorNotification(op: StorageOp, err: unknown): NotifyOptions {
|
||||||
|
// Storage full — the one failure the user can act on themselves. No detail:
|
||||||
|
// there's nothing to report, the next step is in the message.
|
||||||
|
if (err instanceof StorageQuotaError) {
|
||||||
|
return {
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Storage full',
|
||||||
|
message:
|
||||||
|
"This snippet couldn't be saved because snippet storage is full. " +
|
||||||
|
'Delete snippets you no longer need to free space, then edit again to retry.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Couldn't read the library on startup — almost always blocked storage.
|
||||||
|
if (op === 'load') {
|
||||||
|
return {
|
||||||
|
kind: 'error',
|
||||||
|
title: "Couldn't open your library",
|
||||||
|
message:
|
||||||
|
"Astrolabe couldn't open local storage, so your saved snippets aren't available yet. " +
|
||||||
|
'This usually means the browser is blocking storage — for example, private-browsing mode. ' +
|
||||||
|
'Reload to try again.',
|
||||||
|
detail: diagnostic(op, err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unexpected save/delete failure the user can't do anything about. Plain
|
||||||
|
// explanation for them; diagnostic detail for whoever they report it to.
|
||||||
|
const title = op === 'delete' ? "Couldn't delete the snippet" : "Couldn't save your changes";
|
||||||
|
return {
|
||||||
|
kind: 'error',
|
||||||
|
title,
|
||||||
|
message:
|
||||||
|
'A storage error stopped Astrolabe from completing the last change, so it may not survive a reload. ' +
|
||||||
|
'If this keeps happening, your browser may be blocking local storage (such as private-browsing mode).',
|
||||||
|
detail: diagnostic(op, err),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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