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,437 @@
|
||||
/* Datasets manager — two-pane body inside the modal shell (spec §05 → Layout). */
|
||||
|
||||
.manager {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* --- List pane --- */
|
||||
.listPane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-right: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.newButton {
|
||||
flex: 0 0 auto;
|
||||
margin: var(--space-4);
|
||||
height: 40px;
|
||||
padding: 0 var(--space-5);
|
||||
border: var(--border-width) solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.newButton:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border-top: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
padding: var(--space-5) var(--space-4);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-4);
|
||||
border-left: 2px solid transparent;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.item + .item {
|
||||
border-top: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
.itemActive {
|
||||
background: var(--layer-01);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.itemMain {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: var(--space-3) 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Usage badge — count of referencing snippets (spec §05 → List item). */
|
||||
.badge {
|
||||
flex: 0 0 auto;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 var(--space-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--layer-02);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* --- Detail pane --- */
|
||||
.detailPane {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.detailEmpty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail,
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.detailHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detailName {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detailActions,
|
||||
.formActions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.formActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.action {
|
||||
height: 32px;
|
||||
padding: 0 var(--space-4);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.action:hover:not(:disabled) {
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
color: var(--text-placeholder);
|
||||
border-color: var(--border);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.danger {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--support-error);
|
||||
}
|
||||
|
||||
.danger:hover:not(:disabled) {
|
||||
background: var(--support-error);
|
||||
color: var(--on-status);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.comment {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.stats dt {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stats dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.columns {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.column + .column {
|
||||
border-top: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.columnName {
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.columnType {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--layer-01);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.timestamps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: var(--space-3);
|
||||
background: var(--layer-01);
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.linked {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.linkButton:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.linkButton:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --- Create / edit form --- */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
font-family: var(--font-mono);
|
||||
resize: vertical;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.input:focus-visible,
|
||||
.textarea:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.input::placeholder,
|
||||
.textarea::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.detected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-top: calc(-1 * var(--space-2));
|
||||
}
|
||||
|
||||
.detectedBadge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: var(--layer-02);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.detectedHint {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.formError {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--support-error);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/* Extract-to-Dataset — a single-form modal body (spec §03F). */
|
||||
|
||||
.extract {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
padding: var(--space-5);
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.input:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin: 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
padding: var(--space-3);
|
||||
background: var(--layer-01);
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--support-error);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.action {
|
||||
height: 36px;
|
||||
padding: 0 var(--space-5);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Extract-to-Dataset — the modal body (spec §03F).
|
||||
*
|
||||
* Shows a read-only preview of the active snippet draft's inline data and asks
|
||||
* for a dataset name. On confirm it saves the data as a new dataset and rewrites
|
||||
* the draft to reference it by name (logic in ExtractStore), then force-closes
|
||||
* (the commit is the user's confirmation, so no discard prompt). Cancel leaves
|
||||
* the spec unchanged.
|
||||
*/
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { closeModal } from '../modals/ModalCoordinator';
|
||||
import { useExtractStore } from '../stores/ExtractStore';
|
||||
import styles from './ExtractModal.module.css';
|
||||
|
||||
const MAX_PREVIEW = 1500;
|
||||
|
||||
function previewOf(values: unknown): string {
|
||||
let text: string;
|
||||
try {
|
||||
text = typeof values === 'string' ? values : JSON.stringify(values, null, 2);
|
||||
} catch {
|
||||
text = String(values);
|
||||
}
|
||||
return text.length > MAX_PREVIEW ? `${text.slice(0, MAX_PREVIEW)}\n…` : text;
|
||||
}
|
||||
|
||||
export function ExtractModal() {
|
||||
const { name, source, error } = useExtractStore(
|
||||
useShallow((s) => ({ name: s.name, source: s.source, error: s.error })),
|
||||
);
|
||||
const setName = useExtractStore((s) => s.setName);
|
||||
const runConfirm = useExtractStore((s) => s.confirm);
|
||||
|
||||
const handleCreate = () => {
|
||||
if (runConfirm()) void closeModal(true);
|
||||
};
|
||||
|
||||
if (!source) {
|
||||
return <p className={styles.muted}>This snippet has no inline data to extract.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.extract}>
|
||||
<p className={styles.intro}>
|
||||
Save this snippet’s inline data as a reusable dataset. The spec will be rewritten to
|
||||
reference it by name.
|
||||
</p>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Dataset name</span>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
}}
|
||||
placeholder="e.g. Sales 2024"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Data to extract ({source.format.toUpperCase()})</span>
|
||||
<pre className={styles.preview}>{previewOf(source.values)}</pre>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.action} onClick={() => void closeModal()}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.action} ${styles.primary}`}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
Create dataset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,12 +16,14 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import { prepareSpecForRender } from '@core/rendering';
|
||||
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||||
import { chartConfigFor } from '@core/vega-themes';
|
||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
@@ -73,6 +75,9 @@ export function LivePreview() {
|
||||
const shownText = useSnippetStore(selectShownText);
|
||||
const fitMode = useAppStore((s) => s.previewFitMode);
|
||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||
// Datasets feed reference resolution (spec §04 step 1). Re-rendering on a
|
||||
// dataset change keeps a referencing chart live as its data is edited.
|
||||
const datasets = useDatasetStore(useShallow((s) => s.datasets));
|
||||
const error = usePreviewStore((s) => s.error);
|
||||
const setError = usePreviewStore((s) => s.setError);
|
||||
|
||||
@@ -107,7 +112,7 @@ export function LivePreview() {
|
||||
|
||||
const mine = ++generationRef.current;
|
||||
try {
|
||||
const prepared = prepareSpecForRender(parsed, { fitMode });
|
||||
const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
|
||||
const config = chartConfigFor(uiTheme);
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
@@ -126,17 +131,27 @@ export function LivePreview() {
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
if (mine === generationRef.current) {
|
||||
setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
||||
// tailored, fixable message instead of the generic syntax hint (council:
|
||||
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix).
|
||||
if (e instanceof DatasetNotFoundError) {
|
||||
setError(
|
||||
`Dataset "${e.datasetName}" not found. Create it from Datasets ` +
|
||||
`(⌘/Ctrl+K), or check the dataset name in your spec.`,
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, RENDER_DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [shownText, fitMode, uiTheme, setError]);
|
||||
}, [shownText, fitMode, uiTheme, datasets, setError]);
|
||||
|
||||
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
||||
// observe the element, so we do: one observer on the stable host node for the
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/* Feature-modal shell (docs/architecture/03 → Layer 3). Sits below the confirm
|
||||
layer (z 1000) so a discard prompt can appear above an open modal. */
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-5);
|
||||
background: rgb(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--layer-01);
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 2px 12px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
/* Large: the two-pane managers (Datasets). Definite height so inner panes scroll. */
|
||||
.large {
|
||||
width: min(960px, 94vw);
|
||||
height: min(700px, 88vh);
|
||||
}
|
||||
|
||||
/* Small: single-form modals (Extract). Grows with content up to a cap. */
|
||||
.small {
|
||||
width: min(560px, 92vw);
|
||||
max-height: 88vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
height: var(--header-height);
|
||||
padding: 0 var(--space-5);
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* The title is only a programmatic focus target (large-manager initial focus);
|
||||
it shouldn't paint a ring the way a keyboard-reached control would. */
|
||||
.title:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.close {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: var(--layer-02);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.close:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The modal body. Background steps to --bg so the panes read as the work surface
|
||||
against the --layer-01 chrome. Fills the remaining height; inner content scrolls. */
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Modal shell (docs/architecture/03 → Layer 3).
|
||||
*
|
||||
* Renders exactly ONE modal — whichever `activeModal` names — inside a single
|
||||
* reusable chrome: backdrop, header (title + close), and a focus trap. The
|
||||
* modal's registered `component` fills the body. This is the only place a modal
|
||||
* name maps to a view (`<Body />` from the registry), so there is no
|
||||
* `name === 'datasets' && <DatasetsModal/>` chain anywhere.
|
||||
*
|
||||
* Dismissal is uniform for these passive feature modals: the close button,
|
||||
* Escape, or a backdrop click — never a click inside the body (which stops
|
||||
* propagation). Each modal owns its own action buttons; the shell stays generic.
|
||||
*/
|
||||
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { getModalConfig, getModalTitle } from '../modals/modal-registry';
|
||||
import { closeModal } from '../modals/ModalCoordinator';
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||
import styles from './ModalShell.module.css';
|
||||
|
||||
export function ModalShell() {
|
||||
const name = useAppStore((s) => s.activeModal);
|
||||
const config = getModalConfig(name);
|
||||
|
||||
const isLarge = name === 'datasets';
|
||||
|
||||
// Move focus into the modal on open, return it to the trigger on close. For a
|
||||
// large manager (list + detail), APG dialog-modal advises focusing a static
|
||||
// element at the top — the title — so the content's start is perceived rather
|
||||
// than jumping past it to the first control. Small form modals keep their
|
||||
// first-field focus (Extract autofocuses its name input).
|
||||
const modalRef = useFocusTrap<HTMLDivElement>(
|
||||
name !== null,
|
||||
isLarge ? '#modal-title' : undefined,
|
||||
);
|
||||
|
||||
if (!config) return null;
|
||||
const Body = config.component;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
onClick={() => void closeModal()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
void closeModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`${styles.modal} ${isLarge ? styles.large : styles.small}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
{/* tabIndex -1 makes the title a programmatic focus target for the
|
||||
large-manager initial focus (APG dialog-modal), without adding it to
|
||||
the Tab order. */}
|
||||
<h2 id="modal-title" className={styles.title} tabIndex={-1}>
|
||||
{getModalTitle(name)}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.close}
|
||||
aria-label="Close"
|
||||
onClick={() => void closeModal()}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.body}>
|
||||
<Body />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,6 +129,18 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Linked-datasets indicator (spec §02): icon + count, meaning carried by the
|
||||
icon + accessible label, not colour. Pushed to the row's trailing edge. */
|
||||
.datasets {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.delete {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
|
||||
@@ -14,6 +14,36 @@ import { confirm } from '../stores/ConfirmStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import styles from './SnippetLibrary.module.css';
|
||||
|
||||
/** A small database glyph for the linked-datasets indicator (icons keep their own
|
||||
* rounded geometry per the design tokens). Inherits `currentColor` so it themes. */
|
||||
function DatasetIcon() {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<ellipse
|
||||
cx="8"
|
||||
cy="3.5"
|
||||
rx="5.5"
|
||||
ry="2.2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path
|
||||
d="M2.5 3.5v9c0 1.2 2.46 2.2 5.5 2.2s5.5-1 5.5-2.2v-9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path
|
||||
d="M2.5 8c0 1.2 2.46 2.2 5.5 2.2s5.5-1 5.5-2.2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact relative date for the list (full date formatting lands in M5). */
|
||||
function relativeDate(iso: string): string {
|
||||
const then = new Date(iso);
|
||||
@@ -97,6 +127,18 @@ export function SnippetLibrary() {
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.date}>{size ? `${date} · ${size}` : date}</span>
|
||||
{/* Linked datasets (spec §02): the snippet side of the
|
||||
bidirectional link, maintained on publish via datasetRefs. */}
|
||||
{s.datasetRefs.length > 0 && (
|
||||
<span
|
||||
className={styles.datasets}
|
||||
title={`Linked datasets: ${s.datasetRefs.join(', ')}`}
|
||||
aria-label={`${s.datasetRefs.length} linked dataset${s.datasetRefs.length === 1 ? '' : 's'}`}
|
||||
>
|
||||
<DatasetIcon />
|
||||
{s.datasetRefs.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -23,8 +23,10 @@ import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import 'monaco-editor/esm/vs/language/json/monaco.contribution';
|
||||
import '../infrastructure/monaco-env'; // side-effect: wire workers before create
|
||||
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
|
||||
import { openModal } from '../modals/ModalCoordinator';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { confirm } from '../stores/ConfirmStore';
|
||||
import { hasInlineData } from '../stores/ExtractStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
@@ -53,6 +55,11 @@ function EditorToolbar() {
|
||||
const draft = s.editorView === 'draft' ? s.draftText : active.draftSpec;
|
||||
return draft !== active.spec;
|
||||
});
|
||||
// Offer Extract only when the live draft carries top-level inline data to lift
|
||||
// out (spec §03F → hidden when the spec has no inline data).
|
||||
const canExtract = useSnippetStore(
|
||||
(s) => s.activeSnippetId !== null && hasInlineData(s.draftText),
|
||||
);
|
||||
|
||||
const handlePublish = () => {
|
||||
if (!useSnippetStore.getState().activeSnippetId) return;
|
||||
@@ -85,6 +92,16 @@ function EditorToolbar() {
|
||||
|
||||
<span className={styles.spacer} />
|
||||
|
||||
{canExtract && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
onClick={() => openModal('extract')}
|
||||
title="Extract inline data into a reusable dataset"
|
||||
>
|
||||
Extract to Dataset
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
|
||||
Reference in New Issue
Block a user