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