feat(spa): real Combine editor with template + RichTextInput + detected inputs

Replaces the JSON-stub Combine form with the full editor matching the
prototype: identity card, output-variable card with `$` addon and
auto-suggested identifier, RichTextInput-driven template with `$var`
autocomplete pulled from upstream wildcards / fixed_values / other
combines, detected-inputs chip list with kind dots, and a live preview
that re-rolls every 1.5s. Tests cover heading, load, save shape, and
detected-input extraction (including `$$` escape skipping).
This commit is contained in:
DumiFlex
2026-04-27 05:58:07 +03:00
parent 4c603a20db
commit d50dd8d601
2 changed files with 586 additions and 26 deletions
+174
View File
@@ -0,0 +1,174 @@
import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMemoryHistory, createRouter } from "vue-router";
import PrimeVue from "primevue/config";
import ToastService from "primevue/toastservice";
vi.mock("../api/client", () => ({
api: {
modules: {
create: vi.fn(),
get: vi.fn(),
update: vi.fn(),
list: vi.fn().mockResolvedValue({ items: [], total: 0 }),
},
categories: { list: vi.fn().mockResolvedValue({ items: [] }) },
},
}));
import { api } from "../api/client";
import CombineForm from "../views/CombineForm.vue";
import type { CombinePayload } from "../api/types";
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
const apiCat = api.categories as unknown as Record<string, ReturnType<typeof vi.fn>>;
beforeEach(() => {
setActivePinia(createPinia());
Object.values(apiMod).forEach((fn) => fn.mockReset());
apiMod.list.mockResolvedValue({ items: [], total: 0 });
apiCat.list.mockResolvedValue({ items: [] });
});
afterEach(() => {
vi.clearAllMocks();
});
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/combines", component: { template: "<div/>" } },
],
});
}
function findByText(wrap: ReturnType<typeof mount>, text: string) {
return wrap.findAll("button").find((b) => b.text().includes(text));
}
describe("CombineForm.vue", () => {
it("renders 'New combine' heading when no id", async () => {
const wrap = mount(CombineForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
expect(wrap.text()).toContain("New combine");
});
it("loads existing module when id prop given", async () => {
apiMod.get.mockResolvedValue({
id: "cb_a",
name: "Subject Phrase",
description: "desc",
category_id: null,
tags: [],
type: "combine",
payload: {
template: "$first_name with $hair_color hair",
output_var: "subject_phrase",
input_vars: ["first_name", "hair_color"],
},
version: 1,
created_at: "",
updated_at: "",
is_favorite: false,
});
const wrap = mount(CombineForm, {
props: { id: "cb_a" },
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
expect(wrap.text()).toContain("Edit combine");
expect(apiMod.get).toHaveBeenCalledWith("cb_a");
const outInput = wrap.find('[data-test="cb-output-var"]')
.element as HTMLInputElement;
expect(outInput.value).toBe("subject_phrase");
});
it("save without name shows warn toast and does not call api", async () => {
const wrap = mount(CombineForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
expect(apiMod.create).not.toHaveBeenCalled();
});
it("save creates combine with type/payload shape", async () => {
apiMod.create.mockResolvedValue({
id: "cb_a",
type: "combine",
name: "Subject Phrase",
description: "",
category_id: null,
tags: [],
is_favorite: false,
payload: {
template: "$first_name with $hair_color hair",
output_var: "subject_phrase",
input_vars: ["first_name", "hair_color"],
},
version: 1,
created_at: "",
updated_at: "",
});
const wrap = mount(CombineForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const nameInput = wrap.find("#cb-name");
await nameInput.setValue("Subject Phrase");
await flushPromises();
// Type a template directly into the rich-text textarea.
const ta = wrap.find('[data-test="cb-template"] textarea');
await ta.setValue("$first_name with $hair_color hair");
await flushPromises();
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
expect(apiMod.create).toHaveBeenCalledTimes(1);
const call = apiMod.create.mock.calls[0]?.[0] as {
type: string;
payload: CombinePayload;
};
expect(call.type).toBe("combine");
expect(call.payload.template).toBe("$first_name with $hair_color hair");
expect(call.payload.output_var).toBe("subject_phrase");
expect(call.payload.input_vars).toEqual(["first_name", "hair_color"]);
// Strict shape — no extras.
expect(Object.keys(call.payload).sort()).toEqual(
["input_vars", "output_var", "template"].sort(),
);
});
it("detected inputs panel shows the $vars from the template", async () => {
const wrap = mount(CombineForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
// Empty-state visible initially.
expect(wrap.find('[data-test="cb-detected-empty"]').exists()).toBe(true);
const ta = wrap.find('[data-test="cb-template"] textarea');
await ta.setValue("$alpha plus $beta and $alpha again, $$literal");
await flushPromises();
const detected = wrap.find('[data-test="cb-detected"]');
expect(detected.exists()).toBe(true);
expect(detected.text()).toContain("$alpha");
expect(detected.text()).toContain("$beta");
// $$literal should not be a detected var.
expect(detected.text()).not.toContain("$literal");
// De-duplicated — only two chips.
expect(detected.findAll(".cb-chip").length).toBe(2);
});
});
+412 -26
View File
@@ -1,16 +1,36 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
/**
* CombineForm
*
* Editor for `combine` modules. A combine renders a template string with
* `$var` placeholders into a single resolved value, then publishes that
* value under `output_var` for downstream modules to read.
*
* Reference prototype: docs/design-handoff/wildcardpipeline/project/screens/editors.jsx
* (function CombineEditor).
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import Button from "primevue/button";
import Card from "primevue/card";
import InputText from "primevue/inputtext";
import InputGroup from "primevue/inputgroup";
import InputGroupAddon from "primevue/inputgroupaddon";
import Textarea from "primevue/textarea";
import Select from "primevue/select";
import AutoComplete from "primevue/autocomplete";
import { useToast } from "primevue/usetoast";
import RichTextInput from "../components/RichTextInput.vue";
import RichTextPreview from "../components/RichTextPreview.vue";
import { tokenizeRich } from "../utils/richTokenize";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import type { CombinePayload } from "../api/types";
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
import type {
CombinePayload,
ModuleRow,
WildcardPayload,
WildcardOption,
} from "../api/types";
const props = defineProps<{ id?: string }>();
const router = useRouter();
@@ -22,10 +42,18 @@ const name = ref("");
const description = ref("");
const categoryId = ref<string | null>(null);
const tags = ref<string[]>([]);
const payload = ref<CombinePayload>({ template: "", output_var: "", input_vars: [] });
const template = ref("");
const outputVar = ref("");
// Tracks whether the user manually edited `outputVar`; while false the
// field auto-tracks `toIdentifier(name)` like WildcardForm's var_binding.
const outputVarTouched = ref(false);
const outputVarError = ref("");
const saving = ref(false);
const isEdit = computed(() => !!props.id);
const PLACEHOLDER = "$first_name, a $age-year-old with $hair_color hair";
// --- Identity: tag autocomplete (same pattern as WildcardForm) ---
const tagSuggestions = ref<string[]>([]);
function searchTags(event: { query: string }) {
const q = event.query.toLowerCase();
@@ -38,6 +66,177 @@ function searchTags(event: { query: string }) {
.slice(0, 10);
}
// --- Output var: auto-suggest from name until user edits manually. ---
watch(name, (next) => {
if (!outputVarTouched.value) {
outputVar.value = toIdentifier(next);
}
});
function onOutputVarInput(value: string | undefined): void {
outputVarTouched.value = true;
// Strip any leading `$` the user types — the addon already shows it.
outputVar.value = (value ?? "").replace(/^\$+/, "");
if (outputVarError.value) outputVarError.value = "";
}
// --- Variable suggestions (everything visible upstream as a $var). ---
//
// Pulls the union of:
// * wildcard `var_binding` (or slug fallback)
// * fixed_values entries' `name`
// * other combines' `output_var`
// excluding self so the editor never suggests its own output as an input.
interface VarHint { label: string; kind: "wildcard" | "fixed_values" | "combine" }
const varHints = computed<VarHint[]>(() => {
const seen = new Set<string>();
const out: VarHint[] = [];
for (const m of moduleStore.items) {
if (props.id && m.id === props.id) continue; // exclude self
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({ label: binding, kind: "wildcard" });
}
} 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({ label: n, kind: "fixed_values" });
}
}
} 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({ label: o, kind: "combine" });
}
}
}
return out.sort((a, b) => a.label.localeCompare(b.label));
});
const varSuggestions = computed<string[]>(() => varHints.value.map((h) => h.label));
const hintByLabel = computed<Map<string, VarHint>>(() => {
const m = new Map<string, VarHint>();
for (const h of varHints.value) m.set(h.label, h);
return m;
});
// --- Detected inputs: extract `$ident` from template, skipping `$$` escapes. ---
const detected = computed<string[]>(() => {
const tokens = tokenizeRich(template.value);
const seen = new Set<string>();
const out: string[] = [];
for (const t of tokens) {
if (t.kind === "var" && t.meta?.name) {
if (!seen.has(t.meta.name)) {
seen.add(t.meta.name);
out.push(t.meta.name);
}
}
}
return out;
});
// --- Live preview: best-effort resolution of each detected $var. ---
//
// Each render picks a fresh sample so authors can re-roll. Only `wildcard`
// kinds change between renders (random pick from options); fixed/combine
// values render their first value / placeholder respectively.
const previewSeed = ref(0);
function pickWildcardValue(mod: ModuleRow): string {
const p = (mod.payload ?? {}) as Partial<WildcardPayload>;
const opts: WildcardOption[] = p.options ?? [];
if (!opts.length) return `<empty:${mod.name}>`;
// Weighted random pick.
let total = 0;
for (const o of opts) total += Math.max(0, o.weight ?? 1);
if (total <= 0) return opts[0].value ?? "";
let r = Math.random() * total;
for (const o of opts) {
r -= Math.max(0, o.weight ?? 1);
if (r <= 0) return o.value ?? "";
}
return opts[opts.length - 1].value ?? "";
}
function pickFixedValue(varName: string): string | null {
for (const m of moduleStore.items) {
if (m.type !== "fixed_values") continue;
const values = ((m.payload ?? {}) as { values?: { name?: string; value?: string }[] }).values ?? [];
for (const row of values) {
const n = (row.name ?? "").replace(/^\$+/, "").trim();
if (n === varName) return row.value ?? "";
}
}
return null;
}
function findWildcardForVar(varName: string): ModuleRow | null {
for (const m of moduleStore.items) {
if (m.type !== "wildcard") continue;
const p = (m.payload ?? {}) as Partial<WildcardPayload>;
const binding = (p.var_binding && p.var_binding.trim()) || toIdentifier(m.name);
if (binding === varName) return m;
}
return null;
}
const livePreview = computed<string>(() => {
// Reactive read of the seed so re-roll bumps invalidate this computed.
void previewSeed.value;
const tpl = template.value;
if (!tpl) return "";
// Replace each $ident (skipping $$) with a sampled value. Use the
// tokenizer to find positions so we don't accidentally rewrite a
// ${something} block or a `$$` escape.
const tokens = tokenizeRich(tpl);
let out = "";
for (const t of tokens) {
if (t.kind === "var" && t.meta?.name) {
const v = t.meta.name;
const wc = findWildcardForVar(v);
if (wc) {
out += pickWildcardValue(wc);
continue;
}
const fv = pickFixedValue(v);
if (fv != null) {
out += fv;
continue;
}
out += `<unbound:${v}>`;
} else if (t.kind === "escape" && t.raw === "$$") {
out += "$";
} else {
out += t.raw;
}
}
return out;
});
let rerollTimer: number | null = null;
function reroll(): void {
previewSeed.value++;
}
function startRerollTimer(): void {
stopRerollTimer();
rerollTimer = window.setInterval(reroll, 1500);
}
function stopRerollTimer(): void {
if (rerollTimer != null) {
window.clearInterval(rerollTimer);
rerollTimer = null;
}
}
onMounted(async () => {
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
if (props.id) {
@@ -48,31 +247,49 @@ onMounted(async () => {
categoryId.value = row.category_id;
tags.value = row.tags;
const p = row.payload as Partial<CombinePayload>;
payload.value = {
template: p.template ?? "",
output_var: p.output_var ?? "",
input_vars: p.input_vars ?? [],
};
template.value = p.template ?? "";
const o = (p.output_var ?? "").replace(/^\$+/, "");
if (o.trim()) {
outputVar.value = o;
outputVarTouched.value = true;
} else {
outputVar.value = toIdentifier(row.name);
}
} catch {
toast.add({ severity: "error", summary: "Combine not found", life: 3000 });
router.replace("/combines");
}
}
startRerollTimer();
});
onBeforeUnmount(stopRerollTimer);
async function save() {
if (!name.value.trim()) {
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
return;
}
const finalOutput = (outputVar.value.trim()) || toIdentifier(name.value);
if (!VALID_IDENTIFIER_RE.test(finalOutput)) {
outputVarError.value = "Use letters, digits, underscores; must not start with a digit.";
toast.add({ severity: "warn", summary: "Invalid output variable", life: 2500 });
return;
}
outputVarError.value = "";
saving.value = true;
try {
const payload: CombinePayload = {
template: template.value,
output_var: finalOutput,
input_vars: [...detected.value],
};
const body = {
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: payload.value as unknown as Record<string, unknown>,
payload: payload as unknown as Record<string, unknown>,
};
if (isEdit.value && props.id) {
await moduleStore.update(props.id, body);
@@ -99,6 +316,7 @@ async function save() {
</div>
<div class="form-page__body">
<!-- Identity ------------------------------------------------------- -->
<section class="form-section">
<h2 class="form-section__label">Identity</h2>
<div class="grid grid-cols-2 gap-4">
@@ -134,16 +352,115 @@ async function save() {
</div>
</section>
<!-- Output --------------------------------------------------------- -->
<section class="form-section">
<h2 class="form-section__label">Output</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="cb-output" class="block text-xs text-wp-text2 mb-1">Output variable</label>
<InputGroup>
<InputGroupAddon><i class="pi pi-dollar" /></InputGroupAddon>
<InputText
id="cb-output"
:model-value="outputVar"
data-test="cb-output-var"
placeholder="subject_phrase"
:class="{ 'p-invalid': !!outputVarError }"
@update:model-value="onOutputVarInput"
/>
</InputGroup>
<p class="text-xs text-wp-text2 mt-1">
Downstream modules will read this combine's value as
<span class="cb-mono">${{ outputVar || "name" }}</span>.
</p>
<p v-if="outputVarError" class="text-xs text-wp-danger mt-1" data-test="cb-output-error">
{{ outputVarError }}
</p>
<p v-else class="text-xs text-wp-text3 mt-1">
Must be a valid identifier (letters, digits, underscores).
</p>
</div>
</div>
</section>
<!-- Template ------------------------------------------------------- -->
<section class="form-section">
<h2 class="form-section__label">Template</h2>
<Card>
<template #content>
<p class="text-sm text-wp-text2 m-0 mb-2">
Editor coming soon — payload shown below.
</p>
<pre class="payload-preview" data-test="payload-preview">{{ JSON.stringify(payload, null, 2) }}</pre>
</template>
</Card>
<RichTextInput
v-model="template"
:multiline="true"
:rows="4"
:placeholder="PLACEHOLDER"
:var-suggestions="varSuggestions"
aria-label="Combine template"
data-test="cb-template"
/>
<p class="text-xs text-wp-text2 mt-1">
Type <span class="cb-mono">$</span> to autocomplete known variables. Use
<span class="cb-mono">$$</span> to escape a literal dollar sign.
</p>
</section>
<!-- Detected inputs ------------------------------------------------ -->
<section class="form-section">
<h2 class="form-section__label">
Detected inputs <span class="cb-count">({{ detected.length }})</span>
</h2>
<div v-if="detected.length" class="cb-chip-list" data-test="cb-detected">
<span
v-for="d in detected"
:key="d"
class="cb-chip"
:data-kind="hintByLabel.get(d)?.kind ?? 'unknown'"
>
<span
v-if="hintByLabel.get(d)"
class="cb-chip__dot"
:data-kind="hintByLabel.get(d)?.kind"
/>
<i v-else class="pi pi-question-circle cb-chip__icon" aria-hidden="true" />
${{ d }}
</span>
</div>
<p v-else class="text-xs text-wp-text3" data-test="cb-detected-empty">
No <span class="cb-mono">$variable</span> references yet — type
<span class="cb-mono">$</span> in the template above.
</p>
</section>
<!-- Preview -------------------------------------------------------- -->
<section class="form-section">
<div class="flex items-center justify-between mb-2">
<h2 class="form-section__label m-0">Preview</h2>
<Button
label="Re-roll"
icon="pi pi-refresh"
size="small"
severity="secondary"
outlined
data-test="cb-reroll"
@click="reroll"
/>
</div>
<div class="cb-preview">
<div class="cb-preview__row">
<span class="cb-preview__label">Template</span>
<span class="cb-preview__value">
<RichTextPreview v-if="template" :value="template" />
<span v-else class="text-wp-text3">(empty template)</span>
</span>
</div>
<div class="cb-preview__row">
<span class="cb-preview__label">Sample</span>
<span class="cb-preview__value cb-preview__sample" data-test="cb-preview-sample">
{{ livePreview || "(no template)" }}
</span>
</div>
<div class="cb-preview__row">
<span class="cb-preview__label">Stored as</span>
<span class="cb-preview__value cb-mono">${{ outputVar || "output" }}</span>
</div>
</div>
</section>
</div>
@@ -171,17 +488,86 @@ async function save() {
letter-spacing: 0.5px; color: var(--wp-text2);
margin: 0 0 8px 0;
}
.payload-preview {
.cb-mono {
font-family: var(--wp-font-mono, ui-monospace, monospace);
color: var(--wp-accent-text, #c4b5fd);
}
.cb-count {
text-transform: none;
letter-spacing: 0;
color: var(--wp-text3, #6e6e7c);
font-weight: 400;
}
/* Detected-inputs chip list ------------------------------------------- */
.cb-chip-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cb-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 12px;
font-weight: 500;
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 12%, transparent);
color: var(--wp-accent-text, #c4b5fd);
border: 1px solid color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 32%, transparent);
}
.cb-chip[data-kind="unknown"] {
background: color-mix(in oklab, var(--wp-warn, #f59e0b) 10%, transparent);
border-color: color-mix(in oklab, var(--wp-warn, #f59e0b) 32%, transparent);
color: var(--wp-warn, #fcd34d);
}
.cb-chip__dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--wp-text-muted);
}
.cb-chip__dot[data-kind="wildcard"] { background: var(--wp-kind-wildcard, #c026d3); }
.cb-chip__dot[data-kind="fixed_values"] { background: var(--wp-kind-fixed, #0ea5e9); }
.cb-chip__dot[data-kind="combine"] { background: var(--wp-kind-combine, #f59e0b); }
.cb-chip__icon {
font-size: 11px;
opacity: 0.85;
}
/* Preview card -------------------------------------------------------- */
.cb-preview {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 8px 10px;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.cb-preview__row {
display: grid;
grid-template-columns: 88px 1fr;
gap: 12px;
align-items: baseline;
font-size: 13px;
line-height: 1.5;
}
.cb-preview__label {
font-size: 10px;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--wp-text3, #6e6e7c);
}
.cb-preview__value {
color: var(--wp-text);
max-height: 320px;
overflow: auto;
word-break: break-word;
}
.cb-preview__sample {
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--wp-text2);
}
</style>