/** * 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 { const counts = new Map(); 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> = [ { 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 (
    {ordered.length === 0 && (
  • No datasets yet — create one to reuse data across snippets.
  • )} {ordered.map((d) => ( select(d.id)} /> ))}
{view === 'new' || view === 'edit' ? ( ) : selected ? ( ) : (
Select a dataset or create a new one.
)}
); } 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 (
  • {usage > 0 && ( {usage} )}
  • ); } 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, }); if (!ok) return; const removedName = dataset.name; remove(dataset.id); // Confirm the deletion (spec §05). The message names which dataset went // (council toast-copy rule, docs/architecture/10 → Toast copy). notify({ kind: 'success', title: 'Dataset deleted', message: `"${removedName}" was permanently removed.`, }); }; return (

    {dataset.name}

    {/* The clipboard write is invisible, so the success is confirmed inline ("Copied") rather than by a toast (docs/architecture/10 → Toast copy). This polite live region announces it to assistive tech, which the button's visual label swap alone would not reliably do. */} {copied ? 'Reference copied to clipboard' : ''} {/* "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. */}
    {dataset.comment &&

    {dataset.comment}

    }

    Overview

    Rows
    {dataset.rowCount ?? 'N/A'}
    Columns
    {dataset.columnCount ?? 'N/A'}
    Format
    {formatLabel(dataset.format)}
    Size
    {dataset.source === 'url' ? 'N/A' : humanBytes(dataset.size)}
    {dataset.columnTypes.length > 0 && (
      {dataset.columnTypes.map((col) => (
    • {col.name} {col.type}
    • ))}
    )}
    Created {new Date(dataset.created).toLocaleString()} Modified {new Date(dataset.modified).toLocaleString()}

    Preview

    {previewText(dataset)}

    Linked Snippets

    {linked.length === 0 ? (

    No snippets reference this dataset yet.

    ) : (
      {linked.map((s) => (
    • ))}
    )}
    ); } /** 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 (

    {editing ? 'Edit dataset' : 'New dataset'}

    Source updateForm({ source })} />