Workspace transfer: custom themes ride the export/import envelope

This commit is contained in:
2026-06-12 21:45:44 +03:00
parent 80d13c9b6f
commit dbb4522d78
11 changed files with 405 additions and 173 deletions
+44 -5
View File
@@ -20,10 +20,12 @@ vi.mock('../infrastructure/snippet-store', async (importOriginal) => {
};
});
import { createCustomTheme } from '@core/custom-theme';
import { createDataset } from '@core/dataset';
import { createSnippet } from '@core/snippet';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useNotificationStore } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
@@ -50,6 +52,7 @@ beforeEach(() => {
vi.clearAllMocks();
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
useCustomThemeStore.getState().reset();
useNotificationStore.getState().clear();
});
@@ -68,18 +71,25 @@ describe('exportWorkspace', () => {
.addDatasets([
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }),
]);
useCustomThemeStore.getState().addThemes([createCustomTheme({ name: 'Brand', config: {} })]);
exportWorkspace(new Date('2026-06-07T12:00:00.000Z'));
expect(mockedDownload).toHaveBeenCalledTimes(1);
const [filename, json] = mockedDownload.mock.calls[0];
expect(filename).toBe('astrolabe-project-2026-06-07.json');
const env = JSON.parse(json) as { version: string; snippets: unknown[]; datasets: unknown[] };
const env = JSON.parse(json) as {
version: string;
snippets: unknown[];
datasets: unknown[];
themes: unknown[];
};
expect(env.version).toBe('1.0');
expect(env.snippets).toHaveLength(1);
expect(env.datasets).toHaveLength(1);
expect(env.themes).toHaveLength(1);
expect(lastNote()).toMatchObject({ kind: 'success' });
expect(lastNote().message).toBe('Exported 1 snippet and 1 dataset');
expect(lastNote().message).toBe('Exported 1 snippet, 1 dataset and 1 theme');
});
});
@@ -153,6 +163,33 @@ describe('importWorkspace', () => {
expect(lastNote().message).toContain('Sales → Sales 2');
});
it('merges envelope themes: ids reassigned, clashing names auto-suffixed and reported', async () => {
const existing = useCustomThemeStore
.getState()
.add(createCustomTheme({ name: 'Brand', config: { background: '#000' } }));
await importJson(
JSON.stringify({
version: '1.0',
snippets: [{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{}' }],
themes: [
{ id: existing.id, name: 'Brand', config: { background: '#fff' } },
{ id: 99, name: 'Mono', config: { font: 'Courier' } },
],
}),
);
const themes = useCustomThemeStore.getState().themes;
expect(themes.map((t) => t.name).sort()).toEqual(['Brand', 'Brand 2', 'Mono']);
// The existing record is untouched; incoming ids were reassigned past it.
expect(themes.find((t) => t.id === existing.id)!.config).toEqual({ background: '#000' });
expect(new Set(themes.map((t) => t.id)).size).toBe(3);
expect(lastNote()).toMatchObject({ kind: 'warning' });
expect(lastNote().message).toContain('Imported 1 snippet and 2 themes');
expect(lastNote().message).toContain('Brand → Brand 2');
});
it('reassigns a colliding snippet id, keeping the existing one', async () => {
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'Existing' })]);
@@ -224,8 +261,8 @@ describe('importWorkspace', () => {
expect(mockedDelete).toHaveBeenCalledTimes(1);
});
it('rolls back datasets added to the store before the snippet write failed', async () => {
// The import contains both a dataset and a snippet; the snippet write fails.
it('rolls back datasets and themes added to the store before the snippet write failed', async () => {
// The import contains a dataset, a theme, and a snippet; the snippet write fails.
mockedSave.mockRejectedValueOnce(new StorageQuotaError());
await importJson(
@@ -235,12 +272,14 @@ describe('importWorkspace', () => {
{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'S', spec: '{"mark":"bar"}' },
],
datasets: [{ id: 1, name: 'DS', data: [{ x: 1 }], format: 'json', source: 'inline' }],
themes: [{ id: 1, name: 'T', config: {} }],
}),
);
// Neither snippets nor datasets should persist in the store.
// Neither snippets, datasets, nor themes should persist in the store.
expect(useSnippetStore.getState().snippets).toHaveLength(0);
expect(useDatasetStore.getState().datasets).toHaveLength(0);
expect(useCustomThemeStore.getState().themes).toHaveLength(0);
});
it('surfaces a quota error as a clear actionable notification without a detail field', async () => {
+50 -22
View File
@@ -5,24 +5,24 @@
* in `@core/import-normalize` and `@core/export-envelope`; this layer reads the
* stores, drives the browser file adapter, commits the merge, and reports.
*
* Order matters on import (spec §08 → Merge): datasets are committed **before**
* snippets so a snippet's by-name reference resolves against the just-added
* dataset (whose name may have been auto-suffixed to avoid a clash).
* Order matters on import (spec §08 → Merge): datasets and custom themes are
* committed **before** snippets so a snippet's by-name reference resolves
* against the just-added dataset (whose name may have been auto-suffixed to
* avoid a clash).
*
* Atomicity (spec §08 → Storage limit handling / spec §10 → Non-destructive
* import): snippet writes are performed directly to IDB before touching the
* Zustand store. If any write fails the already-written IDB records are deleted
* and the just-added datasets are rolled back from the store, so neither the
* store nor IDB retains a partial import. The write-through subscriber sees
* and the just-added datasets/themes are rolled back from the store, so neither
* the store nor IDB retains a partial import. The write-through subscriber sees
* store changes only after all IDB writes succeed; because `put` is idempotent
* the second (subscriber) write of each record is a harmless no-op.
*/
import { buildExportEnvelope, exportFilename, exportSummaryMessage } from '@core/export-envelope';
import { buildExportEnvelope, exportFilename, transferSummaryMessage } from '@core/export-envelope';
import {
applyDatasetRenamesToSnippets,
dedupeIncomingDatasetNames,
importSummaryMessage,
dedupeIncomingNames,
normalizeImport,
reassignCollidingSnippetIds,
} from '@core/import-normalize';
@@ -31,6 +31,7 @@ import { humanizeBytes } from '@core/storage-estimate';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
import { notify } from '../stores/NotificationStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
@@ -48,6 +49,7 @@ export function exportWorkspace(now: Date = new Date()): void {
const snippets = useSnippetStore.getState().snippets;
const datasets = useDatasetStore.getState().datasets;
const themes = useCustomThemeStore.getState().themes;
if (snippets.length === 0) {
notify({
@@ -58,13 +60,13 @@ export function exportWorkspace(now: Date = new Date()): void {
return;
}
const envelope = buildExportEnvelope(snippets, datasets, { now });
const envelope = buildExportEnvelope(snippets, datasets, themes, { now });
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
notify({
kind: 'success',
title: 'Workspace exported',
message: exportSummaryMessage(snippets.length, datasets.length),
message: transferSummaryMessage('Exported', snippets.length, datasets.length, themes.length),
});
}
@@ -99,7 +101,11 @@ export async function importWorkspace(file: File): Promise<void> {
return;
}
const { snippets: normSnippets, datasets: normDatasets } = normalizeImport(parsed);
const {
snippets: normSnippets,
datasets: normDatasets,
themes: normThemes,
} = normalizeImport(parsed);
if (normSnippets.length === 0) {
notify({
@@ -112,23 +118,33 @@ export async function importWorkspace(file: File): Promise<void> {
// Datasets first: de-dupe their names against the library (and within the batch),
// then propagate any rename into the imported snippets so their references still
// resolve (spec §08 → Dataset conflicts; docs/architecture/07 §56).
// resolve (spec §08 → Name conflicts; docs/architecture/07 §56).
const existingDatasetNames = useDatasetStore.getState().datasets.map((d) => d.name);
const { datasets: dedupedDatasets, renames } = dedupeIncomingDatasetNames(
const { records: dedupedDatasets, renames } = dedupeIncomingNames(
existingDatasetNames,
normDatasets,
);
const renamedSnippets = applyDatasetRenamesToSnippets(normSnippets, renames);
// Themes key on a unique name too, but nothing references them by name, so a
// rename needs no propagation — only reporting (spec §08 → Name conflicts).
const existingThemeNames = useCustomThemeStore.getState().themes.map((t) => t.name);
const { records: dedupedThemes, renames: themeRenames } = dedupeIncomingNames(
existingThemeNames,
normThemes,
);
// Reassign incoming snippet ids that clash with the library (spec §08 → ID collisions).
const existingSnippetIds = useSnippetStore.getState().snippets.map((s) => s.id);
const finalSnippets = reassignCollidingSnippetIds(existingSnippetIds, renamedSnippets);
// Commit datasets BEFORE snippets so by-name references resolve (spec §08).
// Record which dataset ids existed before the add so we can roll them back if
// the subsequent snippet writes fail.
// Commit datasets and themes BEFORE snippets so by-name references resolve
// (spec §08). Record which ids existed before the add so we can roll them back
// if the subsequent snippet writes fail.
const datasetIdsBefore = new Set(useDatasetStore.getState().datasets.map((d) => d.id));
useDatasetStore.getState().addDatasets(dedupedDatasets);
const themeIdsBefore = new Set(useCustomThemeStore.getState().themes.map((t) => t.id));
useCustomThemeStore.getState().addThemes(dedupedThemes);
// Storage budget pre-check (spec §08 → Storage limit handling): warn on overage
// but still attempt the save.
@@ -156,14 +172,20 @@ export async function importWorkspace(file: File): Promise<void> {
// Roll back: delete every IDB record we already wrote.
await Promise.allSettled(writtenSnippetIds.map((id) => deleteSnippet(id)));
// Roll back: remove the datasets we just added to the store so neither
// store nor IDB retains any trace of this import.
// Roll back: remove the datasets and themes we just added to the store so
// neither store nor IDB retains any trace of this import.
const addedDatasets = useDatasetStore
.getState()
.datasets.filter((d) => !datasetIdsBefore.has(d.id));
for (const d of addedDatasets) {
useDatasetStore.getState().remove(d.id);
}
const addedThemes = useCustomThemeStore
.getState()
.themes.filter((t) => !themeIdsBefore.has(t.id));
for (const t of addedThemes) {
useCustomThemeStore.getState().remove(t.id);
}
// Surface a clear, actionable error (spec §08 "Quota failure"; NN/g #9 /
// GOV.UK plain language — no codes, tell the user what to do next).
@@ -192,13 +214,19 @@ export async function importWorkspace(file: File): Promise<void> {
// All IDB writes succeeded — now make the snippets visible in the store.
useSnippetStore.getState().addSnippets(finalSnippets);
// Feedback (spec §08 → Feedback): one summary toast; a warning when datasets were
// Feedback (spec §08 → Feedback): one summary toast; a warning when records were
// renamed or storage is over budget, otherwise a success.
const summary = importSummaryMessage(finalSnippets.length, dedupedDatasets.length);
const summary = transferSummaryMessage(
'Imported',
finalSnippets.length,
dedupedDatasets.length,
dedupedThemes.length,
);
const clauses: string[] = [];
if (renames.length > 0) {
const allRenames = [...renames, ...themeRenames];
if (allRenames.length > 0) {
clauses.push(
`Renamed to avoid clashes: ${renames.map((r) => `${r.from}${r.to}`).join(', ')}.`,
`Renamed to avoid clashes: ${allRenames.map((r) => `${r.from}${r.to}`).join(', ')}.`,
);
}
if (overage > 0) {