mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add dataset library, extract-to-dataset, and render-time reference resolution
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user