mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add dataset library, extract-to-dataset, and render-time reference resolution
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user