/** * 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 { useMemo, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { datasetReference, tabularRows, type DataSource, type Dataset } from '@core/dataset'; import { detectFormat, detectFormatFromUrl, type DataFormat } from '@core/format-detection'; import { datasetUsageCounts, snippetsReferencingDataset } from '@core/relationships'; import { closeModal, openModal, resnapshot } from '../modals/ModalCoordinator'; import { fetchRemoteData } from '../infrastructure/remote-data'; import { remoteFetchErrorMessage } from '../services/remote-data-errors'; import { confirm } from '../stores/ConfirmStore'; import { notify } from '../stores/NotificationStore'; import { selectCanSave, selectSelectedDataset, useDatasetStore, byModifiedDesc, } from '../stores/DatasetStore'; import { useSnippetStore } from '../stores/SnippetStore'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { Icon } from './Icon'; 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`; } const SOURCE_OPTIONS: ReadonlyArray> = [ { value: 'inline', label: 'Inline' }, { value: 'url', label: 'URL' }, ]; /** Rows shown in the tabular preview before truncating (spec §05 → Detail Panel). */ const PREVIEW_ROW_LIMIT = 50; /** One table cell's text: blank for empty, the string as-is, else JSON (numbers, * booleans, nested values). Avoids `String()` on objects ("[object Object]"). */ function cellText(value: unknown): string { if (value == null) return ''; if (typeof value === 'string') return value; return JSON.stringify(value); } 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 = datasetUsageCounts(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. // A fetched URL snapshot reads like an inline dataset (rows + size); an unfetched // URL reference shows "not fetched" in place of figures it doesn't have yet. const unfetched = dataset.source === 'url' && dataset.data == null; const parts: string[] = []; if (dataset.source === 'url') parts.push('URL'); if (unfetched) parts.push('not fetched'); else if (dataset.rowCount !== null) parts.push(`${dataset.rowCount} rows`); parts.push(formatLabel(dataset.format)); if (!unfetched) 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 refreshDataset = useDatasetStore((s) => s.refreshDataset); const selectSnippet = useSnippetStore((s) => s.selectSnippet); const [copied, setCopied] = useState(false); const [refreshing, setRefreshing] = useState(false); const linked = snippetsReferencingDataset(snippets, dataset.name); // Tabular data (CSV/TSV/JSON-array, inline or fetched) previews as a table of the // first rows under the profiled columns; non-tabular payloads fall back to text. // Memoized on the record so a large CSV isn't re-parsed on unrelated re-renders. const previewRows = useMemo( () => tabularRows(dataset.data, dataset.format, PREVIEW_ROW_LIMIT), [dataset], ); const handleEdit = () => { startEdit(); resnapshot(); }; // Re-fetch a URL dataset's source and re-snapshot it. The visible result (updated // rows + "Fetched" time) is the confirmation, so success raises no toast; only a // failure surfaces one (spec §05 → Actions; docs/architecture/10 → Toast copy). const handleRefresh = async () => { if (!dataset.url) return; setRefreshing(true); try { const { text } = await fetchRemoteData(dataset.url); refreshDataset(dataset.id, { text }); } catch (err) { notify({ kind: 'error', title: "Couldn't refresh dataset", message: remoteFetchErrorMessage(err, 'retry'), }); } finally { setRefreshing(false); } }; 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' : ''} {dataset.source === 'url' && ( )} {/* Build Chart (spec §05 → §06) — opens the Chart Builder on this dataset. Replaces the Datasets modal (one modal at a time, §01C); detail view has no transient form state, so no discard prompt. */}
    {dataset.comment &&

    {dataset.comment}

    } {dataset.source === 'url' && (

    {dataset.url} {dataset.fetchedAt ? `Fetched ${new Date(dataset.fetchedAt).toLocaleString()}` : 'Not fetched yet'}

    )}

    Overview

    Rows
    {dataset.rowCount ?? 'N/A'}
    Columns
    {dataset.columnCount ?? 'N/A'}
    Format
    {formatLabel(dataset.format)}
    Size
    {dataset.data == null ? '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

    {previewRows ? ( <> {/* A scrollable region needs a tab stop + name so keyboard-only users can reach and scroll it when the preview overflows (WCAG 2.1.1; WAI-ARIA APG — a focusable `region` with an accessible name). */}
    {dataset.columns.map((col, ci) => ( ))} {previewRows.map((row, ri) => ( {dataset.columns.map((col, ci) => ( ))} ))}
    {col}
    {cellText(row[col])}
    {dataset.rowCount != null && dataset.rowCount > previewRows.length && (

    Showing the first {previewRows.length} of {dataset.rowCount} rows.

    )} ) : (
    {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, pretty JSON otherwise. */ function previewText(dataset: Dataset): string { const MAX = 2000; // No snapshot yet (an unfetched URL reference) — there is nothing to preview. if (dataset.data == null) { return dataset.source === 'url' ? 'Not fetched yet — use Refresh to load the data.' : ''; } let text: string; if (dataset.format === 'csv' || dataset.format === 'tsv') { // CSV/TSV payloads are raw text; fall back to JSON for any non-string value. text = typeof dataset.data === 'string' ? dataset.data : JSON.stringify(dataset.data); } else { try { text = JSON.stringify(dataset.data, null, 2); } catch { text = typeof dataset.data === 'string' ? dataset.data : '[unserializable 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 selected = useDatasetStore(selectSelectedDataset); const updateForm = useDatasetStore((s) => s.updateForm); const cancelForm = useDatasetStore((s) => s.cancelForm); const save = useDatasetStore((s) => s.save); const commitUrlSnapshot = useDatasetStore((s) => s.commitUrlSnapshot); // Save stays disabled until a name and valid data/URL are present (spec §05). const canSave = useDatasetStore(selectCanSave); // URL datasets fetch on save (snapshot model): `fetching` drives the button's // busy state; `fetchError` holds a failed fetch's message + the inline fallback. const [fetching, setFetching] = useState(false); const [fetchError, setFetchError] = useState(null); // 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 = async () => { // Inline saves are synchronous; only URL datasets touch the network. if (form.source !== 'url') { if (save()) resnapshot(); // committed — re-baseline so a later close won't prompt return; } const url = form.input.trim(); // A metadata-only edit (same URL, snapshot already present) needs no re-fetch. const metaOnly = editing && selected?.source === 'url' && selected.data != null && url === (selected.url ?? ''); if (metaOnly) { if (save()) resnapshot(); return; } // Create, changed URL, or inline→URL: fetch once, then snapshot + commit. setFetchError(null); setFetching(true); try { const { text } = await fetchRemoteData(url); if (commitUrlSnapshot({ text })) resnapshot(); } catch (err) { setFetchError(remoteFetchErrorMessage(err)); } finally { setFetching(false); } }; // Recovery from a failed fetch: switch to an inline paste, keeping name + comment. const handlePasteInline = () => { setFetchError(null); updateForm({ source: 'inline', input: '' }); }; const handleCancel = () => { cancelForm(); resnapshot(); }; return (

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

    Source { setFetchError(null); updateForm({ source }); }} />