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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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_]*$/;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user