mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Add dataset library, extract-to-dataset, and render-time reference resolution
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import type { UiTheme } from '@core/theme';
|
||||
import type { ModalName } from '../modals/types';
|
||||
|
||||
/**
|
||||
* Centralized cross-cutting application state, as a Zustand store. Keep this
|
||||
@@ -13,9 +14,9 @@ import type { UiTheme } from '@core/theme';
|
||||
*/
|
||||
|
||||
export type { UiTheme };
|
||||
|
||||
/** Which modal, if any, is currently open. At most one at a time (spec §01C). */
|
||||
export type ModalName = 'datasets' | 'settings' | 'about' | 'donate' | 'chartBuilder' | 'extract';
|
||||
// The modal union is defined once in the modal system (docs/architecture/03) and
|
||||
// re-exported here for the many callers that reach it through the app store.
|
||||
export type { ModalName };
|
||||
|
||||
export interface AppState {
|
||||
/** Active UI theme; mirrored onto <html data-theme> by a subscriber. */
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import { createDataset } from '@core/dataset';
|
||||
import { createSnippet } from '@core/snippet';
|
||||
import { selectSelectedDataset, useDatasetStore, type DatasetForm } from './DatasetStore';
|
||||
import { useSnippetStore } from './SnippetStore';
|
||||
|
||||
const store = () => useDatasetStore.getState();
|
||||
const T = new Date('2026-06-01T00:00:00Z');
|
||||
|
||||
beforeEach(() => {
|
||||
store().reset();
|
||||
useSnippetStore.getState().reset();
|
||||
});
|
||||
|
||||
describe('save — create', () => {
|
||||
test('valid inline JSON is added, profiled, and selected', () => {
|
||||
store().startCreate();
|
||||
store().updateForm({ name: 'Sales', input: '[{"a":1,"b":2},{"a":3,"b":4}]' });
|
||||
|
||||
expect(store().save(T)).toBe(true);
|
||||
const ds = selectSelectedDataset(store());
|
||||
expect(ds?.name).toBe('Sales');
|
||||
expect(ds?.format).toBe('json');
|
||||
expect(ds?.rowCount).toBe(2);
|
||||
expect(ds?.columns).toEqual(['a', 'b']);
|
||||
expect(store().view).toBe('detail');
|
||||
});
|
||||
|
||||
test('CSV input is stored as raw text and profiled', () => {
|
||||
store().startCreate();
|
||||
store().updateForm({ name: 'Regions', input: 'city,pop\nA,10\nB,20' });
|
||||
|
||||
expect(store().save(T)).toBe(true);
|
||||
const ds = selectSelectedDataset(store());
|
||||
expect(ds?.format).toBe('csv');
|
||||
expect(ds?.data).toBe('city,pop\nA,10\nB,20');
|
||||
expect(ds?.rowCount).toBe(2);
|
||||
});
|
||||
|
||||
test('a valid URL infers format from the extension', () => {
|
||||
store().startCreate();
|
||||
store().updateForm({ name: 'Remote', source: 'url', input: 'https://example.com/data.csv' });
|
||||
|
||||
expect(store().save(T)).toBe(true);
|
||||
const ds = selectSelectedDataset(store());
|
||||
expect(ds?.source).toBe('url');
|
||||
expect(ds?.format).toBe('csv');
|
||||
expect(ds?.rowCount).toBeNull(); // URL datasets aren't profiled
|
||||
});
|
||||
});
|
||||
|
||||
describe('save — validation', () => {
|
||||
const submit = (patch: Partial<DatasetForm>) => {
|
||||
store().startCreate();
|
||||
store().updateForm(patch);
|
||||
return store().save(T);
|
||||
};
|
||||
|
||||
test('blank name is rejected', () => {
|
||||
expect(submit({ name: ' ', input: '[{"a":1}]' })).toBe(false);
|
||||
expect(store().formError).toMatch(/name/i);
|
||||
expect(store().datasets).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('duplicate name (case-insensitive) is rejected', () => {
|
||||
store().add(
|
||||
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
|
||||
);
|
||||
expect(submit({ name: 'sales', input: '[{"a":1}]' })).toBe(false);
|
||||
expect(store().formError).toMatch(/already exists/i);
|
||||
});
|
||||
|
||||
test('empty and unrecognized inline data are rejected', () => {
|
||||
expect(submit({ name: 'Empty', input: ' ' })).toBe(false);
|
||||
expect(submit({ name: 'Junk', input: 'this is not data' })).toBe(false);
|
||||
expect(store().formError).toMatch(/valid JSON/i);
|
||||
});
|
||||
|
||||
test('a non-http URL is rejected', () => {
|
||||
expect(submit({ name: 'Bad', source: 'url', input: 'ftp://x/y.csv' })).toBe(false);
|
||||
expect(store().formError).toMatch(/url/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save — edit', () => {
|
||||
test('updating inline data re-profiles and advances modified', () => {
|
||||
store().add(
|
||||
createDataset({ name: 'D', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
|
||||
);
|
||||
const id = store().selectedId!;
|
||||
store().startEdit();
|
||||
store().updateForm({ input: '[{"a":1,"b":2},{"a":3,"b":4}]' });
|
||||
|
||||
const later = new Date('2026-07-01T00:00:00Z');
|
||||
expect(store().save(later)).toBe(true);
|
||||
const ds = store().datasets.find((d) => d.id === id)!;
|
||||
expect(ds.columnCount).toBe(2);
|
||||
expect(ds.modified).toBe(later.toISOString());
|
||||
});
|
||||
|
||||
test('renaming a referenced dataset propagates into referencing snippets', () => {
|
||||
store().add(
|
||||
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
|
||||
);
|
||||
const snippet = createSnippet({
|
||||
id: 's1',
|
||||
spec: JSON.stringify({ data: { name: 'Sales' }, mark: 'bar' }),
|
||||
now: T,
|
||||
});
|
||||
useSnippetStore.getState().hydrate([snippet], 's1');
|
||||
useSnippetStore.getState().publish(T); // seed datasetRefs = ['Sales']
|
||||
|
||||
store().startEdit();
|
||||
store().updateForm({ name: 'Revenue' });
|
||||
expect(store().save(new Date('2026-08-01T00:00:00Z'))).toBe(true);
|
||||
|
||||
expect(store().datasets[0].name).toBe('Revenue');
|
||||
const s = useSnippetStore.getState().snippets.find((x) => x.id === 's1')!;
|
||||
expect(s.datasetRefs).toEqual(['Revenue']);
|
||||
expect(s.spec).toContain('"Revenue"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove & view transitions', () => {
|
||||
test('removing the selected dataset clears the selection and returns to the list', () => {
|
||||
store().add(
|
||||
createDataset({ name: 'D', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
|
||||
);
|
||||
const id = store().selectedId!;
|
||||
store().remove(id);
|
||||
expect(store().selectedId).toBeNull();
|
||||
expect(store().view).toBe('list');
|
||||
});
|
||||
|
||||
test('cancelForm returns to detail when a dataset is selected', () => {
|
||||
store().add(
|
||||
createDataset({ name: 'D', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
|
||||
);
|
||||
store().startEdit();
|
||||
expect(store().view).toBe('edit');
|
||||
store().cancelForm();
|
||||
expect(store().view).toBe('detail');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Dataset library state (spec §05, docs/architecture/01 + 07).
|
||||
*
|
||||
* Holds the durable dataset collection plus the Datasets-manager view state: which
|
||||
* dataset is selected, which pane is showing (list browse, detail, the create
|
||||
* form, or the edit form), and the in-progress create/edit form with its inline
|
||||
* validation error.
|
||||
*
|
||||
* Two layers of action live here:
|
||||
* - Low-level mutators (`add`/`update`/`remove`) are the single place the
|
||||
* `datasets` array changes; the persistence subscriber writes them through to
|
||||
* IndexedDB, and services (RelationshipService, future import) reuse them.
|
||||
* - Form orchestration (`save`) validates the form (name required + unique,
|
||||
* data detectable) and turns it into an `add`/`update`, keeping the modal
|
||||
* component thin.
|
||||
*
|
||||
* Persistence is NOT done here — a startup subscriber observes this store and
|
||||
* writes through to the IndexedDB adapter, so the store stays browser-free.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { detectFormat, detectFormatFromUrl, type DataFormat } from '@core/format-detection';
|
||||
import { createDataset, computeDatasetProfile, type DataSource, type Dataset } from '@core/dataset';
|
||||
import { isNameTaken } from '@core/naming';
|
||||
import { useSnippetStore } from './SnippetStore';
|
||||
|
||||
/** Which pane the Datasets manager is showing (spec §05 → Layout). */
|
||||
export type DatasetView = 'list' | 'detail' | 'new' | 'edit';
|
||||
|
||||
/** The in-progress create/edit form. `input` is the paste area (inline) or URL field. */
|
||||
export interface DatasetForm {
|
||||
name: string;
|
||||
source: DataSource;
|
||||
input: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: DatasetForm = { name: '', source: 'inline', input: '', comment: '' };
|
||||
|
||||
export interface DatasetState {
|
||||
datasets: Dataset[];
|
||||
selectedId: number | null;
|
||||
view: DatasetView;
|
||||
form: DatasetForm;
|
||||
/** Inline validation message for the create/edit form, or null when valid. */
|
||||
formError: string | null;
|
||||
|
||||
/** Replace the library from storage. */
|
||||
hydrate: (datasets: Dataset[]) => void;
|
||||
/** Select a dataset (→ detail), or clear the selection (→ list). */
|
||||
select: (id: number | null) => void;
|
||||
/** Set the manager view directly (used by the modal's own navigation). */
|
||||
setView: (view: DatasetView) => void;
|
||||
/** Open the create form with an empty draft. */
|
||||
startCreate: () => void;
|
||||
/** Open the edit form pre-filled from the selected dataset. */
|
||||
startEdit: () => void;
|
||||
/** Patch the in-progress form (clears any stale error). */
|
||||
updateForm: (patch: Partial<DatasetForm>) => void;
|
||||
/** Leave the form, returning to the selected detail or the list. */
|
||||
cancelForm: () => void;
|
||||
/**
|
||||
* Validate and commit the current form: creates a new dataset (view `new`) or
|
||||
* updates the selected one (view `edit`), re-profiling its data and renaming
|
||||
* referencing snippets when an edit changes the name. Returns whether it
|
||||
* committed; on failure `formError` is set. `now` is injectable for tests.
|
||||
*/
|
||||
save: (now?: Date) => boolean;
|
||||
|
||||
/** Low-level: add a fully-formed dataset and select it. */
|
||||
add: (dataset: Dataset) => void;
|
||||
/** Low-level: merge a patch into a dataset, advancing `modified`. */
|
||||
update: (id: number, patch: Partial<Dataset>, now?: Date) => void;
|
||||
/** Low-level: remove a dataset; clears the selection if it was selected. */
|
||||
remove: (id: number) => void;
|
||||
|
||||
/** Reset to initial state (tests, future "new workspace"). */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/** Newest-modified first — the manager's default ordering (spec §05 → Layout). */
|
||||
export function byModifiedDesc(a: Dataset, b: Dataset): number {
|
||||
return b.modified.localeCompare(a.modified);
|
||||
}
|
||||
|
||||
/** Validate a form into a saveable shape, or return an error message. */
|
||||
function resolveForm(
|
||||
form: DatasetForm,
|
||||
datasets: Dataset[],
|
||||
excludeId: number | undefined,
|
||||
): { name: string; data: unknown; format: DataFormat; source: DataSource } | { error: string } {
|
||||
// Error copy follows the council resolution (docs/architecture/10 §error copy →
|
||||
// GOV.UK error-message): action-oriented, specific, and says how to fix it.
|
||||
const name = form.name.trim();
|
||||
if (name === '') return { error: 'Enter a dataset name.' };
|
||||
if (isNameTaken(name, datasets, excludeId)) {
|
||||
return { error: `A dataset named "${name}" already exists. Choose a different name.` };
|
||||
}
|
||||
|
||||
const input = form.input.trim();
|
||||
if (input === '') {
|
||||
return {
|
||||
error: form.source === 'url' ? 'Enter a URL.' : 'Paste JSON, CSV, or TSV data to save.',
|
||||
};
|
||||
}
|
||||
|
||||
if (form.source === 'url') {
|
||||
if (!/^https?:\/\//i.test(input)) {
|
||||
return { error: 'Enter a URL starting with http:// or https://.' };
|
||||
}
|
||||
// Format is inferred from the extension; default to JSON when unknown (§05).
|
||||
return { name, data: input, format: detectFormatFromUrl(input) ?? 'json', source: 'url' };
|
||||
}
|
||||
|
||||
// Inline: the data must auto-detect to a known format (spec §05 → Auto-detection).
|
||||
const { format } = detectFormat(form.input);
|
||||
if (!format) {
|
||||
return { error: 'Enter valid JSON, CSV, or TSV data.' };
|
||||
}
|
||||
// JSON/TopoJSON are stored parsed; CSV/TSV keep their raw text (data model §09B).
|
||||
const data =
|
||||
format === 'json' || format === 'topojson' ? (JSON.parse(form.input) as unknown) : form.input;
|
||||
return { name, data, format, source: 'inline' };
|
||||
}
|
||||
|
||||
export const useDatasetStore = create<DatasetState>((set, get) => ({
|
||||
datasets: [],
|
||||
selectedId: null,
|
||||
view: 'list',
|
||||
form: EMPTY_FORM,
|
||||
formError: null,
|
||||
|
||||
hydrate: (datasets) => set({ datasets }),
|
||||
|
||||
select: (id) =>
|
||||
set({
|
||||
selectedId: id,
|
||||
view: id === null ? 'list' : 'detail',
|
||||
form: EMPTY_FORM,
|
||||
formError: null,
|
||||
}),
|
||||
|
||||
setView: (view) => set({ view }),
|
||||
|
||||
startCreate: () => set({ view: 'new', form: EMPTY_FORM, formError: null }),
|
||||
|
||||
startEdit: () => {
|
||||
const { datasets, selectedId } = get();
|
||||
const ds = datasets.find((d) => d.id === selectedId);
|
||||
if (!ds) return;
|
||||
set({
|
||||
view: 'edit',
|
||||
formError: null,
|
||||
form: {
|
||||
name: ds.name,
|
||||
source: ds.source,
|
||||
comment: ds.comment,
|
||||
// Re-render the stored payload as editable text: raw for csv/tsv/url,
|
||||
// pretty-printed JSON for json/topojson.
|
||||
input:
|
||||
ds.source === 'url' || ds.format === 'csv' || ds.format === 'tsv'
|
||||
? String(ds.data)
|
||||
: JSON.stringify(ds.data, null, 2),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
updateForm: (patch) => set((s) => ({ form: { ...s.form, ...patch }, formError: null })),
|
||||
|
||||
cancelForm: () =>
|
||||
set((s) => ({
|
||||
view: s.selectedId === null ? 'list' : 'detail',
|
||||
form: EMPTY_FORM,
|
||||
formError: null,
|
||||
})),
|
||||
|
||||
save: (now) => {
|
||||
const { view, form, datasets, selectedId } = get();
|
||||
const editing = view === 'edit';
|
||||
const excludeId = editing ? (selectedId ?? undefined) : undefined;
|
||||
|
||||
const resolved = resolveForm(form, datasets, excludeId);
|
||||
if ('error' in resolved) {
|
||||
set({ formError: resolved.error });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (editing && selectedId !== null) {
|
||||
const existing = datasets.find((d) => d.id === selectedId);
|
||||
if (!existing) return false;
|
||||
const profile = computeDatasetProfile(resolved.data, resolved.format, resolved.source);
|
||||
// Re-profile and update the record (including any new name), then propagate
|
||||
// the rename across referencing snippets so each spec and its datasetRefs
|
||||
// stay consistent (docs/architecture/07 §6). SnippetStore never imports this
|
||||
// store, so the direct call is cycle-free.
|
||||
get().update(
|
||||
selectedId,
|
||||
{
|
||||
name: resolved.name,
|
||||
data: resolved.data,
|
||||
format: resolved.format,
|
||||
source: resolved.source,
|
||||
comment: form.comment,
|
||||
...profile,
|
||||
},
|
||||
now,
|
||||
);
|
||||
if (resolved.name !== existing.name) {
|
||||
useSnippetStore.getState().renameDatasetRefs(existing.name, resolved.name, now);
|
||||
}
|
||||
set({ view: 'detail', form: EMPTY_FORM, formError: null });
|
||||
return true;
|
||||
}
|
||||
|
||||
const dataset = createDataset({
|
||||
name: resolved.name,
|
||||
data: resolved.data,
|
||||
format: resolved.format,
|
||||
source: resolved.source,
|
||||
comment: form.comment,
|
||||
now,
|
||||
});
|
||||
get().add(dataset);
|
||||
set({ view: 'detail', form: EMPTY_FORM, formError: null });
|
||||
return true;
|
||||
},
|
||||
|
||||
// TODO: createDataset ids default to `Date.now()` and `add` does not reassign
|
||||
// on collision (despite that file's comment). Interactive create is safe, but
|
||||
// the documented non-interactive import path (naming.ts) creates many datasets
|
||||
// in a tight loop where `Date.now()` repeats — duplicate numeric keys would
|
||||
// collide in IndexedDB. Give the store a monotonic id source (or reassign here)
|
||||
// before wiring import.
|
||||
add: (dataset) => set((s) => ({ datasets: [dataset, ...s.datasets], selectedId: dataset.id })),
|
||||
|
||||
update: (id, patch, now) => {
|
||||
const modified = patch.modified ?? (now ?? new Date()).toISOString();
|
||||
set((s) => ({
|
||||
datasets: s.datasets.map((d) => (d.id === id ? { ...d, ...patch, modified } : d)),
|
||||
}));
|
||||
},
|
||||
|
||||
remove: (id) =>
|
||||
set((s) => ({
|
||||
datasets: s.datasets.filter((d) => d.id !== id),
|
||||
selectedId: s.selectedId === id ? null : s.selectedId,
|
||||
view: s.selectedId === id ? 'list' : s.view,
|
||||
form: s.selectedId === id ? EMPTY_FORM : s.form,
|
||||
})),
|
||||
|
||||
reset: () =>
|
||||
set({ datasets: [], selectedId: null, view: 'list', form: EMPTY_FORM, formError: null }),
|
||||
}));
|
||||
|
||||
/** Selector: the selected dataset record, or null. Derive — never store. */
|
||||
export const selectSelectedDataset = (s: DatasetState): Dataset | null =>
|
||||
s.datasets.find((d) => d.id === s.selectedId) ?? null;
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Extract-inline-data → Dataset state (spec §03F).
|
||||
*
|
||||
* Backs the Extract modal: the reverse of a named reference. It captures the
|
||||
* active snippet draft's inline data, takes a dataset name, and on confirm saves
|
||||
* the data as a new dataset and rewrites the draft so the inline data is replaced
|
||||
* by a by-name reference (`{ "data": { "name": … } }`).
|
||||
*
|
||||
* Scope (M3): the **top-level** `data` block of the draft spec — the common case
|
||||
* for a single-view chart. Inline data nested inside layers/concats is left for a
|
||||
* later pass; `hasInlineData` reflects exactly what `confirm` can lift, so the
|
||||
* editor only offers the action when this store can act on it.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { DataFormat } from '@core/format-detection';
|
||||
import { createDataset } from '@core/dataset';
|
||||
import { isNameTaken } from '@core/naming';
|
||||
import { useDatasetStore } from './DatasetStore';
|
||||
import { useSnippetStore } from './SnippetStore';
|
||||
|
||||
/** The inline `data` block of a parsed spec, if it carries `values`. */
|
||||
interface InlineData {
|
||||
values: unknown;
|
||||
format: DataFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the top-level inline data from a draft spec's text, or null when there is
|
||||
* none (no snippet, unparseable, or no `data.values`). The format comes from an
|
||||
* explicit `data.format.type` when present (raw CSV/TSV strings), else JSON.
|
||||
*/
|
||||
export function readInlineData(draftText: string): InlineData | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(draftText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const data = (parsed as Record<string, unknown>).data;
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const values = (data as Record<string, unknown>).values;
|
||||
if (values === undefined) return null;
|
||||
const declared = (data as Record<string, unknown>).format;
|
||||
const type =
|
||||
declared && typeof declared === 'object'
|
||||
? (declared as Record<string, unknown>).type
|
||||
: undefined;
|
||||
const format: DataFormat =
|
||||
type === 'csv' || type === 'tsv' || type === 'topojson' ? type : 'json';
|
||||
return { values, format };
|
||||
}
|
||||
|
||||
/** True when the active snippet's draft has top-level inline data to extract. */
|
||||
export function hasInlineData(draftText: string): boolean {
|
||||
return readInlineData(draftText) !== null;
|
||||
}
|
||||
|
||||
export interface ExtractState {
|
||||
/** Proposed dataset name (required, unique). */
|
||||
name: string;
|
||||
/** The inline data captured at open, for the read-only preview. */
|
||||
source: InlineData | null;
|
||||
/** Inline validation message, or null. */
|
||||
error: string | null;
|
||||
|
||||
/** Capture the active snippet's inline data and reset the form. */
|
||||
init: () => void;
|
||||
setName: (name: string) => void;
|
||||
/**
|
||||
* Validate, create the dataset, and rewrite the active draft to reference it by
|
||||
* name. Returns whether it committed; on failure `error` is set. `now`
|
||||
* injectable for tests.
|
||||
*/
|
||||
confirm: (now?: Date) => boolean;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const INITIAL = { name: '', source: null as InlineData | null, error: null as string | null };
|
||||
|
||||
export const useExtractStore = create<ExtractState>((set, get) => ({
|
||||
...INITIAL,
|
||||
|
||||
init: () => {
|
||||
const draft = useSnippetStore.getState().draftText;
|
||||
set({ name: '', source: readInlineData(draft), error: null });
|
||||
},
|
||||
|
||||
setName: (name) => set({ name, error: null }),
|
||||
|
||||
confirm: (now) => {
|
||||
const name = get().name.trim();
|
||||
if (name === '') {
|
||||
set({ error: 'Enter a dataset name.' });
|
||||
return false;
|
||||
}
|
||||
if (isNameTaken(name, useDatasetStore.getState().datasets)) {
|
||||
set({ error: `A dataset named "${name}" already exists. Choose a different name.` });
|
||||
return false;
|
||||
}
|
||||
const source = get().source;
|
||||
if (!source) {
|
||||
set({ error: 'No inline data to extract.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON/TopoJSON store the parsed value; CSV/TSV keep raw text — but inline
|
||||
// `values` is already the right runtime shape for each, so store it directly.
|
||||
const dataset = createDataset({
|
||||
name,
|
||||
data: source.values,
|
||||
format: source.format,
|
||||
source: 'inline',
|
||||
now,
|
||||
});
|
||||
useDatasetStore.getState().add(dataset);
|
||||
|
||||
// Rewrite the top-level data block to a by-name reference, preserving the rest
|
||||
// of the spec and its pretty-printed text shape.
|
||||
const draftText = useSnippetStore.getState().draftText;
|
||||
const spec = JSON.parse(draftText) as Record<string, unknown>;
|
||||
spec.data = { name };
|
||||
useSnippetStore.getState().replaceActiveDraft(JSON.stringify(spec, null, 2), now);
|
||||
|
||||
// TODO (M6, spec §03F): success toast "Dataset created" — deferred with the
|
||||
// other success toasts (see SnippetStore publish/revert breadcrumbs).
|
||||
set(INITIAL);
|
||||
return true;
|
||||
},
|
||||
|
||||
reset: () => set(INITIAL),
|
||||
}));
|
||||
@@ -224,3 +224,92 @@ describe('editorView + selectShownText (spec §03D)', () => {
|
||||
expect(store().editorView).toBe('draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish — datasetRefs recomputation', () => {
|
||||
const refSpec = (name: string) => JSON.stringify({ data: { name }, mark: 'bar' });
|
||||
|
||||
test('publishing recomputes datasetRefs from the now-published spec', () => {
|
||||
const a = createSnippet({ id: 'a', spec: '{}', now: new Date('2026-01-01T00:00:00Z') });
|
||||
store().hydrate([a], 'a');
|
||||
store().updateDraft(refSpec('Sales'));
|
||||
store().publish(new Date('2026-02-01T00:00:00Z'));
|
||||
|
||||
expect(selectActiveSnippet(store())?.datasetRefs).toEqual(['Sales']);
|
||||
});
|
||||
|
||||
test('refs drop when a published spec no longer references a dataset', () => {
|
||||
const a = createSnippet({
|
||||
id: 'a',
|
||||
spec: refSpec('Sales'),
|
||||
now: new Date('2026-01-01T00:00:00Z'),
|
||||
});
|
||||
store().hydrate([a], 'a');
|
||||
// The factory leaves datasetRefs empty until a publish runs; publish to seed it.
|
||||
store().publish(new Date('2026-01-02T00:00:00Z'));
|
||||
expect(selectActiveSnippet(store())?.datasetRefs).toEqual(['Sales']);
|
||||
|
||||
store().updateDraft('{"mark":"bar"}');
|
||||
store().publish(new Date('2026-02-01T00:00:00Z'));
|
||||
expect(selectActiveSnippet(store())?.datasetRefs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameDatasetRefs', () => {
|
||||
const refSpec = (name: string) => JSON.stringify({ data: { name }, mark: 'bar' }, null, 2);
|
||||
|
||||
test('rewrites spec, draftSpec, and refs of referencing snippets only', () => {
|
||||
const a = createSnippet({
|
||||
id: 'a',
|
||||
spec: refSpec('Sales'),
|
||||
now: new Date('2026-01-01T00:00:00Z'),
|
||||
});
|
||||
const b = createSnippet({
|
||||
id: 'b',
|
||||
spec: '{"mark":"line"}',
|
||||
now: new Date('2026-01-02T00:00:00Z'),
|
||||
});
|
||||
store().hydrate([a, b], 'b');
|
||||
store().publish(new Date('2026-01-03T00:00:00Z')); // seed a's refs would need a active; seed via select
|
||||
store().selectSnippet('a');
|
||||
store().publish(new Date('2026-01-04T00:00:00Z'));
|
||||
|
||||
const updated = store().renameDatasetRefs('Sales', 'Revenue', new Date('2026-02-01T00:00:00Z'));
|
||||
|
||||
expect(updated).toBe(1);
|
||||
const renamed = store().snippets.find((s) => s.id === 'a')!;
|
||||
expect(renamed.datasetRefs).toEqual(['Revenue']);
|
||||
expect(renamed.spec).toContain('"Revenue"');
|
||||
expect(renamed.draftSpec).toContain('"Revenue"');
|
||||
// The non-referencing snippet is untouched.
|
||||
expect(store().snippets.find((s) => s.id === 'b')!.spec).toBe('{"mark":"line"}');
|
||||
});
|
||||
|
||||
test('refreshes the active draft buffer when the active snippet is rewritten', () => {
|
||||
const a = createSnippet({
|
||||
id: 'a',
|
||||
spec: refSpec('Sales'),
|
||||
now: new Date('2026-01-01T00:00:00Z'),
|
||||
});
|
||||
store().hydrate([a], 'a');
|
||||
store().publish(new Date('2026-01-02T00:00:00Z'));
|
||||
const epochBefore = store().bufferEpoch;
|
||||
|
||||
store().renameDatasetRefs('Sales', 'Revenue', new Date('2026-02-01T00:00:00Z'));
|
||||
|
||||
expect(store().draftText).toContain('"Revenue"');
|
||||
expect(store().bufferEpoch).toBe(epochBefore + 1);
|
||||
});
|
||||
|
||||
test('no-ops when the names are equal or nothing references the old name', () => {
|
||||
const a = createSnippet({
|
||||
id: 'a',
|
||||
spec: refSpec('Sales'),
|
||||
now: new Date('2026-01-01T00:00:00Z'),
|
||||
});
|
||||
store().hydrate([a], 'a');
|
||||
store().publish(new Date('2026-01-02T00:00:00Z'));
|
||||
|
||||
expect(store().renameDatasetRefs('Sales', 'Sales')).toBe(0);
|
||||
expect(store().renameDatasetRefs('Unknown', 'Other')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { createSnippet, type CreateSnippetOptions, type Snippet } from '@core/snippet';
|
||||
import { recomputeDatasetRefs, renameDatasetInSpec } from '@core/spec-refs';
|
||||
|
||||
/** Which version of the active snippet the editor is showing (spec §03D). */
|
||||
export type EditorView = 'draft' | 'published';
|
||||
@@ -48,6 +49,13 @@ export interface SnippetState {
|
||||
removeSnippet: (id: string) => void;
|
||||
/** Update the draft buffer only (no persistence; debounced commit follows). */
|
||||
updateDraft: (text: string) => void;
|
||||
/**
|
||||
* Replace the active snippet's draft spec with new text and reload the editor
|
||||
* on the draft view (bumps `bufferEpoch`). Used by programmatic rewrites such
|
||||
* as Extract-to-Dataset (spec §03F), which substitutes inline data for a
|
||||
* by-name reference. No-op when no snippet is active. `now` injectable.
|
||||
*/
|
||||
replaceActiveDraft: (text: string, now?: Date) => void;
|
||||
/**
|
||||
* Persist the editor buffer into the active snippet's **draft**, if it parses
|
||||
* as JSON. Returns whether it committed (a half-typed, unparseable buffer is
|
||||
@@ -57,6 +65,14 @@ export interface SnippetState {
|
||||
commitDraft: (now?: Date) => boolean;
|
||||
/** Switch the editor between the draft and published views (spec §03D). */
|
||||
setEditorView: (view: EditorView) => void;
|
||||
/**
|
||||
* Propagate a dataset rename across every referencing snippet: rewrite the
|
||||
* named-data references in both `spec` and `draftSpec`, recompute `datasetRefs`,
|
||||
* and refresh the live editor buffer if the active snippet was rewritten — so a
|
||||
* user mid-edit doesn't see their draft silently break (docs/architecture/07
|
||||
* §6). Returns the number of snippets changed. `now` injectable.
|
||||
*/
|
||||
renameDatasetRefs: (oldName: string, newName: string, now?: Date) => number;
|
||||
/**
|
||||
* Promote the active snippet's current draft to its published version (spec
|
||||
* §03D → Publish). Flushes the live buffer first, then makes `spec` identical
|
||||
@@ -151,6 +167,20 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
|
||||
updateDraft: (draftText) => set({ draftText }),
|
||||
|
||||
replaceActiveDraft: (text, now) => {
|
||||
const { activeSnippetId } = get();
|
||||
if (!activeSnippetId) return;
|
||||
const modified = (now ?? new Date()).toISOString();
|
||||
set((s) => ({
|
||||
snippets: s.snippets.map((x) =>
|
||||
x.id === activeSnippetId ? { ...x, draftSpec: text, modified } : x,
|
||||
),
|
||||
draftText: text,
|
||||
editorView: 'draft',
|
||||
bufferEpoch: s.bufferEpoch + 1,
|
||||
}));
|
||||
},
|
||||
|
||||
commitDraft: (now) => {
|
||||
const { activeSnippetId, draftText, snippets } = get();
|
||||
if (!activeSnippetId) return false;
|
||||
@@ -178,6 +208,43 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
|
||||
setEditorView: (editorView) => set({ editorView }),
|
||||
|
||||
renameDatasetRefs: (oldName, newName, now) => {
|
||||
if (oldName === newName) return 0;
|
||||
const lower = oldName.toLowerCase();
|
||||
const { snippets, activeSnippetId, editorView } = get();
|
||||
let updated = 0;
|
||||
let activeDraftAfter: string | null = null;
|
||||
|
||||
const next = snippets.map((s) => {
|
||||
if (!s.datasetRefs.some((r) => r.toLowerCase() === lower)) return s;
|
||||
updated++;
|
||||
const spec = renameDatasetInSpec(s.spec, oldName, newName);
|
||||
const draftSpec = renameDatasetInSpec(s.draftSpec, oldName, newName);
|
||||
if (s.id === activeSnippetId) activeDraftAfter = draftSpec;
|
||||
return {
|
||||
...s,
|
||||
spec,
|
||||
draftSpec,
|
||||
datasetRefs: recomputeDatasetRefs(spec),
|
||||
modified: (now ?? new Date()).toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
if (updated === 0) return 0;
|
||||
set((st) => ({
|
||||
snippets: next,
|
||||
// If the active snippet's draft was rewritten, reload the editor buffer so
|
||||
// the live (draft-view) buffer reflects the new name and the next auto-save
|
||||
// doesn't clobber the rewrite with the stale old name.
|
||||
...(activeDraftAfter !== null && editorView === 'draft'
|
||||
? { draftText: activeDraftAfter, bufferEpoch: st.bufferEpoch + 1 }
|
||||
: activeDraftAfter !== null
|
||||
? { bufferEpoch: st.bufferEpoch + 1 }
|
||||
: {}),
|
||||
}));
|
||||
return updated;
|
||||
},
|
||||
|
||||
publish: (now) => {
|
||||
// Flush the live buffer into the draft first, so Publish promotes exactly
|
||||
// what the user sees (an invalid buffer leaves the last valid draft in place).
|
||||
@@ -189,8 +256,15 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
set({
|
||||
snippets: snippets.map((s) =>
|
||||
s.id === activeSnippetId
|
||||
? // M3: recompute datasetRefs from the now-published spec (spec §03D).
|
||||
{ ...s, spec: s.draftSpec, modified }
|
||||
? // Promote the draft and recompute datasetRefs from the now-published
|
||||
// spec, so the bidirectional snippet↔dataset link mirrors reality
|
||||
// (spec §03D, docs/architecture/07 §3).
|
||||
{
|
||||
...s,
|
||||
spec: s.draftSpec,
|
||||
datasetRefs: recomputeDatasetRefs(s.draftSpec),
|
||||
modified,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user