feat(spa): uuid pills show var-names everywhere; clickable in test runner
Plumb a 8-hex UUID → human var-name map through every RichTextInput +
RichTextPreview consumer so `@{uuid}` chips read as `@name` instead of
raw hex. Centralised the build via a new `buildUuidToName(modules)`
helper in `wildcardSyntax.ts` (a label-only sibling of
`buildWildcardGraph`).
Wired:
- TestRunner histogram + combine-render preview
- CombineEditor template input + preview
- DerivationEditor / DerivationRuleCard (forwarded via prop)
Plus: ref pills are now clickable. RichTextPreview gained a
`clickable-refs` prop + a `ref-click(uuid)` event. When set, ref spans
get `data-uuid` + `role="link"` and click/keyboard activation routes
the event back to the parent. TestRunner listens, looks up the full
slugged module id, and `router.push`'es to the wildcard editor — so a
chip in the histogram is a one-click jump to the underlying module.
Hover/focus chrome lives in `rich-text.css` under
`.wp-rt-preview--clickable .wp-rt-ref--clickable`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
5f123722f0
commit
df046f5793
@@ -19,12 +19,16 @@ interface Props {
|
||||
modelValue: DerivationRule;
|
||||
index: number;
|
||||
varSuggestions?: string[];
|
||||
/** UUID → display-name map forwarded to every nested RichTextInput so
|
||||
* stray `@{uuid}` chips read as `@name` instead of raw hex. */
|
||||
uuidToName?: Map<string, string>;
|
||||
/** Default-collapsed when many rules exist (set by editor). */
|
||||
defaultCollapsed?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
varSuggestions: () => [],
|
||||
uuidToName: () => new Map(),
|
||||
defaultCollapsed: false,
|
||||
});
|
||||
|
||||
@@ -232,6 +236,7 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
:model-value="branch.condition.value"
|
||||
surface="derivation"
|
||||
:var-suggestions="varSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
placeholder="value"
|
||||
class="field-value"
|
||||
:aria-label="`Condition value for rule ${ruleNumber} branch ${bi + 1}`"
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
* helpers, without any textarea or autocomplete chrome. Used in list-row
|
||||
* expansions and any other place we want to show wildcard syntax with
|
||||
* styled chips at rest.
|
||||
*
|
||||
* When `clickable-refs` is set, `@{uuid}` ref pills emit a `ref-click`
|
||||
* event with the resolved UUID — consumers (e.g. TestRunner) can navigate
|
||||
* to the referenced wildcard editor on click. The event is wired via
|
||||
* delegation on the wrapper so we keep the single-`v-html` render path.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { tokenizeRich } from "../utils/richTokenize";
|
||||
@@ -18,14 +23,21 @@ interface Props {
|
||||
warnings?: ResolveWarning[];
|
||||
/** Map from UUID to display name; used to render `@{uuid}` refs as human labels. */
|
||||
uuidToName?: Map<string, string>;
|
||||
/** When true, ref pills become clickable and emit `ref-click(uuid)`. */
|
||||
clickableRefs?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
surface: "wildcard",
|
||||
warnings: () => [],
|
||||
uuidToName: () => new Map(),
|
||||
clickableRefs: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"ref-click": [uuid: string];
|
||||
}>();
|
||||
|
||||
const HTML_ESC: Record<string, string> = {
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
};
|
||||
@@ -37,35 +49,74 @@ const tokens = computed(() => tokenizeRich(props.value || ""));
|
||||
// SAFE: all token text is HTML-escaped before concatenation.
|
||||
// `@{uuid}` ref tokens are rendered with a human-readable display name
|
||||
// when `uuidToName` provides one. Non-wildcard surfaces get a muted class.
|
||||
// Refs that resolve to a known UUID get a `data-uuid` attribute so the
|
||||
// click-delegation handler below can map a click back to its target.
|
||||
const html = computed(() => {
|
||||
const toks = tokens.value;
|
||||
const isWildcard = props.surface === "wildcard";
|
||||
const map = props.uuidToName;
|
||||
const clickable = props.clickableRefs;
|
||||
let out = "";
|
||||
for (let i = 0; i < toks.length; i++) {
|
||||
const t = toks[i];
|
||||
let cls = `wp-rt-${t.kind}`;
|
||||
let display: string;
|
||||
let extra = "";
|
||||
if (t.kind === "ref") {
|
||||
if (!isWildcard) cls += " wp-rt-ref--ignored";
|
||||
const uuid = t.meta?.uuid;
|
||||
if (uuid && map.has(uuid)) {
|
||||
display = "@" + esc(map.get(uuid)!);
|
||||
if (clickable) {
|
||||
cls += " wp-rt-ref--clickable";
|
||||
// UUIDs are 8 hex chars — safe inside an attribute value.
|
||||
extra = ` data-uuid="${uuid}" role="link" tabindex="0"`;
|
||||
}
|
||||
} else {
|
||||
display = esc(t.raw);
|
||||
}
|
||||
} else {
|
||||
display = esc(t.raw);
|
||||
}
|
||||
out += `<span class="${cls}" data-idx="${i}">${display}</span>`;
|
||||
out += `<span class="${cls}" data-idx="${i}"${extra}>${display}</span>`;
|
||||
}
|
||||
out += '<span class="wp-rt-tail">​</span>';
|
||||
return out;
|
||||
});
|
||||
|
||||
function onClick(e: MouseEvent): void {
|
||||
if (!props.clickableRefs) return;
|
||||
const target = (e.target as HTMLElement | null)?.closest?.("[data-uuid]");
|
||||
if (!target) return;
|
||||
const uuid = (target as HTMLElement).getAttribute("data-uuid");
|
||||
if (uuid) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
emit("ref-click", uuid);
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (!props.clickableRefs) return;
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
const target = (e.target as HTMLElement | null)?.closest?.("[data-uuid]");
|
||||
if (!target) return;
|
||||
const uuid = (target as HTMLElement).getAttribute("data-uuid");
|
||||
if (uuid) {
|
||||
e.preventDefault();
|
||||
emit("ref-click", uuid);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="wp-rt-preview wp-rt--rest" v-html="html" />
|
||||
<span
|
||||
class="wp-rt-preview wp-rt--rest"
|
||||
:class="{ 'wp-rt-preview--clickable': clickableRefs }"
|
||||
v-html="html"
|
||||
@click="onClick"
|
||||
@keydown="onKey"
|
||||
/>
|
||||
<span
|
||||
v-for="w in warnings"
|
||||
:key="`${w.position}-${w.severity}`"
|
||||
|
||||
@@ -117,6 +117,21 @@
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
/* Clickable ref pill (e.g. test-runner histogram) — pointer cursor +
|
||||
* subtle hover/focus feedback so the user reads it as a link. The
|
||||
* `data-uuid` + `role="link"` attributes are added by RichTextPreview
|
||||
* when `clickable-refs` is on. */
|
||||
.wp-rt-preview--clickable .wp-rt-ref--clickable {
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.wp-rt-preview--clickable .wp-rt-ref--clickable:hover,
|
||||
.wp-rt-preview--clickable .wp-rt-ref--clickable:focus-visible {
|
||||
background: color-mix(in oklab, var(--wp-kind-wildcard, #f0abfc) 36%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-kind-wildcard, #f0abfc) 70%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Warning marker spans ── *
|
||||
* Emitted by RichTextInput + RichTextPreview when `warnings` prop is set.
|
||||
* Markers are zero-width spans with a coloured bottom border and a small
|
||||
|
||||
@@ -82,6 +82,25 @@ export function getWildcardSyntax(mod: ModuleRow): SyntaxFlags {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical 8-hex UUID → display-name map every consumer of
|
||||
* `@{uuid}` ref tokens needs (RichTextInput, RichTextPreview, autocomplete).
|
||||
*
|
||||
* Standalone helper rather than only `buildWildcardGraph().uuidToName` so
|
||||
* callers that just need labels (TestRunner histogram, Combine preview, …)
|
||||
* don't pay the cost of walking option values to build edge sets.
|
||||
*/
|
||||
export function buildUuidToName(modules: ModuleRow[]): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const m of modules) {
|
||||
if (m.type !== "wildcard") continue;
|
||||
const uuid = extractModuleUuid(m.id);
|
||||
if (!uuid) continue;
|
||||
out.set(uuid, wildcardVarName(m));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface WildcardGraph {
|
||||
/** outgoing[uuid] = Set of UUIDs it references via `@{uuid}` refs. */
|
||||
outgoing: Map<string, Set<string>>;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useToast } from "../composables/useToast";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
import { buildUuidToName } from "../utils/wildcardSyntax";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import type {
|
||||
CombinePayload,
|
||||
@@ -87,6 +88,10 @@ const varHints = computed<VarHint[]>(() => {
|
||||
});
|
||||
|
||||
const varSuggestions = computed<string[]>(() => varHints.value.map((h) => h.label));
|
||||
// Surfaces `@{uuid}` chips with human var-names in template + preview.
|
||||
// Combine surface ignores `@` refs at resolve time, but stray UUIDs
|
||||
// pasted in still benefit from the labelled chip.
|
||||
const uuidToName = computed(() => buildUuidToName(moduleStore.items));
|
||||
const hintByLabel = computed<Map<string, VarHint>>(() => {
|
||||
const m = new Map<string, VarHint>();
|
||||
for (const h of varHints.value) m.set(h.label, h);
|
||||
@@ -247,6 +252,7 @@ function cancel() { router.push("/combines"); }
|
||||
<RichTextInput
|
||||
v-model="template"
|
||||
:var-suggestions="varSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
:multiline="true"
|
||||
:rows="3"
|
||||
:placeholder="PLACEHOLDER"
|
||||
@@ -291,7 +297,11 @@ function cancel() { router.push("/combines"); }
|
||||
<div class="wp-snippet" data-test="cb-preview">
|
||||
<div><span class="wp-token-com">// Highlighted template syntax:</span></div>
|
||||
<div class="cb-preview__row">
|
||||
<RichTextPreview v-if="template" :value="template" />
|
||||
<RichTextPreview
|
||||
v-if="template"
|
||||
:value="template"
|
||||
:uuid-to-name="uuidToName"
|
||||
/>
|
||||
<span v-else class="wp-dim">(empty template)</span>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import { toIdentifier } from "../utils/slug";
|
||||
import { buildUuidToName } from "../utils/wildcardSyntax";
|
||||
import type {
|
||||
CombinePayload,
|
||||
DerivationAction,
|
||||
@@ -74,6 +75,12 @@ const varSuggestions = computed<string[]>(() => {
|
||||
return out.sort();
|
||||
});
|
||||
|
||||
// 8-hex UUID → wildcard var-name. Forwarded into every nested
|
||||
// DerivationRuleCard so stray `@{uuid}` tokens (pasted, copied from a
|
||||
// wildcard editor, etc.) render as `@name` chips even though `@` refs
|
||||
// don't resolve on the derivation surface.
|
||||
const uuidToName = computed(() => buildUuidToName(moduleStore.items));
|
||||
|
||||
let ruleSeq = 0;
|
||||
function newRuleId(): string {
|
||||
ruleSeq += 1;
|
||||
@@ -290,6 +297,7 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
|
||||
:model-value="rule"
|
||||
:index="idx"
|
||||
:var-suggestions="varSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
:default-collapsed="rules.length > 1"
|
||||
:data-test="`rule-${idx}`"
|
||||
@update:model-value="(v) => updateRule(idx, v)"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* Uses ui/* primitives only — no PrimeVue.
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Field from "../components/ui/Field.vue";
|
||||
@@ -27,7 +28,8 @@ import {
|
||||
runStep,
|
||||
wildcardVar,
|
||||
} from "../utils/resolver";
|
||||
import { toIdentifier } from "../utils/slug";
|
||||
import { extractModuleUuid, toIdentifier } from "../utils/slug";
|
||||
import { buildUuidToName } from "../utils/wildcardSyntax";
|
||||
import type {
|
||||
CombinePayload,
|
||||
ConstraintPayload,
|
||||
@@ -41,6 +43,7 @@ import type {
|
||||
} from "../api/types";
|
||||
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
/* -------------------------- kind metadata -------------------------- */
|
||||
|
||||
@@ -76,6 +79,37 @@ const samples = ref<number>(KIND_DEFAULT_SAMPLES.wildcard);
|
||||
const running = ref(false);
|
||||
const allModules = ref<ModuleRow[]>([]);
|
||||
|
||||
// 8-hex UUID → human var-name. Drives `@{uuid}` chip labels in every
|
||||
// RichTextPreview below (histogram templates, combine renderings).
|
||||
const uuidToName = computed(() => buildUuidToName(allModules.value));
|
||||
|
||||
// Reverse map: 8-hex UUID → full slugged module id, so a click on a ref
|
||||
// pill can route to /wildcards/<id>/edit. Built lazily off the same
|
||||
// `allModules` ref so it stays in sync as the catalog refreshes.
|
||||
const idByUuid = computed(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const m of allModules.value) {
|
||||
if (m.type !== "wildcard") continue;
|
||||
const uuid = extractModuleUuid(m.id);
|
||||
if (uuid) map.set(uuid, m.id);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
function onRefClick(uuid: string): void {
|
||||
const id = idByUuid.value.get(uuid);
|
||||
if (!id) {
|
||||
toast.push({
|
||||
severity: "warn",
|
||||
summary: "Wildcard not found",
|
||||
detail: `No wildcard with UUID ${uuid} in the catalog.`,
|
||||
life: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.push({ name: "wildcards-edit", params: { id } });
|
||||
}
|
||||
|
||||
interface WildcardEntry { template: string; count: number; pct: number; resolved: string[] }
|
||||
interface WildcardResult { type: "wildcard"; samples: number; entries: WildcardEntry[]; hasInline: boolean; hasRefs: boolean }
|
||||
interface FixedResult { type: "fixed_values"; bindings: { name: string; value: string }[] }
|
||||
@@ -466,7 +500,12 @@ function pickKind(k: ModuleType) {
|
||||
<div class="wp-hist">
|
||||
<div v-for="entry in result.entries" :key="entry.template" class="wp-hist__row">
|
||||
<div class="wp-hist__template">
|
||||
<RichTextPreview :value="entry.template" />
|
||||
<RichTextPreview
|
||||
:value="entry.template"
|
||||
:uuid-to-name="uuidToName"
|
||||
clickable-refs
|
||||
@ref-click="onRefClick"
|
||||
/>
|
||||
<div v-if="entry.resolved.length" class="wp-hist__resolved wp-mono wp-dim">
|
||||
<div v-for="(s, j) in entry.resolved" :key="j" class="wp-hist__resolved-line">↳ {{ s }}</div>
|
||||
</div>
|
||||
@@ -513,7 +552,12 @@ function pickKind(k: ModuleType) {
|
||||
</template>
|
||||
<div class="wp-tr-combine-list">
|
||||
<div v-for="(s, i) in result.rendered" :key="i" class="wp-tr-combine-row">
|
||||
<RichTextPreview :value="s" />
|
||||
<RichTextPreview
|
||||
:value="s"
|
||||
:uuid-to-name="uuidToName"
|
||||
clickable-refs
|
||||
@ref-click="onRefClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user