/** * 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 (e.g. the import flow in services/transfer) 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, type DataFormat } from '@core/format-detection'; import { createDataset, computeDatasetProfile, snapshotFromText, 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). */ 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) => 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; /** * Commit a URL dataset from an already-fetched body. The component performs the * network fetch via the remote-data adapter and passes the result here, keeping * this store browser-free. Validates the form, snapshots + profiles the body, and * creates (view `new`) or updates (view `edit`) the dataset — including renaming * referencing snippets on an edit. Returns whether it committed. */ commitUrlSnapshot: (fetched: { text: string }, now?: Date) => boolean; /** * Re-snapshot an existing URL dataset from a freshly-fetched body ("Refresh"): * re-detect the format, re-profile, and advance `fetchedAt`/`modified`. Name, * comment, and URL are preserved. Returns false for a non-URL dataset. */ refreshDataset: (id: number, fetched: { text: string }, now?: Date) => boolean; /** * Low-level: add a fully-formed dataset and select it. The id is (re)assigned * via `nextDatasetId`, so a `createDataset` default id (`Date.now()`) can never * reach storage — see `nextDatasetId` for why that matters. */ add: (dataset: Dataset) => void; /** * Append imported datasets (spec §08 → datasets imported before snippets). Each * is given a fresh monotonic numeric id (`nextDatasetId`, the same authority as * `add`) so a batch never collides on the `Date.now()` default nor with existing * ids — safe because datasets are referenced by **name**, not id * (docs/architecture/07 §1). Names are assumed already de-duped by the import * service. Selection is unchanged. */ addDatasets: (incoming: Dataset[]) => void; /** Low-level: merge a patch into a dataset, advancing `modified`. */ update: (id: number, patch: Partial, 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); } /** * Next free numeric id for a dataset: one past the current maximum. This store is * the **single id authority** — both single (`add`) and batch (`addDatasets`) * insertion route through it — so a `createDataset` default id (`Date.now()`) * never reaches IndexedDB, where a tight creation loop (the documented import path) * could repeat the same millisecond and collide on the numeric key. Dataset ids * are an internal IndexedDB key only — snippets reference datasets by **name** * (docs/architecture/07 §1) — so reusing an id freed by a deletion is harmless, * which is why one-past-the-max suffices without a persistent counter. */ function nextDatasetId(datasets: ReadonlyArray): number { return datasets.reduce((max, d) => Math.max(max, d.id), 0) + 1; } /** * Cheap form validation: returns an error message, or `null` when the form is * saveable. Deliberately does **not** `JSON.parse` the input — it only checks * name/input presence, name uniqueness, and that a format is detectable — so it * is safe to run on every render to drive the Save button's disabled state * (spec §05). Error copy follows the council resolution (docs/architecture/10 * §error copy → GOV.UK error-message): action-oriented, specific, says how to fix. */ function validateForm( form: DatasetForm, datasets: Dataset[], excludeId: number | undefined, ): string | null { const name = form.name.trim(); if (name === '') return 'Enter a dataset name.'; if (isNameTaken(name, datasets, excludeId)) { return `A dataset named "${name}" already exists. Choose a different name.`; } const input = form.input.trim(); if (input === '') { return form.source === 'url' ? 'Enter a URL.' : 'Paste JSON, CSV, or TSV data to save.'; } if (form.source === 'url') { if (!/^https?:\/\//i.test(input)) return 'Enter a URL starting with http:// or https://.'; return null; } // Inline: the data must auto-detect to a known format (spec §05 → Auto-detection). if (!detectFormat(form.input).format) return 'Enter valid JSON, CSV, or TSV data.'; return null; } /** * Validate an **inline** form into a saveable shape, or return an error message. * URL datasets never reach here — they are fetched first and committed through * `commitUrlSnapshot` (which snapshots the fetched body), so this only shapes the * pasted-inline payload. */ function resolveForm( form: DatasetForm, datasets: Dataset[], excludeId: number | undefined, ): { name: string; data: unknown; format: DataFormat; source: DataSource } | { error: string } { const error = validateForm(form, datasets, excludeId); if (error) return { error }; // validateForm guaranteed a detectable format above. const format = detectFormat(form.input).format as DataFormat; // 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: form.name.trim(), data, format, source: 'inline' }; } export const useDatasetStore = create((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 editable text: the URL for url datasets, raw text for // csv/tsv, pretty-printed JSON for json/topojson. input: ds.source === 'url' ? (ds.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; // URL datasets that need the network — a create, a URL change, or an inline→URL // conversion — are routed through `commitUrlSnapshot` after the component // fetches; save() never fetches. The one URL case it commits is a metadata-only // edit of an existing snapshot (same URL): just update name/comment, no re-fetch. if (form.source === 'url') { const error = validateForm(form, datasets, excludeId); if (error) { set({ formError: error }); return false; } if (!editing || selectedId === null) return false; const existing = datasets.find((d) => d.id === selectedId); if (!existing || existing.source !== 'url' || form.input.trim() !== (existing.url ?? '')) { return false; } const name = form.name.trim(); get().update(selectedId, { name, comment: form.comment }, now); if (name !== existing.name) { useSnippetStore.getState().renameDatasetRefs(existing.name, name, now); } set({ view: 'detail', form: EMPTY_FORM, formError: null }); return true; } 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); // 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. Clear any url/fetchedAt left over // from a URL→inline conversion so the record carries no stale remote origin. get().update( selectedId, { name: resolved.name, data: resolved.data, format: resolved.format, source: resolved.source, // A URL→inline conversion must shed the remote origin; `update` deletes // keys set to `undefined`, so these are removed rather than left // present-but-undefined. url: undefined, fetchedAt: undefined, 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); // Switching to the detail view with the new dataset selected IS the success // confirmation, so no toast is raised — the result is on-screen (spec §05; // docs/architecture/10 → Toast copy). Extract-to-dataset, which creates a // dataset off-screen, does toast (see ExtractStore). set({ view: 'detail', form: EMPTY_FORM, formError: null }); return true; }, commitUrlSnapshot: (fetched, now) => { const { view, form, datasets, selectedId } = get(); const editing = view === 'edit'; const excludeId = editing ? (selectedId ?? undefined) : undefined; const error = validateForm(form, datasets, excludeId); if (error) { set({ formError: error }); return false; } const name = form.name.trim(); const url = form.input.trim(); const iso = (now ?? new Date()).toISOString(); // The fetched body snapshots + profiles exactly like inline data (snapshot // model): detect format from content, shape, and profile through core. const { data, format } = snapshotFromText(fetched.text, url); if (editing && selectedId !== null) { const existing = datasets.find((d) => d.id === selectedId); if (!existing) return false; const profile = computeDatasetProfile(data, format); get().update( selectedId, { name, data, format, source: 'url', url, fetchedAt: iso, comment: form.comment, ...profile, }, now, ); if (name !== existing.name) { useSnippetStore.getState().renameDatasetRefs(existing.name, name, now); } set({ view: 'detail', form: EMPTY_FORM, formError: null }); return true; } const dataset = createDataset({ name, data, format, source: 'url', url, fetchedAt: iso, comment: form.comment, now, }); get().add(dataset); set({ view: 'detail', form: EMPTY_FORM, formError: null }); return true; }, refreshDataset: (id, fetched, now) => { const dataset = get().datasets.find((d) => d.id === id); if (!dataset || dataset.source !== 'url' || !dataset.url) return false; const iso = (now ?? new Date()).toISOString(); const { data, format } = snapshotFromText(fetched.text, dataset.url); const profile = computeDatasetProfile(data, format); // Name, comment, and url are preserved; only the snapshot + its profile and the // fetch time change. `update` advances `modified`. get().update(id, { data, format, fetchedAt: iso, ...profile }, now); return true; }, add: (dataset) => set((s) => { const withId = { ...dataset, id: nextDatasetId(s.datasets) }; return { datasets: [withId, ...s.datasets], selectedId: withId.id }; }), addDatasets: (incoming) => { if (incoming.length === 0) return; set((s) => { let nextId = nextDatasetId(s.datasets); const withIds = incoming.map((d) => ({ ...d, id: nextId++ })); return { datasets: [...withIds, ...s.datasets] }; }); }, update: (id, patch, now) => { const modified = patch.modified ?? (now ?? new Date()).toISOString(); set((s) => ({ datasets: s.datasets.map((d) => { if (d.id !== id) return d; const merged = { ...d, ...patch, modified }; // A patch key set to `undefined` removes it, rather than leaving a // present-but-undefined key — so a URL→inline conversion drops `url`/ // `fetchedAt` cleanly, keeping the "inline records carry no remote-origin // keys" invariant (docs/architecture/07 §6). for (const key of Object.keys(patch) as (keyof Dataset)[]) { if (patch[key] === undefined) delete merged[key]; } return merged; }), })); }, 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; /** * Selector: whether the current form is saveable, powering the Save button's * disabled state (spec §05 → Save is disabled until a name and valid data/URL * are present). Mirrors the `save()` action's `excludeId` derivation so an edit * can keep its own name. */ export const selectCanSave = (s: DatasetState): boolean => validateForm(s.form, s.datasets, s.view === 'edit' ? (s.selectedId ?? undefined) : undefined) === null;