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

This commit is contained in:
2026-06-05 15:49:40 +03:00
parent 25849461e0
commit a4e4d96d3b
41 changed files with 3909 additions and 19 deletions
+437
View File
@@ -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);
}
+419
View File
@@ -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>
);
}
+114
View File
@@ -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);
}
+90
View File
@@ -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&rsquo;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>
);
}
+22 -7
View File
@@ -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
+95
View File
@@ -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);
}
+81
View File
@@ -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;
+42
View File
@@ -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
+17
View File
@@ -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}