mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
575 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|