fix(spa): @ autocomplete UUIDs, editor cross-refs, derivation $ vars
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user