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:
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user