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:
@@ -61,6 +61,30 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Header text-button (Datasets, and future header actions). */
|
||||
.headerButton {
|
||||
height: 32px;
|
||||
padding: 0 var(--space-4);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.headerButton:hover {
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
.headerButton:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.panes {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
|
||||
+38
-2
@@ -1,10 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { LivePreview } from './components/LivePreview';
|
||||
import { ModalShell } from './components/ModalShell';
|
||||
import { ResizeHandle } from './components/ResizeHandle';
|
||||
import { SnippetLibrary } from './components/SnippetLibrary';
|
||||
import { SpecEditor } from './components/SpecEditor';
|
||||
import { ThemeToggle } from './components/ThemeToggle';
|
||||
import { Toaster } from './components/Toaster';
|
||||
import { openModal, setConfirm, toggleDatasets } from './modals/ModalCoordinator';
|
||||
import { confirm } from './stores/ConfirmStore';
|
||||
import { usePanesStore } from './stores/PanesStore';
|
||||
import styles from './App.module.css';
|
||||
|
||||
@@ -21,6 +25,25 @@ export function App() {
|
||||
const libraryWidth = usePanesStore((s) => s.libraryWidth);
|
||||
const previewWidth = usePanesStore((s) => s.previewWidth);
|
||||
|
||||
// Route the modal coordinator's discard prompt through the in-app confirm
|
||||
// dialog (docs/architecture/03 → "The coordinator seam").
|
||||
useEffect(() => {
|
||||
setConfirm((message) => confirm({ title: 'Discard changes?', message, danger: true }));
|
||||
}, []);
|
||||
|
||||
// Cmd/Ctrl+K toggles the Datasets manager (spec §05 → Opening). The full
|
||||
// keyboard router (other shortcuts) lands in M6 (docs/architecture/04).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
toggleDatasets();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
{/* Skip link (WCAG 2.4.1 / GOV.UK): the first focusable element, hidden
|
||||
@@ -32,6 +55,15 @@ export function App() {
|
||||
<h1 className={styles.title}>Astrolabe</h1>
|
||||
<span className={styles.version}>v{__APP_VERSION__}</span>
|
||||
<span className={styles.spacer} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.headerButton}
|
||||
onClick={() => openModal('datasets')}
|
||||
aria-keyshortcuts="Meta+K Control+K"
|
||||
title="Datasets (⌘/Ctrl+K)"
|
||||
>
|
||||
Datasets
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
@@ -60,8 +92,12 @@ export function App() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Global confirmation layer — sits above the (future) feature-modal
|
||||
shell so a discard-changes prompt can appear over an open modal. */}
|
||||
{/* The one feature modal (Datasets / Extract / …), rendered from the
|
||||
registry by the shared shell. At most one open at a time (spec §01C). */}
|
||||
<ModalShell />
|
||||
|
||||
{/* Global confirmation layer — sits above the feature-modal shell so a
|
||||
discard-changes prompt can appear over an open modal. */}
|
||||
<ConfirmDialog />
|
||||
|
||||
{/* Non-blocking notifications (failed saves, etc.) — top-right toasts,
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
/* Datasets manager — two-pane body inside the modal shell (spec §05 → Layout). */
|
||||
|
||||
.manager {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* --- List pane --- */
|
||||
.listPane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-right: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.newButton {
|
||||
flex: 0 0 auto;
|
||||
margin: var(--space-4);
|
||||
height: 40px;
|
||||
padding: 0 var(--space-5);
|
||||
border: var(--border-width) solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.newButton:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border-top: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
padding: var(--space-5) var(--space-4);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-4);
|
||||
border-left: 2px solid transparent;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.item + .item {
|
||||
border-top: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
.itemActive {
|
||||
background: var(--layer-01);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.itemMain {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: var(--space-3) 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Usage badge — count of referencing snippets (spec §05 → List item). */
|
||||
.badge {
|
||||
flex: 0 0 auto;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 var(--space-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--layer-02);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* --- Detail pane --- */
|
||||
.detailPane {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.detailEmpty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail,
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.detailHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detailName {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detailActions,
|
||||
.formActions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.formActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.action {
|
||||
height: 32px;
|
||||
padding: 0 var(--space-4);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.action:hover:not(:disabled) {
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
color: var(--text-placeholder);
|
||||
border-color: var(--border);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.danger {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--support-error);
|
||||
}
|
||||
|
||||
.danger:hover:not(:disabled) {
|
||||
background: var(--support-error);
|
||||
color: var(--on-status);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.comment {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.stats dt {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stats dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.columns {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.column + .column {
|
||||
border-top: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.columnName {
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.columnType {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--layer-01);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.timestamps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: var(--space-3);
|
||||
background: var(--layer-01);
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.linked {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.linkButton:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.linkButton:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --- Create / edit form --- */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
font-family: var(--font-mono);
|
||||
resize: vertical;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.input:focus-visible,
|
||||
.textarea:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.input::placeholder,
|
||||
.textarea::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.detected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-top: calc(-1 * var(--space-2));
|
||||
}
|
||||
|
||||
.detectedBadge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: var(--layer-02);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.detectedHint {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.formError {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--support-error);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Datasets manager — the modal body (spec §05).
|
||||
*
|
||||
* A two-pane manager rendered inside the shared modal shell (App provides the
|
||||
* backdrop, header, close, and focus trap). Left: a "New Dataset" action plus the
|
||||
* dataset list, newest-modified first, each row carrying a source/rows/format/size
|
||||
* meta line and a usage badge. Right: the selected dataset's detail, the
|
||||
* create/edit form, or an empty prompt.
|
||||
*
|
||||
* State lives in DatasetStore; the bidirectional snippet↔dataset link is derived
|
||||
* by scanning SnippetStore (docs/architecture/07 §4), so usage counts and Linked
|
||||
* Snippets stay reactive without a stored back-pointer.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { datasetReference, type DataSource, type Dataset } from '@core/dataset';
|
||||
import { detectFormat, detectFormatFromUrl, type DataFormat } from '@core/format-detection';
|
||||
import { closeModal, resnapshot } from '../modals/ModalCoordinator';
|
||||
import { confirm } from '../stores/ConfirmStore';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { selectSelectedDataset, useDatasetStore, byModifiedDesc } from '../stores/DatasetStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import styles from './DatasetsModal.module.css';
|
||||
|
||||
/** Display label for a format (spec §05 → List item: JSON / CSV / TSV / TopoJSON). */
|
||||
function formatLabel(format: DataFormat): string {
|
||||
return format === 'topojson' ? 'TopoJSON' : format.toUpperCase();
|
||||
}
|
||||
|
||||
/** Human-readable byte size (B / KB / MB). */
|
||||
function humanBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)} KB`;
|
||||
const mb = kb / 1024;
|
||||
return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`;
|
||||
}
|
||||
|
||||
/** Map of dataset name (lower-cased) → how many snippets reference it. */
|
||||
function usageByName(snippets: ReadonlyArray<{ datasetRefs: string[] }>): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const s of snippets) {
|
||||
for (const ref of s.datasetRefs) {
|
||||
const key = ref.toLowerCase();
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
const SOURCE_OPTIONS: ReadonlyArray<SegmentedOption<DataSource>> = [
|
||||
{ value: 'inline', label: 'Inline' },
|
||||
{ value: 'url', label: 'URL' },
|
||||
];
|
||||
|
||||
export function DatasetsModal() {
|
||||
const datasets = useDatasetStore(useShallow((s) => s.datasets));
|
||||
const view = useDatasetStore((s) => s.view);
|
||||
const selected = useDatasetStore(selectSelectedDataset);
|
||||
const snippets = useSnippetStore(useShallow((s) => s.snippets));
|
||||
|
||||
const select = useDatasetStore((s) => s.select);
|
||||
const startCreate = useDatasetStore((s) => s.startCreate);
|
||||
|
||||
const usage = usageByName(snippets);
|
||||
const ordered = [...datasets].sort(byModifiedDesc);
|
||||
|
||||
const handleNew = () => {
|
||||
startCreate();
|
||||
resnapshot(); // baseline the discard check to the freshly-opened empty form
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.manager}>
|
||||
<div className={styles.listPane}>
|
||||
<button type="button" className={styles.newButton} onClick={handleNew}>
|
||||
+ New Dataset
|
||||
</button>
|
||||
<ul className={styles.list}>
|
||||
{ordered.length === 0 && (
|
||||
<li className={styles.empty}>
|
||||
No datasets yet — create one to reuse data across snippets.
|
||||
</li>
|
||||
)}
|
||||
{ordered.map((d) => (
|
||||
<DatasetListItem
|
||||
key={d.id}
|
||||
dataset={d}
|
||||
active={d.id === selected?.id}
|
||||
usage={usage.get(d.name.toLowerCase()) ?? 0}
|
||||
onSelect={() => select(d.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.detailPane}>
|
||||
{view === 'new' || view === 'edit' ? (
|
||||
<DatasetFormView editing={view === 'edit'} />
|
||||
) : selected ? (
|
||||
<DatasetDetail dataset={selected} snippets={snippets} />
|
||||
) : (
|
||||
<div className={styles.detailEmpty}>Select a dataset or create a new one.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DatasetListItem({
|
||||
dataset,
|
||||
active,
|
||||
usage,
|
||||
onSelect,
|
||||
}: {
|
||||
dataset: Dataset;
|
||||
active: boolean;
|
||||
usage: number;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
// Meta line: source ("URL" prefix), row count when known, format label, size.
|
||||
const parts: string[] = [];
|
||||
if (dataset.source === 'url') parts.push('URL');
|
||||
if (dataset.rowCount !== null) parts.push(`${dataset.rowCount} rows`);
|
||||
parts.push(formatLabel(dataset.format));
|
||||
if (dataset.source !== 'url') parts.push(humanBytes(dataset.size));
|
||||
|
||||
return (
|
||||
<li className={`${styles.item} ${active ? styles.itemActive : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.itemMain}
|
||||
aria-current={active || undefined}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className={styles.itemName}>{dataset.name}</span>
|
||||
<span className={styles.itemMeta}>{parts.join(' · ')}</span>
|
||||
</button>
|
||||
{usage > 0 && (
|
||||
<span className={styles.badge} title={`Used by ${usage} snippet${usage === 1 ? '' : 's'}`}>
|
||||
{usage}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function DatasetDetail({
|
||||
dataset,
|
||||
snippets,
|
||||
}: {
|
||||
dataset: Dataset;
|
||||
snippets: ReadonlyArray<{ id: string; name: string; datasetRefs: string[] }>;
|
||||
}) {
|
||||
const startEdit = useDatasetStore((s) => s.startEdit);
|
||||
const remove = useDatasetStore((s) => s.remove);
|
||||
const selectSnippet = useSnippetStore((s) => s.selectSnippet);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const lower = dataset.name.toLowerCase();
|
||||
const linked = snippets.filter((s) => s.datasetRefs.some((r) => r.toLowerCase() === lower));
|
||||
|
||||
const handleEdit = () => {
|
||||
startEdit();
|
||||
resnapshot();
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = JSON.stringify(datasetReference(dataset.name), null, 2);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
// Lightweight local feedback; the success-toast wiring is deferred to M6
|
||||
// with the other success toasts (spec §05 → Actions).
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: "Couldn't copy",
|
||||
message: 'Your browser blocked clipboard access. Select and copy the reference manually.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete dataset',
|
||||
message: `Delete "${dataset.name}"? This cannot be undone.`,
|
||||
confirmLabel: 'Delete',
|
||||
danger: true,
|
||||
});
|
||||
// TODO (M6, spec §05): success toast on delete.
|
||||
if (ok) remove(dataset.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.detailHead}>
|
||||
<h3 className={styles.detailName}>{dataset.name}</h3>
|
||||
<div className={styles.detailActions}>
|
||||
<button type="button" className={styles.action} onClick={() => void handleCopy()}>
|
||||
{copied ? 'Copied' : 'Copy Reference'}
|
||||
</button>
|
||||
<button type="button" className={styles.action} onClick={handleEdit}>
|
||||
Edit
|
||||
</button>
|
||||
{/* "Build Chart from dataset" (spec §05) lands enabled with the Chart
|
||||
Builder in M4. Per council (GOV.UK / NN/g), we don't ship a dead
|
||||
disabled control in the meantime — the action appears when it works. */}
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.action} ${styles.danger}`}
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dataset.comment && <p className={styles.comment}>{dataset.comment}</p>}
|
||||
|
||||
<section className={styles.section}>
|
||||
<h4 className={styles.sectionTitle}>Overview</h4>
|
||||
<dl className={styles.stats}>
|
||||
<div>
|
||||
<dt>Rows</dt>
|
||||
<dd>{dataset.rowCount ?? 'N/A'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Columns</dt>
|
||||
<dd>{dataset.columnCount ?? 'N/A'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Format</dt>
|
||||
<dd>{formatLabel(dataset.format)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{dataset.source === 'url' ? 'N/A' : humanBytes(dataset.size)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{dataset.columnTypes.length > 0 && (
|
||||
<ul className={styles.columns}>
|
||||
{dataset.columnTypes.map((col) => (
|
||||
<li key={col.name} className={styles.column}>
|
||||
<span className={styles.columnName}>{col.name}</span>
|
||||
<span className={styles.columnType}>{col.type}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className={styles.timestamps}>
|
||||
<span>Created {new Date(dataset.created).toLocaleString()}</span>
|
||||
<span>Modified {new Date(dataset.modified).toLocaleString()}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h4 className={styles.sectionTitle}>Preview</h4>
|
||||
<pre className={styles.preview}>{previewText(dataset)}</pre>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h4 className={styles.sectionTitle}>Linked Snippets</h4>
|
||||
{linked.length === 0 ? (
|
||||
<p className={styles.muted}>No snippets reference this dataset yet.</p>
|
||||
) : (
|
||||
<ul className={styles.linked}>
|
||||
{linked.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={() => {
|
||||
selectSnippet(s.id);
|
||||
void closeModal();
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A truncated rendering of the data: raw for csv/tsv/url, pretty JSON otherwise. */
|
||||
function previewText(dataset: Dataset): string {
|
||||
const MAX = 2000;
|
||||
let text: string;
|
||||
if (dataset.source === 'url') {
|
||||
text = String(dataset.data);
|
||||
} else if (dataset.format === 'csv' || dataset.format === 'tsv') {
|
||||
text = String(dataset.data);
|
||||
} else {
|
||||
try {
|
||||
text = JSON.stringify(dataset.data, null, 2);
|
||||
} catch {
|
||||
text = String(dataset.data);
|
||||
}
|
||||
}
|
||||
return text.length > MAX ? `${text.slice(0, MAX)}\n…` : text;
|
||||
}
|
||||
|
||||
function DatasetFormView({ editing }: { editing: boolean }) {
|
||||
const form = useDatasetStore((s) => s.form);
|
||||
const formError = useDatasetStore((s) => s.formError);
|
||||
const updateForm = useDatasetStore((s) => s.updateForm);
|
||||
const cancelForm = useDatasetStore((s) => s.cancelForm);
|
||||
const save = useDatasetStore((s) => s.save);
|
||||
|
||||
// Live format/source hint from the current input (spec §05 → Auto-detection).
|
||||
const detected =
|
||||
form.source === 'url'
|
||||
? { format: detectFormatFromUrl(form.input.trim()), confidence: 'url' as const }
|
||||
: detectFormat(form.input);
|
||||
|
||||
const handleSave = () => {
|
||||
if (save()) resnapshot(); // committed — re-baseline so a later close won't prompt
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cancelForm();
|
||||
resnapshot();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.form}>
|
||||
<h3 className={styles.detailName}>{editing ? 'Edit dataset' : 'New dataset'}</h3>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Name</span>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={form.name}
|
||||
onChange={(e) => updateForm({ name: e.target.value })}
|
||||
placeholder="e.g. Sales 2024"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Source</span>
|
||||
<SegmentedControl
|
||||
label="Dataset source"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={form.source}
|
||||
onChange={(source) => updateForm({ source })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>{form.source === 'url' ? 'URL' : 'Data'}</span>
|
||||
{form.source === 'url' ? (
|
||||
<input
|
||||
type="url"
|
||||
className={styles.input}
|
||||
value={form.input}
|
||||
onChange={(e) => updateForm({ input: e.target.value })}
|
||||
placeholder="https://example.com/data.csv"
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={form.input}
|
||||
onChange={(e) => updateForm({ input: e.target.value })}
|
||||
placeholder="Paste JSON, CSV, or TSV…"
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{form.input.trim() !== '' && (
|
||||
<div className={styles.detected}>
|
||||
<span className={styles.detectedBadge}>
|
||||
{detected.format ? formatLabel(detected.format) : 'Unrecognized'}
|
||||
</span>
|
||||
{form.source !== 'url' && 'confidence' in detected && (
|
||||
<span className={styles.detectedHint}>{detected.confidence} confidence</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Comment (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={form.comment}
|
||||
onChange={(e) => updateForm({ comment: e.target.value })}
|
||||
placeholder="Notes about this dataset"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{formError && (
|
||||
<p className={styles.formError} role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className={styles.action} onClick={handleCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className={`${styles.action} ${styles.primary}`} onClick={handleSave}>
|
||||
{editing ? 'Save changes' : 'Create dataset'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/* Extract-to-Dataset — a single-form modal body (spec §03F). */
|
||||
|
||||
.extract {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
padding: var(--space-5);
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.input:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin: 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
padding: var(--space-3);
|
||||
background: var(--layer-01);
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--support-error);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.action {
|
||||
height: 36px;
|
||||
padding: 0 var(--space-5);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Extract-to-Dataset — the modal body (spec §03F).
|
||||
*
|
||||
* Shows a read-only preview of the active snippet draft's inline data and asks
|
||||
* for a dataset name. On confirm it saves the data as a new dataset and rewrites
|
||||
* the draft to reference it by name (logic in ExtractStore), then force-closes
|
||||
* (the commit is the user's confirmation, so no discard prompt). Cancel leaves
|
||||
* the spec unchanged.
|
||||
*/
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { closeModal } from '../modals/ModalCoordinator';
|
||||
import { useExtractStore } from '../stores/ExtractStore';
|
||||
import styles from './ExtractModal.module.css';
|
||||
|
||||
const MAX_PREVIEW = 1500;
|
||||
|
||||
function previewOf(values: unknown): string {
|
||||
let text: string;
|
||||
try {
|
||||
text = typeof values === 'string' ? values : JSON.stringify(values, null, 2);
|
||||
} catch {
|
||||
text = String(values);
|
||||
}
|
||||
return text.length > MAX_PREVIEW ? `${text.slice(0, MAX_PREVIEW)}\n…` : text;
|
||||
}
|
||||
|
||||
export function ExtractModal() {
|
||||
const { name, source, error } = useExtractStore(
|
||||
useShallow((s) => ({ name: s.name, source: s.source, error: s.error })),
|
||||
);
|
||||
const setName = useExtractStore((s) => s.setName);
|
||||
const runConfirm = useExtractStore((s) => s.confirm);
|
||||
|
||||
const handleCreate = () => {
|
||||
if (runConfirm()) void closeModal(true);
|
||||
};
|
||||
|
||||
if (!source) {
|
||||
return <p className={styles.muted}>This snippet has no inline data to extract.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.extract}>
|
||||
<p className={styles.intro}>
|
||||
Save this snippet’s inline data as a reusable dataset. The spec will be rewritten to
|
||||
reference it by name.
|
||||
</p>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Dataset name</span>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
}}
|
||||
placeholder="e.g. Sales 2024"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Data to extract ({source.format.toUpperCase()})</span>
|
||||
<pre className={styles.preview}>{previewOf(source.values)}</pre>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.action} onClick={() => void closeModal()}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.action} ${styles.primary}`}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
Create dataset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,12 +16,14 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import { prepareSpecForRender } from '@core/rendering';
|
||||
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||||
import { chartConfigFor } from '@core/vega-themes';
|
||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
@@ -73,6 +75,9 @@ export function LivePreview() {
|
||||
const shownText = useSnippetStore(selectShownText);
|
||||
const fitMode = useAppStore((s) => s.previewFitMode);
|
||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||
// Datasets feed reference resolution (spec §04 step 1). Re-rendering on a
|
||||
// dataset change keeps a referencing chart live as its data is edited.
|
||||
const datasets = useDatasetStore(useShallow((s) => s.datasets));
|
||||
const error = usePreviewStore((s) => s.error);
|
||||
const setError = usePreviewStore((s) => s.setError);
|
||||
|
||||
@@ -107,7 +112,7 @@ export function LivePreview() {
|
||||
|
||||
const mine = ++generationRef.current;
|
||||
try {
|
||||
const prepared = prepareSpecForRender(parsed, { fitMode });
|
||||
const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
|
||||
const config = chartConfigFor(uiTheme);
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
@@ -126,17 +131,27 @@ export function LivePreview() {
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
if (mine === generationRef.current) {
|
||||
setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
||||
// tailored, fixable message instead of the generic syntax hint (council:
|
||||
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix).
|
||||
if (e instanceof DatasetNotFoundError) {
|
||||
setError(
|
||||
`Dataset "${e.datasetName}" not found. Create it from Datasets ` +
|
||||
`(⌘/Ctrl+K), or check the dataset name in your spec.`,
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, RENDER_DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [shownText, fitMode, uiTheme, setError]);
|
||||
}, [shownText, fitMode, uiTheme, datasets, setError]);
|
||||
|
||||
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
||||
// observe the element, so we do: one observer on the stable host node for the
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/* Feature-modal shell (docs/architecture/03 → Layer 3). Sits below the confirm
|
||||
layer (z 1000) so a discard prompt can appear above an open modal. */
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-5);
|
||||
background: rgb(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--layer-01);
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 2px 12px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
/* Large: the two-pane managers (Datasets). Definite height so inner panes scroll. */
|
||||
.large {
|
||||
width: min(960px, 94vw);
|
||||
height: min(700px, 88vh);
|
||||
}
|
||||
|
||||
/* Small: single-form modals (Extract). Grows with content up to a cap. */
|
||||
.small {
|
||||
width: min(560px, 92vw);
|
||||
max-height: 88vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
height: var(--header-height);
|
||||
padding: 0 var(--space-5);
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* The title is only a programmatic focus target (large-manager initial focus);
|
||||
it shouldn't paint a ring the way a keyboard-reached control would. */
|
||||
.title:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.close {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: var(--layer-02);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.close:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The modal body. Background steps to --bg so the panes read as the work surface
|
||||
against the --layer-01 chrome. Fills the remaining height; inner content scrolls. */
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Modal shell (docs/architecture/03 → Layer 3).
|
||||
*
|
||||
* Renders exactly ONE modal — whichever `activeModal` names — inside a single
|
||||
* reusable chrome: backdrop, header (title + close), and a focus trap. The
|
||||
* modal's registered `component` fills the body. This is the only place a modal
|
||||
* name maps to a view (`<Body />` from the registry), so there is no
|
||||
* `name === 'datasets' && <DatasetsModal/>` chain anywhere.
|
||||
*
|
||||
* Dismissal is uniform for these passive feature modals: the close button,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { getModalConfig, getModalTitle } from '../modals/modal-registry';
|
||||
import { closeModal } from '../modals/ModalCoordinator';
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||
import styles from './ModalShell.module.css';
|
||||
|
||||
export function ModalShell() {
|
||||
const name = useAppStore((s) => s.activeModal);
|
||||
const config = getModalConfig(name);
|
||||
|
||||
const isLarge = name === 'datasets';
|
||||
|
||||
// Move focus into the modal on open, return it to the trigger on close. For a
|
||||
// large manager (list + detail), APG dialog-modal advises focusing a static
|
||||
// element at the top — the title — so the content's start is perceived rather
|
||||
// than jumping past it to the first control. Small form modals keep their
|
||||
// first-field focus (Extract autofocuses its name input).
|
||||
const modalRef = useFocusTrap<HTMLDivElement>(
|
||||
name !== null,
|
||||
isLarge ? '#modal-title' : undefined,
|
||||
);
|
||||
|
||||
if (!config) return null;
|
||||
const Body = config.component;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
onClick={() => void closeModal()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
void closeModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`${styles.modal} ${isLarge ? styles.large : styles.small}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
{/* tabIndex -1 makes the title a programmatic focus target for the
|
||||
large-manager initial focus (APG dialog-modal), without adding it to
|
||||
the Tab order. */}
|
||||
<h2 id="modal-title" className={styles.title} tabIndex={-1}>
|
||||
{getModalTitle(name)}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.close}
|
||||
aria-label="Close"
|
||||
onClick={() => void closeModal()}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.body}>
|
||||
<Body />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,6 +129,18 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Linked-datasets indicator (spec §02): icon + count, meaning carried by the
|
||||
icon + accessible label, not colour. Pushed to the row's trailing edge. */
|
||||
.datasets {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.delete {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
|
||||
@@ -14,6 +14,36 @@ import { confirm } from '../stores/ConfirmStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import styles from './SnippetLibrary.module.css';
|
||||
|
||||
/** A small database glyph for the linked-datasets indicator (icons keep their own
|
||||
* rounded geometry per the design tokens). Inherits `currentColor` so it themes. */
|
||||
function DatasetIcon() {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<ellipse
|
||||
cx="8"
|
||||
cy="3.5"
|
||||
rx="5.5"
|
||||
ry="2.2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path
|
||||
d="M2.5 3.5v9c0 1.2 2.46 2.2 5.5 2.2s5.5-1 5.5-2.2v-9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path
|
||||
d="M2.5 8c0 1.2 2.46 2.2 5.5 2.2s5.5-1 5.5-2.2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact relative date for the list (full date formatting lands in M5). */
|
||||
function relativeDate(iso: string): string {
|
||||
const then = new Date(iso);
|
||||
@@ -97,6 +127,18 @@ export function SnippetLibrary() {
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.date}>{size ? `${date} · ${size}` : date}</span>
|
||||
{/* Linked datasets (spec §02): the snippet side of the
|
||||
bidirectional link, maintained on publish via datasetRefs. */}
|
||||
{s.datasetRefs.length > 0 && (
|
||||
<span
|
||||
className={styles.datasets}
|
||||
title={`Linked datasets: ${s.datasetRefs.join(', ')}`}
|
||||
aria-label={`${s.datasetRefs.length} linked dataset${s.datasetRefs.length === 1 ? '' : 's'}`}
|
||||
>
|
||||
<DatasetIcon />
|
||||
{s.datasetRefs.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -23,8 +23,10 @@ import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import 'monaco-editor/esm/vs/language/json/monaco.contribution';
|
||||
import '../infrastructure/monaco-env'; // side-effect: wire workers before create
|
||||
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
|
||||
import { openModal } from '../modals/ModalCoordinator';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { confirm } from '../stores/ConfirmStore';
|
||||
import { hasInlineData } from '../stores/ExtractStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
@@ -53,6 +55,11 @@ function EditorToolbar() {
|
||||
const draft = s.editorView === 'draft' ? s.draftText : active.draftSpec;
|
||||
return draft !== active.spec;
|
||||
});
|
||||
// Offer Extract only when the live draft carries top-level inline data to lift
|
||||
// out (spec §03F → hidden when the spec has no inline data).
|
||||
const canExtract = useSnippetStore(
|
||||
(s) => s.activeSnippetId !== null && hasInlineData(s.draftText),
|
||||
);
|
||||
|
||||
const handlePublish = () => {
|
||||
if (!useSnippetStore.getState().activeSnippetId) return;
|
||||
@@ -85,6 +92,16 @@ function EditorToolbar() {
|
||||
|
||||
<span className={styles.spacer} />
|
||||
|
||||
{canExtract && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
onClick={() => openModal('extract')}
|
||||
title="Extract inline data into a reusable dataset"
|
||||
>
|
||||
Extract to Dataset
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Read-time migration for Dataset records (docs/architecture/02 §4, spec §09B).
|
||||
*
|
||||
* Mirrors snippet-migrations: every dataset read from storage passes through
|
||||
* `migrateDataset`, which fills missing/old fields and stamps the current
|
||||
* version. It tolerates unknown fields (spread the original, only fill gaps) so a
|
||||
* record written by a newer build round-trips without data loss. Records written
|
||||
* before versioning existed (no `version`) are treated as v1.
|
||||
*/
|
||||
|
||||
import { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from '@core/dataset';
|
||||
import type { DataFormat } from '@core/format-detection';
|
||||
import type { ColumnType } from '@core/type-inference';
|
||||
|
||||
const FORMATS: ReadonlyArray<DataFormat> = ['json', 'csv', 'tsv', 'topojson'];
|
||||
|
||||
function asFormat(v: unknown): DataFormat {
|
||||
return typeof v === 'string' && (FORMATS as string[]).includes(v) ? (v as DataFormat) : 'json';
|
||||
}
|
||||
|
||||
function asSource(v: unknown): DataSource {
|
||||
return v === 'url' ? 'url' : 'inline';
|
||||
}
|
||||
|
||||
function asCount(v: unknown): number | null {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
/** Upgrade a raw stored record to the current Dataset shape. */
|
||||
export function migrateDataset(raw: unknown): Dataset {
|
||||
const r = { ...(raw as Record<string, unknown>) };
|
||||
return {
|
||||
...r,
|
||||
id: typeof r.id === 'number' ? r.id : Number(r.id),
|
||||
version: CURRENT_DATASET_VERSION,
|
||||
name: typeof r.name === 'string' ? r.name : 'Untitled',
|
||||
data: r.data,
|
||||
format: asFormat(r.format),
|
||||
source: asSource(r.source),
|
||||
comment: typeof r.comment === 'string' ? r.comment : '',
|
||||
rowCount: asCount(r.rowCount),
|
||||
columnCount: asCount(r.columnCount),
|
||||
columns: Array.isArray(r.columns) ? (r.columns as string[]) : [],
|
||||
columnTypes: Array.isArray(r.columnTypes)
|
||||
? (r.columnTypes as Array<{ name: string; type: ColumnType }>)
|
||||
: [],
|
||||
size: typeof r.size === 'number' ? r.size : 0,
|
||||
created: typeof r.created === 'string' ? r.created : new Date(0).toISOString(),
|
||||
modified: typeof r.modified === 'string' ? r.modified : new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Dataset persistence adapter (docs/architecture/02, spec §09E).
|
||||
*
|
||||
* The typed seam between the dataset store and IndexedDB's high-capacity
|
||||
* `datasets` object store (separate from snippets so large payloads live in a
|
||||
* tier suited to them). Exposes plain async functions returning domain `Dataset`
|
||||
* objects and migrates every record on read.
|
||||
*/
|
||||
|
||||
import { CURRENT_DATASET_VERSION, type Dataset } from '@core/dataset';
|
||||
import { DATASETS_STORE, del, getAll, put } from './db';
|
||||
import { migrateDataset } from './dataset-migrations';
|
||||
|
||||
/** Load every dataset, upgrading each record to the current shape. */
|
||||
export async function loadDatasets(): Promise<Dataset[]> {
|
||||
const records = await getAll<unknown>(DATASETS_STORE);
|
||||
return records.map(migrateDataset);
|
||||
}
|
||||
|
||||
/** Persist a dataset at the current schema version. Propagates failures. */
|
||||
export async function saveDataset(dataset: Dataset): Promise<void> {
|
||||
await put(DATASETS_STORE, { ...dataset, version: CURRENT_DATASET_VERSION });
|
||||
}
|
||||
|
||||
/** Permanently remove a dataset by id. */
|
||||
export async function deleteDataset(id: number): Promise<void> {
|
||||
await del(DATASETS_STORE, id);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Dataset persistence wiring (docs/architecture/01 §5, spec §09E).
|
||||
*
|
||||
* The dataset sibling of `persistence.ts`: a startup subscriber that diffs the
|
||||
* `datasets` array against the previous snapshot and writes upserts/deletes
|
||||
* through to the IndexedDB adapter. The store stays browser-free; failures
|
||||
* surface as a toast rather than silent loss (spec §10). There is no debounced
|
||||
* auto-save here — datasets change on explicit create/edit/delete, not per
|
||||
* keystroke, so every change is a structural array edit.
|
||||
*/
|
||||
|
||||
import { deleteDataset, saveDataset } from '../infrastructure/dataset-store';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
|
||||
type Unsubscribe = () => void;
|
||||
|
||||
function datasetError(op: 'save' | 'delete', err: unknown) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: op === 'delete' ? "Couldn't delete the dataset" : "Couldn't save the dataset",
|
||||
message:
|
||||
'A storage error stopped Astrolabe from completing the last dataset change, so it may not ' +
|
||||
'survive a reload. If this keeps happening, your browser may be blocking local storage.',
|
||||
detail:
|
||||
err instanceof Error ? `Dataset ${op} failed: ${err.name}: ${err.message}` : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist dataset upserts and deletions whenever the array changes. */
|
||||
function wireDatasetWriteThrough(): Unsubscribe {
|
||||
let prevDatasets = useDatasetStore.getState().datasets;
|
||||
return useDatasetStore.subscribe((s) => {
|
||||
const next = s.datasets;
|
||||
if (next === prevDatasets) return;
|
||||
const prev = prevDatasets;
|
||||
prevDatasets = next;
|
||||
|
||||
for (const old of prev) {
|
||||
if (!next.some((n) => n.id === old.id)) {
|
||||
deleteDataset(old.id).catch((err) => datasetError('delete', err));
|
||||
}
|
||||
}
|
||||
for (const n of next) {
|
||||
const old = prev.find((p) => p.id === n.id);
|
||||
if (old !== n) saveDataset(n).catch((err) => datasetError('save', err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Wire dataset persistence subscribers. Returns a teardown that detaches them. */
|
||||
export function wireDatasetPersistence(): Unsubscribe {
|
||||
return wireDatasetWriteThrough();
|
||||
}
|
||||
@@ -8,11 +8,15 @@
|
||||
*/
|
||||
|
||||
import { createSnippet, type Snippet } from '@core/snippet';
|
||||
import type { Dataset } from '@core/dataset';
|
||||
import { loadSnippets, saveSnippet } from '../infrastructure/snippet-store';
|
||||
import { loadDatasets } from '../infrastructure/dataset-store';
|
||||
import { storageErrorNotification } from '../services/storage-errors';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { wirePersistence } from './persistence';
|
||||
import { wireDatasetPersistence } from './dataset-persistence';
|
||||
|
||||
let started = false;
|
||||
|
||||
@@ -47,9 +51,20 @@ export async function initApp(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Load the dataset library too (no first-run seed — datasets are user-created).
|
||||
// A failure here is surfaced; the app stays usable with an empty dataset library.
|
||||
let datasets: Dataset[] = [];
|
||||
try {
|
||||
datasets = await loadDatasets();
|
||||
} catch (err) {
|
||||
notify(storageErrorNotification('load', err));
|
||||
}
|
||||
|
||||
useSnippetStore.getState().hydrate(snippets);
|
||||
useDatasetStore.getState().hydrate(datasets);
|
||||
|
||||
// Wire persistence AFTER hydrate so write-through's baseline is the loaded set
|
||||
// — otherwise it would redundantly re-save every snippet on each startup.
|
||||
// — otherwise it would redundantly re-save every record on each startup.
|
||||
wirePersistence();
|
||||
wireDatasetPersistence();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import { createDataset } from '@core/dataset';
|
||||
import { createSnippet } from '@core/snippet';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import {
|
||||
datasetUsageCount,
|
||||
findSnippetsReferencingDataset,
|
||||
renameDatasetEverywhere,
|
||||
} from './RelationshipService';
|
||||
|
||||
const T = new Date('2026-06-01T00:00:00Z');
|
||||
|
||||
/** Seed a snippet whose published spec references `datasetName`. */
|
||||
function seedSnippet(id: string, datasetName: string) {
|
||||
return createSnippet({
|
||||
id,
|
||||
spec: JSON.stringify({ data: { name: datasetName }, mark: 'bar' }),
|
||||
now: T,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useDatasetStore.getState().reset();
|
||||
useSnippetStore.getState().reset();
|
||||
});
|
||||
|
||||
describe('reverse lookup', () => {
|
||||
test('finds referencing snippets case-insensitively and counts them', () => {
|
||||
useSnippetStore.getState().hydrate([seedSnippet('a', 'Sales'), seedSnippet('b', 'SALES')], 'a');
|
||||
// datasetRefs are recomputed on publish; publish each active snippet to seed.
|
||||
useSnippetStore.getState().publish(T);
|
||||
useSnippetStore.getState().selectSnippet('b');
|
||||
useSnippetStore.getState().publish(T);
|
||||
|
||||
expect(
|
||||
findSnippetsReferencingDataset('sales')
|
||||
.map((s) => s.id)
|
||||
.sort(),
|
||||
).toEqual(['a', 'b']);
|
||||
expect(datasetUsageCount('Sales')).toBe(2);
|
||||
expect(datasetUsageCount('Other')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameDatasetEverywhere', () => {
|
||||
test('renames the dataset record and every referencing snippet', () => {
|
||||
useDatasetStore
|
||||
.getState()
|
||||
.add(
|
||||
createDataset({
|
||||
name: 'Sales',
|
||||
data: [{ a: 1 }],
|
||||
format: 'json',
|
||||
source: 'inline',
|
||||
now: T,
|
||||
}),
|
||||
);
|
||||
useSnippetStore.getState().hydrate([seedSnippet('a', 'Sales')], 'a');
|
||||
useSnippetStore.getState().publish(T);
|
||||
|
||||
const updated = renameDatasetEverywhere('Sales', 'Revenue', new Date('2026-07-01T00:00:00Z'));
|
||||
|
||||
expect(updated).toBe(1);
|
||||
expect(useDatasetStore.getState().datasets[0].name).toBe('Revenue');
|
||||
const s = useSnippetStore.getState().snippets[0];
|
||||
expect(s.datasetRefs).toEqual(['Revenue']);
|
||||
expect(s.spec).toContain('"Revenue"');
|
||||
});
|
||||
|
||||
test('is a no-op when old and new names are equal', () => {
|
||||
expect(renameDatasetEverywhere('Sales', 'Sales')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Snippet ↔ dataset relationships (docs/architecture/07 §4 + §6).
|
||||
*
|
||||
* The bidirectional link is name-based and has a single source of truth: a
|
||||
* snippet's `datasetRefs` (recomputed from its published spec). The reverse
|
||||
* direction — "which snippets use this dataset" — is therefore DERIVED by a scan,
|
||||
* never stored, so it can't drift. Rename is the one graph operation: it renames
|
||||
* the dataset record and propagates the new name into every referencing snippet's
|
||||
* spec/draftSpec/refs.
|
||||
*
|
||||
* These functions read and mutate Zustand stores, so they live in the app layer
|
||||
* (the pure rewrite/extraction helpers they build on live in `core/spec-refs`).
|
||||
*/
|
||||
|
||||
import type { Snippet } from '@core/snippet';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
|
||||
// TODO: nothing in production calls this service yet — DatasetsModal derives
|
||||
// usage/linked-snippets inline (it must read `snippets` reactively, not via the
|
||||
// getState snapshot these use), and DatasetStore.save propagates rename itself
|
||||
// (it updates other fields besides the name, so it can't delegate cleanly).
|
||||
// `renameDatasetEverywhere` also matches by exact name, unlike the rest of the
|
||||
// case-insensitive naming policy, and arch/07 documents it returning `{ updated }`
|
||||
// not a bare number. Either reconcile the duplication (extract a reactive-friendly
|
||||
// pure `snippetsReferencing(snippets, name)` helper both sides share) or fold this
|
||||
// service in when M4's "Build Chart from dataset" needs a non-reactive caller.
|
||||
|
||||
/** Snippets whose `datasetRefs` include `name` (case-insensitive). */
|
||||
export function findSnippetsReferencingDataset(name: string): Snippet[] {
|
||||
const lower = name.toLowerCase();
|
||||
return useSnippetStore
|
||||
.getState()
|
||||
.snippets.filter((s) => s.datasetRefs.some((ref) => ref.toLowerCase() === lower));
|
||||
}
|
||||
|
||||
/** Count for the dataset usage badge (spec §05 → List item). */
|
||||
export function datasetUsageCount(name: string): number {
|
||||
return findSnippetsReferencingDataset(name).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a dataset and propagate everywhere (docs/architecture/07 §6): renames
|
||||
* the dataset record, then rewrites every referencing snippet's spec, draftSpec,
|
||||
* and datasetRefs. The caller is responsible for collision policy on `newName`
|
||||
* (the edit form rejects a taken name; programmatic paths pre-resolve a unique
|
||||
* one). Returns the number of snippets updated.
|
||||
*/
|
||||
export function renameDatasetEverywhere(oldName: string, newName: string, now?: Date): number {
|
||||
if (oldName === newName) return 0;
|
||||
const dataset = useDatasetStore.getState().datasets.find((d) => d.name === oldName);
|
||||
if (dataset) useDatasetStore.getState().update(dataset.id, { name: newName }, now);
|
||||
return useSnippetStore.getState().renameDatasetRefs(oldName, newName, now);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import type { UiTheme } from '@core/theme';
|
||||
import type { ModalName } from '../modals/types';
|
||||
|
||||
/**
|
||||
* Centralized cross-cutting application state, as a Zustand store. Keep this
|
||||
@@ -13,9 +14,9 @@ import type { UiTheme } from '@core/theme';
|
||||
*/
|
||||
|
||||
export type { UiTheme };
|
||||
|
||||
/** Which modal, if any, is currently open. At most one at a time (spec §01C). */
|
||||
export type ModalName = 'datasets' | 'settings' | 'about' | 'donate' | 'chartBuilder' | 'extract';
|
||||
// The modal union is defined once in the modal system (docs/architecture/03) and
|
||||
// re-exported here for the many callers that reach it through the app store.
|
||||
export type { ModalName };
|
||||
|
||||
export interface AppState {
|
||||
/** Active UI theme; mirrored onto <html data-theme> by a subscriber. */
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import { createDataset } from '@core/dataset';
|
||||
import { createSnippet } from '@core/snippet';
|
||||
import { selectSelectedDataset, useDatasetStore, type DatasetForm } from './DatasetStore';
|
||||
import { useSnippetStore } from './SnippetStore';
|
||||
|
||||
const store = () => useDatasetStore.getState();
|
||||
const T = new Date('2026-06-01T00:00:00Z');
|
||||
|
||||
beforeEach(() => {
|
||||
store().reset();
|
||||
useSnippetStore.getState().reset();
|
||||
});
|
||||
|
||||
describe('save — create', () => {
|
||||
test('valid inline JSON is added, profiled, and selected', () => {
|
||||
store().startCreate();
|
||||
store().updateForm({ name: 'Sales', input: '[{"a":1,"b":2},{"a":3,"b":4}]' });
|
||||
|
||||
expect(store().save(T)).toBe(true);
|
||||
const ds = selectSelectedDataset(store());
|
||||
expect(ds?.name).toBe('Sales');
|
||||
expect(ds?.format).toBe('json');
|
||||
expect(ds?.rowCount).toBe(2);
|
||||
expect(ds?.columns).toEqual(['a', 'b']);
|
||||
expect(store().view).toBe('detail');
|
||||
});
|
||||
|
||||
test('CSV input is stored as raw text and profiled', () => {
|
||||
store().startCreate();
|
||||
store().updateForm({ name: 'Regions', input: 'city,pop\nA,10\nB,20' });
|
||||
|
||||
expect(store().save(T)).toBe(true);
|
||||
const ds = selectSelectedDataset(store());
|
||||
expect(ds?.format).toBe('csv');
|
||||
expect(ds?.data).toBe('city,pop\nA,10\nB,20');
|
||||
expect(ds?.rowCount).toBe(2);
|
||||
});
|
||||
|
||||
test('a valid URL infers format from the extension', () => {
|
||||
store().startCreate();
|
||||
store().updateForm({ name: 'Remote', source: 'url', input: 'https://example.com/data.csv' });
|
||||
|
||||
expect(store().save(T)).toBe(true);
|
||||
const ds = selectSelectedDataset(store());
|
||||
expect(ds?.source).toBe('url');
|
||||
expect(ds?.format).toBe('csv');
|
||||
expect(ds?.rowCount).toBeNull(); // URL datasets aren't profiled
|
||||
});
|
||||
});
|
||||
|
||||
describe('save — validation', () => {
|
||||
const submit = (patch: Partial<DatasetForm>) => {
|
||||
store().startCreate();
|
||||
store().updateForm(patch);
|
||||
return store().save(T);
|
||||
};
|
||||
|
||||
test('blank name is rejected', () => {
|
||||
expect(submit({ name: ' ', input: '[{"a":1}]' })).toBe(false);
|
||||
expect(store().formError).toMatch(/name/i);
|
||||
expect(store().datasets).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('duplicate name (case-insensitive) is rejected', () => {
|
||||
store().add(
|
||||
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
|
||||
);
|
||||
expect(submit({ name: 'sales', input: '[{"a":1}]' })).toBe(false);
|
||||
expect(store().formError).toMatch(/already exists/i);
|
||||
});
|
||||
|
||||
test('empty and unrecognized inline data are rejected', () => {
|
||||
expect(submit({ name: 'Empty', input: ' ' })).toBe(false);
|
||||
expect(submit({ name: 'Junk', input: 'this is not data' })).toBe(false);
|
||||
expect(store().formError).toMatch(/valid JSON/i);
|
||||
});
|
||||
|
||||
test('a non-http URL is rejected', () => {
|
||||
expect(submit({ name: 'Bad', source: 'url', input: 'ftp://x/y.csv' })).toBe(false);
|
||||
expect(store().formError).toMatch(/url/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save — edit', () => {
|
||||
test('updating inline data re-profiles and advances modified', () => {
|
||||
store().add(
|
||||
createDataset({ name: 'D', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
|
||||
);
|
||||
const id = store().selectedId!;
|
||||
store().startEdit();
|
||||
store().updateForm({ input: '[{"a":1,"b":2},{"a":3,"b":4}]' });
|
||||
|
||||
const later = new Date('2026-07-01T00:00:00Z');
|
||||
expect(store().save(later)).toBe(true);
|
||||
const ds = store().datasets.find((d) => d.id === id)!;
|
||||
expect(ds.columnCount).toBe(2);
|
||||
expect(ds.modified).toBe(later.toISOString());
|
||||
});
|
||||
|
||||
test('renaming a referenced dataset propagates into referencing snippets', () => {
|
||||
store().add(
|
||||
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
|
||||
);
|
||||
const snippet = createSnippet({
|
||||
id: 's1',
|
||||
spec: JSON.stringify({ data: { name: 'Sales' }, mark: 'bar' }),
|
||||
now: T,
|
||||
});
|
||||
useSnippetStore.getState().hydrate([snippet], 's1');
|
||||
useSnippetStore.getState().publish(T); // seed datasetRefs = ['Sales']
|
||||
|
||||
store().startEdit();
|
||||
store().updateForm({ name: 'Revenue' });
|
||||
expect(store().save(new Date('2026-08-01T00:00:00Z'))).toBe(true);
|
||||
|
||||
expect(store().datasets[0].name).toBe('Revenue');
|
||||
const s = useSnippetStore.getState().snippets.find((x) => x.id === 's1')!;
|
||||
expect(s.datasetRefs).toEqual(['Revenue']);
|
||||
expect(s.spec).toContain('"Revenue"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove & view transitions', () => {
|
||||
test('removing the selected dataset clears the selection and returns to the list', () => {
|
||||
store().add(
|
||||
createDataset({ name: 'D', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
|
||||
);
|
||||
const id = store().selectedId!;
|
||||
store().remove(id);
|
||||
expect(store().selectedId).toBeNull();
|
||||
expect(store().view).toBe('list');
|
||||
});
|
||||
|
||||
test('cancelForm returns to detail when a dataset is selected', () => {
|
||||
store().add(
|
||||
createDataset({ name: 'D', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
|
||||
);
|
||||
store().startEdit();
|
||||
expect(store().view).toBe('edit');
|
||||
store().cancelForm();
|
||||
expect(store().view).toBe('detail');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Extract-inline-data → Dataset state (spec §03F).
|
||||
*
|
||||
* Backs the Extract modal: the reverse of a named reference. It captures the
|
||||
* active snippet draft's inline data, takes a dataset name, and on confirm saves
|
||||
* the data as a new dataset and rewrites the draft so the inline data is replaced
|
||||
* by a by-name reference (`{ "data": { "name": … } }`).
|
||||
*
|
||||
* Scope (M3): the **top-level** `data` block of the draft spec — the common case
|
||||
* for a single-view chart. Inline data nested inside layers/concats is left for a
|
||||
* later pass; `hasInlineData` reflects exactly what `confirm` can lift, so the
|
||||
* editor only offers the action when this store can act on it.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { DataFormat } from '@core/format-detection';
|
||||
import { createDataset } from '@core/dataset';
|
||||
import { isNameTaken } from '@core/naming';
|
||||
import { useDatasetStore } from './DatasetStore';
|
||||
import { useSnippetStore } from './SnippetStore';
|
||||
|
||||
/** The inline `data` block of a parsed spec, if it carries `values`. */
|
||||
interface InlineData {
|
||||
values: unknown;
|
||||
format: DataFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the top-level inline data from a draft spec's text, or null when there is
|
||||
* none (no snippet, unparseable, or no `data.values`). The format comes from an
|
||||
* explicit `data.format.type` when present (raw CSV/TSV strings), else JSON.
|
||||
*/
|
||||
export function readInlineData(draftText: string): InlineData | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(draftText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const data = (parsed as Record<string, unknown>).data;
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const values = (data as Record<string, unknown>).values;
|
||||
if (values === undefined) return null;
|
||||
const declared = (data as Record<string, unknown>).format;
|
||||
const type =
|
||||
declared && typeof declared === 'object'
|
||||
? (declared as Record<string, unknown>).type
|
||||
: undefined;
|
||||
const format: DataFormat =
|
||||
type === 'csv' || type === 'tsv' || type === 'topojson' ? type : 'json';
|
||||
return { values, format };
|
||||
}
|
||||
|
||||
/** True when the active snippet's draft has top-level inline data to extract. */
|
||||
export function hasInlineData(draftText: string): boolean {
|
||||
return readInlineData(draftText) !== null;
|
||||
}
|
||||
|
||||
export interface ExtractState {
|
||||
/** Proposed dataset name (required, unique). */
|
||||
name: string;
|
||||
/** The inline data captured at open, for the read-only preview. */
|
||||
source: InlineData | null;
|
||||
/** Inline validation message, or null. */
|
||||
error: string | null;
|
||||
|
||||
/** Capture the active snippet's inline data and reset the form. */
|
||||
init: () => void;
|
||||
setName: (name: string) => void;
|
||||
/**
|
||||
* Validate, create the dataset, and rewrite the active draft to reference it by
|
||||
* name. Returns whether it committed; on failure `error` is set. `now`
|
||||
* injectable for tests.
|
||||
*/
|
||||
confirm: (now?: Date) => boolean;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const INITIAL = { name: '', source: null as InlineData | null, error: null as string | null };
|
||||
|
||||
export const useExtractStore = create<ExtractState>((set, get) => ({
|
||||
...INITIAL,
|
||||
|
||||
init: () => {
|
||||
const draft = useSnippetStore.getState().draftText;
|
||||
set({ name: '', source: readInlineData(draft), error: null });
|
||||
},
|
||||
|
||||
setName: (name) => set({ name, error: null }),
|
||||
|
||||
confirm: (now) => {
|
||||
const name = get().name.trim();
|
||||
if (name === '') {
|
||||
set({ error: 'Enter a dataset name.' });
|
||||
return false;
|
||||
}
|
||||
if (isNameTaken(name, useDatasetStore.getState().datasets)) {
|
||||
set({ error: `A dataset named "${name}" already exists. Choose a different name.` });
|
||||
return false;
|
||||
}
|
||||
const source = get().source;
|
||||
if (!source) {
|
||||
set({ error: 'No inline data to extract.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON/TopoJSON store the parsed value; CSV/TSV keep raw text — but inline
|
||||
// `values` is already the right runtime shape for each, so store it directly.
|
||||
const dataset = createDataset({
|
||||
name,
|
||||
data: source.values,
|
||||
format: source.format,
|
||||
source: 'inline',
|
||||
now,
|
||||
});
|
||||
useDatasetStore.getState().add(dataset);
|
||||
|
||||
// Rewrite the top-level data block to a by-name reference, preserving the rest
|
||||
// of the spec and its pretty-printed text shape.
|
||||
const draftText = useSnippetStore.getState().draftText;
|
||||
const spec = JSON.parse(draftText) as Record<string, unknown>;
|
||||
spec.data = { name };
|
||||
useSnippetStore.getState().replaceActiveDraft(JSON.stringify(spec, null, 2), now);
|
||||
|
||||
// TODO (M6, spec §03F): success toast "Dataset created" — deferred with the
|
||||
// other success toasts (see SnippetStore publish/revert breadcrumbs).
|
||||
set(INITIAL);
|
||||
return true;
|
||||
},
|
||||
|
||||
reset: () => set(INITIAL),
|
||||
}));
|
||||
@@ -224,3 +224,92 @@ describe('editorView + selectShownText (spec §03D)', () => {
|
||||
expect(store().editorView).toBe('draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish — datasetRefs recomputation', () => {
|
||||
const refSpec = (name: string) => JSON.stringify({ data: { name }, mark: 'bar' });
|
||||
|
||||
test('publishing recomputes datasetRefs from the now-published spec', () => {
|
||||
const a = createSnippet({ id: 'a', spec: '{}', now: new Date('2026-01-01T00:00:00Z') });
|
||||
store().hydrate([a], 'a');
|
||||
store().updateDraft(refSpec('Sales'));
|
||||
store().publish(new Date('2026-02-01T00:00:00Z'));
|
||||
|
||||
expect(selectActiveSnippet(store())?.datasetRefs).toEqual(['Sales']);
|
||||
});
|
||||
|
||||
test('refs drop when a published spec no longer references a dataset', () => {
|
||||
const a = createSnippet({
|
||||
id: 'a',
|
||||
spec: refSpec('Sales'),
|
||||
now: new Date('2026-01-01T00:00:00Z'),
|
||||
});
|
||||
store().hydrate([a], 'a');
|
||||
// The factory leaves datasetRefs empty until a publish runs; publish to seed it.
|
||||
store().publish(new Date('2026-01-02T00:00:00Z'));
|
||||
expect(selectActiveSnippet(store())?.datasetRefs).toEqual(['Sales']);
|
||||
|
||||
store().updateDraft('{"mark":"bar"}');
|
||||
store().publish(new Date('2026-02-01T00:00:00Z'));
|
||||
expect(selectActiveSnippet(store())?.datasetRefs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameDatasetRefs', () => {
|
||||
const refSpec = (name: string) => JSON.stringify({ data: { name }, mark: 'bar' }, null, 2);
|
||||
|
||||
test('rewrites spec, draftSpec, and refs of referencing snippets only', () => {
|
||||
const a = createSnippet({
|
||||
id: 'a',
|
||||
spec: refSpec('Sales'),
|
||||
now: new Date('2026-01-01T00:00:00Z'),
|
||||
});
|
||||
const b = createSnippet({
|
||||
id: 'b',
|
||||
spec: '{"mark":"line"}',
|
||||
now: new Date('2026-01-02T00:00:00Z'),
|
||||
});
|
||||
store().hydrate([a, b], 'b');
|
||||
store().publish(new Date('2026-01-03T00:00:00Z')); // seed a's refs would need a active; seed via select
|
||||
store().selectSnippet('a');
|
||||
store().publish(new Date('2026-01-04T00:00:00Z'));
|
||||
|
||||
const updated = store().renameDatasetRefs('Sales', 'Revenue', new Date('2026-02-01T00:00:00Z'));
|
||||
|
||||
expect(updated).toBe(1);
|
||||
const renamed = store().snippets.find((s) => s.id === 'a')!;
|
||||
expect(renamed.datasetRefs).toEqual(['Revenue']);
|
||||
expect(renamed.spec).toContain('"Revenue"');
|
||||
expect(renamed.draftSpec).toContain('"Revenue"');
|
||||
// The non-referencing snippet is untouched.
|
||||
expect(store().snippets.find((s) => s.id === 'b')!.spec).toBe('{"mark":"line"}');
|
||||
});
|
||||
|
||||
test('refreshes the active draft buffer when the active snippet is rewritten', () => {
|
||||
const a = createSnippet({
|
||||
id: 'a',
|
||||
spec: refSpec('Sales'),
|
||||
now: new Date('2026-01-01T00:00:00Z'),
|
||||
});
|
||||
store().hydrate([a], 'a');
|
||||
store().publish(new Date('2026-01-02T00:00:00Z'));
|
||||
const epochBefore = store().bufferEpoch;
|
||||
|
||||
store().renameDatasetRefs('Sales', 'Revenue', new Date('2026-02-01T00:00:00Z'));
|
||||
|
||||
expect(store().draftText).toContain('"Revenue"');
|
||||
expect(store().bufferEpoch).toBe(epochBefore + 1);
|
||||
});
|
||||
|
||||
test('no-ops when the names are equal or nothing references the old name', () => {
|
||||
const a = createSnippet({
|
||||
id: 'a',
|
||||
spec: refSpec('Sales'),
|
||||
now: new Date('2026-01-01T00:00:00Z'),
|
||||
});
|
||||
store().hydrate([a], 'a');
|
||||
store().publish(new Date('2026-01-02T00:00:00Z'));
|
||||
|
||||
expect(store().renameDatasetRefs('Sales', 'Sales')).toBe(0);
|
||||
expect(store().renameDatasetRefs('Unknown', 'Other')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { createSnippet, type CreateSnippetOptions, type Snippet } from '@core/snippet';
|
||||
import { recomputeDatasetRefs, renameDatasetInSpec } from '@core/spec-refs';
|
||||
|
||||
/** Which version of the active snippet the editor is showing (spec §03D). */
|
||||
export type EditorView = 'draft' | 'published';
|
||||
@@ -48,6 +49,13 @@ export interface SnippetState {
|
||||
removeSnippet: (id: string) => void;
|
||||
/** Update the draft buffer only (no persistence; debounced commit follows). */
|
||||
updateDraft: (text: string) => void;
|
||||
/**
|
||||
* Replace the active snippet's draft spec with new text and reload the editor
|
||||
* on the draft view (bumps `bufferEpoch`). Used by programmatic rewrites such
|
||||
* as Extract-to-Dataset (spec §03F), which substitutes inline data for a
|
||||
* by-name reference. No-op when no snippet is active. `now` injectable.
|
||||
*/
|
||||
replaceActiveDraft: (text: string, now?: Date) => void;
|
||||
/**
|
||||
* Persist the editor buffer into the active snippet's **draft**, if it parses
|
||||
* as JSON. Returns whether it committed (a half-typed, unparseable buffer is
|
||||
@@ -57,6 +65,14 @@ export interface SnippetState {
|
||||
commitDraft: (now?: Date) => boolean;
|
||||
/** Switch the editor between the draft and published views (spec §03D). */
|
||||
setEditorView: (view: EditorView) => void;
|
||||
/**
|
||||
* Propagate a dataset rename across every referencing snippet: rewrite the
|
||||
* named-data references in both `spec` and `draftSpec`, recompute `datasetRefs`,
|
||||
* and refresh the live editor buffer if the active snippet was rewritten — so a
|
||||
* user mid-edit doesn't see their draft silently break (docs/architecture/07
|
||||
* §6). Returns the number of snippets changed. `now` injectable.
|
||||
*/
|
||||
renameDatasetRefs: (oldName: string, newName: string, now?: Date) => number;
|
||||
/**
|
||||
* Promote the active snippet's current draft to its published version (spec
|
||||
* §03D → Publish). Flushes the live buffer first, then makes `spec` identical
|
||||
@@ -151,6 +167,20 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
|
||||
updateDraft: (draftText) => set({ draftText }),
|
||||
|
||||
replaceActiveDraft: (text, now) => {
|
||||
const { activeSnippetId } = get();
|
||||
if (!activeSnippetId) return;
|
||||
const modified = (now ?? new Date()).toISOString();
|
||||
set((s) => ({
|
||||
snippets: s.snippets.map((x) =>
|
||||
x.id === activeSnippetId ? { ...x, draftSpec: text, modified } : x,
|
||||
),
|
||||
draftText: text,
|
||||
editorView: 'draft',
|
||||
bufferEpoch: s.bufferEpoch + 1,
|
||||
}));
|
||||
},
|
||||
|
||||
commitDraft: (now) => {
|
||||
const { activeSnippetId, draftText, snippets } = get();
|
||||
if (!activeSnippetId) return false;
|
||||
@@ -178,6 +208,43 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
|
||||
setEditorView: (editorView) => set({ editorView }),
|
||||
|
||||
renameDatasetRefs: (oldName, newName, now) => {
|
||||
if (oldName === newName) return 0;
|
||||
const lower = oldName.toLowerCase();
|
||||
const { snippets, activeSnippetId, editorView } = get();
|
||||
let updated = 0;
|
||||
let activeDraftAfter: string | null = null;
|
||||
|
||||
const next = snippets.map((s) => {
|
||||
if (!s.datasetRefs.some((r) => r.toLowerCase() === lower)) return s;
|
||||
updated++;
|
||||
const spec = renameDatasetInSpec(s.spec, oldName, newName);
|
||||
const draftSpec = renameDatasetInSpec(s.draftSpec, oldName, newName);
|
||||
if (s.id === activeSnippetId) activeDraftAfter = draftSpec;
|
||||
return {
|
||||
...s,
|
||||
spec,
|
||||
draftSpec,
|
||||
datasetRefs: recomputeDatasetRefs(spec),
|
||||
modified: (now ?? new Date()).toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
if (updated === 0) return 0;
|
||||
set((st) => ({
|
||||
snippets: next,
|
||||
// If the active snippet's draft was rewritten, reload the editor buffer so
|
||||
// the live (draft-view) buffer reflects the new name and the next auto-save
|
||||
// doesn't clobber the rewrite with the stale old name.
|
||||
...(activeDraftAfter !== null && editorView === 'draft'
|
||||
? { draftText: activeDraftAfter, bufferEpoch: st.bufferEpoch + 1 }
|
||||
: activeDraftAfter !== null
|
||||
? { bufferEpoch: st.bufferEpoch + 1 }
|
||||
: {}),
|
||||
}));
|
||||
return updated;
|
||||
},
|
||||
|
||||
publish: (now) => {
|
||||
// Flush the live buffer into the draft first, so Publish promotes exactly
|
||||
// what the user sees (an invalid buffer leaves the last valid draft in place).
|
||||
@@ -189,8 +256,15 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
set({
|
||||
snippets: snippets.map((s) =>
|
||||
s.id === activeSnippetId
|
||||
? // M3: recompute datasetRefs from the now-published spec (spec §03D).
|
||||
{ ...s, spec: s.draftSpec, modified }
|
||||
? // Promote the draft and recompute datasetRefs from the now-published
|
||||
// spec, so the bidirectional snippet↔dataset link mirrors reality
|
||||
// (spec §03D, docs/architecture/07 §3).
|
||||
{
|
||||
...s,
|
||||
spec: s.draftSpec,
|
||||
datasetRefs: recomputeDatasetRefs(s.draftSpec),
|
||||
modified,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user