Add dataset library, extract-to-dataset, and render-time reference resolution

This commit is contained in:
2026-06-05 15:49:40 +03:00
parent 25849461e0
commit a4e4d96d3b
41 changed files with 3909 additions and 19 deletions
+78
View File
@@ -0,0 +1,78 @@
/**
* Modal coordinator (docs/architecture/03 → Layer 2).
*
* Owns the modal lifecycle: open (close any previous — at most one at a time),
* init transient state, snapshot for unsaved-change detection, and keep the URL
* in sync. Framework-light — pure functions over `useAppStore` — so it's unit
* testable without a DOM. The only coordinator-internal state is the change
* snapshot, a module-local variable (no component reads it).
*/
import { useAppStore } from '../stores/AppStore';
import type { ModalName } from './types';
import { getModalConfig } from './modal-registry';
import { clearModalFromUrl, syncModalToUrl } from './UrlStateSync';
/** Discard-confirmation seam — wired to ConfirmStore at startup (see App). */
let confirmDiscard: (message: string) => Promise<boolean> = () => Promise.resolve(true);
export const setConfirm = (fn: typeof confirmDiscard): void => {
confirmDiscard = fn;
};
/** The getState() JSON captured at open (or re-baselined), compared on close. */
let stateSnapshot: string | null = null;
function snapshotOf(name: ModalName | null): string | null {
const get = getModalConfig(name)?.getState;
if (!get) return null;
const state = get();
return state === null ? null : JSON.stringify(state);
}
/** Open `name`, optionally with a sub-target (dataset id, source key). */
export function openModal(name: ModalName, arg?: string): void {
useAppStore.getState().setActiveModal(name);
getModalConfig(name)?.init?.(arg);
stateSnapshot = snapshotOf(name);
syncModalToUrl(name, arg);
}
/**
* Re-baseline the change snapshot to the modal's current state. Call after a
* commit (the form was saved) or when a form view opens, so subsequent
* unsaved-change detection compares against the right baseline rather than the
* state at modal-open (a multi-view manager opens its form after open).
*/
export function resnapshot(): void {
stateSnapshot = snapshotOf(useAppStore.getState().activeModal);
}
/** True when the active modal's editable state differs from its baseline. */
export function hasUnsavedChanges(): boolean {
const name = useAppStore.getState().activeModal;
if (!name) return false;
const current = snapshotOf(name);
if (current === null) return false; // nothing editable open ⇒ nothing to lose
return current !== stateSnapshot;
}
/** Close the active modal. Prompts on unsaved changes unless `force`. */
export async function closeModal(force = false): Promise<void> {
const name = useAppStore.getState().activeModal;
if (!name) return;
if (!force && hasUnsavedChanges()) {
const ok = await confirmDiscard('Discard your unsaved changes? This cannot be undone.');
if (!ok) return;
}
clearModalFromUrl(name);
useAppStore.getState().setActiveModal(null);
stateSnapshot = null;
}
/** Cmd/Ctrl+K toggle for the Datasets manager (spec §05 → Opening). */
export function toggleDatasets(): void {
if (useAppStore.getState().activeModal === 'datasets') void closeModal();
else openModal('datasets');
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Modal ↔ URL hash sync (docs/architecture/03 → "URL & Keyboard Integration").
*
* The coordinator calls these so a navigable modal (Datasets, Settings, Chart
* Builder) becomes a shareable / back-navigable location, e.g.
* `#datasets/dataset-<id>`. Full hash routing — view-state restore on load,
* Back/Forward — is milestone M6 (spec §01E, docs/architecture/04). Until then
* these are intentional no-ops so the coordinator's shape is final and M6 fills
* the bodies in without the call sites changing.
*/
import type { ModalName } from './types';
import { getModalConfig } from './modal-registry';
/** Reflect an open navigable modal (and optional sub-target) in the URL hash. */
export function syncModalToUrl(name: ModalName, _arg?: string): void {
if (!getModalConfig(name)?.isUrlNavigable) return;
// TODO (M6, spec §01E): write `#${name}` / `#${name}/dataset-${arg}` to the
// hash via the routing layer (docs/architecture/04).
}
/** Return the hash to the underlying workspace when a navigable modal closes. */
export function clearModalFromUrl(name: ModalName): void {
if (!getModalConfig(name)?.isUrlNavigable) return;
// TODO (M6, spec §01E): restore the pre-modal workspace hash.
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Modal registry (docs/architecture/03 → Layer 1).
*
* One metadata entry per feature modal — the single source of truth the
* coordinator and shell read, so adding a modal is one entry plus its component
* rather than edits scattered across the shell, URL sync, and close logic.
*
* Divergence from the arch sketch's optional generic footer: each modal renders
* its OWN action row inside its body (the Datasets manager is multi-view, so a
* single shell-level Save/Cancel doesn't fit). The registry therefore omits
* `hasError`/`getError` (validity is the modal's own concern) and keeps only
* `getState` for unsaved-change detection on close. The registry is a partial
* map: modals land milestone by milestone (settings/about/donate → M5/M6,
* chartBuilder → M4), so only the implemented ones are registered here.
*/
import type { ComponentType } from 'react';
import type { ActiveModal, ModalName } from './types';
import { DatasetsModal } from '../components/DatasetsModal';
import { ExtractModal } from '../components/ExtractModal';
import { useDatasetStore } from '../stores/DatasetStore';
import { useExtractStore } from '../stores/ExtractStore';
export interface ModalConfig {
name: ModalName;
/** Header title (literal for now; an i18n key once strings are centralized). */
title: string;
/** The body rendered inside the shell. */
component: ComponentType;
/** Initialize transient state on open. `arg` carries an optional sub-target. */
init?: (arg?: string) => void;
/**
* Serializable snapshot of in-progress edits for unsaved-change detection.
* Return null when there is nothing to lose (browsing, or no open form) so
* closing doesn't prompt. Omit entirely for modals that apply immediately.
*/
getState?: () => Record<string, unknown> | null;
/** Whether the modal is reflected in the URL hash (navigable). */
isUrlNavigable?: boolean;
}
export const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
// Navigable, multi-view manager. The snapshot captures only the open create/edit
// form, so browsing list↔detail never trips a false discard prompt.
datasets: {
name: 'datasets',
title: 'Datasets',
component: DatasetsModal,
isUrlNavigable: true,
init: (datasetId) => useDatasetStore.getState().select(datasetId ? Number(datasetId) : null),
getState: () => {
const s = useDatasetStore.getState();
return s.view === 'new' || s.view === 'edit' ? { form: s.form } : null;
},
},
// Opened from the snippet editor with the active draft's inline data to lift out.
extract: {
name: 'extract',
title: 'Extract to Dataset',
component: ExtractModal,
init: () => useExtractStore.getState().init(),
getState: () => ({ name: useExtractStore.getState().name }),
},
};
export const getModalConfig = (name: ActiveModal): ModalConfig | undefined =>
name ? MODAL_REGISTRY[name] : undefined;
export const getModalTitle = (name: ActiveModal): string => getModalConfig(name)?.title ?? '';
export const isUrlNavigable = (name: ActiveModal): boolean =>
getModalConfig(name)?.isUrlNavigable ?? false;
+19
View File
@@ -0,0 +1,19 @@
/**
* Modal system — the closed set of named feature modals (docs/architecture/03).
*
* Modelled as a closed union so the registry, coordinator, and shell are
* exhaustively type-checked: a new modal that isn't handled everywhere fails to
* compile. Confirmation/alert dialogs are deliberately NOT in this set — they
* are a separate, lighter layer (see ConfirmStore) that may stack above a modal.
*/
export type ModalName =
| 'datasets' // Datasets manager (list / detail / new-dataset form)
| 'settings' // Appearance, editor, performance, formatting prefs (M5)
| 'about' // About & Help (M6)
| 'donate' // Donate (M6)
| 'chartBuilder' // Visual no-JSON chart composition for a dataset (M4)
| 'extract'; // Extract inline spec data into a new dataset (M3)
/** The active modal, or `null` when none is open (at most one at a time, §01C). */
export type ActiveModal = ModalName | null;