mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Workspace transfer: custom themes ride the export/import envelope
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 §5–6).
|
||||
// resolve (spec §08 → Name conflicts; docs/architecture/07 §5–6).
|
||||
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) {
|
||||
|
||||
@@ -37,6 +37,33 @@ describe('createTheme', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('addThemes (import batch)', () => {
|
||||
it('reassigns ids from the store id authority and appends', () => {
|
||||
const existing = store().createTheme('Existing', {});
|
||||
store().addThemes([
|
||||
{ id: existing.id, version: 1, name: 'A', config: {}, created: 'x', modified: 'x' },
|
||||
{ id: existing.id, version: 1, name: 'B', config: {}, created: 'x', modified: 'x' },
|
||||
]);
|
||||
const themes = store().themes;
|
||||
expect(themes.map((t) => t.name)).toEqual(['Existing', 'A', 'B']);
|
||||
expect(new Set(themes.map((t) => t.id)).size).toBe(3);
|
||||
});
|
||||
|
||||
it('does not touch the builder selection or draft', () => {
|
||||
store().createTheme('Open', {});
|
||||
const before = store().selectedId;
|
||||
store().addThemes([{ id: 9, version: 1, name: 'A', config: {}, created: 'x', modified: 'x' }]);
|
||||
expect(store().selectedId).toBe(before);
|
||||
expect(store().draft?.name).toBe('Open');
|
||||
});
|
||||
|
||||
it('is a no-op for an empty batch (no state churn)', () => {
|
||||
const before = store().themes;
|
||||
store().addThemes([]);
|
||||
expect(store().themes).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('draft editing', () => {
|
||||
beforeEach(() => store().createTheme('Brand', { font: 'Helvetica' }));
|
||||
|
||||
|
||||
@@ -72,6 +72,12 @@ export interface CustomThemeState {
|
||||
|
||||
/** Low-level: add a fully-formed theme and select it. Returns the record with its assigned id. */
|
||||
add: (theme: CustomTheme) => CustomTheme;
|
||||
/**
|
||||
* Low-level batch add (import path): reassign ids from the store's id
|
||||
* authority and append, WITHOUT touching the builder selection — an import
|
||||
* must not hijack an open Theme Builder draft.
|
||||
*/
|
||||
addThemes: (incoming: CustomTheme[]) => void;
|
||||
/** Low-level: merge a patch into a theme, advancing `modified`. */
|
||||
update: (id: number, patch: Partial<CustomTheme>, now?: Date) => void;
|
||||
/**
|
||||
@@ -208,6 +214,15 @@ export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
|
||||
return withId;
|
||||
},
|
||||
|
||||
addThemes: (incoming) => {
|
||||
if (incoming.length === 0) return;
|
||||
set((s) => {
|
||||
let nextId = nextThemeId(s.themes);
|
||||
const withIds = incoming.map((t) => ({ ...t, id: nextId++ }));
|
||||
return { themes: [...s.themes, ...withIds] };
|
||||
});
|
||||
},
|
||||
|
||||
update: (id, patch, now) => {
|
||||
const modified = patch.modified ?? (now ?? new Date()).toISOString();
|
||||
set((s) => ({
|
||||
|
||||
Reference in New Issue
Block a user