Files
astrolabe/src/app/components/DatasetsModal.tsx
T

575 lines
20 KiB
TypeScript

/**
* 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 {
cellText,
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 { humanizeBytes } from '@core/storage-estimate';
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 { Button } from './Button';
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();
}
const SOURCE_OPTIONS: ReadonlyArray<SegmentedOption<DataSource>> = [
{ 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;
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 (
<div className={styles.manager}>
<div className={styles.listPane}>
<Button variant="primary" size="lg" className={styles.newButton} onClick={handleNew}>
<Icon name="add" /> 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.
// 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(humanizeBytes(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 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 (
<div className={styles.detail}>
<div className={styles.detailHead}>
<h3 className={styles.detailName}>{dataset.name}</h3>
<div className={styles.detailActions}>
<Button onClick={() => void handleCopy()}>{copied ? 'Copied' : 'Copy Reference'}</Button>
{/* 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. */}
<span role="status" className="visually-hidden">
{copied ? 'Reference copied to clipboard' : ''}
</span>
{dataset.source === 'url' && (
<Button onClick={() => void handleRefresh()} disabled={refreshing}>
{refreshing ? 'Refreshing…' : 'Refresh'}
</Button>
)}
<Button onClick={handleEdit}>Edit</Button>
{/* 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. */}
<Button onClick={() => openModal('chartBuilder', String(dataset.id))}>Build Chart</Button>
<Button variant="danger-outline" onClick={() => void handleDelete()}>
Delete
</Button>
</div>
</div>
{dataset.comment && <p className={styles.comment}>{dataset.comment}</p>}
{dataset.source === 'url' && (
<p className={styles.sourceMeta}>
<a className={styles.sourceLink} href={dataset.url} target="_blank" rel="noreferrer">
{dataset.url}
</a>
<span>
{dataset.fetchedAt
? `Fetched ${new Date(dataset.fetchedAt).toLocaleString()}`
: 'Not fetched yet'}
</span>
</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.data == null ? 'N/A' : humanizeBytes(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>
{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). */}
<div
className={styles.previewTableWrap}
role="region"
aria-label={`Data preview for ${dataset.name}`}
tabIndex={0}
>
<table className={styles.previewTable}>
<thead>
<tr>
{dataset.columns.map((col, ci) => (
<th key={ci} scope="col">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.map((row, ri) => (
<tr key={ri}>
{dataset.columns.map((col, ci) => (
<td key={ci}>{cellText(row[col])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{dataset.rowCount != null && dataset.rowCount > previewRows.length && (
<p className={styles.muted}>
Showing the first {previewRows.length} of {dataset.rowCount} rows.
</p>
)}
</>
) : (
<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, 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<string | null>(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 (
<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) => {
setFetchError(null);
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) => {
setFetchError(null);
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 || fetchError) && (
<p className={styles.formError} role="alert">
{formError ?? fetchError}
</p>
)}
{/* A failed fetch is recoverable by pasting the data inline (the user's choice
when CORS or offline blocks the URL) — offered as a direct one-click path. */}
{fetchError && (
<Button className={styles.fallback} onClick={handlePasteInline}>
Paste data inline instead
</Button>
)}
<div className={styles.formActions}>
<Button onClick={handleCancel} disabled={fetching}>
Cancel
</Button>
<Button variant="primary" disabled={!canSave || fetching} onClick={() => void handleSave()}>
{fetching ? 'Fetching…' : editing ? 'Save changes' : 'Create dataset'}
</Button>
</div>
</div>
);
}