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:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user