fix(spa): @ autocomplete UUIDs, editor cross-refs, derivation $ vars

This commit is contained in:
DumiFlex
2026-04-28 22:14:15 +03:00
committed by GitHub
13 changed files with 230 additions and 22 deletions
@@ -164,6 +164,46 @@ describe("RichTextInput.vue", () => {
wrap.unmount();
});
// Regression: `@`-trigger autocomplete had a bug where callers passed
// wildcard NAMES as `refSuggestions`, and the popover then inserted
// `@{name}` instead of the canonical `@{8hex}` UUID form. The fix is a
// contract change: callers pass UUIDs in `refSuggestions`, plus a
// `uuidToName` map for display, and the popover filters/labels by name
// while inserting the UUID. This test pins the new contract.
it("`@` autocomplete filters by display name and inserts the UUID", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "",
surface: "wildcard",
refSuggestions: ["a1b2c3d4", "deadbeef"],
uuidToName: new Map([
["a1b2c3d4", "color"],
["deadbeef", "outfit"],
]),
},
attachTo: document.body,
});
const input = wrap.find("input");
const el = input.element as HTMLInputElement;
el.value = "@col";
el.setSelectionRange(4, 4);
await input.trigger("input");
await tick();
// Popover shows display names (rendered via uuidToName), not UUIDs.
const labels = Array.from(
document.querySelectorAll(".wp-rt-suggestions__label"),
).map((n) => (n.textContent ?? "").trim());
expect(labels).toContain("@color");
expect(labels.some((l) => l.includes("a1b2c3d4"))).toBe(false);
// Pressing Enter on the highlighted row inserts the UUID, not the name.
await input.trigger("keydown", { key: "Enter" });
const events = wrap.emitted("update:modelValue") ?? [];
expect(events[events.length - 1]).toEqual(["@{a1b2c3d4}"]);
wrap.unmount();
});
it("closes autocomplete on Escape", async () => {
const wrap = mount(RichTextInput, {
props: {
+21
View File
@@ -47,6 +47,27 @@ describe("moduleStore", () => {
expect(apiMod.list).toHaveBeenCalledWith({ type: "wildcard", q: "color" });
});
// Regression: editors call this when they need cross-references (e.g.
// ConstraintEditor needs all wildcards even when the user came from the
// Constraints list page that pinned `filter.type = "constraint"`). The
// fix must drop the persistent filter so cross-refs aren't silently empty.
it("fetchCatalog ignores persisted filters", async () => {
apiMod.list.mockResolvedValue({
items: [
{ id: "wc_a", type: "wildcard" },
{ id: "co_b", type: "combine" },
],
total: 2,
});
const s = useModuleStore();
s.filter.type = "constraint";
s.filter.q = "anything";
s.filter.favorites = true;
await s.fetchCatalog();
expect(apiMod.list).toHaveBeenCalledWith({});
expect(s.items).toHaveLength(2);
});
it("create unshifts to items", async () => {
apiMod.create.mockResolvedValue({ id: "wc_y_2", name: "y", type: "wildcard" });
const s = useModuleStore();
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
import { extractModuleUuid, toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
describe("toIdentifier", () => {
it("lowercases and slug-joins runs of non-identifier chars", () => {
@@ -34,3 +34,25 @@ describe("toIdentifier", () => {
}
});
});
describe("extractModuleUuid", () => {
it("returns the trailing 8-hex suffix from a normal DB id", () => {
expect(extractModuleUuid("wc_test2_ea67b173")).toBe("ea67b173");
expect(extractModuleUuid("co_subject_aabbccdd")).toBe("aabbccdd");
expect(extractModuleUuid("fv_my_consts_00112233")).toBe("00112233");
});
it("accepts ids whose slug section contains digits and underscores", () => {
expect(extractModuleUuid("wc_test_2_ea67b173")).toBe("ea67b173");
expect(extractModuleUuid("wc_a_b_c_d_e_deadbeef")).toBe("deadbeef");
});
it("returns null when the id does not end in `_<8 lowercase hex>`", () => {
expect(extractModuleUuid("wc_test2")).toBeNull();
expect(extractModuleUuid("wc_test2_EA67B173")).toBeNull(); // uppercase rejected
expect(extractModuleUuid("wc_test2_eaa67b173")).toBeNull(); // 9 chars
expect(extractModuleUuid("wc_test2_ea67b17")).toBeNull(); // 7 chars
expect(extractModuleUuid("ea67b173")).toBeNull(); // missing underscore prefix
expect(extractModuleUuid("")).toBeNull();
});
});
+20 -4
View File
@@ -116,16 +116,32 @@ const mirrorHtml = computed(() => {
// --- Suggestion list filtering. ---
// `@` autocomplete is only available in the "wildcard" surface.
//
// For the `@` trigger, `refSuggestions` contains UUIDs (canonical stored
// form per syntax spec) and we filter on the human display name resolved
// through `uuidToName` so the user types `@col` and matches a wildcard
// named "color". The inserted token is still the UUID — see
// `applyAutocomplete`. Falling back to the raw UUID keeps filtering useful
// even before `uuidToName` is hydrated.
const acItems = computed(() => {
if (!acOpen.value) return [];
if (acTrigger.value === "@" && props.surface !== "wildcard") return [];
const pool = acTrigger.value === "@" ? props.refSuggestions : props.varSuggestions;
const q = acQuery.value.toLowerCase();
return pool
.filter((label) => label.toLowerCase().includes(q))
.slice(0, 8);
const labelOf = acTrigger.value === "@"
? (uuid: string) => (props.uuidToName.get(uuid) ?? uuid).toLowerCase()
: (name: string) => name.toLowerCase();
return pool.filter((id) => labelOf(id).includes(q)).slice(0, 8);
});
// Display label for a popover row: name for `@` (UUID → name lookup),
// raw token for `$`. Used by the template binding below so the popover
// stays consistent with the textarea even when refSuggestions are UUIDs.
function suggestionLabel(token: string): string {
if (acTrigger.value === "@") return props.uuidToName.get(token) ?? token;
return token;
}
watch(acItems, (items) => {
if (acActive.value >= items.length) acActive.value = 0;
});
@@ -443,7 +459,7 @@ onBeforeUnmount(() => {
@mouseenter="acActive = i"
>
<span class="wp-rt-suggestions__label">
<span class="wp-rt-suggestions__trigger">{{ acTrigger }}</span>{{ label }}
<span class="wp-rt-suggestions__trigger">{{ acTrigger }}</span>{{ suggestionLabel(label) }}
</span>
</button>
</div>
+24 -1
View File
@@ -34,6 +34,29 @@ export const useModuleStore = defineStore("modules", () => {
}
}
/**
* Fetch the full module catalog, ignoring the persistent `filter.*` state.
*
* `fetchAll()` honors `filter.type` so that list views (Wildcards, Combines,
* etc.) only load same-kind rows — but editors need ALL modules for cross-
* references (`$var` autocomplete sourced from upstream wildcards/combines,
* Constraint editor wildcard pickers, …). Calling `fetchAll()` from an editor
* after the user came from a typed list page would silently scope `items` to
* one type and break those cross-refs. Use this method from editors instead.
*
* Mutates the same `items` ref. The next list-view mount re-applies its
* filter via `fetchAll()`, so this does not pollute long-term state.
*/
async function fetchCatalog() {
loading.value = true;
try {
const res = await api.modules.list({});
items.value = res.items;
} finally {
loading.value = false;
}
}
async function get(id: string) {
return await api.modules.get(id);
}
@@ -74,7 +97,7 @@ export const useModuleStore = defineStore("modules", () => {
return {
items, loading, filter,
fetchAll, get, create, update, remove, duplicate, toggleFavorite,
fetchAll, fetchCatalog, get, create, update, remove, duplicate, toggleFavorite,
wildcards, fixedValues,
};
});
+20
View File
@@ -19,3 +19,23 @@ export function toIdentifier(input: string): string {
}
export const VALID_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
/**
* Extract the canonical 8-hex wildcard UUID from a full module DB id.
*
* `ModuleRepository._gen_id` builds ids as `<prefix>_<slug>_<token_hex(4)>`,
* e.g. `wc_test2_ea67b173`. The syntax spec (§2.4 in 2026-04-28 design doc)
* locks the wildcard ref form to `@{8hex}` — the trailing 8 hex chars are
* the UUID. SPA editors that surface refs in autocomplete must extract this
* suffix instead of inserting the whole DB id verbatim, otherwise the
* tokenizer regex (matching `@\{[0-9a-f]{8}\}`) rejects the token and the
* user sees raw `@{wc_test2_ea67b173}` text in the textarea with no chip.
*
* Returns null when the id does not end in `_<8hex>` (legacy/fixture data,
* imported bundles with non-standard ids, etc.) so callers can decide
* whether to skip or fall back.
*/
export function extractModuleUuid(id: string): string | null {
const m = id.match(/_([0-9a-f]{8})$/);
return m ? m[1] : null;
}
+1 -1
View File
@@ -33,7 +33,7 @@ const countsByCategory = computed(() => {
});
onMounted(async () => {
await Promise.all([store.fetchAll(), moduleStore.fetchAll()]);
await Promise.all([store.fetchAll(), moduleStore.fetchCatalog()]);
});
function moduleCount(row: CategoryRow): number {
+1 -1
View File
@@ -106,7 +106,7 @@ const detected = computed<string[]>(() => {
});
onMounted(async () => {
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchCatalog()]);
if (props.id) {
try {
const row = await moduleStore.get(props.id);
+1 -1
View File
@@ -144,7 +144,7 @@ function normalizeExceptions(raw: unknown): ConstraintException[] {
}
onMounted(async () => {
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchCatalog()]);
if (props.id) {
try {
const row = await moduleStore.get(props.id);
+33 -2
View File
@@ -17,13 +17,16 @@ import { useToast } from "../composables/useToast";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import { appendSnapshot, readHistory } from "../utils/history";
import { toIdentifier } from "../utils/slug";
import type {
CombinePayload,
DerivationAction,
DerivationBranch,
DerivationElse,
DerivationPayload,
DerivationRule,
ModuleHistoryEntry,
WildcardPayload,
} from "../api/types";
const props = defineProps<{ id?: string }>();
@@ -41,7 +44,35 @@ const saving = ref(false);
const isEdit = computed(() => !!props.id);
const historyEntries = ref<ModuleHistoryEntry[]>([]);
const varSuggestions = ref<string[]>([]);
// Suggestions for `$`-trigger autocomplete inside derivation action values
// and condition values. Mirror the CombineEditor aggregation: wildcards
// surface their `var_binding` (or name-derived identifier), fixed_values
// surface each row's name, and combines surface their `output_var`. We
// exclude the module currently being edited so the suggestion list reflects
// only what is reachable upstream of this derivation.
const varSuggestions = computed<string[]>(() => {
const seen = new Set<string>();
const out: string[] = [];
for (const m of moduleStore.items) {
if (props.id && m.id === props.id) continue;
if (m.type === "wildcard") {
const p = (m.payload ?? {}) as Partial<WildcardPayload>;
const b = (p.var_binding && p.var_binding.trim()) || toIdentifier(m.name);
if (b && !seen.has(b)) { seen.add(b); out.push(b); }
} else if (m.type === "fixed_values") {
const values = ((m.payload ?? {}) as { values?: { name?: string }[] }).values ?? [];
for (const row of values) {
const n = (row.name ?? "").replace(/^\$+/, "").trim();
if (n && !seen.has(n)) { seen.add(n); out.push(n); }
}
} else if (m.type === "combine") {
const p = (m.payload ?? {}) as Partial<CombinePayload>;
const o = (p.output_var ?? "").replace(/^\$+/, "").trim();
if (o && !seen.has(o)) { seen.add(o); out.push(o); }
}
}
return out.sort();
});
let ruleSeq = 0;
function newRuleId(): string {
@@ -118,7 +149,7 @@ function migrateAction(raw: unknown): DerivationAction {
}
onMounted(async () => {
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchCatalog()]);
if (props.id) {
try {
const row = await moduleStore.get(props.id);
+1 -1
View File
@@ -43,7 +43,7 @@ const isEdit = computed(() => !!props.id);
const historyEntries = ref<ModuleHistoryEntry[]>([]);
onMounted(async () => {
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchCatalog()]);
if (props.id) {
try {
const row = await moduleStore.get(props.id);
+43 -8
View File
@@ -23,7 +23,7 @@ import RichTextInput from "../components/RichTextInput.vue";
import { useToast } from "../composables/useToast";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
import { extractModuleUuid, toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
import { appendSnapshot, readHistory } from "../utils/history";
import type {
CombinePayload,
@@ -54,18 +54,52 @@ const saving = ref(false);
const isEdit = computed(() => !!props.id);
const historyEntries = ref<ModuleHistoryEntry[]>([]);
// Suggestions: every other wildcard's binding (excluding self) for `@`-trigger
// nested-reference autocomplete.
// Suggestions: every other wildcard's 8-hex UUID (excluding self) for the
// `@`-trigger nested-reference autocomplete. The canonical stored form is
// `@{8hex}` per the syntax spec — the popover surfaces the human display
// name (via `nameByUuid` below) but the inserted token is the bare UUID.
//
// Module DB ids are `<prefix>_<slug>_<8hex>` (see `ModuleRepository._gen_id`).
// We strip everything except the trailing 8 hex chars: that is what the
// tokenizer regex `@\{[0-9a-f]{8}\}` accepts, and it matches the runtime
// catalog key shape consumed by `engine.modules.get_module(uuid)`.
//
// Two earlier shapes were wrong:
// 1. var_binding/name → produced `@{test2}` (caught by users)
// 2. full DB id (e.g. `wc_test2_ea67b173`) → tokenizer rejected it and the
// textarea showed raw `@{wc_test2_ea67b173}` text with no ref chip
const wcSuggestions = computed<string[]>(() => {
const out: string[] = [];
for (const m of moduleStore.items) {
if (m.type !== "wildcard") continue;
if (props.id && m.id === props.id) continue;
const p = (m.payload ?? {}) as { var_binding?: string };
const b = (p.var_binding && p.var_binding.trim()) || toIdentifier(m.name);
if (b && !out.includes(b)) out.push(b);
const uuid = extractModuleUuid(m.id);
if (uuid) out.push(uuid);
}
return out.sort();
// Sort by display name so the popover orders alphabetically by what the
// user actually sees, not by UUID hex.
return out.sort((a, b) => {
const na = nameByUuid.value.get(a) ?? a;
const nb = nameByUuid.value.get(b) ?? b;
return na.localeCompare(nb);
});
});
// UUID → display-name map used by RichTextInput to render `@{uuid}` chips
// and the `@`-trigger autocomplete popover with human labels. Keyed by the
// same 8-hex UUID surfaced through `wcSuggestions` so the popover, the
// inline chip, and the resolver all agree on identity.
const nameByUuid = computed<Map<string, string>>(() => {
const m = new Map<string, string>();
for (const mod of moduleStore.items) {
if (mod.type !== "wildcard") continue;
const uuid = extractModuleUuid(mod.id);
if (!uuid) continue;
const p = (mod.payload ?? {}) as { var_binding?: string };
const display = (p.var_binding && p.var_binding.trim()) || toIdentifier(mod.name);
if (display) m.set(uuid, display);
}
return m;
});
// Suggestions: union of upstream `$var` names for inline `$`-trigger autocomplete.
@@ -108,7 +142,7 @@ function probabilityFor(o: WildcardOption): number {
}
onMounted(async () => {
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchCatalog()]);
if (props.id) {
try {
const row = await moduleStore.get(props.id);
@@ -312,6 +346,7 @@ defineExpose({ historyEntries, applyRestore });
v-model="o.value"
:ref-suggestions="wcSuggestions"
:var-suggestions="varSuggestions"
:uuid-to-name="nameByUuid"
placeholder="value (type @ for nested wildcards · {a|b|c} for inline choices)"
aria-label="Option value"
/>
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
<link rel="manifest" href="/wp/images/site.webmanifest" />
<script type="module" crossorigin src="/wp/assets/manager-Bs_mFjO4.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-CCg9HOCG.css">
<script type="module" crossorigin src="/wp/assets/manager-yLWQMHbh.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-Dgq4_qmM.css">
</head>
<body>
<div id="app"></div>