feat(fixed-values): rich text input, tags on and $ / @ off

The canvas fixed-values row was a bare <textarea>, so booru tag autocomplete
never reached it. It is a RichTextInput now.

`$` and `@` stay off there, and that is the ENGINE's rule rather than a UI
preference. `fixed_values_handler.py` says the surface is a binding PRODUCER,
not a consumer: a fixed value DEFINES what `$name` resolves to, so `resolve_text`
gates var reads and `@{}` refs off and renders such a token as literal text
with a warning.

The frontend did not encode that. It hardcoded `surface === "wildcard"` in the
`$` gate, which made the SPA's fixed-values editor MORE permissive than the
engine -- offering `$` autocomplete for tokens the engine would never resolve,
while `parseForSurface` already collapsed those same vars to literal text. The
gate now derives from the producer/consumer split the engine defines, so both
surfaces agree and the existing SPA divergence closes with it.

No machinery was lost in the swap: RichTextInput uses the SAME
`useGrowableField` composable ValueRow did, so auto-grow, the overflow fade and
the drag grip come with it, and ValueRow's duplicate copy goes.

A Vue prop-casting trap cost two RichTextInput tests on the way, and the fix is
the interesting part. `allowVars?: boolean` is inferred as a Boolean prop, and
Vue casts an ABSENT Boolean prop to `false` rather than undefined -- so
`props.allowVars ?? <surface default>` never reached the default and EVERY
surface silently lost `$`. It is now `"auto" | "on" | "off"`, which no
prop-casting rule can quietly rewrite. Two existing tests caught it; without
them this ships as "$ autocomplete stopped working everywhere".

