feat(spa): real Derivation editor with multi-rule IF/ELIF/ELSE

- DerivationRuleCard: per-rule card with IF/ELIF branches stack +
  optional ELSE clause; condition operator + comparator + value;
  action select (set | append) + target var + value
- DerivationForm: rules array (each independently evaluated), add/
  remove/duplicate per rule, drag-free reorder via chevrons
- types.ts: DerivationRule shape locked (branches[] + else? action)
- utils/slug.ts: shared identifier helper used by varBinding + step ids
- 11 new tests (DerivationForm, DerivationRuleCard, slug)
This commit is contained in:
DumiFlex
2026-04-27 05:51:33 +03:00
parent 054a777ec1
commit 4c603a20db
8 changed files with 1084 additions and 29 deletions
@@ -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 DerivationForm from "../views/DerivationForm.vue";
import type { DerivationRule } 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: "/derivations", component: { template: "<div/>" } },
],
});
}
function findByText(wrap: ReturnType<typeof mount>, text: string) {
return wrap.findAll("button").find((b) => b.text().includes(text));
}
describe("DerivationForm.vue", () => {
it("renders 'New derivation' heading when no id", async () => {
const wrap = mount(DerivationForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
expect(wrap.text()).toContain("New derivation");
expect(wrap.text()).toContain("All rules evaluate independently");
});
it("starts with no rules and shows the empty-state card", async () => {
const wrap = mount(DerivationForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
expect(wrap.find('[data-test="rules-empty"]').exists()).toBe(true);
const vm = wrap.vm as unknown as { rules: DerivationRule[] };
expect(vm.rules).toEqual([]);
});
it("Add rule increments the rules length", async () => {
const wrap = mount(DerivationForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const vm = wrap.vm as unknown as { rules: DerivationRule[] };
expect(vm.rules.length).toBe(0);
const btn = wrap.find('[data-test="add-rule"]');
await btn.trigger("click");
await flushPromises();
expect(vm.rules.length).toBe(1);
// First rule has a single IF branch by default
expect(vm.rules[0].branches.length).toBe(1);
expect(vm.rules[0].else).toBeUndefined();
});
it("save calls api.modules.create with type 'derivation' and the rules payload", async () => {
apiMod.create.mockResolvedValue({
id: "dv_a", type: "derivation", name: "D1",
description: "", category_id: null, tags: [], is_favorite: false,
payload: { rules: [] }, version: 1, created_at: "", updated_at: "",
});
const wrap = mount(DerivationForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const nameInput = wrap.find("#dv-name");
await nameInput.setValue("D1");
await nameInput.trigger("input");
// Add one rule so the payload is non-trivial
await wrap.find('[data-test="add-rule"]').trigger("click");
await flushPromises();
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
expect(apiMod.create).toHaveBeenCalledTimes(1);
expect(apiMod.create).toHaveBeenCalledWith(
expect.objectContaining({
type: "derivation",
name: "D1",
payload: expect.objectContaining({
rules: expect.arrayContaining([
expect.objectContaining({
branches: expect.any(Array),
}),
]),
}),
}),
);
});
it("save without name shows warn and does not call api", async () => {
const wrap = mount(DerivationForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
expect(apiMod.create).not.toHaveBeenCalled();
});
it("loads existing derivation payload when id is given", async () => {
apiMod.get.mockResolvedValue({
id: "dv_a",
type: "derivation",
name: "Wet weather",
description: "",
category_id: null,
tags: [],
is_favorite: false,
payload: {
rules: [
{
id: "r1",
branches: [
{
condition: { var: "weather", op: "equals", value: "rain" },
action: { target_var: "outfit", mode: "replace", value: "raincoat" },
},
],
},
],
},
version: 1,
created_at: "",
updated_at: "",
});
const wrap = mount(DerivationForm, {
props: { id: "dv_a" },
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
expect(wrap.text()).toContain("Edit derivation");
expect(apiMod.get).toHaveBeenCalledWith("dv_a");
const vm = wrap.vm as unknown as { rules: DerivationRule[] };
expect(vm.rules.length).toBe(1);
expect(vm.rules[0].branches[0].condition.op).toBe("equals");
expect(vm.rules[0].branches[0].action.target_var).toBe("outfit");
});
});
@@ -0,0 +1,137 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import PrimeVue from "primevue/config";
import DerivationRuleCard from "../components/DerivationRuleCard.vue";
import type { DerivationRule } from "../api/types";
function makeRule(overrides: Partial<DerivationRule> = {}): DerivationRule {
return {
id: "r_1",
branches: [
{
condition: { var: "x", op: "equals", value: "y" },
action: { target_var: "out", mode: "replace", value: "v" },
},
],
...overrides,
};
}
function mountCard(rule: DerivationRule, index = 0) {
return mount(DerivationRuleCard, {
props: { modelValue: rule, index },
global: { plugins: [PrimeVue] },
});
}
describe("DerivationRuleCard.vue", () => {
it("renders Rule label with index+1 and IF tag for the first branch", () => {
const wrap = mountCard(makeRule(), 0);
expect(wrap.text()).toContain("Rule 1");
expect(wrap.text()).toContain("IF");
// Single branch => no ELIF tag
expect(wrap.text()).not.toContain("ELIF");
});
it("renders ELIF labels for branches after the first", () => {
const wrap = mountCard(
makeRule({
branches: [
{
condition: { var: "x", op: "equals", value: "a" },
action: { target_var: "out", mode: "replace", value: "1" },
},
{
condition: { var: "x", op: "equals", value: "b" },
action: { target_var: "out", mode: "replace", value: "2" },
},
{
condition: { var: "x", op: "equals", value: "c" },
action: { target_var: "out", mode: "replace", value: "3" },
},
],
}),
2,
);
expect(wrap.text()).toContain("Rule 3");
expect(wrap.text()).toContain("IF");
// Two ELIF branches → at least one ELIF label rendered
const html = wrap.html();
const elifMatches = html.match(/ELIF/g) ?? [];
expect(elifMatches.length).toBeGreaterThanOrEqual(2);
});
it("Add elif emits a new rule with one extra branch", async () => {
const rule = makeRule();
const wrap = mountCard(rule, 0);
await wrap.find('[data-test="add-elif-0"]').trigger("click");
const events = wrap.emitted("update:modelValue") ?? [];
expect(events.length).toBe(1);
const next = events[0][0] as DerivationRule;
expect(next.branches.length).toBe(2);
// First branch unchanged
expect(next.branches[0].condition.var).toBe("x");
// New branch has default condition
expect(next.branches[1].condition.op).toBe("equals");
expect(next.branches[1].action.mode).toBe("replace");
});
it("Add else emits a rule with rule.else populated", async () => {
const wrap = mountCard(makeRule(), 0);
expect(wrap.find('[data-test="add-else-0"]').exists()).toBe(true);
await wrap.find('[data-test="add-else-0"]').trigger("click");
const events = wrap.emitted("update:modelValue") ?? [];
expect(events.length).toBe(1);
const next = events[0][0] as DerivationRule;
expect(next.else).toBeDefined();
expect(next.else?.action.mode).toBe("replace");
expect(next.else?.action.target_var).toBe("");
});
it("Remove else emits a rule without rule.else", async () => {
const ruleWithElse = makeRule({
else: {
action: { target_var: "out", mode: "append", value: "z" },
},
});
const wrap = mountCard(ruleWithElse, 0);
// The "Add else" button is hidden when else already exists
expect(wrap.find('[data-test="add-else-0"]').exists()).toBe(false);
expect(wrap.find('[data-test="remove-else-0"]').exists()).toBe(true);
await wrap.find('[data-test="remove-else-0"]').trigger("click");
const events = wrap.emitted("update:modelValue") ?? [];
expect(events.length).toBe(1);
const next = events[0][0] as DerivationRule;
expect(next.else).toBeUndefined();
});
it("Remove rule emits the remove event", async () => {
const wrap = mountCard(makeRule(), 4);
await wrap.find('[data-test="remove-rule-4"]').trigger("click");
expect(wrap.emitted("remove")).toBeDefined();
expect((wrap.emitted("remove") ?? []).length).toBe(1);
});
it("Remove ELIF branch emits a rule with that branch dropped", async () => {
const rule = makeRule({
branches: [
{
condition: { var: "x", op: "equals", value: "a" },
action: { target_var: "out", mode: "replace", value: "1" },
},
{
condition: { var: "x", op: "equals", value: "b" },
action: { target_var: "out", mode: "replace", value: "2" },
},
],
});
const wrap = mountCard(rule, 0);
await wrap.find('[data-test="remove-elif-0-1"]').trigger("click");
const events = wrap.emitted("update:modelValue") ?? [];
expect(events.length).toBe(1);
const next = events[0][0] as DerivationRule;
expect(next.branches.length).toBe(1);
expect(next.branches[0].condition.value).toBe("a");
});
});
@@ -102,4 +102,72 @@ describe("WildcardForm.vue", () => {
await flushPromises();
expect(apiMod.create).not.toHaveBeenCalled();
});
it("auto-derives var_binding from name while untouched", async () => {
const wrap = mount(WildcardForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const nameInput = wrap.find("#wc-name");
await nameInput.setValue("Hair Color");
await flushPromises();
const varInput = wrap.find('[data-test="wc-var-binding"]')
.element as HTMLInputElement;
expect(varInput.value).toBe("hair_color");
});
it("manual edit to var_binding sticks across name changes", async () => {
const wrap = mount(WildcardForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const nameInput = wrap.find("#wc-name");
await nameInput.setValue("Hair Color");
await flushPromises();
const varInput = wrap.find('[data-test="wc-var-binding"]');
await varInput.setValue("custom_var");
await flushPromises();
await nameInput.setValue("Eye Color");
await flushPromises();
const el = varInput.element as HTMLInputElement;
expect(el.value).toBe("custom_var");
});
it("save includes var_binding in payload", async () => {
apiMod.create.mockResolvedValue({
id: "wc_a", type: "wildcard", name: "Outfit Style",
description: "", category_id: null, tags: [], is_favorite: false,
payload: { options: [], sub_categories: [], var_binding: "outfit_style" },
version: 1, created_at: "", updated_at: "",
});
const wrap = mount(WildcardForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const nameInput = wrap.find("#wc-name");
await nameInput.setValue("Outfit Style");
await flushPromises();
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
const call = apiMod.create.mock.calls[0]?.[0] as { payload: { var_binding?: string } };
expect(call.payload.var_binding).toBe("outfit_style");
});
it("loads var_binding from existing payload", async () => {
apiMod.get.mockResolvedValue({
id: "wc_a", name: "Hair Color", description: "", category_id: null,
tags: [], type: "wildcard",
payload: { options: [], sub_categories: [], var_binding: "my_hair" },
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 varInput = wrap.find('[data-test="wc-var-binding"]')
.element as HTMLInputElement;
expect(varInput.value).toBe("my_hair");
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
describe("toIdentifier", () => {
it("lowercases and slug-joins runs of non-identifier chars", () => {
expect(toIdentifier("Hello World")).toBe("hello_world");
expect(toIdentifier("Foo--Bar Baz")).toBe("foo_bar_baz");
});
it("strips leading/trailing underscores left over from punctuation", () => {
expect(toIdentifier(" Outfit! ")).toBe("outfit");
expect(toIdentifier("--name--")).toBe("name");
});
it("prefixes wc_ when the result would start with a digit", () => {
expect(toIdentifier("123 Things")).toBe("wc_123_things");
expect(toIdentifier("9lives")).toBe("wc_9lives");
});
it("returns wc_x for empty / fully-stripped inputs", () => {
expect(toIdentifier("")).toBe("wc_x");
expect(toIdentifier(" ")).toBe("wc_x");
expect(toIdentifier("???")).toBe("wc_x");
});
it("preserves already-valid identifiers (after lowercasing)", () => {
expect(toIdentifier("subject_phrase")).toBe("subject_phrase");
expect(toIdentifier("HairColor")).toBe("haircolor");
});
it("produces output that always satisfies VALID_IDENTIFIER_RE", () => {
for (const input of ["Hello World", "123 zip", "", "$$$", "Foo!Bar"]) {
expect(toIdentifier(input)).toMatch(VALID_IDENTIFIER_RE);
}
});
});
+36 -7
View File
@@ -8,22 +8,43 @@ export type ModuleType =
// ----- Per-type payload shapes (cross-referenced from data.jsx prototype) -----
export interface WildcardOption {
id: string;
value: string;
weight: number;
sub_category?: string | null;
}
export interface WildcardPayload {
options: WildcardOption[];
sub_categories: string[];
/**
* The `$varname` other modules use to read this wildcard's resolved value.
* Optional — defaults to `slug(name)` when missing/blank. User-editable so
* collisions or renames can be resolved without breaking downstream refs.
*/
var_binding?: string;
}
export interface CombinePayload {
template: string;
output_var: string;
input_vars: string[];
}
export type DerivationOp = "equals" | "not_equals" | "contains" | "matches";
export type DerivationMode = "replace" | "append" | "prepend";
export interface DerivationCondition {
kind: "always" | "contains" | "equals" | "absent" | string;
var?: string;
value?: string;
var: string;
op: DerivationOp;
value: string;
}
export interface DerivationAction {
kind: "append" | "prepend" | "replace" | "remove" | "set" | string;
target: string;
value?: string;
target_var: string;
mode: DerivationMode;
value: string;
}
export interface DerivationBranch {
@@ -31,10 +52,18 @@ export interface DerivationBranch {
action: DerivationAction;
}
/**
* Else clause matches the backend validator shape: an object with an `action`.
* (See engine/modules/derivation_handler.py::_validate_action.)
*/
export interface DerivationElse {
action: DerivationAction;
}
export interface DerivationRule {
id: string;
branches: DerivationBranch[];
else?: DerivationAction;
else?: DerivationElse;
}
export interface DerivationPayload {
@@ -0,0 +1,465 @@
<script setup lang="ts">
import { computed } from "vue";
import Card from "primevue/card";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import Textarea from "primevue/textarea";
import Select from "primevue/select";
import Tag from "primevue/tag";
import type {
DerivationAction,
DerivationBranch,
DerivationElse,
DerivationMode,
DerivationOp,
DerivationRule,
} from "../api/types";
interface Props {
modelValue: DerivationRule;
index: number;
varSuggestions?: string[];
}
const props = withDefaults(defineProps<Props>(), {
varSuggestions: () => [],
});
const emit = defineEmits<{
"update:modelValue": [value: DerivationRule];
remove: [];
}>();
const OP_OPTIONS: Array<{ label: string; value: DerivationOp }> = [
{ label: "equals", value: "equals" },
{ label: "not equals", value: "not_equals" },
{ label: "contains", value: "contains" },
{ label: "matches (regex)", value: "matches" },
];
const MODE_OPTIONS: Array<{ label: string; value: DerivationMode }> = [
{ label: "Replace", value: "replace" },
{ label: "Append", value: "append" },
{ label: "Prepend", value: "prepend" },
];
function blankAction(): DerivationAction {
return { target_var: "", mode: "replace", value: "" };
}
function blankBranch(): DerivationBranch {
return {
condition: { var: "", op: "equals", value: "" },
action: blankAction(),
};
}
const rule = computed<DerivationRule>(() => props.modelValue);
const varOptions = computed(() =>
(props.varSuggestions ?? []).map((v) => ({ label: v, value: v })),
);
function patch(next: Partial<DerivationRule>) {
emit("update:modelValue", { ...rule.value, ...next });
}
function setBranch(bi: number, branch: DerivationBranch) {
const branches = rule.value.branches.map((b, i) => (i === bi ? branch : b));
patch({ branches });
}
function setCondition(bi: number, condition: DerivationBranch["condition"]) {
const branch = rule.value.branches[bi];
if (!branch) return;
setBranch(bi, { ...branch, condition });
}
function setAction(bi: number, action: DerivationAction) {
const branch = rule.value.branches[bi];
if (!branch) return;
setBranch(bi, { ...branch, action });
}
function addBranch() {
patch({ branches: [...rule.value.branches, blankBranch()] });
}
function removeBranch(bi: number) {
if (bi === 0) return; // first branch is the IF — can't be removed
const branches = rule.value.branches.filter((_, i) => i !== bi);
patch({ branches });
}
function addElse() {
patch({ else: { action: blankAction() } });
}
function removeElse() {
const next = { ...rule.value };
delete next.else;
emit("update:modelValue", next);
}
function setElseAction(action: DerivationAction) {
const elseClause: DerivationElse = { action };
patch({ else: elseClause });
}
function onConditionVar(bi: number, value: string) {
const branch = rule.value.branches[bi];
if (!branch) return;
setCondition(bi, { ...branch.condition, var: value });
}
function onConditionOp(bi: number, value: DerivationOp) {
const branch = rule.value.branches[bi];
if (!branch) return;
setCondition(bi, { ...branch.condition, op: value });
}
function onConditionValue(bi: number, value: string) {
const branch = rule.value.branches[bi];
if (!branch) return;
setCondition(bi, { ...branch.condition, value });
}
function onActionTarget(bi: number, value: string) {
const branch = rule.value.branches[bi];
if (!branch) return;
setAction(bi, { ...branch.action, target_var: value });
}
function onActionMode(bi: number, value: DerivationMode) {
const branch = rule.value.branches[bi];
if (!branch) return;
setAction(bi, { ...branch.action, mode: value });
}
function onActionValue(bi: number, value: string) {
const branch = rule.value.branches[bi];
if (!branch) return;
setAction(bi, { ...branch.action, value });
}
function onElseTarget(value: string) {
const cur = rule.value.else?.action ?? blankAction();
setElseAction({ ...cur, target_var: value });
}
function onElseMode(value: DerivationMode) {
const cur = rule.value.else?.action ?? blankAction();
setElseAction({ ...cur, mode: value });
}
function onElseValue(value: string) {
const cur = rule.value.else?.action ?? blankAction();
setElseAction({ ...cur, value });
}
const ruleNumber = computed(() => props.index + 1);
const branchCount = computed(() => rule.value.branches.length);
</script>
<template>
<Card class="derivation-rule-card" :data-test="`rule-card-${index}`">
<template #content>
<div class="rule-head">
<Tag severity="info" :value="`Rule ${ruleNumber}`" data-test="rule-label" />
<span class="rule-meta">
{{ branchCount }} branch{{ branchCount === 1 ? "" : "es" }}
<span v-if="rule.else"> + ELSE</span>
</span>
<span class="spacer" />
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
size="small"
:aria-label="`Remove rule ${ruleNumber}`"
:data-test="`remove-rule-${index}`"
@click="emit('remove')"
/>
</div>
<div class="branches">
<div
v-for="(branch, bi) in rule.branches"
:key="bi"
class="branch"
:data-kind="bi === 0 ? 'if' : 'elif'"
:data-test="`branch-${index}-${bi}`"
>
<div class="branch-head">
<span class="branch-tag" :data-kind="bi === 0 ? 'if' : 'elif'">
{{ bi === 0 ? "IF" : "ELIF" }}
</span>
<span class="spacer" />
<Button
v-if="bi > 0"
icon="pi pi-times"
severity="danger"
text
size="small"
:aria-label="`Remove ELIF branch ${bi} from rule ${ruleNumber}`"
:data-test="`remove-elif-${index}-${bi}`"
@click="removeBranch(bi)"
/>
</div>
<!-- Condition row -->
<div class="row">
<span class="row-label">When</span>
<div class="row-fields">
<Select
v-if="varOptions.length"
:model-value="branch.condition.var"
:options="varOptions"
option-label="label"
option-value="value"
editable
placeholder="$variable"
class="field-var"
:aria-label="`Condition variable for rule ${ruleNumber} branch ${bi + 1}`"
@update:model-value="(v: string) => onConditionVar(bi, v ?? '')"
/>
<InputText
v-else
:model-value="branch.condition.var"
placeholder="$variable"
class="field-var"
:aria-label="`Condition variable for rule ${ruleNumber} branch ${bi + 1}`"
@update:model-value="(v: string | undefined) => onConditionVar(bi, v ?? '')"
/>
<Select
:model-value="branch.condition.op"
:options="OP_OPTIONS"
option-label="label"
option-value="value"
class="field-op"
:aria-label="`Condition operator for rule ${ruleNumber} branch ${bi + 1}`"
@update:model-value="(v: DerivationOp) => onConditionOp(bi, v)"
/>
<InputText
:model-value="branch.condition.value"
placeholder="value"
class="field-value"
:aria-label="`Condition value for rule ${ruleNumber} branch ${bi + 1}`"
@update:model-value="(v: string | undefined) => onConditionValue(bi, v ?? '')"
/>
</div>
</div>
<!-- Action row -->
<div class="row">
<span class="row-label">Then</span>
<div class="row-fields">
<InputText
:model-value="branch.action.target_var"
placeholder="target_var"
class="field-var"
:aria-label="`Action target variable for rule ${ruleNumber} branch ${bi + 1}`"
@update:model-value="(v: string | undefined) => onActionTarget(bi, v ?? '')"
/>
<Select
:model-value="branch.action.mode"
:options="MODE_OPTIONS"
option-label="label"
option-value="value"
class="field-op"
:aria-label="`Action mode for rule ${ruleNumber} branch ${bi + 1}`"
@update:model-value="(v: DerivationMode) => onActionMode(bi, v)"
/>
</div>
</div>
<div class="row row--textarea">
<span class="row-label">Value</span>
<Textarea
:model-value="branch.action.value"
rows="2"
auto-resize
class="field-textarea"
placeholder="The new / appended / prepended value"
:aria-label="`Action value for rule ${ruleNumber} branch ${bi + 1}`"
@update:model-value="(v: string | undefined) => onActionValue(bi, v ?? '')"
/>
</div>
</div>
<!-- ELSE branch -->
<div v-if="rule.else" class="branch branch--else" data-kind="else" :data-test="`branch-else-${index}`">
<div class="branch-head">
<span class="branch-tag" data-kind="else">ELSE</span>
<span class="spacer" />
<Button
icon="pi pi-times"
severity="danger"
text
size="small"
:aria-label="`Remove ELSE branch from rule ${ruleNumber}`"
:data-test="`remove-else-${index}`"
@click="removeElse"
/>
</div>
<div class="row">
<span class="row-label">Then</span>
<div class="row-fields">
<InputText
:model-value="rule.else.action.target_var"
placeholder="target_var"
class="field-var"
:aria-label="`ELSE action target variable for rule ${ruleNumber}`"
@update:model-value="(v: string | undefined) => onElseTarget(v ?? '')"
/>
<Select
:model-value="rule.else.action.mode"
:options="MODE_OPTIONS"
option-label="label"
option-value="value"
class="field-op"
:aria-label="`ELSE action mode for rule ${ruleNumber}`"
@update:model-value="(v: DerivationMode) => onElseMode(v)"
/>
</div>
</div>
<div class="row row--textarea">
<span class="row-label">Value</span>
<Textarea
:model-value="rule.else.action.value"
rows="2"
auto-resize
class="field-textarea"
placeholder="The new / appended / prepended value"
:aria-label="`ELSE action value for rule ${ruleNumber}`"
@update:model-value="(v: string | undefined) => onElseValue(v ?? '')"
/>
</div>
</div>
</div>
<div class="addbar">
<Button
icon="pi pi-plus"
label="Add elif"
severity="secondary"
text
size="small"
:aria-label="`Add ELIF branch to rule ${ruleNumber}`"
:data-test="`add-elif-${index}`"
@click="addBranch"
/>
<Button
v-if="!rule.else"
icon="pi pi-plus"
label="Add else"
severity="secondary"
text
size="small"
:aria-label="`Add ELSE branch to rule ${ruleNumber}`"
:data-test="`add-else-${index}`"
@click="addElse"
/>
</div>
</template>
</Card>
</template>
<style scoped>
.derivation-rule-card {
border-left: 3px solid var(--wp-kind-derivation, #fbbf24);
background: var(--wp-bg, #1e1e22);
}
.derivation-rule-card :deep(.p-card-body) {
padding: 12px 14px;
}
.derivation-rule-card :deep(.p-card-content) {
padding: 0;
}
.rule-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.rule-meta {
font-size: 11.5px;
color: var(--wp-text2, #9ca3af);
}
.spacer { flex: 1; }
.branches {
display: flex;
flex-direction: column;
gap: 10px;
padding-left: 10px;
border-left: 2px solid color-mix(in oklab, var(--wp-kind-derivation, #fbbf24) 30%, transparent);
}
.branch {
background: var(--wp-bg2, #18181b);
border: 1px solid var(--wp-border, #2c2c34);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.branch--else {
border-style: dashed;
}
.branch-head {
display: flex;
align-items: center;
gap: 8px;
}
.branch-tag {
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
padding: 2px 7px;
border-radius: 4px;
text-transform: uppercase;
}
.branch-tag[data-kind="if"] {
background: color-mix(in oklab, var(--wp-kind-derivation, #fbbf24) 22%, transparent);
color: var(--wp-kind-derivation, #fbbf24);
}
.branch-tag[data-kind="elif"] {
background: color-mix(in oklab, var(--wp-info, #60a5fa) 22%, transparent);
color: var(--wp-info, #60a5fa);
}
.branch-tag[data-kind="else"] {
background: color-mix(in oklab, var(--wp-warn, #f59e0b) 22%, transparent);
color: var(--wp-warn, #f59e0b);
}
.row {
display: grid;
grid-template-columns: 56px 1fr;
gap: 8px;
align-items: start;
}
.row-label {
font-size: 11px;
color: var(--wp-text2, #9ca3af);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 500;
padding-top: 6px;
}
.row-fields {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.field-var { min-width: 160px; flex: 1 1 160px; }
.field-op { min-width: 150px; }
.field-value { flex: 1 1 200px; min-width: 200px; }
.field-textarea { width: 100%; }
.addbar {
display: flex;
gap: 6px;
margin-top: 10px;
padding-left: 12px;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
/**
* Identifier coercion helpers shared by editor forms.
*
* The pipeline expression syntax binds resolved values to `$varname` symbols.
* `varname` must satisfy `^[a-zA-Z_][a-zA-Z0-9_]*$` — otherwise the engine's
* tokenizer won't recognise it as a reference and downstream modules can't
* read the value back. UI fields auto-derive these identifiers from human
* names, so we centralise the slug rules here.
*/
/** Coerce a string to a valid `$varname` identifier — `[a-zA-Z_][a-zA-Z0-9_]*`. */
export function toIdentifier(input: string): string {
const cleaned = (input ?? "")
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_+/g, "_");
return /^[a-z]/.test(cleaned) ? cleaned : `wc_${cleaned || "x"}`;
}
export const VALID_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+147 -22
View File
@@ -10,7 +10,14 @@ import AutoComplete from "primevue/autocomplete";
import { useToast } from "primevue/usetoast";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import type { DerivationPayload } from "../api/types";
import DerivationRuleCard from "../components/DerivationRuleCard.vue";
import type {
DerivationAction,
DerivationBranch,
DerivationElse,
DerivationPayload,
DerivationRule,
} from "../api/types";
const props = defineProps<{ id?: string }>();
const router = useRouter();
@@ -22,10 +29,15 @@ const name = ref("");
const description = ref("");
const categoryId = ref<string | null>(null);
const tags = ref<string[]>([]);
const payload = ref<DerivationPayload>({ rules: [] });
const rules = ref<DerivationRule[]>([]);
const saving = ref(false);
const isEdit = computed(() => !!props.id);
// Variable suggestions are populated by the parent surface in the future. For
// now this is intentionally an empty list — the rule card falls back to a free
// InputText when no suggestions are provided.
const varSuggestions = ref<string[]>([]);
const tagSuggestions = ref<string[]>([]);
function searchTags(event: { query: string }) {
const q = event.query.toLowerCase();
@@ -38,6 +50,82 @@ function searchTags(event: { query: string }) {
.slice(0, 10);
}
let ruleSeq = 0;
function newRuleId(): string {
ruleSeq += 1;
return `r_${Date.now().toString(36)}_${ruleSeq}`;
}
function blankAction(): DerivationAction {
return { target_var: "", mode: "replace", value: "" };
}
function blankBranch(): DerivationBranch {
return {
condition: { var: "", op: "equals", value: "" },
action: blankAction(),
};
}
function blankRule(): DerivationRule {
return { id: newRuleId(), branches: [blankBranch()] };
}
function migrateRule(raw: unknown): DerivationRule {
const r = (raw ?? {}) as Partial<DerivationRule> & {
else?: Partial<DerivationElse> | DerivationAction | null;
};
const id = typeof r.id === "string" && r.id ? r.id : newRuleId();
const branchesIn = Array.isArray(r.branches) ? r.branches : [];
const branches: DerivationBranch[] = branchesIn.length
? branchesIn.map((b) => migrateBranch(b))
: [blankBranch()];
const out: DerivationRule = { id, branches };
if (r.else && typeof r.else === "object") {
// Tolerate either {action: ...} (canonical) or a bare action object (older
// designs / the simplified handoff spec).
const wrapped = (r.else as Partial<DerivationElse>).action;
const action = wrapped
? migrateAction(wrapped)
: migrateAction(r.else as Partial<DerivationAction>);
out.else = { action };
}
return out;
}
function migrateBranch(raw: unknown): DerivationBranch {
const b = (raw ?? {}) as Partial<DerivationBranch>;
const cIn = (b.condition ?? {}) as Record<string, unknown>;
const aIn = (b.action ?? {}) as Record<string, unknown>;
const op = typeof cIn.op === "string" ? cIn.op : "equals";
return {
condition: {
var: typeof cIn.var === "string" ? cIn.var : "",
op: (op === "equals" || op === "not_equals" || op === "contains" || op === "matches"
? op
: "equals") as DerivationBranch["condition"]["op"],
value: typeof cIn.value === "string" ? cIn.value : "",
},
action: migrateAction(aIn),
};
}
function migrateAction(raw: unknown): DerivationAction {
const a = (raw ?? {}) as Record<string, unknown>;
const target =
typeof a.target_var === "string"
? a.target_var
: typeof a.target === "string"
? a.target
: "";
const mode = typeof a.mode === "string" ? a.mode : "replace";
return {
target_var: target,
mode: (mode === "replace" || mode === "append" || mode === "prepend"
? mode
: "replace") as DerivationAction["mode"],
value: typeof a.value === "string" ? a.value : "",
};
}
onMounted(async () => {
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
if (props.id) {
@@ -48,7 +136,8 @@ onMounted(async () => {
categoryId.value = row.category_id;
tags.value = row.tags;
const p = row.payload as Partial<DerivationPayload>;
payload.value = { rules: p.rules ?? [] };
const incoming = Array.isArray(p.rules) ? p.rules : [];
rules.value = incoming.length ? incoming.map(migrateRule) : [];
} catch {
toast.add({ severity: "error", summary: "Derivation not found", life: 3000 });
router.replace("/derivations");
@@ -56,6 +145,18 @@ onMounted(async () => {
}
});
function addRule() {
rules.value = [...rules.value, blankRule()];
}
function removeRule(idx: number) {
rules.value = rules.value.filter((_, i) => i !== idx);
}
function updateRule(idx: number, value: DerivationRule) {
rules.value = rules.value.map((r, i) => (i === idx ? value : r));
}
async function save() {
if (!name.value.trim()) {
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
@@ -63,12 +164,13 @@ async function save() {
}
saving.value = true;
try {
const payload: DerivationPayload = { rules: rules.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);
@@ -83,6 +185,8 @@ async function save() {
saving.value = false;
}
}
defineExpose({ rules, addRule, removeRule });
</script>
<template>
@@ -95,6 +199,7 @@ async function save() {
</div>
<div class="form-page__body">
<!-- A) Identity -->
<section class="form-section">
<h2 class="form-section__label">Identity</h2>
<div class="grid grid-cols-2 gap-4">
@@ -130,16 +235,44 @@ async function save() {
</div>
</section>
<!-- B) Rules -->
<section class="form-section">
<h2 class="form-section__label">Rules</h2>
<Card>
<div class="flex items-center justify-between mb-1">
<h2 class="form-section__label m-0">Rules ({{ rules.length }})</h2>
<Button
label="Add rule"
icon="pi pi-plus"
size="small"
severity="primary"
data-test="add-rule"
aria-label="Add rule"
@click="addRule"
/>
</div>
<p class="text-xs text-wp-text2 mb-3">
All rules evaluate independently. Each rule may have multiple ELIF branches and an optional ELSE.
</p>
<Card v-if="rules.length === 0" data-test="rules-empty">
<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>
<div class="text-sm text-wp-text2 py-6 text-center">
No rules yet. Click <strong>Add rule</strong> to start defining IF / ELIF / ELSE behaviour.
</div>
</template>
</Card>
<div v-else class="rules-stack" data-test="rules-stack">
<DerivationRuleCard
v-for="(rule, idx) in rules"
:key="rule.id"
:model-value="rule"
:index="idx"
:var-suggestions="varSuggestions"
:data-test="`rule-${idx}`"
@update:model-value="(v) => updateRule(idx, v)"
@remove="removeRule(idx)"
/>
</div>
</section>
</div>
@@ -167,17 +300,9 @@ async function save() {
letter-spacing: 0.5px; color: var(--wp-text2);
margin: 0 0 8px 0;
}
.payload-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;
color: var(--wp-text);
max-height: 320px;
overflow: auto;
.rules-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
</style>