Files
astrolabe/src/app/modals/modal-registry.ts
T

113 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 — only the implemented ones are registered here. Settings is deliberately NOT
* a modal — preferences are distributed to per-pane disclosure popovers (spec §07;
* see components/SettingsPopover).
*/
import type { ComponentType } from 'react';
import type { ActiveModal, ModalName } from './types';
import { AboutModal } from '../components/AboutModal';
import { ChartBuilderModal } from '../components/ChartBuilderModal';
import { DatasetsModal } from '../components/DatasetsModal';
import { DonateModal } from '../components/DonateModal';
import { ExtractModal } from '../components/ExtractModal';
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
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;
/**
* Whether a backdrop (click-outside) closes the modal. Defaults to `true`. Set
* `false` for modals holding in-progress work an accidental click shouldn't
* discard (the Chart Builder) — Escape and the close button still dismiss.
*/
dismissOnBackdrop?: 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 }),
},
// Opened from a selected dataset's "Build Chart" action; `arg` is its id. Loads
// the dataset and pre-populates a smart default config (§06). Applies on Create
// (a new snippet), so there is nothing transient to lose on close — no getState.
// Backdrop dismissal is off: the config is real in-progress work, and a stray
// click outside this large surface shouldn't throw it away (Escape/× still close).
chartBuilder: {
name: 'chartBuilder',
title: 'Chart Builder',
component: ChartBuilderModal,
isUrlNavigable: true,
dismissOnBackdrop: false,
init: (datasetId) => useChartBuilderStore.getState().init(datasetId ? Number(datasetId) : null),
},
// Pure info modals — no state, no validity check, no discard prompt on close.
// `about` is URL-navigable (reload-restore); `donate` is not (spec §01E hash
// table omits both, but `about` is still a permanent, bookmark-worthy surface).
about: {
name: 'about',
title: 'About & Help',
component: AboutModal,
},
donate: {
name: 'donate',
title: 'Donate',
component: DonateModal,
},
};
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;