mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Fix dataset-ref walk false positive and wire spec-mandated success toasts
- Prune the data payload and top-level datasets keys in all three ref walks (extractDatasetRefs, renameDatasetInSpec, resolveDatasetRefs) so a data row carrying a field named "data" is no longer misread as a library reference, spuriously rewritten, or made to throw DatasetNotFoundError. Adds tests, including a guard that lookup-transform refs (from.data) still resolve. - Wire the deferred success toasts now the Toaster has landed: publish, revert, extract-to-dataset, and snippet/dataset delete. Copy follows the council title-vs-message rule (title states the action, message adds the consequence). - Reconcile the spec's blanket toast mandate to "toast only what the user can't already see": no toast on visible-result creates (snippet, dataset form); Copy Reference stays inline and gains an aria-live announcement (new shared .visually-hidden utility) instead of a toast-per-copy. - Move the toast region to bottom-right so it stops covering the header action cluster (Publish/Revert, theme/datasets). - Update docs/spec 01F/02/05 and docs/architecture/07 + 10 to match.
This commit is contained in:
@@ -132,9 +132,14 @@ always mirrors the dataset names actually referenced in the published spec.
|
||||
### 3.1 Extracting referenced names from a spec (pure — `src/core/spec-refs.ts`)
|
||||
|
||||
A Vega-Lite spec can reference named data in several places: the top-level
|
||||
`data`, per-layer `data`, `data` inside `spec`/`facet`/`hconcat`/`vconcat`, and
|
||||
named entries in top-level `datasets`. Rather than enumerate Vega-Lite's grammar,
|
||||
we walk the spec recursively and collect every `{ data: { name } }` we find.
|
||||
`data`, per-layer `data`, `data` inside `spec`/`facet`/`hconcat`/`vconcat`, and a
|
||||
lookup transform's `from.data`. A spec may also define its OWN inline datasets via
|
||||
a top-level `datasets` map — those are self-defined, not library references.
|
||||
Rather than enumerate Vega-Lite's grammar, we walk the spec recursively and
|
||||
collect every `{ data: { name } }` — but **prune two keys**: never recurse into a
|
||||
`data` object's payload (its `values`/rows) or the top-level `datasets` map,
|
||||
because those hold user data, not nested specs. Without the prune, a data _row_
|
||||
carrying a field literally named `data: { name: "x" }` is misread as a reference.
|
||||
This is pure, deterministic, and the most heavily unit-tested function here.
|
||||
|
||||
```ts
|
||||
@@ -142,8 +147,10 @@ This is pure, deterministic, and the most heavily unit-tested function here.
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
/** Collects every dataset name referenced by `{ data: { name } }` anywhere in the spec. */
|
||||
/** Collects every library dataset name referenced by `{ data: { name } }`, excluding self-defined ones. */
|
||||
export function extractDatasetRefs(spec: Json): string[] {
|
||||
const root = typeof spec === 'string' ? safeParse(spec) : spec;
|
||||
const selfDefined = selfDefinedNames(root); // names from the spec's own top-level `datasets`
|
||||
const names = new Set<string>();
|
||||
|
||||
const walk = (node: Json): void => {
|
||||
@@ -155,13 +162,17 @@ export function extractDatasetRefs(spec: Json): string[] {
|
||||
const obj = node as Record<string, Json>;
|
||||
const data = obj.data as Record<string, Json> | undefined;
|
||||
if (data && typeof data === 'object' && typeof data.name === 'string') {
|
||||
names.add(data.name);
|
||||
if (!selfDefined.has(data.name)) names.add(data.name);
|
||||
}
|
||||
// Prune: a `data` payload and the `datasets` map hold user data, not refs.
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key === 'data' || key === 'datasets') continue;
|
||||
walk(obj[key]);
|
||||
}
|
||||
for (const key of Object.keys(obj)) walk(obj[key]);
|
||||
}
|
||||
};
|
||||
|
||||
walk(typeof spec === 'string' ? safeParse(spec) : spec);
|
||||
walk(root);
|
||||
return [...names];
|
||||
}
|
||||
|
||||
@@ -193,11 +204,20 @@ export function recomputeDatasetRefs(spec: Json): string[] {
|
||||
agreeing with what the renderer actually resolves.
|
||||
- Recompute and store `datasetRefs` on **publish**, not on every keystroke —
|
||||
the draft can be transiently invalid, and only the published spec is shared.
|
||||
- Prune the **same two keys** (`data`, `datasets`) in all three ref walks —
|
||||
extraction here, `renameDatasetInSpec`, and the renderer's `resolveDatasetRefs`
|
||||
(`src/core/rendering.ts`). They must agree on what counts as a reference; if one
|
||||
descends into data payloads and another doesn't, extraction and rendering
|
||||
disagree and a row field named `data` either gets counted, rewritten, or throws
|
||||
`DatasetNotFoundError`.
|
||||
|
||||
**Don't**
|
||||
|
||||
- Don't let two code paths each have their own idea of "referenced names".
|
||||
Renamer and ref-recomputer must use the same extractor.
|
||||
Renamer, ref-recomputer, and renderer must use the same walk shape.
|
||||
- Don't "enumerate the grammar" (scope the walk to a fixed list of container
|
||||
keys) to fix the payload-descent problem — pruning the two data-bearing keys
|
||||
stays correct as Vega-Lite's composition grammar grows; an allow-list rots.
|
||||
|
||||
---
|
||||
|
||||
@@ -323,19 +343,21 @@ The spec rewrite is pure; the orchestration reads and writes stores.
|
||||
/** Returns a copy of `spec` with every data.name === oldName replaced by newName. */
|
||||
export function renameDatasetInSpec(spec: Json, oldName: string, newName: string): Json {
|
||||
const obj = typeof spec === 'string' ? safeParse(spec) : spec;
|
||||
const selfDefined = selfDefinedNames(obj); // never rename a spec's own inline dataset name
|
||||
|
||||
const rewrite = (node: Json): Json => {
|
||||
if (Array.isArray(node)) return node.map(rewrite);
|
||||
if (node && typeof node === 'object') {
|
||||
const out: Record<string, Json> = {};
|
||||
for (const [k, v] of Object.entries(node as Record<string, Json>)) {
|
||||
if (
|
||||
k === 'data' &&
|
||||
v &&
|
||||
typeof v === 'object' &&
|
||||
(v as Record<string, Json>).name === oldName
|
||||
) {
|
||||
out[k] = { ...(v as object), name: newName };
|
||||
// A `data` object is a reference site: rename a matching name, but never
|
||||
// recurse into its payload. `datasets` (self-defined inline data) is left
|
||||
// whole. Same prune as extractDatasetRefs — see §3.1.
|
||||
if (k === 'data' && v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
const dv = v as Record<string, Json>;
|
||||
out[k] = dv.name === oldName && !selfDefined.has(oldName) ? { ...dv, name: newName } : dv;
|
||||
} else if (k === 'data' || k === 'datasets') {
|
||||
out[k] = v;
|
||||
} else {
|
||||
out[k] = rewrite(v);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,28 @@ nature of the message, not by convenience.
|
||||
consent-for-destruction in a toast.
|
||||
- **Errors persist; success fades.** An error/warning toast waits for the user (a critical
|
||||
message must not vanish on a timer); success/info auto-dismiss (~6s).
|
||||
- **Toasts sit bottom-right**, not top-right. Carbon's default is the top, but our header's
|
||||
action cluster (Publish/Revert, theme/datasets) lives top-right — a toast there covers the
|
||||
control the user just used. Bottom-anchored, the stack grows upward with the newest toast
|
||||
nearest the corner, and still clears the centered confirm dialog. (`Toaster.module.css`.)
|
||||
- **Toast copy: title states it, message adds to it.** Every toast renders a `title` and a
|
||||
`message`. The title is the short headline — the action or what stopped, **no terminal
|
||||
period** ("Snippet published", "Storage full"). The message is **one short sentence that
|
||||
must not paraphrase the title** (Carbon, `components/notification/usage.mdx` §Body
|
||||
content: _"Don't repeat or paraphrase the title"_); it carries the **consequence** for a
|
||||
success ("Your draft is now the published version") or the **next step** for a fixable
|
||||
error. Name the specific item in the message when toasts can stack — a delete confirms
|
||||
_which_ one went ("…removed `\"Sales\"`…").
|
||||
- **Toast only what the user can't already see.** A success toast is for an outcome with no
|
||||
strong on-screen cue: a **side effect** (Extract creates a dataset off-screen while the
|
||||
user is in the editor), a **disappearance** (delete), or a **state flip** (publish/revert).
|
||||
An action whose result is immediately visible — a created snippet opening in the editor, a
|
||||
new dataset shown selected — is confirmed by that visible change; adding a toast is noise
|
||||
(NN/g aesthetic-and-minimalist; Carbon `notification/usage.mdx` "Deciding what to use").
|
||||
An **invisible** outcome that still shouldn't toast is **copy-to-clipboard**: confirm it
|
||||
_inline on the control_ ("Copied") with a polite `aria-live` announcement for assistive
|
||||
tech, never a toast-per-copy. This refines the spec's earlier blanket "every action
|
||||
toasts" (spec §01F/§02/§05, reconciled).
|
||||
- **The same failure can light up two channels.** An unrenderable spec shows the _same_
|
||||
message inline in both the editor (§03E) and the preview (§04) — one producer
|
||||
(`PreviewStore`), two subscribers. That's intentional, not duplication.
|
||||
|
||||
Reference in New Issue
Block a user