feat(spa): wildcard form gains varBinding field + RichTextInput on option values

Add an editable Variable input to the wildcard editor that auto-derives from
name (and stops auto-tracking once the user touches it), and swap the option-
value column from a plain InputText to RichTextInput so authors get
{a|b|c} highlighting plus @-trigger autocomplete to other wildcards' var
bindings and $-trigger autocomplete to fixed_values + combine outputs.
This commit is contained in:
DumiFlex
2026-04-27 06:06:27 +03:00
parent d50dd8d601
commit d84bd2d60b
2 changed files with 71 additions and 16 deletions
@@ -154,6 +154,23 @@ describe("WildcardForm.vue", () => {
expect(call.payload.var_binding).toBe("outfit_style");
});
it("renders RichTextInput for each option value", async () => {
apiMod.get.mockResolvedValue({
id: "wc_a", name: "alpha", description: "", category_id: null,
tags: [], type: "wildcard",
payload: { options: [{ id: "o1", value: "red", weight: 1 }] },
version: 1, created_at: "", updated_at: "", is_favorite: false,
});
const wrap = mount(WildcardForm, {
props: { id: "wc_a" },
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const rti = wrap.findComponent({ name: "RichTextInput" });
expect(rti.exists()).toBe(true);
expect(rti.props("modelValue")).toBe("red");
});
it("loads var_binding from existing payload", async () => {
apiMod.get.mockResolvedValue({
id: "wc_a", name: "Hair Color", description: "", category_id: null,
+54 -16
View File
@@ -3,6 +3,8 @@ import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import InputGroup from "primevue/inputgroup";
import InputGroupAddon from "primevue/inputgroupaddon";
import Textarea from "primevue/textarea";
import InputNumber from "primevue/inputnumber";
import Select from "primevue/select";
@@ -12,7 +14,7 @@ import RichTextInput from "../components/RichTextInput.vue";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
import type { WildcardOption, WildcardPayload } from "../api/types";
import type { CombinePayload, WildcardOption, WildcardPayload } from "../api/types";
const props = defineProps<{ id?: string }>();
const router = useRouter();
@@ -43,7 +45,7 @@ const subCategoryOptions = computed(() => [
// Suggestions for option-value RichTextInput: every other wildcard's
// var_binding (or slug fallback). Self is excluded so authors don't write
// a recursive reference by accident.
// a recursive reference by accident. Powers `@`-trigger autocomplete.
const otherWildcardBindings = computed<string[]>(() => {
const out: string[] = [];
for (const m of moduleStore.items) {
@@ -56,6 +58,37 @@ const otherWildcardBindings = computed<string[]>(() => {
return out;
});
// `$varname` suggestions for option-value RichTextInput. Authors can splice
// upstream context values into a wildcard option (e.g. `$character_name`).
// Pulls the union of:
// * other wildcards' `var_binding`
// * fixed_values entries' `name`
// * combines' `output_var`
// Self is excluded so the editor never suggests its own binding.
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 binding = (p.var_binding && p.var_binding.trim()) || toIdentifier(m.name);
if (binding && !seen.has(binding)) { seen.add(binding); out.push(binding); }
} 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((a, b) => a.localeCompare(b));
});
watch(name, (next) => {
if (!varBindingTouched.value) {
varBinding.value = toIdentifier(next);
@@ -189,17 +222,19 @@ async function save() {
<InputText id="wc-name" v-model="name" class="w-full" />
</div>
<div>
<label for="wc-var" class="block text-xs text-wp-text2 mb-1">Variable name</label>
<InputText
id="wc-var"
:model-value="varBinding"
data-test="wc-var-binding"
class="w-full"
:class="{ 'p-invalid': !!varBindingError }"
@update:model-value="onVarBindingInput"
/>
<label for="wc-var" class="block text-xs text-wp-text2 mb-1">Variable</label>
<InputGroup>
<InputGroupAddon><i class="pi pi-dollar" /></InputGroupAddon>
<InputText
id="wc-var"
:model-value="varBinding"
data-test="wc-var-binding"
:class="{ 'p-invalid': !!varBindingError }"
@update:model-value="onVarBindingInput"
/>
</InputGroup>
<p class="text-xs text-wp-text2 mt-1">
Other modules will reference this wildcard's resolved value as <span class="wc-var-mono">${{ varBinding || "name" }}</span>.
Resolved options bind to this variable. Auto-suggested from name; you can edit it.
</p>
<p v-if="varBindingError" class="text-xs text-wp-danger mt-1" data-test="wc-var-error">
{{ varBindingError }}
@@ -286,6 +321,8 @@ async function save() {
<RichTextInput
v-model="opt.value"
:ref-suggestions="otherWildcardBindings"
:var-suggestions="varSuggestions"
placeholder="e.g. red, green, or @hair_color"
aria-label="Option value"
/>
</td>
@@ -310,6 +347,11 @@ async function save() {
</tr>
</tbody>
</table>
<p class="text-xs text-wp-text2 mt-2">
<code>@</code> autocompletes other wildcards.
<code>{a|b|c}</code> picks one randomly.
<code>$$</code> and <code>@@</code> escape literal characters.
</p>
</section>
</div>
@@ -350,8 +392,4 @@ async function save() {
}
.sub-cat-chip i { opacity: 0.7; font-size: 10px; }
.sub-cat-chip i:hover { opacity: 1; }
.wc-var-mono {
font-family: var(--wp-font-mono, ui-monospace, monospace);
color: var(--wp-accent-text, #c4b5fd);
}
</style>