mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Modal system: break import cycles — component map moves to the shell
This commit is contained in:
@@ -101,9 +101,13 @@ export interface ModalConfig {
|
|||||||
> `hasError`/`getError`**: each modal renders its **own action row** inside its body (the
|
> `hasError`/`getError`**: each modal renders its **own action row** inside its body (the
|
||||||
> multi-view Datasets manager doesn't fit a single shell-level Save/Cancel), so validity is
|
> multi-view Datasets manager doesn't fit a single shell-level Save/Cancel), so validity is
|
||||||
> each modal's own concern. The shipped `ModalConfig` keeps only `getState` (close-time
|
> each modal's own concern. The shipped `ModalConfig` keeps only `getState` (close-time
|
||||||
> unsaved-change detection) plus `init`/`isUrlNavigable`. The generic-footer sketch through
|
> unsaved-change detection) plus `init`/`isUrlNavigable`. It also **omits `component`**:
|
||||||
> the rest of this section is retained as the simpler pattern for a single-action modal —
|
> the name → component map lives in the shell (`components/ModalShell` →
|
||||||
> treat it as illustrative, not a description of current code.
|
> `MODAL_COMPONENTS`), its only consumer — a registry that imported components would close
|
||||||
|
> an import cycle (coordinator → registry → component → coordinator, since modal bodies
|
||||||
|
> call `closeModal`). The generic-footer sketch through the rest of this section is
|
||||||
|
> retained as the simpler pattern for a single-action modal — treat it as illustrative,
|
||||||
|
> not a description of current code.
|
||||||
|
|
||||||
### Example entries
|
### Example entries
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import {
|
|||||||
type TimeUnit,
|
type TimeUnit,
|
||||||
} from '@core/chart-builder';
|
} from '@core/chart-builder';
|
||||||
import { referencedFields, validateExpression } from '@core/expr-validate';
|
import { referencedFields, validateExpression } from '@core/expr-validate';
|
||||||
import { tabularRows } from '@core/dataset';
|
import { cellText, tabularRows } from '@core/dataset';
|
||||||
import type { ColumnType } from '@core/type-inference';
|
import type { ColumnType } from '@core/type-inference';
|
||||||
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||||||
import { chartConfigFor } from '@core/vega-themes';
|
import { chartConfigFor } from '@core/vega-themes';
|
||||||
@@ -179,13 +179,6 @@ const PREVIEW_ROW_LIMIT = 50;
|
|||||||
*/
|
*/
|
||||||
const VEGA_EXPRESSION_DOCS_URL = 'https://vega.github.io/vega/docs/expressions/';
|
const VEGA_EXPRESSION_DOCS_URL = 'https://vega.github.io/vega/docs/expressions/';
|
||||||
|
|
||||||
/** One preview cell's text: blank for empty, the string as-is, else JSON. */
|
|
||||||
function cellText(value: unknown): string {
|
|
||||||
if (value == null) return '';
|
|
||||||
if (typeof value === 'string') return value;
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A safe `datum` accessor for a column name (dot for identifiers, bracket otherwise). */
|
/** A safe `datum` accessor for a column name (dot for identifiers, bracket otherwise). */
|
||||||
function datumRef(name: string): string {
|
function datumRef(name: string): string {
|
||||||
return /^[A-Za-z_$][\w$]*$/.test(name) ? `datum.${name}` : `datum[${JSON.stringify(name)}]`;
|
return /^[A-Za-z_$][\w$]*$/.test(name) ? `datum.${name}` : `datum[${JSON.stringify(name)}]`;
|
||||||
@@ -1425,7 +1418,10 @@ export function ChartBuilderModal() {
|
|||||||
className={`${styles.action} ${styles.primary}`}
|
className={`${styles.action} ${styles.primary}`}
|
||||||
disabled={!valid}
|
disabled={!valid}
|
||||||
aria-describedby={!valid ? 'cb-create-hint' : undefined}
|
aria-describedby={!valid ? 'cb-create-hint' : undefined}
|
||||||
onClick={() => runCreate()}
|
// The create is the user's confirmation — close with no discard prompt.
|
||||||
|
onClick={() => {
|
||||||
|
if (runCreate()) void closeModal(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Create Snippet
|
Create Snippet
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,23 +2,43 @@
|
|||||||
* Modal shell (docs/architecture/03 → Layer 3).
|
* Modal shell (docs/architecture/03 → Layer 3).
|
||||||
*
|
*
|
||||||
* Renders exactly ONE modal — whichever `activeModal` names — inside a single
|
* Renders exactly ONE modal — whichever `activeModal` names — inside a single
|
||||||
* reusable chrome: backdrop, header (title + close), and a focus trap. The
|
* reusable chrome: backdrop, header (title + close), and a focus trap. The body
|
||||||
* modal's registered `component` fills the body. This is the only place a modal
|
* comes from `MODAL_COMPONENTS` below — the only place a modal name maps to a
|
||||||
* name maps to a view (`<Body />` from the registry), so there is no
|
* view, so there is no `name === 'datasets' && <DatasetsModal/>` chain anywhere.
|
||||||
* `name === 'datasets' && <DatasetsModal/>` chain anywhere.
|
* The map lives here rather than in the registry so the registry (which the
|
||||||
|
* coordinator and URL sync import) never imports components — that edge would
|
||||||
|
* close an import cycle: coordinator → registry → component → coordinator.
|
||||||
*
|
*
|
||||||
* Dismissal is uniform for these passive feature modals: the close button,
|
* Dismissal is uniform for these passive feature modals: the close button,
|
||||||
* Escape, or a backdrop click — never a click inside the body (which stops
|
* Escape, or a backdrop click — never a click inside the body (which stops
|
||||||
* propagation). Each modal owns its own action buttons; the shell stays generic.
|
* propagation). Each modal owns its own action buttons; the shell stays generic.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
import { useAppStore } from '../stores/AppStore';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
import { getModalConfig, getModalTitle } from '../modals/modal-registry';
|
import { getModalConfig, getModalTitle } from '../modals/modal-registry';
|
||||||
|
import type { ModalName } from '../modals/types';
|
||||||
import { closeModal } from '../modals/ModalCoordinator';
|
import { closeModal } from '../modals/ModalCoordinator';
|
||||||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||||
|
import { AboutModal } from './AboutModal';
|
||||||
|
import { ChartBuilderModal } from './ChartBuilderModal';
|
||||||
|
import { DatasetsModal } from './DatasetsModal';
|
||||||
|
import { DonateModal } from './DonateModal';
|
||||||
|
import { ExtractModal } from './ExtractModal';
|
||||||
|
import { ThemeBuilderModal } from './ThemeBuilderModal';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import styles from './ModalShell.module.css';
|
import styles from './ModalShell.module.css';
|
||||||
|
|
||||||
|
/** The body rendered for each modal name (metadata stays in the registry). */
|
||||||
|
const MODAL_COMPONENTS: Record<ModalName, ComponentType> = {
|
||||||
|
datasets: DatasetsModal,
|
||||||
|
extract: ExtractModal,
|
||||||
|
chartBuilder: ChartBuilderModal,
|
||||||
|
themeBuilder: ThemeBuilderModal,
|
||||||
|
about: AboutModal,
|
||||||
|
donate: DonateModal,
|
||||||
|
};
|
||||||
|
|
||||||
export function ModalShell() {
|
export function ModalShell() {
|
||||||
const name = useAppStore((s) => s.activeModal);
|
const name = useAppStore((s) => s.activeModal);
|
||||||
const config = getModalConfig(name);
|
const config = getModalConfig(name);
|
||||||
@@ -40,8 +60,8 @@ export function ModalShell() {
|
|||||||
isLarge ? '#modal-title' : undefined,
|
isLarge ? '#modal-title' : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!config) return null;
|
if (!config || !name) return null;
|
||||||
const Body = config.component;
|
const Body = MODAL_COMPONENTS[name];
|
||||||
|
|
||||||
// Modals with in-progress work (the Chart Builder's config) opt out of
|
// Modals with in-progress work (the Chart Builder's config) opt out of
|
||||||
// click-outside-to-close so an accidental backdrop click can't discard it; Escape
|
// click-outside-to-close so an accidental backdrop click can't discard it; Escape
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Modal registry (docs/architecture/03 → Layer 1).
|
* Modal registry (docs/architecture/03 → Layer 1).
|
||||||
*
|
*
|
||||||
* One metadata entry per feature modal — the single source of truth the
|
* 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
|
* coordinator, URL sync, and shell read, so adding a modal is one entry here,
|
||||||
* rather than edits scattered across the shell, URL sync, and close logic.
|
* one row in the shell's `MODAL_COMPONENTS` map, and its component — rather than
|
||||||
|
* edits scattered across the shell, URL sync, and close logic. The component
|
||||||
|
* map lives in `components/ModalShell` (its only consumer), NOT here: a registry
|
||||||
|
* that imported components would close an import cycle (coordinator → registry →
|
||||||
|
* component → coordinator).
|
||||||
*
|
*
|
||||||
* Divergence from the arch sketch's optional generic footer: each modal renders
|
* 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
|
* its OWN action row inside its body (the Datasets manager is multi-view, so a
|
||||||
@@ -15,15 +19,8 @@
|
|||||||
* see components/SettingsPopover).
|
* see components/SettingsPopover).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ComponentType } from 'react';
|
|
||||||
import type { ActiveModal, ModalName } from './types';
|
import type { ActiveModal, ModalName } from './types';
|
||||||
import { customThemeIdOf } from '@core/vega-themes';
|
import { customThemeIdOf } from '@core/vega-themes';
|
||||||
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 { ThemeBuilderModal } from '../components/ThemeBuilderModal';
|
|
||||||
import { useAppStore } from '../stores/AppStore';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
|
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
|
||||||
import { selectIsDraftDirty, useCustomThemeStore } from '../stores/CustomThemeStore';
|
import { selectIsDraftDirty, useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||||
@@ -34,8 +31,6 @@ export interface ModalConfig {
|
|||||||
name: ModalName;
|
name: ModalName;
|
||||||
/** Header title (literal for now; an i18n key once strings are centralized). */
|
/** Header title (literal for now; an i18n key once strings are centralized). */
|
||||||
title: string;
|
title: string;
|
||||||
/** The body rendered inside the shell. */
|
|
||||||
component: ComponentType;
|
|
||||||
/** Initialize transient state on open. `arg` carries an optional sub-target. */
|
/** Initialize transient state on open. `arg` carries an optional sub-target. */
|
||||||
init?: (arg?: string) => void;
|
init?: (arg?: string) => void;
|
||||||
/**
|
/**
|
||||||
@@ -54,13 +49,12 @@ export interface ModalConfig {
|
|||||||
dismissOnBackdrop?: boolean;
|
dismissOnBackdrop?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
||||||
// Navigable, multi-view manager. The snapshot captures only the open create/edit
|
// Navigable, multi-view manager. The snapshot captures only the open create/edit
|
||||||
// form, so browsing list↔detail never trips a false discard prompt.
|
// form, so browsing list↔detail never trips a false discard prompt.
|
||||||
datasets: {
|
datasets: {
|
||||||
name: 'datasets',
|
name: 'datasets',
|
||||||
title: 'Datasets',
|
title: 'Datasets',
|
||||||
component: DatasetsModal,
|
|
||||||
isUrlNavigable: true,
|
isUrlNavigable: true,
|
||||||
init: (datasetId) => useDatasetStore.getState().select(datasetId ? Number(datasetId) : null),
|
init: (datasetId) => useDatasetStore.getState().select(datasetId ? Number(datasetId) : null),
|
||||||
getState: () => {
|
getState: () => {
|
||||||
@@ -73,7 +67,6 @@ export const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
|||||||
extract: {
|
extract: {
|
||||||
name: 'extract',
|
name: 'extract',
|
||||||
title: 'Extract to Dataset',
|
title: 'Extract to Dataset',
|
||||||
component: ExtractModal,
|
|
||||||
init: () => useExtractStore.getState().init(),
|
init: () => useExtractStore.getState().init(),
|
||||||
getState: () => ({ name: useExtractStore.getState().name }),
|
getState: () => ({ name: useExtractStore.getState().name }),
|
||||||
},
|
},
|
||||||
@@ -86,7 +79,6 @@ export const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
|||||||
chartBuilder: {
|
chartBuilder: {
|
||||||
name: 'chartBuilder',
|
name: 'chartBuilder',
|
||||||
title: 'Chart Builder',
|
title: 'Chart Builder',
|
||||||
component: ChartBuilderModal,
|
|
||||||
isUrlNavigable: true,
|
isUrlNavigable: true,
|
||||||
dismissOnBackdrop: false,
|
dismissOnBackdrop: false,
|
||||||
init: (datasetId) => useChartBuilderStore.getState().init(datasetId ? Number(datasetId) : null),
|
init: (datasetId) => useChartBuilderStore.getState().init(datasetId ? Number(datasetId) : null),
|
||||||
@@ -100,7 +92,6 @@ export const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
|||||||
themeBuilder: {
|
themeBuilder: {
|
||||||
name: 'themeBuilder',
|
name: 'themeBuilder',
|
||||||
title: 'Theme Builder',
|
title: 'Theme Builder',
|
||||||
component: ThemeBuilderModal,
|
|
||||||
dismissOnBackdrop: false,
|
dismissOnBackdrop: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const store = useCustomThemeStore.getState();
|
const store = useCustomThemeStore.getState();
|
||||||
@@ -124,12 +115,10 @@ export const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
|||||||
about: {
|
about: {
|
||||||
name: 'about',
|
name: 'about',
|
||||||
title: 'About & Help',
|
title: 'About & Help',
|
||||||
component: AboutModal,
|
|
||||||
},
|
},
|
||||||
donate: {
|
donate: {
|
||||||
name: 'donate',
|
name: 'donate',
|
||||||
title: 'Donate',
|
title: 'Donate',
|
||||||
component: DonateModal,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,6 +126,3 @@ export const getModalConfig = (name: ActiveModal): ModalConfig | undefined =>
|
|||||||
name ? MODAL_REGISTRY[name] : undefined;
|
name ? MODAL_REGISTRY[name] : undefined;
|
||||||
|
|
||||||
export const getModalTitle = (name: ActiveModal): string => getModalConfig(name)?.title ?? '';
|
export const getModalTitle = (name: ActiveModal): string => getModalConfig(name)?.title ?? '';
|
||||||
|
|
||||||
export const isUrlNavigable = (name: ActiveModal): boolean =>
|
|
||||||
getModalConfig(name)?.isUrlNavigable ?? false;
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import {
|
|||||||
type TimeUnit,
|
type TimeUnit,
|
||||||
} from '@core/chart-builder';
|
} from '@core/chart-builder';
|
||||||
import type { ColumnType } from '@core/type-inference';
|
import type { ColumnType } from '@core/type-inference';
|
||||||
import { closeModal } from '../modals/ModalCoordinator';
|
|
||||||
import { useDatasetStore } from './DatasetStore';
|
import { useDatasetStore } from './DatasetStore';
|
||||||
import { useSnippetStore } from './SnippetStore';
|
import { useSnippetStore } from './SnippetStore';
|
||||||
|
|
||||||
@@ -487,8 +486,9 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
|||||||
|
|
||||||
// No success toast: the new snippet immediately becomes active and opens in the
|
// No success toast: the new snippet immediately becomes active and opens in the
|
||||||
// editor, so the result is visible — toasting it would be noise (contract 10 §1,
|
// editor, so the result is visible — toasting it would be noise (contract 10 §1,
|
||||||
// "toast only what the user can't already see").
|
// "toast only what the user can't already see"). Closing the modal is the
|
||||||
void closeModal(true); // the create is the user's confirmation — no discard prompt
|
// component's choreography (it owns the modal lifecycle; the store stays
|
||||||
|
// coordinator-free) — it closes on this returning true.
|
||||||
get().reset();
|
get().reset();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user