mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Snapshot URL datasets locally on add; preview tabular data as a table
This commit is contained in:
@@ -235,6 +235,32 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* URL dataset provenance: the source address + last-fetched time (spec §05). */
|
||||
.sourceMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sourceLink {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sourceLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sourceLink:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -335,6 +361,46 @@
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Tabular preview — a scrollable grid with a sticky header (spec §05 → Preview). */
|
||||
.previewTableWrap {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.previewTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.previewTable th,
|
||||
.previewTable td {
|
||||
text-align: left;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
white-space: nowrap;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.previewTable thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--layer-01);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.previewTable tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
@@ -439,3 +505,10 @@
|
||||
font-size: 13px;
|
||||
color: var(--support-error);
|
||||
}
|
||||
|
||||
/* The inline-fallback recovery under a failed URL fetch — left-aligned, not
|
||||
stretched, so it reads as a secondary recovery beneath the error message. */
|
||||
.fallback {
|
||||
align-self: flex-start;
|
||||
margin-top: calc(-1 * var(--space-2));
|
||||
}
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
* Snippets stay reactive without a stored back-pointer.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { datasetReference, type DataSource, type Dataset } from '@core/dataset';
|
||||
import { datasetReference, tabularRows, type DataSource, type Dataset } from '@core/dataset';
|
||||
import { detectFormat, detectFormatFromUrl, type DataFormat } from '@core/format-detection';
|
||||
import { datasetUsageCounts, snippetsReferencingDataset } from '@core/relationships';
|
||||
import { closeModal, openModal, resnapshot } from '../modals/ModalCoordinator';
|
||||
import { fetchRemoteData } from '../infrastructure/remote-data';
|
||||
import { remoteFetchErrorMessage } from '../services/remote-data-errors';
|
||||
import { confirm } from '../stores/ConfirmStore';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import {
|
||||
@@ -50,6 +52,17 @@ const SOURCE_OPTIONS: ReadonlyArray<SegmentedOption<DataSource>> = [
|
||||
{ value: 'url', label: 'URL' },
|
||||
];
|
||||
|
||||
/** Rows shown in the tabular preview before truncating (spec §05 → Detail Panel). */
|
||||
const PREVIEW_ROW_LIMIT = 50;
|
||||
|
||||
/** One table cell's text: blank for empty, the string as-is, else JSON (numbers,
|
||||
* booleans, nested values). Avoids `String()` on objects ("[object Object]"). */
|
||||
function cellText(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function DatasetsModal() {
|
||||
const datasets = useDatasetStore(useShallow((s) => s.datasets));
|
||||
const view = useDatasetStore((s) => s.view);
|
||||
@@ -116,11 +129,15 @@ function DatasetListItem({
|
||||
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 (dataset.rowCount !== null) parts.push(`${dataset.rowCount} rows`);
|
||||
if (unfetched) parts.push('not fetched');
|
||||
else if (dataset.rowCount !== null) parts.push(`${dataset.rowCount} rows`);
|
||||
parts.push(formatLabel(dataset.format));
|
||||
if (dataset.source !== 'url') parts.push(humanBytes(dataset.size));
|
||||
if (!unfetched) parts.push(humanBytes(dataset.size));
|
||||
|
||||
return (
|
||||
<li className={`${styles.item} ${active ? styles.itemActive : ''}`}>
|
||||
@@ -151,16 +168,46 @@ function DatasetDetail({
|
||||
}) {
|
||||
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 {
|
||||
@@ -212,6 +259,16 @@ function DatasetDetail({
|
||||
<span role="status" className="visually-hidden">
|
||||
{copied ? 'Reference copied to clipboard' : ''}
|
||||
</span>
|
||||
{dataset.source === 'url' && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
onClick={() => void handleRefresh()}
|
||||
disabled={refreshing}
|
||||
>
|
||||
{refreshing ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className={styles.action} onClick={handleEdit}>
|
||||
Edit
|
||||
</button>
|
||||
@@ -237,6 +294,19 @@ function DatasetDetail({
|
||||
|
||||
{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}>
|
||||
@@ -254,7 +324,7 @@ function DatasetDetail({
|
||||
</div>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{dataset.source === 'url' ? 'N/A' : humanBytes(dataset.size)}</dd>
|
||||
<dd>{dataset.data == null ? 'N/A' : humanBytes(dataset.size)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -277,7 +347,43 @@ function DatasetDetail({
|
||||
|
||||
<section className={styles.section}>
|
||||
<h4 className={styles.sectionTitle}>Preview</h4>
|
||||
<pre className={styles.preview}>{previewText(dataset)}</pre>
|
||||
{previewRows ? (
|
||||
<>
|
||||
{/* TODO: a11y — this scroll container isn't keyboard-reachable; when the
|
||||
preview overflows, keyboard-only users can't scroll it (WCAG 2.1.1).
|
||||
A council pass should decide the pattern (tabindex=0 + aria-label region,
|
||||
or a different overflow treatment) before we lean on it more. */}
|
||||
<div className={styles.previewTableWrap}>
|
||||
<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}>
|
||||
@@ -307,19 +413,22 @@ function DatasetDetail({
|
||||
);
|
||||
}
|
||||
|
||||
/** A truncated rendering of the data: raw for csv/tsv/url, pretty JSON otherwise. */
|
||||
/** 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.source === 'url') {
|
||||
text = String(dataset.data);
|
||||
} else if (dataset.format === 'csv' || dataset.format === 'tsv') {
|
||||
text = String(dataset.data);
|
||||
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 = String(dataset.data);
|
||||
text = typeof dataset.data === 'string' ? dataset.data : '[unserializable data]';
|
||||
}
|
||||
}
|
||||
return text.length > MAX ? `${text.slice(0, MAX)}\n…` : text;
|
||||
@@ -328,20 +437,59 @@ function previewText(dataset: Dataset): string {
|
||||
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 = () => {
|
||||
if (save()) resnapshot(); // committed — re-baseline so a later close won't prompt
|
||||
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 = () => {
|
||||
@@ -370,7 +518,10 @@ function DatasetFormView({ editing }: { editing: boolean }) {
|
||||
label="Dataset source"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={form.source}
|
||||
onChange={(source) => updateForm({ source })}
|
||||
onChange={(source) => {
|
||||
setFetchError(null);
|
||||
updateForm({ source });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -381,7 +532,10 @@ function DatasetFormView({ editing }: { editing: boolean }) {
|
||||
type="url"
|
||||
className={styles.input}
|
||||
value={form.input}
|
||||
onChange={(e) => updateForm({ input: e.target.value })}
|
||||
onChange={(e) => {
|
||||
setFetchError(null);
|
||||
updateForm({ input: e.target.value });
|
||||
}}
|
||||
placeholder="https://example.com/data.csv"
|
||||
/>
|
||||
) : (
|
||||
@@ -418,23 +572,34 @@ function DatasetFormView({ editing }: { editing: boolean }) {
|
||||
/>
|
||||
</label>
|
||||
|
||||
{formError && (
|
||||
{(formError || fetchError) && (
|
||||
<p className={styles.formError} role="alert">
|
||||
{formError}
|
||||
{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
|
||||
type="button"
|
||||
className={`${styles.action} ${styles.fallback}`}
|
||||
onClick={handlePasteInline}
|
||||
>
|
||||
Paste data inline instead
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className={styles.action} onClick={handleCancel}>
|
||||
<button type="button" className={styles.action} onClick={handleCancel} disabled={fetching}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.action} ${styles.primary}`}
|
||||
disabled={!canSave}
|
||||
onClick={handleSave}
|
||||
disabled={!canSave || fetching}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{editing ? 'Save changes' : 'Create dataset'}
|
||||
{fetching ? 'Fetching…' : editing ? 'Save changes' : 'Create dataset'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user