ValueRow's tests updated: the "is a TEXTAREA" assertion became "is multiline
and grows", which is the requirement the tag name was standing in for.
This commit is contained in:
DumiFlex
2026-08-07 01:22:59 +03:00
parent 8aea38dd45
commit ca87384b5b
3 changed files with 120 additions and 97 deletions
@@ -3,65 +3,102 @@ import { mount } from "@vue/test-utils";
import ValueRow from "./ValueRow.vue";
import type { LibraryRow, DraftRow } from "../defaults";
// The value field is a RichTextInput now, so booru tag autocomplete works in
// it. Stubbed here: it is contenteditable with atom spans, which jsdom does
// not implement, and these tests are about ValueRow's wiring.
const RichTextInputStub = {
name: "RichTextInput",
props: {
modelValue: String,
surface: String,
multiline: Boolean,
rows: [Number, String],
disabled: Boolean,
ariaLabel: String,
},
emits: ["update:modelValue"],
template: `<div class="wp-rt-stub" data-test="row-value" :data-model-value="modelValue"></div>`,
};
const globalStubs = { RichTextInput: RichTextInputStub };
/** What the value field is showing. */
const valueOf = (w: any) => w.findComponent(RichTextInputStub).props("modelValue");
/** Type into the value field. */
async function typeValue(w: any, v: string): Promise<void> {
w.findComponent(RichTextInputStub).vm.$emit("update:modelValue", v);
await w.vm.$nextTick();
}
const lib: LibraryRow = { id: "v1", name: "lens", value: "85mm" };
const plainDraft: DraftRow = { id: "v1", name: "lens", value: "85mm", enabled: true, libraryId: "v1" };
describe("ValueRow", () => {
it("renders $-prefixed name + value inputs", () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
expect(w.find('[data-test="row-name-prefix"]').text()).toBe("$");
expect(w.find<HTMLInputElement>('[data-test="row-name"]').element.value).toBe("lens");
expect(w.find<HTMLInputElement>('[data-test="row-value"]').element.value).toBe("85mm");
expect(valueOf(w)).toBe("85mm");
});
it("value field is a growing textarea, not a single-line input", async () => {
it("value field grows rather than being a single line", async () => {
// Users routinely paste a whole sentence into a fixed value; a one-line
// <input> showed a sliver with no way to see the rest.
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const el = w.find('[data-test="row-value"]');
expect(el.element.tagName).toBe("TEXTAREA");
// field showed a sliver with no way to see the rest. The requirement is
// "multiline and grows", not "is a <textarea>" — it is a RichTextInput
// now, which carries the same `useGrowableField` auto-grow, overflow fade
// and drag grip, plus the tag autocomplete a textarea could not host.
const w = mount(ValueRow, {
props: { row: plainDraft, library: lib },
global: { stubs: globalStubs },
});
const rt = w.findComponent(RichTextInputStub);
expect(rt.exists()).toBe(true);
expect(rt.props("multiline")).toBe(true);
});
it("Enter inserts a newline like any textarea", async () => {
// Briefly swallowed for parity with the single-line <input> this replaced,
// which was parity for its own sake: the engine stores the value verbatim,
// multi-line round-trips fine, and the modal saves on Cmd/Ctrl+Enter.
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const el = w.find('[data-test="row-value"]');
const ev = new KeyboardEvent("keydown", { key: "Enter", cancelable: true, bubbles: true });
el.element.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(false);
it("offers tag autocomplete but not $ or @", () => {
// The engine treats fixed_values as a binding PRODUCER: it DEFINES what
// `$name` resolves to, so `$var` reads and `@{}` refs are gated off and
// render as literal text with a warning. The surface prop is what carries
// that rule into the editor.
const w = mount(ValueRow, {
props: { row: plainDraft, library: lib },
global: { stubs: globalStubs },
});
expect(w.findComponent(RichTextInputStub).props("surface")).toBe("fixed_values");
});
it("keeps a multi-line value intact on input", async () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const el = w.find<HTMLTextAreaElement>('[data-test="row-value"]');
el.element.value = "line one\nline two";
await el.trigger("input");
// Multi-line values round-trip: the engine stores the value verbatim, and
// the modal saves on Cmd/Ctrl+Enter so plain Enter stays free for a
// newline. Previously two tests — one drove a keydown to prove Enter was
// not swallowed, which only made sense against a real <textarea>.
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
await typeValue(w, "line one\nline two");
const updates = w.emitted("update") ?? [];
expect(updates[updates.length - 1]).toEqual(["v1", { value: "line one\nline two" }]);
});
it("checkbox is checked when enabled", () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
expect(w.find('[data-test="row-check"]').classes()).toContain("row__check--on");
});
it("checkbox unchecked + line-through when disabled", () => {
const draft = { ...plainDraft, enabled: false };
const w = mount(ValueRow, { props: { row: draft, library: lib } });
const w = mount(ValueRow, { props: { row: draft, library: lib }, global: { stubs: globalStubs } });
expect(w.find('[data-test="row-check"]').classes()).not.toContain("row__check--on");
expect(w.classes()).toContain("row--off");
});
it("emits toggle event with row id when checkbox clicked", async () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
await w.find('[data-test="row-check"]').trigger("click");
expect(w.emitted("toggle")?.[0]).toEqual(["v1"]);
});
it("emits update event with new name on name input", async () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
const input = w.find<HTMLInputElement>('[data-test="row-name"]');
input.element.value = "camera_lens";
await input.trigger("input");
@@ -69,17 +106,15 @@ describe("ValueRow", () => {
});
it("emits update event with new value on value input", async () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const input = w.find<HTMLInputElement>('[data-test="row-value"]');
input.element.value = "50mm";
await input.trigger("input");
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
await typeValue(w, "50mm");
expect(w.emitted("update")?.[0]).toEqual(["v1", { value: "50mm" }]);
});
it("strips comma + punctuation from the name on input (value names must be identifiers)", async () => {
// The name becomes a `$var`; only identifier chars allowed. The VALUE
// field (below) is free-form and must NOT be sanitized.
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
const input = w.find<HTMLInputElement>('[data-test="row-name"]');
input.element.value = "cam,era;lens!";
await input.trigger("input");
@@ -88,15 +123,13 @@ describe("ValueRow", () => {
});
it("does NOT strip punctuation from the value field (values are free-form)", async () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const input = w.find<HTMLInputElement>('[data-test="row-value"]');
input.element.value = "85mm, f/1.8!";
await input.trigger("input");
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
await typeValue(w, "85mm, f/1.8!");
expect(w.emitted("update")?.[0]).toEqual(["v1", { value: "85mm, f/1.8!" }]);
});
it("library row with no override is plain (no --mod, no reset button)", () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
expect(w.find('[data-test="row-name-wrap"]').classes()).not.toContain("row__name-wrap--mod");
expect(w.find('[data-test="row-value-wrap"]').classes()).not.toContain("row__value-wrap--mod");
expect(w.find('[data-test="row-reset"]').exists()).toBe(false);
@@ -104,21 +137,21 @@ describe("ValueRow", () => {
it("library row with value override gets --mod class on value wrap + reset visible", () => {
const draft: DraftRow = { ...plainDraft, value: "50mm" };
const w = mount(ValueRow, { props: { row: draft, library: lib } });
const w = mount(ValueRow, { props: { row: draft, library: lib }, global: { stubs: globalStubs } });
expect(w.find('[data-test="row-value-wrap"]').classes()).toContain("row__value-wrap--mod");
expect(w.find('[data-test="row-reset"]').exists()).toBe(true);
});
it("library row with name override gets --mod class on name wrap", () => {
const draft: DraftRow = { ...plainDraft, name: "camera_lens" };
const w = mount(ValueRow, { props: { row: draft, library: lib } });
const w = mount(ValueRow, { props: { row: draft, library: lib }, global: { stubs: globalStubs } });
expect(w.find('[data-test="row-name-wrap"]').classes()).toContain("row__name-wrap--mod");
expect(w.find('[data-test="row-reset"]').exists()).toBe(true);
});
it("instance-added row (no library) shows --added + delete button (no reset)", () => {
const draft: DraftRow = { id: "fresh01", name: "mood", value: "cozy", enabled: true, libraryId: null };
const w = mount(ValueRow, { props: { row: draft, library: undefined } });
const w = mount(ValueRow, { props: { row: draft, library: undefined }, global: { stubs: globalStubs } });
expect(w.classes()).toContain("row--added");
expect(w.find('[data-test="row-delete"]').exists()).toBe(true);
expect(w.find('[data-test="row-reset"]').exists()).toBe(false);
@@ -126,14 +159,14 @@ describe("ValueRow", () => {
it("reset button click emits reset with row id", async () => {
const draft: DraftRow = { ...plainDraft, value: "50mm" };
const w = mount(ValueRow, { props: { row: draft, library: lib } });
const w = mount(ValueRow, { props: { row: draft, library: lib }, global: { stubs: globalStubs } });
await w.find('[data-test="row-reset"]').trigger("click");
expect(w.emitted("reset")?.[0]).toEqual(["v1"]);
});
it("delete button click emits delete with row id", async () => {
const draft: DraftRow = { id: "fresh01", name: "mood", value: "cozy", enabled: true, libraryId: null };
const w = mount(ValueRow, { props: { row: draft, library: undefined } });
const w = mount(ValueRow, { props: { row: draft, library: undefined }, global: { stubs: globalStubs } });
await w.find('[data-test="row-delete"]').trigger("click");
expect(w.emitted("delete")?.[0]).toEqual(["fresh01"]);
});
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useGrowableField } from "../../../../shared/useGrowableField";
import { computed } from "vue";
import RichTextInput from "../../../../../manager/components/RichTextInput.vue";
import {
rowOverrideKind,
type DraftRow,
@@ -62,12 +62,7 @@ function onNameInput(ev: Event): void {
if (el.value !== name) el.value = name;
emit("update", props.row.id, { name });
}
function onValueInput(ev: Event): void {
const el = ev.target as HTMLTextAreaElement;
emit("update", props.row.id, { value: el.value });
autosize();
updateOverflowHint();
}
/** Grow the value field to fit its content, capped by the CSS `max-height`
* (after which it scrolls). The field is a `<textarea>` purely to get this
@@ -89,19 +84,6 @@ function onValueInput(ev: Event): void {
* plain Enter is free — and users writing paragraph-length values want their
* line breaks. */
const valueEl = ref<HTMLTextAreaElement | null>(null);
/* Shared auto-grow + overflow-hint + grip-follow. See `useGrowableField` for
* why the three belong together — chiefly that auto-grow has to yield to the
* drag handle, which the local copy did not. */
const {
hasMoreBelow,
updateOverflowHint,
scheduleOverflowHint,
autosize,
attach,
startResize,
} = useGrowableField(() => valueEl.value);
/** True when the field is capped and still hiding content below the fold.
* Mirrors RichTextInput's hint — a capped box otherwise looks identical
@@ -112,23 +94,6 @@ const {
/* Size correctly on first paint + whenever the row's value changes from
outside (reset-to-library, undo). */
watch(
() => props.row.value,
() => void nextTick(() => {
autosize();
scheduleOverflowHint();
}),
{ immediate: true },
);
onMounted(() => {
autosize();
scheduleOverflowHint();
attach();
});
function onReset(): void {
@@ -197,33 +162,26 @@ function onDelete(): void {
class="row__value-wrap"
:class="{
'row__value-wrap--mod': valueOverridden,
'row__value-wrap--more': hasMoreBelow,
}"
data-test="row-value-wrap"
>
<textarea
ref="valueEl"
<!-- RichTextInput on the `fixed_values` surface. Tag autocomplete
works here; `$` and `@` do NOT, and that is the engine's rule not a
UI preference: a fixed value DEFINES what `$name` resolves to, so
`resolve_text` gates both off and renders such a token as literal
text with a warning. The producer/consumer default in
RichTextInput encodes exactly that, so nothing is passed here. -->
<RichTextInput
class="row__value"
data-test="row-value"
rows="1"
spellcheck="false"
:value="row.value"
:model-value="row.value"
surface="fixed_values"
multiline
:rows="1"
:disabled="!row.enabled"
:aria-label="`Value for row ${row.id}`"
@input="onValueInput"
@scroll="updateOverflowHint"
@update:model-value="(v: string) => emit('update', props.row.id, { value: v })"
/>
<!-- Resize grip, replacing `resize: vertical`. The native resizer
recomputes from an origin captured at pointerdown and never clamps
to min/max, so over-dragging banks invisible travel the user has to
walk back — measured at 13 of 23 dead frames. Ours applies each
move's delta to the current height. -->
<span
class="row__grip"
data-test="row-grip"
aria-hidden="true"
@pointerdown="startResize"
></span>
</span>
<span
v-if="hasNonTextToken"
+33 -1
View File
@@ -166,6 +166,16 @@ interface Props {
* there (carrier) but compares `condition.value` raw, so condition inputs
* leave this false. The `wildcard` surface enables refs regardless. */
allowNestedRefs?: boolean;
/**
* Override the producer/consumer default for `$var` reads.
*
* Tri-state on purpose. A `boolean` here would be inferred as a Vue Boolean
* prop, and Vue casts an ABSENT Boolean prop to `false` rather than leaving
* it undefined — so `props.allowVars ?? <default>` never reached the
* default and every surface silently lost `$`. `"auto"` says "use the
* surface's rule" in a way no prop-casting rule can quietly rewrite.
*/
allowVars?: "auto" | "on" | "off";
/** Map from UUID to display name; used to render `@{uuid}` refs as human labels. */
uuidToName?: Map<string, string>;
/** Map from wildcard UUID → its declared sub_categories. Used by the
@@ -246,6 +256,28 @@ const effectiveWarnings = computed<ResolveWarning[]>(() => [
* drift apart. */
const refsEnabled = computed(() => props.surface === "wildcard" || props.allowNestedRefs);
/**
* Whether `$var` READS mean anything on this surface.
*
* The engine already draws this line and calls it producer vs consumer:
* `wildcard` and `fixed_values` DEFINE what a `$name` resolves to, so a `$var`
* read inside one is not a reference — `resolve_text` gates both off and
* renders the token as literal text with a warning
* (`engine/modules/fixed_values_handler.py`, `engine/syntax/resolve.py`).
*
* The frontend used to hardcode `surface === "wildcard"` here, which made it
* MORE permissive than the engine: the SPA's fixed-values editor offered `$`
* autocomplete for tokens the engine would never resolve. A prop with a
* surface-derived default keeps the two aligned and lets a caller be explicit
* rather than adding a third name to a growing condition.
*/
const PRODUCER_SURFACES = new Set(["wildcard", "fixed_values"]);
const varsEnabled = computed(() => {
if (props.allowVars === "on") return true;
if (props.allowVars === "off") return false;
return !PRODUCER_SURFACES.has(props.surface ?? "combine");
});
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
@@ -1036,7 +1068,7 @@ function refreshAutocompleteFromHost(): void {
// do. Blocking the popover in wildcard surface stops the user from
// typing `$name` into an option value and ending up with a chip
// that has no engine meaning.
if (hit.trigger === "$" && props.surface === "wildcard") {
if (hit.trigger === "$" && !varsEnabled.value) {
acOpen.value = false;
return;
}