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,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');
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user