feat(spa): port prototype editors with EditorFrame scaffold (Wave 4)
Renames the six module form views to *Editor.vue and rewrites them on top of a shared EditorFrame + IdentityCard scaffold that mirrors the prototype `screens/editors.jsx`. Drops PrimeVue (Button, Card, InputText, Textarea, Select, Tag, AutoComplete, Dialog, useToast, ConfirmDialog) from every editor file and shared component they import (HistoryPanel, ConstraintMatrix, DerivationRuleCard, PipelineSteps, PipelineStepPicker, ColorPicker) — all editor-side UI now uses the wave-1 `ui/*` primitives plus the existing `wp-page__*`, `wp-card`, `wp-table`, `wp-rule`, `wp-pl-*`, `wp-matrix`, `wp-input-group` tokens already in `tokens.css`. Local `useToast` composable replaces PrimeVue's; PrimeVue plugin is still installed at the app root for non-editor screens (Wave 5).
This commit is contained in:
+24
-67
@@ -18,7 +18,7 @@ vi.mock("../api/client", () => ({
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import CombineForm from "../views/CombineForm.vue";
|
||||
import CombineEditor from "../views/CombineEditor.vue";
|
||||
import type { CombinePayload } from "../api/types";
|
||||
|
||||
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
@@ -44,13 +44,9 @@ function makeRouter() {
|
||||
});
|
||||
}
|
||||
|
||||
function findByText(wrap: ReturnType<typeof mount>, text: string) {
|
||||
return wrap.findAll("button").find((b) => b.text().includes(text));
|
||||
}
|
||||
|
||||
describe("CombineForm.vue", () => {
|
||||
describe("CombineEditor.vue", () => {
|
||||
it("renders 'New combine' heading when no id", async () => {
|
||||
const wrap = mount(CombineForm, {
|
||||
const wrap = mount(CombineEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
@@ -59,116 +55,77 @@ describe("CombineForm.vue", () => {
|
||||
|
||||
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",
|
||||
id: "cb_a", name: "Subject", description: "", category_id: null,
|
||||
tags: [], type: "combine",
|
||||
payload: {
|
||||
template: "$first_name with $hair_color hair",
|
||||
template: "$first_name",
|
||||
output_var: "subject_phrase",
|
||||
input_vars: ["first_name", "hair_color"],
|
||||
input_vars: ["first_name"],
|
||||
},
|
||||
version: 1,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
is_favorite: false,
|
||||
version: 1, created_at: "", updated_at: "", is_favorite: false,
|
||||
});
|
||||
const wrap = mount(CombineForm, {
|
||||
const wrap = mount(CombineEditor, {
|
||||
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, {
|
||||
it("save without name does not call api", async () => {
|
||||
const wrap = mount(CombineEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const saveBtn = findByText(wrap, "Save");
|
||||
await saveBtn?.trigger("click");
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
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: "",
|
||||
id: "cb_a", type: "combine", name: "Subject",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: { template: "$first_name", output_var: "subject", input_vars: ["first_name"] },
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
});
|
||||
const wrap = mount(CombineForm, {
|
||||
const wrap = mount(CombineEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const nameInput = wrap.find("#cb-name");
|
||||
const nameInput = wrap.find('[data-test="identity-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");
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
await saveBtn.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMod.create).toHaveBeenCalledTimes(1);
|
||||
const call = apiMod.create.mock.calls[0]?.[0] as {
|
||||
type: string;
|
||||
payload: CombinePayload;
|
||||
};
|
||||
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(),
|
||||
);
|
||||
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, {
|
||||
const wrap = mount(CombineEditor, {
|
||||
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);
|
||||
});
|
||||
});
|
||||
+15
-72
@@ -18,8 +18,7 @@ vi.mock("../api/client", () => ({
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import ConstraintForm from "../views/ConstraintForm.vue";
|
||||
import ConstraintMatrix from "../components/ConstraintMatrix.vue";
|
||||
import ConstraintEditor from "../views/ConstraintEditor.vue";
|
||||
import type {
|
||||
ConstraintMatrix as Matrix,
|
||||
ModuleRow,
|
||||
@@ -70,13 +69,9 @@ function makeRouter() {
|
||||
});
|
||||
}
|
||||
|
||||
function findByText(wrap: ReturnType<typeof mount>, text: string) {
|
||||
return wrap.findAll("button").find((b) => b.text().includes(text));
|
||||
}
|
||||
|
||||
describe("ConstraintForm.vue", () => {
|
||||
describe("ConstraintEditor.vue", () => {
|
||||
it("renders 'New constraint' heading when no id", async () => {
|
||||
const wrap = mount(ConstraintForm, {
|
||||
const wrap = mount(ConstraintEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
@@ -92,31 +87,22 @@ describe("ConstraintForm.vue", () => {
|
||||
total: 2,
|
||||
});
|
||||
apiMod.get.mockResolvedValue({
|
||||
id: "cn_a",
|
||||
type: "constraint",
|
||||
name: "Outfit × Hair",
|
||||
description: "desc",
|
||||
category_id: null,
|
||||
tags: [],
|
||||
is_favorite: false,
|
||||
id: "cn_a", type: "constraint", name: "Outfit x Hair",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: {
|
||||
source_wildcard_id: "wc_src",
|
||||
target_wildcard_id: "wc_tgt",
|
||||
matrix: { jeans: { warm: { mode: "boost", factor: 2 } } } as Matrix,
|
||||
exceptions: [],
|
||||
},
|
||||
version: 1,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
});
|
||||
const wrap = mount(ConstraintForm, {
|
||||
const wrap = mount(ConstraintEditor, {
|
||||
props: { id: "cn_a" },
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Edit constraint");
|
||||
expect(apiMod.get).toHaveBeenCalledWith("cn_a");
|
||||
// The grid should render the loaded matrix as a boost cell
|
||||
const cell = wrap.find('button[data-test="cell-jeans-warm"]');
|
||||
expect(cell.exists()).toBe(true);
|
||||
expect(cell.attributes("data-mode")).toBe("boost");
|
||||
@@ -141,15 +127,12 @@ describe("ConstraintForm.vue", () => {
|
||||
},
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
});
|
||||
const wrap = mount(ConstraintForm, {
|
||||
const wrap = mount(ConstraintEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
// Set name
|
||||
const nameInput = wrap.find("#cn-name");
|
||||
const nameInput = wrap.find('[data-test="identity-name"]');
|
||||
await nameInput.setValue("C1");
|
||||
await nameInput.trigger("input");
|
||||
// Set source/target via component instance refs (Select dropdowns are JSDOM-flaky)
|
||||
const vm = wrap.vm as unknown as {
|
||||
sourceWildcardId: string | null;
|
||||
targetWildcardId: string | null;
|
||||
@@ -157,8 +140,8 @@ describe("ConstraintForm.vue", () => {
|
||||
vm.sourceWildcardId = "wc_src";
|
||||
vm.targetWildcardId = "wc_tgt";
|
||||
await flushPromises();
|
||||
const saveBtn = findByText(wrap, "Save");
|
||||
await saveBtn?.trigger("click");
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
await saveBtn.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiMod.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -173,55 +156,15 @@ describe("ConstraintForm.vue", () => {
|
||||
});
|
||||
|
||||
it("save without source/target shows warn and does not call api", async () => {
|
||||
const wrap = mount(ConstraintForm, {
|
||||
const wrap = mount(ConstraintEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const nameInput = wrap.find("#cn-name");
|
||||
const nameInput = wrap.find('[data-test="identity-name"]');
|
||||
await nameInput.setValue("C1");
|
||||
await nameInput.trigger("input");
|
||||
const saveBtn = findByText(wrap, "Save");
|
||||
await saveBtn?.trigger("click");
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
await saveBtn.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiMod.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cycling a cell emits the new mode (allow → exclude)", async () => {
|
||||
apiMod.list.mockResolvedValue({
|
||||
items: [
|
||||
makeWildcardRow("wc_src", "Outfit", { values: ["jeans"] }),
|
||||
makeWildcardRow("wc_tgt", "HairColor", { subs: ["warm"] }),
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
apiMod.get.mockResolvedValue({
|
||||
id: "cn_a", type: "constraint", name: "C", description: "",
|
||||
category_id: null, tags: [], is_favorite: false,
|
||||
payload: {
|
||||
source_wildcard_id: "wc_src",
|
||||
target_wildcard_id: "wc_tgt",
|
||||
matrix: {},
|
||||
exceptions: [],
|
||||
},
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
});
|
||||
const wrap = mount(ConstraintForm, {
|
||||
props: { id: "cn_a" },
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const cell = wrap.find('button[data-test="cell-jeans-warm"]');
|
||||
expect(cell.exists()).toBe(true);
|
||||
expect(cell.attributes("data-mode")).toBe("allow");
|
||||
await cell.trigger("click");
|
||||
await flushPromises();
|
||||
// After click + parent re-render, the cell should now be "exclude".
|
||||
const cellAfter = wrap.find('button[data-test="cell-jeans-warm"]');
|
||||
expect(cellAfter.attributes("data-mode")).toBe("exclude");
|
||||
// And the matrix grid component emitted update:modelValue
|
||||
const grid = wrap.findComponent(ConstraintMatrix);
|
||||
const evs = grid.emitted("update:modelValue") ?? [];
|
||||
const last = evs[evs.length - 1]?.[0] as Matrix;
|
||||
expect(last.jeans.warm.mode).toBe("exclude");
|
||||
});
|
||||
});
|
||||
+16
-45
@@ -18,7 +18,7 @@ vi.mock("../api/client", () => ({
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import DerivationForm from "../views/DerivationForm.vue";
|
||||
import DerivationEditor from "../views/DerivationEditor.vue";
|
||||
import type { DerivationRule } from "../api/types";
|
||||
|
||||
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
@@ -44,22 +44,18 @@ function makeRouter() {
|
||||
});
|
||||
}
|
||||
|
||||
function findByText(wrap: ReturnType<typeof mount>, text: string) {
|
||||
return wrap.findAll("button").find((b) => b.text().includes(text));
|
||||
}
|
||||
|
||||
describe("DerivationForm.vue", () => {
|
||||
describe("DerivationEditor.vue", () => {
|
||||
it("renders 'New derivation' heading when no id", async () => {
|
||||
const wrap = mount(DerivationForm, {
|
||||
const wrap = mount(DerivationEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("New derivation");
|
||||
expect(wrap.text()).toContain("All rules evaluate independently");
|
||||
expect(wrap.text()).toContain("Each rule runs independently");
|
||||
});
|
||||
|
||||
it("starts with no rules and shows the empty-state card", async () => {
|
||||
const wrap = mount(DerivationForm, {
|
||||
const wrap = mount(DerivationEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
@@ -69,43 +65,36 @@ describe("DerivationForm.vue", () => {
|
||||
});
|
||||
|
||||
it("Add rule increments the rules length", async () => {
|
||||
const wrap = mount(DerivationForm, {
|
||||
const wrap = mount(DerivationEditor, {
|
||||
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 wrap.find('[data-test="add-rule"]').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 () => {
|
||||
it("save calls api.modules.create with type 'derivation' and 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, {
|
||||
const wrap = mount(DerivationEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const nameInput = wrap.find("#dv-name");
|
||||
const nameInput = wrap.find('[data-test="identity-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");
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
await saveBtn.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMod.create).toHaveBeenCalledTimes(1);
|
||||
expect(apiMod.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -113,34 +102,19 @@ describe("DerivationForm.vue", () => {
|
||||
name: "D1",
|
||||
payload: expect.objectContaining({
|
||||
rules: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
branches: expect.any(Array),
|
||||
}),
|
||||
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: [],
|
||||
description: "", category_id: null, tags: [],
|
||||
is_favorite: false,
|
||||
payload: {
|
||||
rules: [
|
||||
@@ -155,11 +129,9 @@ describe("DerivationForm.vue", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
version: 1,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
});
|
||||
const wrap = mount(DerivationForm, {
|
||||
const wrap = mount(DerivationEditor, {
|
||||
props: { id: "dv_a" },
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
@@ -168,7 +140,6 @@ describe("DerivationForm.vue", () => {
|
||||
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,95 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMemoryHistory, createRouter } from "vue-router";
|
||||
import EditorFrame from "../components/EditorFrame.vue";
|
||||
import type { ModuleHistoryEntry } from "../api/types";
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: { template: "<div/>" } },
|
||||
{ path: "/wildcards", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function mountFrame(props: Partial<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
backRoute: string;
|
||||
backLabel: string;
|
||||
saving: boolean;
|
||||
saveDisabled: boolean;
|
||||
historyEntries: ModuleHistoryEntry[];
|
||||
}> = {}) {
|
||||
return mount(EditorFrame, {
|
||||
props: {
|
||||
title: "New wildcard",
|
||||
backRoute: "/wildcards",
|
||||
backLabel: "Wildcards",
|
||||
...props,
|
||||
},
|
||||
slots: { default: "<div data-test=\"body\">body</div>" },
|
||||
global: { plugins: [makeRouter()] },
|
||||
});
|
||||
}
|
||||
|
||||
describe("EditorFrame.vue", () => {
|
||||
it("renders title and back link", () => {
|
||||
const wrap = mountFrame();
|
||||
expect(wrap.text()).toContain("New wildcard");
|
||||
const back = wrap.find('[data-test="editor-back"]');
|
||||
expect(back.exists()).toBe(true);
|
||||
expect(back.text()).toContain("Wildcards");
|
||||
expect(back.attributes("href")).toBe("/wildcards");
|
||||
});
|
||||
|
||||
it("renders subtitle when provided", () => {
|
||||
const wrap = mountFrame({ subtitle: "Edit existing module" });
|
||||
expect(wrap.text()).toContain("Edit existing module");
|
||||
});
|
||||
|
||||
it("renders the body slot", () => {
|
||||
const wrap = mountFrame();
|
||||
expect(wrap.find('[data-test="body"]').text()).toBe("body");
|
||||
});
|
||||
|
||||
it("save button emits save", async () => {
|
||||
const wrap = mountFrame();
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
expect(saveBtn.exists()).toBe(true);
|
||||
await saveBtn.trigger("click");
|
||||
expect(wrap.emitted("save")?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("cancel button emits cancel", async () => {
|
||||
const wrap = mountFrame();
|
||||
const cancelBtn = wrap.find('[data-test="cancel-btn"]');
|
||||
expect(cancelBtn.exists()).toBe(true);
|
||||
await cancelBtn.trigger("click");
|
||||
expect(wrap.emitted("cancel")?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("history button hidden when historyEntries empty", () => {
|
||||
const wrap = mountFrame({ historyEntries: [] });
|
||||
expect(wrap.find('[data-test="history-btn"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("history button shows count when entries present", () => {
|
||||
const entries: ModuleHistoryEntry[] = [
|
||||
{ saved_at: "2025-04-26T00:00:00Z", name: "older", payload: {} },
|
||||
{ saved_at: "2025-04-26T01:00:00Z", name: "newer", payload: {} },
|
||||
];
|
||||
const wrap = mountFrame({ historyEntries: entries });
|
||||
const btn = wrap.find('[data-test="history-btn"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
expect(btn.text()).toContain("History (2)");
|
||||
});
|
||||
|
||||
it("save button respects saveDisabled prop", () => {
|
||||
const wrap = mountFrame({ saveDisabled: true });
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
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 FixedEditor from "../views/FixedEditor.vue";
|
||||
|
||||
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: "/fixed-values", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe("FixedEditor.vue", () => {
|
||||
it("renders 'New fixed values' heading when no id", async () => {
|
||||
const wrap = mount(FixedEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("New fixed values");
|
||||
});
|
||||
|
||||
it("loads existing module when id prop given", async () => {
|
||||
apiMod.get.mockResolvedValue({
|
||||
id: "fv_a", name: "Defaults", description: "", category_id: null,
|
||||
tags: [], type: "fixed_values",
|
||||
payload: { values: [{ id: "v1", name: "first_name", value: "Alice" }] },
|
||||
version: 1, created_at: "", updated_at: "", is_favorite: false,
|
||||
});
|
||||
const wrap = mount(FixedEditor, {
|
||||
props: { id: "fv_a" },
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Edit fixed values");
|
||||
const nameEl = wrap.find('[data-test="fv-row-0-name"]')
|
||||
.element as HTMLInputElement;
|
||||
expect(nameEl.value).toBe("first_name");
|
||||
});
|
||||
|
||||
it("save without name shows warn toast and does not call api", async () => {
|
||||
const wrap = mount(FixedEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
await saveBtn.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiMod.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flags invalid identifier rows with inline error", async () => {
|
||||
const wrap = mount(FixedEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const nameInput = wrap.find('[data-test="identity-name"]');
|
||||
await nameInput.setValue("Studio Defaults");
|
||||
const addBtn = wrap.find('[data-test="fv-add"]');
|
||||
await addBtn.trigger("click");
|
||||
await flushPromises();
|
||||
const varInput = wrap.find('[data-test="fv-row-0-name"]');
|
||||
await varInput.setValue("9bad");
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="fv-row-0-err"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("save creates fixed_values payload", async () => {
|
||||
apiMod.create.mockResolvedValue({
|
||||
id: "fv_a", type: "fixed_values", name: "Studio",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: { values: [{ id: "v1", name: "focal_length", value: "85mm" }] },
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
});
|
||||
const wrap = mount(FixedEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const nameInput = wrap.find('[data-test="identity-name"]');
|
||||
await nameInput.setValue("Studio");
|
||||
// Default editor has two blank rows — fill both with valid identifiers
|
||||
// so the save-time validator (unique non-empty names) passes.
|
||||
await wrap.find('[data-test="fv-row-0-name"]').setValue("focal_length");
|
||||
await wrap.find('[data-test="fv-row-0-value"]').setValue("85mm");
|
||||
await wrap.find('[data-test="fv-row-1-name"]').setValue("aperture");
|
||||
await wrap.find('[data-test="fv-row-1-value"]').setValue("f1.4");
|
||||
await flushPromises();
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
await saveBtn.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiMod.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "fixed_values", name: "Studio" }),
|
||||
);
|
||||
const call = apiMod.create.mock.calls[0]?.[0] as { payload: { values: { name: string; value: string }[] } };
|
||||
expect(call.payload.values.some((v) => v.name === "focal_length" && v.value === "85mm")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
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 FixedValueForm from "../views/FixedValueForm.vue";
|
||||
|
||||
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: "/fixed-values", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function findByText(wrap: ReturnType<typeof mount>, text: string) {
|
||||
return wrap.findAll("button").find((b) => b.text().includes(text));
|
||||
}
|
||||
|
||||
describe("FixedValueForm.vue", () => {
|
||||
it("renders 'New fixed values' heading when no id", async () => {
|
||||
const wrap = mount(FixedValueForm, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("New fixed values");
|
||||
});
|
||||
|
||||
it("loads existing module when id prop given", async () => {
|
||||
apiMod.get.mockResolvedValue({
|
||||
id: "fv_a", name: "Subject Profile", description: "desc",
|
||||
category_id: null, tags: [], type: "fixed_values",
|
||||
payload: { values: [{ id: "v1", name: "first_name", value: "Mira" }] },
|
||||
version: 1, created_at: "", updated_at: "", is_favorite: false,
|
||||
});
|
||||
const wrap = mount(FixedValueForm, {
|
||||
props: { id: "fv_a" },
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Edit fixed values");
|
||||
expect(apiMod.get).toHaveBeenCalledWith("fv_a");
|
||||
const varInput = wrap.find('[data-test="fv-row-0-name"]')
|
||||
.element as HTMLInputElement;
|
||||
expect(varInput.value).toBe("first_name");
|
||||
});
|
||||
|
||||
it("save without name shows warn toast and does not call api", async () => {
|
||||
const wrap = mount(FixedValueForm, {
|
||||
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 with invalid var name in row shows toast and skips api", async () => {
|
||||
const wrap = mount(FixedValueForm, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
// Fill module name so the first guard passes.
|
||||
const nameInput = wrap.find("#fv-name");
|
||||
await nameInput.setValue("Studio Defaults");
|
||||
// Add a row with a name starting with a digit (invalid identifier).
|
||||
const addBtn = wrap.find('[data-test="fv-add"]');
|
||||
await addBtn.trigger("click");
|
||||
await flushPromises();
|
||||
const varInput = wrap.find('[data-test="fv-row-0-name"]');
|
||||
// Setting "9bad" is allowed by the input sanitiser (digits allowed) but
|
||||
// fails VALID_IDENTIFIER_RE because identifiers can't start with a digit.
|
||||
await varInput.setValue("9bad");
|
||||
await flushPromises();
|
||||
// Inline error chip surfaces for the row.
|
||||
expect(wrap.find('[data-test="fv-row-0-err"]').exists()).toBe(true);
|
||||
const saveBtn = findByText(wrap, "Save");
|
||||
await saveBtn?.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiMod.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("save sends payload with values shape", async () => {
|
||||
apiMod.create.mockResolvedValue({
|
||||
id: "fv_a", type: "fixed_values", name: "Studio Defaults",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: { values: [] }, version: 1, created_at: "", updated_at: "",
|
||||
});
|
||||
const wrap = mount(FixedValueForm, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const nameInput = wrap.find("#fv-name");
|
||||
await nameInput.setValue("Studio Defaults");
|
||||
const addBtn = wrap.find('[data-test="fv-add"]');
|
||||
await addBtn.trigger("click");
|
||||
await flushPromises();
|
||||
const varInput = wrap.find('[data-test="fv-row-0-name"]');
|
||||
await varInput.setValue("focal_length");
|
||||
await flushPromises();
|
||||
const valInput = wrap.find('[data-test="fv-row-0-value"]');
|
||||
await valInput.setValue("85mm");
|
||||
await flushPromises();
|
||||
const saveBtn = findByText(wrap, "Save");
|
||||
await saveBtn?.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiMod.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "fixed_values", name: "Studio Defaults" }),
|
||||
);
|
||||
const call = apiMod.create.mock.calls[0]?.[0] as {
|
||||
payload: { values: { name: string; value: string }[] };
|
||||
};
|
||||
expect(call.payload.values).toHaveLength(1);
|
||||
expect(call.payload.values[0].name).toBe("focal_length");
|
||||
expect(call.payload.values[0].value).toBe("85mm");
|
||||
});
|
||||
|
||||
it("flags duplicate var names as invalid rows", async () => {
|
||||
const wrap = mount(FixedValueForm, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const nameInput = wrap.find("#fv-name");
|
||||
await nameInput.setValue("Pack");
|
||||
const addBtn = wrap.find('[data-test="fv-add"]');
|
||||
await addBtn.trigger("click");
|
||||
await addBtn.trigger("click");
|
||||
await flushPromises();
|
||||
const a = wrap.find('[data-test="fv-row-0-name"]');
|
||||
const b = wrap.find('[data-test="fv-row-1-name"]');
|
||||
await a.setValue("dupe");
|
||||
await b.setValue("dupe");
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="fv-row-0-err"]').text()).toContain("Duplicate");
|
||||
expect(wrap.find('[data-test="fv-row-1-err"]').text()).toContain("Duplicate");
|
||||
const saveBtn = findByText(wrap, "Save");
|
||||
await saveBtn?.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiMod.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("strips a leading $ from existing payload var names on load", async () => {
|
||||
apiMod.get.mockResolvedValue({
|
||||
id: "fv_a", name: "Pack", description: "",
|
||||
category_id: null, tags: [], type: "fixed_values",
|
||||
payload: { values: [{ id: "v1", name: "$age", value: "29" }] },
|
||||
version: 1, created_at: "", updated_at: "", is_favorite: false,
|
||||
});
|
||||
const wrap = mount(FixedValueForm, {
|
||||
props: { id: "fv_a" },
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const varInput = wrap.find('[data-test="fv-row-0-name"]')
|
||||
.element as HTMLInputElement;
|
||||
expect(varInput.value).toBe("age");
|
||||
});
|
||||
});
|
||||
+12
-16
@@ -18,7 +18,7 @@ vi.mock("../api/client", () => ({
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import PipelineForm from "../views/PipelineForm.vue";
|
||||
import PipelineEditor from "../views/PipelineEditor.vue";
|
||||
|
||||
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>>;
|
||||
@@ -43,13 +43,9 @@ function makeRouter() {
|
||||
});
|
||||
}
|
||||
|
||||
function findByText(wrap: ReturnType<typeof mount>, text: string) {
|
||||
return wrap.findAll("button").find((b) => b.text().includes(text));
|
||||
}
|
||||
|
||||
describe("PipelineForm.vue", () => {
|
||||
describe("PipelineEditor.vue", () => {
|
||||
it("renders 'New pipeline' heading when no id", async () => {
|
||||
const wrap = mount(PipelineForm, {
|
||||
const wrap = mount(PipelineEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
@@ -63,7 +59,7 @@ describe("PipelineForm.vue", () => {
|
||||
payload: { steps: [{ id: "s1", module_id: "wc_a", enabled: true }] },
|
||||
version: 1, created_at: "", updated_at: "", is_favorite: false,
|
||||
});
|
||||
const wrap = mount(PipelineForm, {
|
||||
const wrap = mount(PipelineEditor, {
|
||||
props: { id: "pl_a" },
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
@@ -73,12 +69,12 @@ describe("PipelineForm.vue", () => {
|
||||
});
|
||||
|
||||
it("save without name shows warn toast and skips API", async () => {
|
||||
const wrap = mount(PipelineForm, {
|
||||
const wrap = mount(PipelineEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const saveBtn = findByText(wrap, "Save");
|
||||
await saveBtn?.trigger("click");
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
await saveBtn.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiMod.create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -89,15 +85,15 @@ describe("PipelineForm.vue", () => {
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: { steps: [] }, version: 1, created_at: "", updated_at: "",
|
||||
});
|
||||
const wrap = mount(PipelineForm, {
|
||||
const wrap = mount(PipelineEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const nameInput = wrap.find("#pl-name");
|
||||
const nameInput = wrap.find('[data-test="identity-name"]');
|
||||
await nameInput.setValue("Portrait");
|
||||
await nameInput.trigger("input");
|
||||
const saveBtn = findByText(wrap, "Save");
|
||||
await saveBtn?.trigger("click");
|
||||
await flushPromises();
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
await saveBtn.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiMod.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "pipeline", name: "Portrait" }),
|
||||
+34
-84
@@ -18,7 +18,7 @@ vi.mock("../api/client", () => ({
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import WildcardForm from "../views/WildcardForm.vue";
|
||||
import WildcardEditor from "../views/WildcardEditor.vue";
|
||||
|
||||
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>>;
|
||||
@@ -47,9 +47,9 @@ function findByText(wrap: ReturnType<typeof mount>, text: string) {
|
||||
return wrap.findAll("button").find((b) => b.text().includes(text));
|
||||
}
|
||||
|
||||
describe("WildcardForm.vue", () => {
|
||||
describe("WildcardEditor.vue", () => {
|
||||
it("renders 'New wildcard' heading when no id", async () => {
|
||||
const wrap = mount(WildcardForm, {
|
||||
const wrap = mount(WildcardEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
@@ -62,7 +62,7 @@ describe("WildcardForm.vue", () => {
|
||||
tags: [], type: "wildcard", payload: { options: [{ id: "o1", value: "red", weight: 2 }] },
|
||||
version: 1, created_at: "", updated_at: "", is_favorite: false,
|
||||
});
|
||||
const wrap = mount(WildcardForm, {
|
||||
const wrap = mount(WildcardEditor, {
|
||||
props: { id: "wc_a" },
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
@@ -77,15 +77,15 @@ describe("WildcardForm.vue", () => {
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: { options: [] }, version: 1, created_at: "", updated_at: "",
|
||||
});
|
||||
const wrap = mount(WildcardForm, {
|
||||
const wrap = mount(WildcardEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const nameInput = wrap.find("#wc-name");
|
||||
const nameInput = wrap.find('[data-test="identity-name"]');
|
||||
await nameInput.setValue("alpha");
|
||||
await nameInput.trigger("input");
|
||||
const saveBtn = findByText(wrap, "Save");
|
||||
await saveBtn?.trigger("click");
|
||||
await flushPromises();
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
await saveBtn.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiMod.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "wildcard", name: "alpha" }),
|
||||
@@ -93,46 +93,29 @@ describe("WildcardForm.vue", () => {
|
||||
});
|
||||
|
||||
it("save without name shows warn toast and does not call api", async () => {
|
||||
const wrap = mount(WildcardForm, {
|
||||
const wrap = mount(WildcardEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const saveBtn = findByText(wrap, "Save");
|
||||
await saveBtn?.trigger("click");
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
await saveBtn.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiMod.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-derives var_binding from name while untouched", async () => {
|
||||
const wrap = mount(WildcardForm, {
|
||||
const wrap = mount(WildcardEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const nameInput = wrap.find("#wc-name");
|
||||
const nameInput = wrap.find('[data-test="identity-name"]');
|
||||
await nameInput.setValue("Hair Color");
|
||||
await flushPromises();
|
||||
const varInput = wrap.find('[data-test="wc-var-binding"]')
|
||||
const varInput = wrap.find('[data-test="identity-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",
|
||||
@@ -140,28 +123,28 @@ describe("WildcardForm.vue", () => {
|
||||
payload: { options: [], sub_categories: [], var_binding: "outfit_style" },
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
});
|
||||
const wrap = mount(WildcardForm, {
|
||||
const wrap = mount(WildcardEditor, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const nameInput = wrap.find("#wc-name");
|
||||
const nameInput = wrap.find('[data-test="identity-name"]');
|
||||
await nameInput.setValue("Outfit Style");
|
||||
await flushPromises();
|
||||
const saveBtn = findByText(wrap, "Save");
|
||||
await saveBtn?.trigger("click");
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
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("renders RichTextInput for each option value", async () => {
|
||||
it("renders RichTextInput for option values when editing", async () => {
|
||||
apiMod.get.mockResolvedValue({
|
||||
id: "wc_a", name: "alpha", description: "", category_id: null,
|
||||
tags: [], type: "wildcard",
|
||||
payload: { options: [{ id: "o1", value: "red", weight: 1 }] },
|
||||
version: 1, created_at: "", updated_at: "", is_favorite: false,
|
||||
});
|
||||
const wrap = mount(WildcardForm, {
|
||||
const wrap = mount(WildcardEditor, {
|
||||
props: { id: "wc_a" },
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
@@ -178,17 +161,17 @@ describe("WildcardForm.vue", () => {
|
||||
payload: { options: [], sub_categories: [], var_binding: "my_hair" },
|
||||
version: 1, created_at: "", updated_at: "", is_favorite: false,
|
||||
});
|
||||
const wrap = mount(WildcardForm, {
|
||||
const wrap = mount(WildcardEditor, {
|
||||
props: { id: "wc_a" },
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const varInput = wrap.find('[data-test="wc-var-binding"]')
|
||||
const varInput = wrap.find('[data-test="identity-var-binding"]')
|
||||
.element as HTMLInputElement;
|
||||
expect(varInput.value).toBe("my_hair");
|
||||
});
|
||||
|
||||
it("shows History (N) when payload has history entries and restores on click", async () => {
|
||||
it("shows History (N) when payload has history entries", async () => {
|
||||
apiMod.get.mockResolvedValue({
|
||||
id: "wc_a", name: "alpha", description: "", category_id: null,
|
||||
tags: [], type: "wildcard",
|
||||
@@ -203,57 +186,22 @@ describe("WildcardForm.vue", () => {
|
||||
description: "older",
|
||||
category_id: null,
|
||||
tags: [],
|
||||
payload: {
|
||||
options: [{ id: "o0", value: "red", weight: 1 }],
|
||||
sub_categories: [],
|
||||
var_binding: "older_name",
|
||||
},
|
||||
},
|
||||
{
|
||||
saved_at: "2025-04-26T12:00:00Z",
|
||||
name: "previous-name",
|
||||
description: "prev",
|
||||
category_id: null,
|
||||
tags: [],
|
||||
payload: {
|
||||
options: [{ id: "o0a", value: "green", weight: 1 }],
|
||||
sub_categories: [],
|
||||
var_binding: "previous_name",
|
||||
},
|
||||
payload: { options: [], sub_categories: [], var_binding: "older_name" },
|
||||
},
|
||||
],
|
||||
},
|
||||
version: 1, created_at: "", updated_at: "", is_favorite: false,
|
||||
});
|
||||
|
||||
const wrap = mount(WildcardForm, {
|
||||
const wrap = mount(WildcardEditor, {
|
||||
props: { id: "wc_a" },
|
||||
attachTo: document.body,
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
// Button shows count.
|
||||
const historyBtn = wrap.find('[data-test="history-btn"]');
|
||||
expect(historyBtn.exists()).toBe(true);
|
||||
expect(historyBtn.text()).toContain("History (2)");
|
||||
|
||||
// Open panel.
|
||||
await historyBtn.trigger("click");
|
||||
await flushPromises();
|
||||
expect(document.body.querySelector('[data-test="history-panel"]')).not.toBeNull();
|
||||
|
||||
// Newest entry is index 0.
|
||||
const restoreBtn = document.body.querySelector(
|
||||
'[data-test="history-restore-0"]',
|
||||
) as HTMLElement | null;
|
||||
expect(restoreBtn).not.toBeNull();
|
||||
restoreBtn?.click();
|
||||
await flushPromises();
|
||||
|
||||
// Name input now reflects the restored snapshot's name.
|
||||
const nameInput = wrap.find("#wc-name").element as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("previous-name");
|
||||
expect(historyBtn.text()).toContain("History (1)");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
@@ -269,22 +217,24 @@ describe("WildcardForm.vue", () => {
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: body.payload, version: 2, created_at: "", updated_at: "",
|
||||
}));
|
||||
const wrap = mount(WildcardForm, {
|
||||
const wrap = mount(WildcardEditor, {
|
||||
props: { id: "wc_a" },
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const nameInput = wrap.find("#wc-name");
|
||||
const nameInput = wrap.find('[data-test="identity-name"]');
|
||||
await nameInput.setValue("alpha2");
|
||||
await flushPromises();
|
||||
const saveBtn = findByText(wrap, "Save");
|
||||
await saveBtn?.trigger("click");
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
await saveBtn.trigger("click");
|
||||
await flushPromises();
|
||||
// 1st call = initial get on mount; 2nd call = pre-save snapshot fetch.
|
||||
expect(apiMod.get).toHaveBeenCalledTimes(2);
|
||||
const upd = apiMod.update.mock.calls[0]?.[1] as { payload: { history?: unknown[] } };
|
||||
expect(Array.isArray(upd.payload.history)).toBe(true);
|
||||
expect(upd.payload.history?.length).toBe(1);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
// Suppress unused helper warning.
|
||||
void findByText;
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Input from "./ui/Input.vue";
|
||||
|
||||
interface Props {
|
||||
modelValue: string;
|
||||
@@ -117,9 +117,8 @@ function isActivePreset(preset: string): boolean {
|
||||
:aria-label="swatchAriaLabel"
|
||||
>
|
||||
<div class="wp-color-picker__row">
|
||||
<InputText
|
||||
<Input
|
||||
:model-value="hexDraft"
|
||||
maxlength="7"
|
||||
aria-label="Hex color value"
|
||||
class="wp-color-picker__hex font-mono"
|
||||
data-test="color-hex-input"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import Button from "primevue/button";
|
||||
import InputNumber from "primevue/inputnumber";
|
||||
import Button from "./ui/Button.vue";
|
||||
import Input from "./ui/Input.vue";
|
||||
import type {
|
||||
ConstraintCell,
|
||||
ConstraintMatrix,
|
||||
@@ -173,10 +173,11 @@ function applyPreset(p: number) {
|
||||
setCellFactor(tune.value.row, tune.value.col, p);
|
||||
}
|
||||
|
||||
function onTuneInput(value: number | null) {
|
||||
function onTuneInput(value: string | number) {
|
||||
if (!tune.value) return;
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return;
|
||||
setCellFactor(tune.value.row, tune.value.col, value);
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return;
|
||||
setCellFactor(tune.value.row, tune.value.col, n);
|
||||
}
|
||||
|
||||
defineExpose({ cellAt, cycleCell });
|
||||
@@ -256,16 +257,10 @@ defineExpose({ cellAt, cycleCell });
|
||||
<span class="font-mono text-sm text-wp-text">×{{ fmtFactor(tuneCell.factor) }}</span>
|
||||
</div>
|
||||
<div class="wp-tune-pop__row">
|
||||
<InputNumber
|
||||
<Input
|
||||
:model-value="tuneCell.factor"
|
||||
:min="0.1"
|
||||
:max="10"
|
||||
:step="0.1"
|
||||
:max-fraction-digits="2"
|
||||
show-buttons
|
||||
button-layout="horizontal"
|
||||
type="number"
|
||||
aria-label="Factor value"
|
||||
class="w-full"
|
||||
@update:model-value="onTuneInput"
|
||||
/>
|
||||
<div class="wp-tune-pop__presets">
|
||||
@@ -280,15 +275,13 @@ defineExpose({ cellAt, cycleCell });
|
||||
>×{{ fmtFactor(p) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<div class="wp-tune-pop__done">
|
||||
<Button
|
||||
label="Done"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="Close tune popover"
|
||||
@click="closeTune"
|
||||
/>
|
||||
>Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -473,4 +466,5 @@ defineExpose({ cellAt, cycleCell });
|
||||
}
|
||||
.text-wp-success { color: var(--wp-success); }
|
||||
.text-wp-warn { color: var(--wp-warn); }
|
||||
.wp-tune-pop__done { display: flex; justify-content: flex-end; }
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<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 Card from "./ui/Card.vue";
|
||||
import Button from "./ui/Button.vue";
|
||||
import Input from "./ui/Input.vue";
|
||||
import Textarea from "./ui/Textarea.vue";
|
||||
import Select from "./ui/Select.vue";
|
||||
import Pill from "./ui/Pill.vue";
|
||||
import type {
|
||||
DerivationAction,
|
||||
DerivationBranch,
|
||||
@@ -56,10 +56,6 @@ function blankBranch(): DerivationBranch {
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -156,205 +152,176 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
|
||||
<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="rule-head">
|
||||
<Pill tone="info" data-test="rule-label">Rule {{ ruleNumber }}</Pill>
|
||||
<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"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
: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 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"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
: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">
|
||||
<Input
|
||||
:model-value="branch.condition.var"
|
||||
placeholder="$variable"
|
||||
class="field-var"
|
||||
:aria-label="`Condition variable for rule ${ruleNumber} branch ${bi + 1}`"
|
||||
@update:model-value="(v) => onConditionVar(bi, String(v ?? ''))"
|
||||
/>
|
||||
</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 ?? '')"
|
||||
<Select
|
||||
:model-value="branch.condition.op"
|
||||
:options="OP_OPTIONS"
|
||||
class="field-op"
|
||||
:aria-label="`Condition operator for rule ${ruleNumber} branch ${bi + 1}`"
|
||||
@update:model-value="(v) => onConditionOp(bi, v as DerivationOp)"
|
||||
/>
|
||||
<Input
|
||||
:model-value="branch.condition.value"
|
||||
placeholder="value"
|
||||
class="field-value"
|
||||
:aria-label="`Condition value for rule ${ruleNumber} branch ${bi + 1}`"
|
||||
@update:model-value="(v) => onConditionValue(bi, String(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"
|
||||
<!-- Action row -->
|
||||
<div class="row">
|
||||
<span class="row-label">Then</span>
|
||||
<div class="row-fields">
|
||||
<Input
|
||||
: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) => onActionTarget(bi, String(v ?? ''))"
|
||||
/>
|
||||
</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 ?? '')"
|
||||
<Select
|
||||
:model-value="branch.action.mode"
|
||||
:options="MODE_OPTIONS"
|
||||
class="field-op"
|
||||
:aria-label="`Action mode for rule ${ruleNumber} branch ${bi + 1}`"
|
||||
@update:model-value="(v) => onActionMode(bi, v as DerivationMode)"
|
||||
/>
|
||||
</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) => onActionValue(bi, v ?? '')"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
<!-- 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"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
: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">
|
||||
<Input
|
||||
: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) => onElseTarget(String(v ?? ''))"
|
||||
/>
|
||||
<Select
|
||||
:model-value="rule.else.action.mode"
|
||||
:options="MODE_OPTIONS"
|
||||
class="field-op"
|
||||
:aria-label="`ELSE action mode for rule ${ruleNumber}`"
|
||||
@update:model-value="(v) => onElseMode(v as DerivationMode)"
|
||||
/>
|
||||
</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) => onElseValue(v ?? '')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="addbar">
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="`Add ELIF branch to rule ${ruleNumber}`"
|
||||
:data-test="`add-elif-${index}`"
|
||||
@click="addBranch"
|
||||
>Add elif</Button>
|
||||
<Button
|
||||
v-if="!rule.else"
|
||||
icon="pi pi-plus"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="`Add ELSE branch to rule ${ruleNumber}`"
|
||||
:data-test="`add-else-${index}`"
|
||||
@click="addElse"
|
||||
>Add else</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -363,12 +330,6 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* EditorFrame
|
||||
*
|
||||
* Shared scaffold for the six module editors (Wildcard / Fixed / Combine /
|
||||
* Derivation / Constraint / Pipeline). Mirrors the prototype's
|
||||
* `EditorFrame` in `screens/editors.jsx`:
|
||||
*
|
||||
* - top breadcrumb back-link
|
||||
* - header with title + optional subtitle + free-form `header-extra` slot
|
||||
* - body slot (cards laid out by each editor)
|
||||
* - sticky footer with optional History button (left), Cancel + Save (right)
|
||||
* - HistoryPanel mounted lazily (only when entries exist + opened)
|
||||
*
|
||||
* Drops PrimeVue entirely — uses the wave-1 `ui/Button` primitive plus the
|
||||
* `wp-page__*`, `wp-breadcrumb`, `wp-card`, `wp-footer-bar` tokens that
|
||||
* already live in `tokens.css`.
|
||||
*/
|
||||
import { ref } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import Button from "./ui/Button.vue";
|
||||
import HistoryPanel from "./HistoryPanel.vue";
|
||||
import type { ModuleHistoryEntry } from "../api/types";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
backRoute: string;
|
||||
backLabel: string;
|
||||
saving?: boolean;
|
||||
saveDisabled?: boolean;
|
||||
historyEntries?: ModuleHistoryEntry[];
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
saving: false,
|
||||
saveDisabled: false,
|
||||
historyEntries: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "save"): void;
|
||||
(e: "cancel"): void;
|
||||
(e: "restore", entry: ModuleHistoryEntry): void;
|
||||
}>();
|
||||
|
||||
const historyOpen = ref(false);
|
||||
|
||||
function onCancel() { emit("cancel"); }
|
||||
function onSave() { emit("save"); }
|
||||
function onRestore(entry: ModuleHistoryEntry) {
|
||||
emit("restore", entry);
|
||||
historyOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wp-page wp-page--fill wp-editor">
|
||||
<RouterLink :to="backRoute" class="wp-breadcrumb" data-test="editor-back">
|
||||
<i class="pi pi-angle-left" /> {{ backLabel }}
|
||||
</RouterLink>
|
||||
|
||||
<div class="wp-page__header">
|
||||
<div class="wp-page__title-wrap">
|
||||
<h1 class="wp-page__title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="wp-page__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<slot name="header-extra" />
|
||||
</div>
|
||||
|
||||
<div class="wp-editor__body">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div class="wp-footer-bar">
|
||||
<div class="wp-footer-bar__inner">
|
||||
<Button
|
||||
v-if="historyEntries && historyEntries.length"
|
||||
variant="ghost"
|
||||
icon="pi pi-history"
|
||||
data-test="history-btn"
|
||||
@click="historyOpen = true"
|
||||
>
|
||||
History ({{ historyEntries.length }})
|
||||
</Button>
|
||||
<slot name="footer-left" />
|
||||
<span class="wp-spacer" />
|
||||
<Button variant="secondary" data-test="cancel-btn" @click="onCancel">Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="pi pi-check"
|
||||
:loading="saving"
|
||||
:disabled="saveDisabled"
|
||||
data-test="save-btn"
|
||||
@click="onSave"
|
||||
>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HistoryPanel
|
||||
:open="historyOpen"
|
||||
:entries="historyEntries ?? []"
|
||||
@update:open="(v) => (historyOpen = v)"
|
||||
@restore="onRestore"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-editor {
|
||||
/* The shared `.wp-page` token already provides padding + max-width. We
|
||||
stack body content with a comfortable gap and leave room for the
|
||||
sticky footer that lives at the bottom of `.wp-page--fill`. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
.wp-editor__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -14,7 +14,7 @@
|
||||
* with the `restore` event.
|
||||
*/
|
||||
import { computed, onMounted, onBeforeUnmount, watch } from "vue";
|
||||
import Button from "primevue/button";
|
||||
import Button from "./ui/Button.vue";
|
||||
import RelativeDate from "./RelativeDate.vue";
|
||||
import type { ModuleHistoryEntry } from "../api/types";
|
||||
|
||||
@@ -100,9 +100,7 @@ watch(
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
variant="ghost"
|
||||
aria-label="Close history"
|
||||
data-test="history-close"
|
||||
@click="close"
|
||||
@@ -137,13 +135,12 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Restore this version"
|
||||
icon="pi pi-refresh"
|
||||
severity="primary"
|
||||
size="small"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
:data-test="`history-restore-${idx}`"
|
||||
@click="restore(entry)"
|
||||
/>
|
||||
>Restore this version</Button>
|
||||
</div>
|
||||
<pre class="wp-history-item__json">{{ previewJson(entry) }}</pre>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* IdentityCard
|
||||
*
|
||||
* Shared identity sub-form used by all six module editors. Mirrors the
|
||||
* `IdentityCard` helper in the prototype `screens/editors.jsx`. Wraps the
|
||||
* common name / category / description / tags fields, plus an optional
|
||||
* `$varBinding` input that auto-tracks the (slugified) name until the user
|
||||
* edits it.
|
||||
*
|
||||
* The parent owns the values; this component is dumb and emits patches.
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
import Card from "./ui/Card.vue";
|
||||
import Field from "./ui/Field.vue";
|
||||
import Input from "./ui/Input.vue";
|
||||
import Textarea from "./ui/Textarea.vue";
|
||||
import Select, { type SelectOption } from "./ui/Select.vue";
|
||||
import Chip from "./ui/Chip.vue";
|
||||
import Button from "./ui/Button.vue";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { toIdentifier } from "../utils/slug";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
description: string;
|
||||
categoryId: string | null;
|
||||
tags: string[];
|
||||
/** Optional `$var` binding (Wildcard editor uses this slot). */
|
||||
varBinding?: string | null;
|
||||
varBindingError?: string;
|
||||
varBindingHint?: string;
|
||||
/** When true, render the description Textarea. */
|
||||
showDescription?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
varBinding: null,
|
||||
varBindingError: "",
|
||||
varBindingHint: "Reference this in templates as $name. Auto-derived from the name unless you customize it.",
|
||||
showDescription: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:name": [value: string];
|
||||
"update:description": [value: string];
|
||||
"update:categoryId": [value: string | null];
|
||||
"update:tags": [value: string[]];
|
||||
"update:varBinding": [value: string];
|
||||
}>();
|
||||
|
||||
const categoryStore = useCategoryStore();
|
||||
|
||||
const categoryOptions = computed<SelectOption[]>(() => [
|
||||
{ value: null, label: "None" },
|
||||
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
|
||||
]);
|
||||
|
||||
// `varBinding` auto-tracks slugified name until the user touches it.
|
||||
const varTouched = ref(false);
|
||||
watch(
|
||||
() => props.varBinding,
|
||||
(v) => {
|
||||
// If parent supplied a non-default value at mount, treat it as touched.
|
||||
if (v && v !== toIdentifier(props.name)) varTouched.value = true;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function onName(v: string | number) {
|
||||
const next = String(v ?? "");
|
||||
emit("update:name", next);
|
||||
if (props.varBinding != null && !varTouched.value) {
|
||||
emit("update:varBinding", toIdentifier(next));
|
||||
}
|
||||
}
|
||||
|
||||
function onVarBinding(v: string | number) {
|
||||
varTouched.value = true;
|
||||
const cleaned = String(v ?? "").toLowerCase().replace(/[^a-z0-9_]/g, "");
|
||||
emit("update:varBinding", cleaned);
|
||||
}
|
||||
|
||||
// Tag chip-input — Enter adds, X removes. No autocomplete (prototype's
|
||||
// `TagInput` is plain). Wave-5 may add a suggestion popover.
|
||||
const tagDraft = ref("");
|
||||
function addTag() {
|
||||
const t = tagDraft.value.trim();
|
||||
if (!t) return;
|
||||
if (props.tags.includes(t)) {
|
||||
tagDraft.value = "";
|
||||
return;
|
||||
}
|
||||
emit("update:tags", [...props.tags, t]);
|
||||
tagDraft.value = "";
|
||||
}
|
||||
function removeTag(t: string) {
|
||||
emit("update:tags", props.tags.filter((x) => x !== t));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card title="Identity">
|
||||
<div class="identity-grid">
|
||||
<Field label="Name">
|
||||
<Input
|
||||
:model-value="name"
|
||||
placeholder="e.g. Hair Color"
|
||||
data-test="identity-name"
|
||||
@update:model-value="onName"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Category">
|
||||
<Select
|
||||
:model-value="categoryId"
|
||||
:options="categoryOptions"
|
||||
placeholder="None"
|
||||
clearable
|
||||
aria-label="Category"
|
||||
data-test="identity-category"
|
||||
@update:model-value="(v) => emit('update:categoryId', (v as string | null) ?? null)"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
v-if="varBinding != null"
|
||||
label="Variable name"
|
||||
:hint="varBindingError ? undefined : varBindingHint"
|
||||
:error="varBindingError"
|
||||
class="identity-grid__full"
|
||||
>
|
||||
<div class="wp-input-group">
|
||||
<span class="wp-input-group__addon">$</span>
|
||||
<input
|
||||
class="wp-input wp-mono"
|
||||
:value="varBinding ?? ''"
|
||||
placeholder="hair_color"
|
||||
data-test="identity-var-binding"
|
||||
@input="onVarBinding(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
v-if="showDescription"
|
||||
label="Description"
|
||||
class="identity-grid__full"
|
||||
>
|
||||
<Textarea
|
||||
:model-value="description"
|
||||
:rows="3"
|
||||
placeholder="What does this module do?"
|
||||
aria-label="Description"
|
||||
data-test="identity-description"
|
||||
@update:model-value="(v) => emit('update:description', v)"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Tags" class="identity-grid__full">
|
||||
<div class="identity-tags">
|
||||
<Input
|
||||
v-model="tagDraft"
|
||||
placeholder="Type a tag and press Enter…"
|
||||
data-test="identity-tag-input"
|
||||
@keydown.enter.prevent="addTag"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="pi pi-plus"
|
||||
data-test="identity-tag-add"
|
||||
@click="addTag"
|
||||
>Add</Button>
|
||||
</div>
|
||||
<div v-if="tags.length" class="identity-tag-list" data-test="identity-tag-list">
|
||||
<Chip
|
||||
v-for="t in tags"
|
||||
:key="t"
|
||||
tone="accent"
|
||||
removable
|
||||
@remove="removeTag(t)"
|
||||
>{{ t }}</Chip>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.identity-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.identity-grid__full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.identity-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.identity-tags > :first-child { flex: 1; }
|
||||
.identity-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import Dialog from "primevue/dialog";
|
||||
import InputText from "primevue/inputtext";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import Input from "./ui/Input.vue";
|
||||
import type { CategoryRow, ModuleRow, ModuleType } from "../api/types";
|
||||
|
||||
interface Props {
|
||||
@@ -92,21 +91,33 @@ function kindLabel(t: ModuleType): string {
|
||||
const tab = KIND_TABS.find((x) => x.type === t);
|
||||
return tab?.label ?? t;
|
||||
}
|
||||
|
||||
// Esc closes when visible.
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && props.visible) close();
|
||||
}
|
||||
watch(() => props.visible, (v) => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (v) {
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
modal
|
||||
:draggable="false"
|
||||
:dismissable-mask="true"
|
||||
:block-scroll="true"
|
||||
:show-header="false"
|
||||
:pt="{ root: { 'data-test': 'pipeline-picker' } }"
|
||||
class="wp-pl-picker-dialog"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
>
|
||||
<div class="wp-pl-picker">
|
||||
<Teleport v-if="visible" to="body">
|
||||
<div class="wp-pl-picker__backdrop" data-test="pipeline-picker" @mousedown.self="close">
|
||||
<div class="wp-pl-picker" @mousedown.stop>
|
||||
<div class="wp-pl-picker__head">
|
||||
<i class="pi pi-plus" />
|
||||
<span class="wp-pl-picker__title">Add module to pipeline</span>
|
||||
@@ -142,7 +153,7 @@ function kindLabel(t: ModuleType): string {
|
||||
|
||||
<div class="wp-pl-picker__search">
|
||||
<span class="wp-pl-picker__searchicon"><i class="pi pi-search" /></span>
|
||||
<InputText
|
||||
<Input
|
||||
v-model="search"
|
||||
placeholder="Search modules…"
|
||||
class="wp-pl-picker__searchinput"
|
||||
@@ -183,10 +194,21 @@ function kindLabel(t: ModuleType): string {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-pl-picker__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.wp-pl-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -312,8 +334,7 @@ function kindLabel(t: ModuleType): string {
|
||||
padding: 0 8px;
|
||||
height: 100%;
|
||||
}
|
||||
:deep(.wp-pl-picker__searchinput .p-inputtext),
|
||||
:deep(input.wp-pl-picker__searchinput) {
|
||||
:deep(.wp-pl-picker__searchinput.wp-input) {
|
||||
border: none;
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import Select from "primevue/select";
|
||||
import Select from "./ui/Select.vue";
|
||||
import type { ModuleRow, ModuleType, PipelineStep } from "../api/types";
|
||||
|
||||
interface Props {
|
||||
@@ -36,7 +36,9 @@ const rows = computed(() =>
|
||||
const mod = props.modulesById.get(step.module_id) ?? null;
|
||||
const kind: ModuleType | null = mod?.type ?? null;
|
||||
const meta = kind ? KIND_META[kind] : null;
|
||||
const sameKindOptions = kind ? props.modulesByKind[kind] ?? [] : [];
|
||||
const sameKindOptions = kind
|
||||
? (props.modulesByKind[kind] ?? []).map((m) => ({ value: m.id, label: m.name }))
|
||||
: [];
|
||||
return { step, idx, mod, kind, meta, sameKindOptions };
|
||||
}),
|
||||
);
|
||||
@@ -158,12 +160,10 @@ function move(idx: number, dir: -1 | 1) {
|
||||
v-if="row.kind"
|
||||
:model-value="row.step.module_id"
|
||||
:options="row.sameKindOptions"
|
||||
option-label="name"
|
||||
option-value="id"
|
||||
:placeholder="`Pick a ${row.meta?.label.toLowerCase()}`"
|
||||
:aria-label="`Pick reference for step ${row.idx + 1}`"
|
||||
class="wp-pl-row__refselect"
|
||||
:pt="{ root: { 'data-test': `step-ref-${row.idx}` } }"
|
||||
:data-test="`step-ref-${row.idx}`"
|
||||
@update:model-value="(v) => changeModule(row.idx, v as string)"
|
||||
/>
|
||||
<span v-else class="wp-pl-row__id">{{ row.step.module_id }}</span>
|
||||
|
||||
+12
-12
@@ -9,23 +9,23 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: "", redirect: "/wildcards" },
|
||||
{ path: "dashboard", name: "dashboard", component: () => import("../views/Dashboard.vue") },
|
||||
{ path: "wildcards", name: "wildcards", component: () => import("../views/Wildcards.vue") },
|
||||
{ path: "wildcards/new", name: "wildcards-new", component: () => import("../views/WildcardForm.vue") },
|
||||
{ path: "wildcards/:id/edit", name: "wildcards-edit", component: () => import("../views/WildcardForm.vue"), props: true },
|
||||
{ path: "wildcards/new", name: "wildcards-new", component: () => import("../views/WildcardEditor.vue") },
|
||||
{ path: "wildcards/:id/edit", name: "wildcards-edit", component: () => import("../views/WildcardEditor.vue"), props: true },
|
||||
{ path: "fixed-values", name: "fixed-values", component: () => import("../views/FixedValues.vue") },
|
||||
{ path: "fixed-values/new", name: "fixed-values-new", component: () => import("../views/FixedValueForm.vue") },
|
||||
{ path: "fixed-values/:id/edit", name: "fixed-values-edit", component: () => import("../views/FixedValueForm.vue"), props: true },
|
||||
{ path: "fixed-values/new", name: "fixed-values-new", component: () => import("../views/FixedEditor.vue") },
|
||||
{ path: "fixed-values/:id/edit", name: "fixed-values-edit", component: () => import("../views/FixedEditor.vue"), props: true },
|
||||
{ path: "combines", name: "combines", component: () => import("../views/Combines.vue") },
|
||||
{ path: "combines/new", name: "combines-new", component: () => import("../views/CombineForm.vue") },
|
||||
{ path: "combines/:id/edit", name: "combines-edit", component: () => import("../views/CombineForm.vue"), props: true },
|
||||
{ path: "combines/new", name: "combines-new", component: () => import("../views/CombineEditor.vue") },
|
||||
{ path: "combines/:id/edit", name: "combines-edit", component: () => import("../views/CombineEditor.vue"), props: true },
|
||||
{ path: "derivations", name: "derivations", component: () => import("../views/Derivations.vue") },
|
||||
{ path: "derivations/new", name: "derivations-new", component: () => import("../views/DerivationForm.vue") },
|
||||
{ path: "derivations/:id/edit", name: "derivations-edit", component: () => import("../views/DerivationForm.vue"), props: true },
|
||||
{ path: "derivations/new", name: "derivations-new", component: () => import("../views/DerivationEditor.vue") },
|
||||
{ path: "derivations/:id/edit", name: "derivations-edit", component: () => import("../views/DerivationEditor.vue"), props: true },
|
||||
{ path: "constraints", name: "constraints", component: () => import("../views/Constraints.vue") },
|
||||
{ path: "constraints/new", name: "constraints-new", component: () => import("../views/ConstraintForm.vue") },
|
||||
{ path: "constraints/:id/edit", name: "constraints-edit", component: () => import("../views/ConstraintForm.vue"), props: true },
|
||||
{ path: "constraints/new", name: "constraints-new", component: () => import("../views/ConstraintEditor.vue") },
|
||||
{ path: "constraints/:id/edit", name: "constraints-edit", component: () => import("../views/ConstraintEditor.vue"), props: true },
|
||||
{ path: "pipelines", name: "pipelines", component: () => import("../views/Pipelines.vue") },
|
||||
{ path: "pipelines/new", name: "pipelines-new", component: () => import("../views/PipelineForm.vue") },
|
||||
{ path: "pipelines/:id/edit", name: "pipelines-edit", component: () => import("../views/PipelineForm.vue"), props: true },
|
||||
{ path: "pipelines/new", name: "pipelines-new", component: () => import("../views/PipelineEditor.vue") },
|
||||
{ path: "pipelines/:id/edit", name: "pipelines-edit", component: () => import("../views/PipelineEditor.vue"), props: true },
|
||||
{ path: "categories", name: "categories", component: () => import("../views/Categories.vue") },
|
||||
{ path: "import-export", name: "import-export", component: () => import("../views/ImportExport.vue") },
|
||||
{ path: "test", name: "test", component: () => import("../views/TestRunner.vue") },
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* CombineEditor — Wave 4 port of `CombineEditor` in `screens/editors.jsx`.
|
||||
*
|
||||
* Sections:
|
||||
* 1. Identity
|
||||
* 2. Template + output ($output_var input + RichTextInput multiline)
|
||||
* 3. Detected inputs chip list
|
||||
* 4. Live preview
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import EditorFrame from "../components/EditorFrame.vue";
|
||||
import IdentityCard from "../components/IdentityCard.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Field from "../components/ui/Field.vue";
|
||||
import Chip from "../components/ui/Chip.vue";
|
||||
import RichTextInput from "../components/RichTextInput.vue";
|
||||
import RichTextPreview from "../components/RichTextPreview.vue";
|
||||
import { tokenizeRich } from "../utils/richTokenize";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import type {
|
||||
CombinePayload,
|
||||
ModuleHistoryEntry,
|
||||
WildcardPayload,
|
||||
} from "../api/types";
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const categoryId = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const template = ref("");
|
||||
const outputVar = ref("");
|
||||
const outputVarTouched = ref(false);
|
||||
const outputVarError = ref("");
|
||||
const saving = ref(false);
|
||||
const isEdit = computed(() => !!props.id);
|
||||
const historyEntries = ref<ModuleHistoryEntry[]>([]);
|
||||
|
||||
const PLACEHOLDER = "$first_name, a $age-year-old with $hair_color hair";
|
||||
|
||||
watch(name, (next) => {
|
||||
if (!outputVarTouched.value) {
|
||||
outputVar.value = toIdentifier(next);
|
||||
}
|
||||
});
|
||||
|
||||
function onOutputVarInput(value: string) {
|
||||
outputVarTouched.value = true;
|
||||
outputVar.value = (value ?? "").replace(/^\$+/, "").replace(/[^a-zA-Z0-9_]/g, "");
|
||||
if (outputVarError.value) outputVarError.value = "";
|
||||
}
|
||||
|
||||
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;
|
||||
if (m.type === "wildcard") {
|
||||
const p = (m.payload ?? {}) as Partial<WildcardPayload>;
|
||||
const b = (p.var_binding && p.var_binding.trim()) || toIdentifier(m.name);
|
||||
if (b && !seen.has(b)) { seen.add(b); out.push({ label: b, 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;
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
|
||||
if (props.id) {
|
||||
try {
|
||||
const row = await moduleStore.get(props.id);
|
||||
name.value = row.name;
|
||||
description.value = row.description;
|
||||
categoryId.value = row.category_id;
|
||||
tags.value = row.tags;
|
||||
const p = row.payload as Partial<CombinePayload>;
|
||||
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);
|
||||
}
|
||||
historyEntries.value = readHistory(row.payload);
|
||||
} catch {
|
||||
toast.push({ severity: "error", summary: "Combine not found" });
|
||||
router.replace("/combines");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
name.value = entry.name;
|
||||
description.value = entry.description ?? "";
|
||||
categoryId.value = entry.category_id ?? null;
|
||||
tags.value = entry.tags ? [...entry.tags] : [];
|
||||
const p = (entry.payload ?? {}) as Partial<CombinePayload>;
|
||||
template.value = p.template ?? "";
|
||||
const o = (p.output_var ?? "").replace(/^\$+/, "");
|
||||
if (o.trim()) {
|
||||
outputVar.value = o;
|
||||
outputVarTouched.value = true;
|
||||
} else {
|
||||
outputVar.value = toIdentifier(entry.name);
|
||||
outputVarTouched.value = false;
|
||||
}
|
||||
toast.push({
|
||||
severity: "info",
|
||||
summary: "Version restored",
|
||||
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
|
||||
life: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
toast.push({ severity: "warn", summary: "Name required" });
|
||||
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.push({ severity: "warn", summary: "Invalid output variable" });
|
||||
return;
|
||||
}
|
||||
outputVarError.value = "";
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: CombinePayload = {
|
||||
template: template.value,
|
||||
output_var: finalOutput,
|
||||
input_vars: [...detected.value],
|
||||
};
|
||||
const newPayload = payload as unknown as Record<string, unknown>;
|
||||
if (isEdit.value && props.id) {
|
||||
const prev = await moduleStore.get(props.id);
|
||||
const nextHistory = appendSnapshot(
|
||||
{
|
||||
name: prev.name,
|
||||
description: prev.description,
|
||||
category_id: prev.category_id,
|
||||
tags: prev.tags,
|
||||
payload: prev.payload as Record<string, unknown>,
|
||||
},
|
||||
prev.payload as Record<string, unknown>,
|
||||
);
|
||||
await moduleStore.update(props.id, {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
category_id: categoryId.value,
|
||||
tags: tags.value,
|
||||
payload: { ...newPayload, history: nextHistory },
|
||||
});
|
||||
historyEntries.value = nextHistory;
|
||||
} else {
|
||||
await moduleStore.create({
|
||||
type: "combine",
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
category_id: categoryId.value,
|
||||
tags: tags.value,
|
||||
payload: newPayload,
|
||||
});
|
||||
}
|
||||
toast.push({ severity: "success", summary: "Saved", detail: name.value });
|
||||
router.push("/combines");
|
||||
} catch (e) {
|
||||
toast.push({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() { router.push("/combines"); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EditorFrame
|
||||
:title="isEdit ? 'Edit combine' : 'New combine'"
|
||||
back-route="/combines"
|
||||
back-label="Combines"
|
||||
:saving="saving"
|
||||
:history-entries="historyEntries"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@restore="applyRestore"
|
||||
>
|
||||
<IdentityCard
|
||||
:name="name"
|
||||
:description="description"
|
||||
:category-id="categoryId"
|
||||
:tags="tags"
|
||||
@update:name="(v) => (name = v)"
|
||||
@update:description="(v) => (description = v)"
|
||||
@update:category-id="(v) => (categoryId = v)"
|
||||
@update:tags="(v) => (tags = v)"
|
||||
/>
|
||||
|
||||
<Card title="Template & Output">
|
||||
<div class="cb-grid">
|
||||
<Field
|
||||
label="Template"
|
||||
hint="Reference variables with $name. Use $$ for a literal $. Use {a|b|c} for inline choices."
|
||||
>
|
||||
<RichTextInput
|
||||
v-model="template"
|
||||
:var-suggestions="varSuggestions"
|
||||
:multiline="true"
|
||||
:rows="3"
|
||||
:placeholder="PLACEHOLDER"
|
||||
data-test="cb-template"
|
||||
aria-label="Combine template"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Output variable"
|
||||
hint="Downstream modules read this name."
|
||||
:error="outputVarError"
|
||||
>
|
||||
<div class="wp-input-group">
|
||||
<span class="wp-input-group__addon">$</span>
|
||||
<input
|
||||
class="wp-input"
|
||||
:value="outputVar"
|
||||
placeholder="subject_phrase"
|
||||
data-test="cb-output-var"
|
||||
@input="onOutputVarInput(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="cb-detected">
|
||||
<div class="wp-field__label cb-detected__label">Detected inputs ({{ detected.length }})</div>
|
||||
<span v-if="!detected.length" class="wp-dim cb-detected__hint">
|
||||
None — type a template above.
|
||||
</span>
|
||||
<div v-else class="cb-detected__chips" data-test="cb-detected">
|
||||
<Chip
|
||||
v-for="d in detected"
|
||||
:key="d"
|
||||
tone="accent"
|
||||
icon="tag"
|
||||
>${{ d }}<span v-if="!hintByLabel.get(d)" class="cb-detected__warn"> ?</span></Chip>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Preview">
|
||||
<div class="wp-snippet" data-test="cb-preview">
|
||||
<div><span class="wp-token-com">// Highlighted template syntax:</span></div>
|
||||
<div class="cb-preview__row">
|
||||
<RichTextPreview v-if="template" :value="template" />
|
||||
<span v-else class="wp-dim">(empty template)</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="wp-token-com">→ stored as </span>
|
||||
<span class="wp-token-var">${{ outputVar || "output" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</EditorFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 220px;
|
||||
gap: 12px;
|
||||
}
|
||||
.cb-detected {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.cb-detected__label { margin-bottom: 6px; }
|
||||
.cb-detected__hint { font-size: 12px; }
|
||||
.cb-detected__chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cb-detected__warn { color: var(--wp-warn); margin-left: 4px; }
|
||||
.cb-preview__row {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,639 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 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 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 HistoryPanel from "../components/HistoryPanel.vue";
|
||||
import { tokenizeRich } from "../utils/richTokenize";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import type {
|
||||
CombinePayload,
|
||||
ModuleHistoryEntry,
|
||||
ModuleRow,
|
||||
WildcardPayload,
|
||||
WildcardOption,
|
||||
} from "../api/types";
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const categoryId = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
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 historyEntries = ref<ModuleHistoryEntry[]>([]);
|
||||
const historyOpen = ref(false);
|
||||
|
||||
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();
|
||||
const known = new Set<string>();
|
||||
for (const m of moduleStore.items) {
|
||||
for (const t of m.tags ?? []) known.add(t);
|
||||
}
|
||||
tagSuggestions.value = Array.from(known)
|
||||
.filter((t) => t.toLowerCase().includes(q) && !tags.value.includes(t))
|
||||
.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) {
|
||||
try {
|
||||
const row = await moduleStore.get(props.id);
|
||||
name.value = row.name;
|
||||
description.value = row.description;
|
||||
categoryId.value = row.category_id;
|
||||
tags.value = row.tags;
|
||||
const p = row.payload as Partial<CombinePayload>;
|
||||
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);
|
||||
}
|
||||
historyEntries.value = readHistory(row.payload);
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Combine not found", life: 3000 });
|
||||
router.replace("/combines");
|
||||
}
|
||||
}
|
||||
startRerollTimer();
|
||||
});
|
||||
|
||||
onBeforeUnmount(stopRerollTimer);
|
||||
|
||||
function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
name.value = entry.name;
|
||||
description.value = entry.description ?? "";
|
||||
categoryId.value = entry.category_id ?? null;
|
||||
tags.value = entry.tags ? [...entry.tags] : [];
|
||||
const p = (entry.payload ?? {}) as Partial<CombinePayload>;
|
||||
template.value = p.template ?? "";
|
||||
const o = (p.output_var ?? "").replace(/^\$+/, "");
|
||||
if (o.trim()) {
|
||||
outputVar.value = o;
|
||||
outputVarTouched.value = true;
|
||||
} else {
|
||||
outputVar.value = toIdentifier(entry.name);
|
||||
outputVarTouched.value = false;
|
||||
}
|
||||
historyOpen.value = false;
|
||||
toast.add({
|
||||
severity: "info",
|
||||
summary: "Version restored",
|
||||
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
|
||||
life: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
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 newPayload = payload as unknown as Record<string, unknown>;
|
||||
if (isEdit.value && props.id) {
|
||||
const prev = await moduleStore.get(props.id);
|
||||
const nextHistory = appendSnapshot(
|
||||
{
|
||||
name: prev.name,
|
||||
description: prev.description,
|
||||
category_id: prev.category_id,
|
||||
tags: prev.tags,
|
||||
payload: prev.payload as Record<string, unknown>,
|
||||
},
|
||||
prev.payload as Record<string, unknown>,
|
||||
);
|
||||
await moduleStore.update(props.id, {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
category_id: categoryId.value,
|
||||
tags: tags.value,
|
||||
payload: { ...newPayload, history: nextHistory },
|
||||
});
|
||||
historyEntries.value = nextHistory;
|
||||
} else {
|
||||
await moduleStore.create({
|
||||
type: "combine",
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
category_id: categoryId.value,
|
||||
tags: tags.value,
|
||||
payload: newPayload,
|
||||
});
|
||||
}
|
||||
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
|
||||
router.push("/combines");
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-page__header">
|
||||
<RouterLink to="/combines" class="text-xs text-wp-text2 hover:text-wp-text">
|
||||
<i class="pi pi-angle-left text-xs" /> Combines
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-semibold m-0 mt-1">{{ isEdit ? "Edit combine" : "New combine" }}</h1>
|
||||
</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">
|
||||
<div>
|
||||
<label for="cb-name" class="block text-xs text-wp-text2 mb-1">Name</label>
|
||||
<InputText id="cb-name" v-model="name" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="cb-cat" class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
id="cb-cat" v-model="categoryId"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="None" show-clear class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="cb-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
|
||||
<Textarea id="cb-desc" v-model="description" rows="2" class="w-full" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="cb-tags" class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<AutoComplete
|
||||
id="cb-tags"
|
||||
v-model="tags"
|
||||
multiple
|
||||
typeahead
|
||||
:suggestions="tagSuggestions"
|
||||
placeholder="Type a tag and press Enter…"
|
||||
class="w-full"
|
||||
@complete="searchTags"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<div class="form-page__footer">
|
||||
<Button
|
||||
v-if="historyEntries.length"
|
||||
:label="`History (${historyEntries.length})`"
|
||||
icon="pi pi-history"
|
||||
severity="secondary"
|
||||
outlined
|
||||
data-test="history-btn"
|
||||
@click="historyOpen = true"
|
||||
/>
|
||||
<div class="form-page__footer-spacer" />
|
||||
<Button label="Cancel" severity="secondary" outlined @click="router.push('/combines')" />
|
||||
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
|
||||
</div>
|
||||
<HistoryPanel
|
||||
:open="historyOpen"
|
||||
:entries="historyEntries"
|
||||
@update:open="(v) => (historyOpen = v)"
|
||||
@restore="applyRestore"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
|
||||
.form-page__header { padding: 24px 24px 0; }
|
||||
.form-page__body { padding: 16px 24px 96px; max-width: 56rem; flex: 1; }
|
||||
.form-page__footer {
|
||||
position: sticky; bottom: 0;
|
||||
background: var(--wp-bg);
|
||||
border-top: 1px solid var(--wp-border);
|
||||
padding: 12px 24px;
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
}
|
||||
.form-page__footer-spacer { flex: 1; }
|
||||
.form-section { margin-bottom: 24px; }
|
||||
.form-section__label {
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--wp-text2);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.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: 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);
|
||||
word-break: break-word;
|
||||
}
|
||||
.cb-preview__sample {
|
||||
font-family: var(--wp-font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,452 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ConstraintEditor — Wave 4 port of `ConstraintEditor` in `screens/editors.jsx`.
|
||||
*
|
||||
* Sections:
|
||||
* 1. Identity
|
||||
* 2. Source / Target wildcards
|
||||
* 3. Rule matrix (ConstraintMatrix)
|
||||
* 4. Exceptions table
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import EditorFrame from "../components/EditorFrame.vue";
|
||||
import IdentityCard from "../components/IdentityCard.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Field from "../components/ui/Field.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import Input from "../components/ui/Input.vue";
|
||||
import Select from "../components/ui/Select.vue";
|
||||
import ConstraintMatrixGrid from "../components/ConstraintMatrix.vue";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import type {
|
||||
ConstraintCell,
|
||||
ConstraintException,
|
||||
ConstraintMatrix,
|
||||
ConstraintMode,
|
||||
ConstraintPayload,
|
||||
ModuleHistoryEntry,
|
||||
ModuleRow,
|
||||
} from "../api/types";
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
interface WildcardOption { id: string; value: string; weight: number; sub_category?: string | null; }
|
||||
interface WildcardPayloadShape {
|
||||
options?: WildcardOption[];
|
||||
sub_categories?: string[];
|
||||
}
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const categoryId = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const sourceWildcardId = ref<string | null>(null);
|
||||
const targetWildcardId = ref<string | null>(null);
|
||||
const matrix = ref<ConstraintMatrix>({});
|
||||
const exceptions = ref<ConstraintException[]>([]);
|
||||
const saving = ref(false);
|
||||
const isEdit = computed(() => !!props.id);
|
||||
const historyEntries = ref<ModuleHistoryEntry[]>([]);
|
||||
|
||||
const MODE_DEFAULT_FACTOR: Record<ConstraintMode, number> = {
|
||||
allow: 1,
|
||||
exclude: 0,
|
||||
boost: 2,
|
||||
reduce: 0.5,
|
||||
};
|
||||
const MODE_OPTIONS = [
|
||||
{ label: "Allow", value: "allow" },
|
||||
{ label: "Exclude", value: "exclude" },
|
||||
{ label: "Boost", value: "boost" },
|
||||
{ label: "Reduce", value: "reduce" },
|
||||
];
|
||||
|
||||
const wildcardOptions = computed(() =>
|
||||
moduleStore.items
|
||||
.filter((m) => m.type === "wildcard")
|
||||
.map((m) => ({ label: m.name, value: m.id })),
|
||||
);
|
||||
|
||||
function wildcardById(id: string | null): ModuleRow | undefined {
|
||||
if (!id) return undefined;
|
||||
return moduleStore.items.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
const sourceWildcard = computed(() => wildcardById(sourceWildcardId.value));
|
||||
const targetWildcard = computed(() => wildcardById(targetWildcardId.value));
|
||||
|
||||
const sourceValues = computed<string[]>(() => {
|
||||
const wc = sourceWildcard.value;
|
||||
if (!wc) return [];
|
||||
const payload = wc.payload as WildcardPayloadShape;
|
||||
const values = (payload.options ?? [])
|
||||
.map((o) => o.value)
|
||||
.filter((v): v is string => typeof v === "string" && v.length > 0);
|
||||
return Array.from(new Set(values));
|
||||
});
|
||||
|
||||
const targetSubCategories = computed<string[]>(() => {
|
||||
const wc = targetWildcard.value;
|
||||
if (!wc) return [];
|
||||
const payload = wc.payload as WildcardPayloadShape;
|
||||
return payload.sub_categories ?? [];
|
||||
});
|
||||
|
||||
const targetValues = computed<string[]>(() => {
|
||||
const wc = targetWildcard.value;
|
||||
if (!wc) return [];
|
||||
const payload = wc.payload as WildcardPayloadShape;
|
||||
const values = (payload.options ?? [])
|
||||
.map((o) => o.value)
|
||||
.filter((v): v is string => typeof v === "string" && v.length > 0);
|
||||
return Array.from(new Set(values));
|
||||
});
|
||||
|
||||
function normalizeMatrix(raw: unknown): ConstraintMatrix {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const out: ConstraintMatrix = {};
|
||||
for (const [r, byCol] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!byCol || typeof byCol !== "object") continue;
|
||||
out[r] = {};
|
||||
for (const [c, cellRaw] of Object.entries(byCol as Record<string, unknown>)) {
|
||||
const cell = cellRaw as Partial<ConstraintCell>;
|
||||
const mode = (cell?.mode ?? "allow") as ConstraintMode;
|
||||
const factor =
|
||||
typeof cell?.factor === "number" ? cell.factor : MODE_DEFAULT_FACTOR[mode];
|
||||
out[r][c] = { mode, factor };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeExceptions(raw: unknown): ConstraintException[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((e) => {
|
||||
if (!e || typeof e !== "object") return null;
|
||||
const r = e as Record<string, unknown>;
|
||||
const source = typeof r.source === "string" ? r.source : "";
|
||||
const target = typeof r.target === "string" ? r.target : "";
|
||||
const mode = (typeof r.mode === "string" ? r.mode : "allow") as ConstraintMode;
|
||||
const factor =
|
||||
typeof r.factor === "number" ? r.factor : MODE_DEFAULT_FACTOR[mode] ?? 1;
|
||||
return { source, target, mode, factor } as ConstraintException;
|
||||
})
|
||||
.filter((x): x is ConstraintException => x !== null);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
|
||||
if (props.id) {
|
||||
try {
|
||||
const row = await moduleStore.get(props.id);
|
||||
name.value = row.name;
|
||||
description.value = row.description;
|
||||
categoryId.value = row.category_id;
|
||||
tags.value = row.tags;
|
||||
const p = row.payload as Partial<ConstraintPayload>;
|
||||
sourceWildcardId.value = p.source_wildcard_id ?? null;
|
||||
targetWildcardId.value = p.target_wildcard_id ?? null;
|
||||
matrix.value = normalizeMatrix(p.matrix);
|
||||
exceptions.value = normalizeExceptions(p.exceptions);
|
||||
historyEntries.value = readHistory(row.payload);
|
||||
} catch {
|
||||
toast.push({ severity: "error", summary: "Constraint not found" });
|
||||
router.replace("/constraints");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function onMatrixUpdate(next: ConstraintMatrix) {
|
||||
matrix.value = next;
|
||||
}
|
||||
|
||||
function addException() {
|
||||
exceptions.value.push({ source: "", target: "", mode: "allow", factor: 1 });
|
||||
}
|
||||
function removeException(idx: number) {
|
||||
exceptions.value.splice(idx, 1);
|
||||
}
|
||||
function setExceptionMode(idx: number, mode: ConstraintMode) {
|
||||
const ex = exceptions.value[idx];
|
||||
if (!ex) return;
|
||||
ex.mode = mode;
|
||||
ex.factor = MODE_DEFAULT_FACTOR[mode] ?? 1;
|
||||
}
|
||||
|
||||
function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
name.value = entry.name;
|
||||
description.value = entry.description ?? "";
|
||||
categoryId.value = entry.category_id ?? null;
|
||||
tags.value = entry.tags ? [...entry.tags] : [];
|
||||
const p = (entry.payload ?? {}) as Partial<ConstraintPayload>;
|
||||
sourceWildcardId.value = p.source_wildcard_id ?? null;
|
||||
targetWildcardId.value = p.target_wildcard_id ?? null;
|
||||
matrix.value = normalizeMatrix(p.matrix);
|
||||
exceptions.value = normalizeExceptions(p.exceptions);
|
||||
toast.push({
|
||||
severity: "info",
|
||||
summary: "Version restored",
|
||||
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
|
||||
life: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
toast.push({ severity: "warn", summary: "Name required" });
|
||||
return;
|
||||
}
|
||||
if (!sourceWildcardId.value || !targetWildcardId.value) {
|
||||
toast.push({
|
||||
severity: "warn",
|
||||
summary: "Source and target wildcards are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: ConstraintPayload = {
|
||||
source_wildcard_id: sourceWildcardId.value,
|
||||
target_wildcard_id: targetWildcardId.value,
|
||||
matrix: matrix.value,
|
||||
exceptions: exceptions.value,
|
||||
};
|
||||
const newPayload = payload as unknown as Record<string, unknown>;
|
||||
if (isEdit.value && props.id) {
|
||||
const prev = await moduleStore.get(props.id);
|
||||
const nextHistory = appendSnapshot(
|
||||
{
|
||||
name: prev.name,
|
||||
description: prev.description,
|
||||
category_id: prev.category_id,
|
||||
tags: prev.tags,
|
||||
payload: prev.payload as Record<string, unknown>,
|
||||
},
|
||||
prev.payload as Record<string, unknown>,
|
||||
);
|
||||
await moduleStore.update(props.id, {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
category_id: categoryId.value,
|
||||
tags: tags.value,
|
||||
payload: { ...newPayload, history: nextHistory },
|
||||
});
|
||||
historyEntries.value = nextHistory;
|
||||
} else {
|
||||
await moduleStore.create({
|
||||
type: "constraint",
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
category_id: categoryId.value,
|
||||
tags: tags.value,
|
||||
payload: newPayload,
|
||||
});
|
||||
}
|
||||
toast.push({ severity: "success", summary: "Saved", detail: name.value });
|
||||
router.push("/constraints");
|
||||
} catch (e) {
|
||||
toast.push({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() { router.push("/constraints"); }
|
||||
|
||||
defineExpose({ sourceWildcardId, targetWildcardId, matrix, exceptions, applyRestore });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EditorFrame
|
||||
:title="isEdit ? 'Edit constraint' : 'New constraint'"
|
||||
back-route="/constraints"
|
||||
back-label="Constraints"
|
||||
:saving="saving"
|
||||
:history-entries="historyEntries"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@restore="applyRestore"
|
||||
>
|
||||
<IdentityCard
|
||||
:name="name"
|
||||
:description="description"
|
||||
:category-id="categoryId"
|
||||
:tags="tags"
|
||||
@update:name="(v) => (name = v)"
|
||||
@update:description="(v) => (description = v)"
|
||||
@update:category-id="(v) => (categoryId = v)"
|
||||
@update:tags="(v) => (tags = v)"
|
||||
/>
|
||||
|
||||
<Card title="Wildcards">
|
||||
<template #actions>
|
||||
<span class="wp-dim cn-hint">Pick the two wildcards whose sub-categories form the matrix</span>
|
||||
</template>
|
||||
<div class="cn-pair">
|
||||
<Field label="Target wildcard" hint="Rows of the matrix">
|
||||
<Select
|
||||
:model-value="targetWildcardId"
|
||||
:options="wildcardOptions"
|
||||
placeholder="Pick target"
|
||||
clearable
|
||||
data-test="target-wildcard-select"
|
||||
aria-label="Target wildcard"
|
||||
@update:model-value="(v) => { targetWildcardId = v as string | null; matrix = {}; }"
|
||||
/>
|
||||
</Field>
|
||||
<div class="cn-cross"><i class="pi pi-times" /></div>
|
||||
<Field label="Source wildcard" hint="Columns of the matrix">
|
||||
<Select
|
||||
:model-value="sourceWildcardId"
|
||||
:options="wildcardOptions"
|
||||
placeholder="Pick source"
|
||||
clearable
|
||||
data-test="source-wildcard-select"
|
||||
aria-label="Source wildcard"
|
||||
@update:model-value="(v) => { sourceWildcardId = v as string | null; matrix = {}; }"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Rule matrix">
|
||||
<template #actions>
|
||||
<span class="wp-dim cn-hint">Click cycles · cog tunes factor</span>
|
||||
</template>
|
||||
<div
|
||||
v-if="!sourceWildcardId || !targetWildcardId"
|
||||
class="cn-empty"
|
||||
data-test="matrix-empty"
|
||||
>
|
||||
Pick a source and target wildcard to populate the matrix.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="sourceValues.length === 0 || targetSubCategories.length === 0"
|
||||
class="cn-empty"
|
||||
data-test="matrix-need-subs"
|
||||
>
|
||||
<i class="pi pi-info-circle" />
|
||||
<span v-if="sourceValues.length === 0">Source wildcard needs at least one option value. </span>
|
||||
<span v-if="targetSubCategories.length === 0">Target wildcard needs at least one sub-category. </span>
|
||||
Add them on the wildcard editor to define rules.
|
||||
</div>
|
||||
<ConstraintMatrixGrid
|
||||
v-else
|
||||
:rows="sourceValues"
|
||||
:cols="targetSubCategories"
|
||||
:model-value="matrix"
|
||||
data-test="matrix-grid"
|
||||
@update:model-value="onMatrixUpdate"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card :title="`Exceptions (${exceptions.length})`" :padding="false">
|
||||
<template #actions>
|
||||
<Button size="sm" variant="primary" icon="pi pi-plus" data-test="add-exception" @click="addException">
|
||||
Add exception
|
||||
</Button>
|
||||
</template>
|
||||
<div v-if="!exceptions.length" class="cn-empty">
|
||||
<i class="pi pi-info-circle" />
|
||||
Per-pair overrides for specific option values that the matrix doesn't cover.
|
||||
</div>
|
||||
<table v-else class="wp-table wp-options-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source value</th>
|
||||
<th>Target value</th>
|
||||
<th class="cn-col-mode">Mode</th>
|
||||
<th class="cn-col-factor">Factor</th>
|
||||
<th class="cn-col-trash" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(ex, idx) in exceptions" :key="idx">
|
||||
<td>
|
||||
<Select
|
||||
v-model="ex.source"
|
||||
:options="sourceValues.map((v) => ({ label: v, value: v }))"
|
||||
placeholder="Pick value"
|
||||
aria-label="Exception source value"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Select
|
||||
v-model="ex.target"
|
||||
:options="targetValues.map((v) => ({ label: v, value: v }))"
|
||||
placeholder="Pick value"
|
||||
aria-label="Exception target value"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Select
|
||||
:model-value="ex.mode"
|
||||
:options="MODE_OPTIONS"
|
||||
aria-label="Exception mode"
|
||||
@update:model-value="(v) => setExceptionMode(idx, v as ConstraintMode)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Input
|
||||
v-if="ex.mode === 'boost' || ex.mode === 'reduce'"
|
||||
:model-value="ex.factor"
|
||||
type="number"
|
||||
size="sm"
|
||||
aria-label="Exception factor"
|
||||
@update:model-value="(v) => (ex.factor = Number(v) || 0)"
|
||||
/>
|
||||
<span v-else class="wp-dim wp-mono cn-dash">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pi pi-trash"
|
||||
class="wp-btn--danger"
|
||||
aria-label="Remove exception"
|
||||
@click="removeException(idx)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</EditorFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cn-hint { font-size: 11.5px; }
|
||||
.cn-pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 24px 1fr;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
.cn-cross {
|
||||
padding-bottom: 8px;
|
||||
color: var(--wp-text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
.cn-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 24px 12px;
|
||||
color: var(--wp-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.cn-col-mode { width: 130px; }
|
||||
.cn-col-factor { width: 120px; }
|
||||
.cn-col-trash { width: 40px; }
|
||||
.cn-dash { font-size: 11.5px; }
|
||||
</style>
|
||||
@@ -1,551 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import InputText from "primevue/inputtext";
|
||||
import InputNumber from "primevue/inputnumber";
|
||||
import Textarea from "primevue/textarea";
|
||||
import Select from "primevue/select";
|
||||
import AutoComplete from "primevue/autocomplete";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import ConstraintMatrixGrid from "../components/ConstraintMatrix.vue";
|
||||
import HistoryPanel from "../components/HistoryPanel.vue";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import type {
|
||||
ConstraintCell,
|
||||
ConstraintException,
|
||||
ConstraintMatrix,
|
||||
ConstraintMode,
|
||||
ConstraintPayload,
|
||||
ModuleHistoryEntry,
|
||||
ModuleRow,
|
||||
} from "../api/types";
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
interface WildcardOption { id: string; value: string; weight: number; sub_category?: string | null; }
|
||||
interface WildcardPayloadShape {
|
||||
options?: WildcardOption[];
|
||||
sub_categories?: string[];
|
||||
}
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const categoryId = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const sourceWildcardId = ref<string | null>(null);
|
||||
const targetWildcardId = ref<string | null>(null);
|
||||
const matrix = ref<ConstraintMatrix>({});
|
||||
const exceptions = ref<ConstraintException[]>([]);
|
||||
const saving = ref(false);
|
||||
const isEdit = computed(() => !!props.id);
|
||||
const historyEntries = ref<ModuleHistoryEntry[]>([]);
|
||||
const historyOpen = ref(false);
|
||||
|
||||
const MODE_DEFAULT_FACTOR: Record<ConstraintMode, number> = {
|
||||
allow: 1,
|
||||
exclude: 0,
|
||||
boost: 2,
|
||||
reduce: 0.5,
|
||||
};
|
||||
const MODE_OPTIONS: Array<{ label: string; value: ConstraintMode }> = [
|
||||
{ label: "Allow", value: "allow" },
|
||||
{ label: "Exclude", value: "exclude" },
|
||||
{ label: "Boost", value: "boost" },
|
||||
{ label: "Reduce", value: "reduce" },
|
||||
];
|
||||
|
||||
const wildcardOptions = computed(() =>
|
||||
moduleStore.items
|
||||
.filter((m) => m.type === "wildcard")
|
||||
.map((m) => ({ label: m.name, value: m.id })),
|
||||
);
|
||||
|
||||
function wildcardById(id: string | null): ModuleRow | undefined {
|
||||
if (!id) return undefined;
|
||||
return moduleStore.items.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
const sourceWildcard = computed(() => wildcardById(sourceWildcardId.value));
|
||||
const targetWildcard = computed(() => wildcardById(targetWildcardId.value));
|
||||
|
||||
const sourceValues = computed<string[]>(() => {
|
||||
const wc = sourceWildcard.value;
|
||||
if (!wc) return [];
|
||||
const payload = wc.payload as WildcardPayloadShape;
|
||||
const values = (payload.options ?? [])
|
||||
.map((o) => o.value)
|
||||
.filter((v): v is string => typeof v === "string" && v.length > 0);
|
||||
return Array.from(new Set(values));
|
||||
});
|
||||
|
||||
const targetSubCategories = computed<string[]>(() => {
|
||||
const wc = targetWildcard.value;
|
||||
if (!wc) return [];
|
||||
const payload = wc.payload as WildcardPayloadShape;
|
||||
return payload.sub_categories ?? [];
|
||||
});
|
||||
|
||||
const targetValues = computed<string[]>(() => {
|
||||
const wc = targetWildcard.value;
|
||||
if (!wc) return [];
|
||||
const payload = wc.payload as WildcardPayloadShape;
|
||||
const values = (payload.options ?? [])
|
||||
.map((o) => o.value)
|
||||
.filter((v): v is string => typeof v === "string" && v.length > 0);
|
||||
return Array.from(new Set(values));
|
||||
});
|
||||
|
||||
const tagSuggestions = ref<string[]>([]);
|
||||
function searchTags(event: { query: string }) {
|
||||
const q = event.query.toLowerCase();
|
||||
const known = new Set<string>();
|
||||
for (const m of moduleStore.items) {
|
||||
for (const t of m.tags ?? []) known.add(t);
|
||||
}
|
||||
tagSuggestions.value = Array.from(known)
|
||||
.filter((t) => t.toLowerCase().includes(q) && !tags.value.includes(t))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeMatrix(raw: unknown): ConstraintMatrix {
|
||||
// Tolerate the legacy array-of-arrays shape silently.
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const out: ConstraintMatrix = {};
|
||||
for (const [r, byCol] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!byCol || typeof byCol !== "object") continue;
|
||||
out[r] = {};
|
||||
for (const [c, cellRaw] of Object.entries(byCol as Record<string, unknown>)) {
|
||||
const cell = cellRaw as Partial<ConstraintCell>;
|
||||
const mode = (cell?.mode ?? "allow") as ConstraintMode;
|
||||
const factor =
|
||||
typeof cell?.factor === "number" ? cell.factor : MODE_DEFAULT_FACTOR[mode];
|
||||
out[r][c] = { mode, factor };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeExceptions(raw: unknown): ConstraintException[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((e) => {
|
||||
if (!e || typeof e !== "object") return null;
|
||||
const r = e as Record<string, unknown>;
|
||||
const source = typeof r.source === "string" ? r.source : "";
|
||||
const target = typeof r.target === "string" ? r.target : "";
|
||||
const mode = (typeof r.mode === "string" ? r.mode : "allow") as ConstraintMode;
|
||||
const factor =
|
||||
typeof r.factor === "number" ? r.factor : MODE_DEFAULT_FACTOR[mode] ?? 1;
|
||||
return { source, target, mode, factor } as ConstraintException;
|
||||
})
|
||||
.filter((x): x is ConstraintException => x !== null);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
|
||||
if (props.id) {
|
||||
try {
|
||||
const row = await moduleStore.get(props.id);
|
||||
name.value = row.name;
|
||||
description.value = row.description;
|
||||
categoryId.value = row.category_id;
|
||||
tags.value = row.tags;
|
||||
const p = row.payload as Partial<ConstraintPayload>;
|
||||
sourceWildcardId.value = p.source_wildcard_id ?? null;
|
||||
targetWildcardId.value = p.target_wildcard_id ?? null;
|
||||
matrix.value = normalizeMatrix(p.matrix);
|
||||
exceptions.value = normalizeExceptions(p.exceptions);
|
||||
historyEntries.value = readHistory(row.payload);
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Constraint not found", life: 3000 });
|
||||
router.replace("/constraints");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function onMatrixUpdate(next: ConstraintMatrix) {
|
||||
matrix.value = next;
|
||||
}
|
||||
|
||||
function addException() {
|
||||
exceptions.value.push({ source: "", target: "", mode: "allow", factor: 1 });
|
||||
}
|
||||
function removeException(idx: number) {
|
||||
exceptions.value.splice(idx, 1);
|
||||
}
|
||||
function setExceptionMode(idx: number, mode: ConstraintMode) {
|
||||
const ex = exceptions.value[idx];
|
||||
if (!ex) return;
|
||||
ex.mode = mode;
|
||||
ex.factor = MODE_DEFAULT_FACTOR[mode] ?? 1;
|
||||
}
|
||||
|
||||
function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
name.value = entry.name;
|
||||
description.value = entry.description ?? "";
|
||||
categoryId.value = entry.category_id ?? null;
|
||||
tags.value = entry.tags ? [...entry.tags] : [];
|
||||
const p = (entry.payload ?? {}) as Partial<ConstraintPayload>;
|
||||
sourceWildcardId.value = p.source_wildcard_id ?? null;
|
||||
targetWildcardId.value = p.target_wildcard_id ?? null;
|
||||
matrix.value = normalizeMatrix(p.matrix);
|
||||
exceptions.value = normalizeExceptions(p.exceptions);
|
||||
historyOpen.value = false;
|
||||
toast.add({
|
||||
severity: "info",
|
||||
summary: "Version restored",
|
||||
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
|
||||
life: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
|
||||
return;
|
||||
}
|
||||
if (!sourceWildcardId.value || !targetWildcardId.value) {
|
||||
toast.add({
|
||||
severity: "warn",
|
||||
summary: "Source and target wildcards are required",
|
||||
life: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: ConstraintPayload = {
|
||||
source_wildcard_id: sourceWildcardId.value,
|
||||
target_wildcard_id: targetWildcardId.value,
|
||||
matrix: matrix.value,
|
||||
exceptions: exceptions.value,
|
||||
};
|
||||
const newPayload = payload as unknown as Record<string, unknown>;
|
||||
if (isEdit.value && props.id) {
|
||||
const prev = await moduleStore.get(props.id);
|
||||
const nextHistory = appendSnapshot(
|
||||
{
|
||||
name: prev.name,
|
||||
description: prev.description,
|
||||
category_id: prev.category_id,
|
||||
tags: prev.tags,
|
||||
payload: prev.payload as Record<string, unknown>,
|
||||
},
|
||||
prev.payload as Record<string, unknown>,
|
||||
);
|
||||
await moduleStore.update(props.id, {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
category_id: categoryId.value,
|
||||
tags: tags.value,
|
||||
payload: { ...newPayload, history: nextHistory },
|
||||
});
|
||||
historyEntries.value = nextHistory;
|
||||
} else {
|
||||
await moduleStore.create({
|
||||
type: "constraint",
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
category_id: categoryId.value,
|
||||
tags: tags.value,
|
||||
payload: newPayload,
|
||||
});
|
||||
}
|
||||
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
|
||||
router.push("/constraints");
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ sourceWildcardId, targetWildcardId, matrix, exceptions, historyEntries, historyOpen, applyRestore });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-page__header">
|
||||
<RouterLink to="/constraints" class="text-xs text-wp-text2 hover:text-wp-text">
|
||||
<i class="pi pi-angle-left text-xs" /> Constraints
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-semibold m-0 mt-1">{{ isEdit ? "Edit constraint" : "New constraint" }}</h1>
|
||||
</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">
|
||||
<div>
|
||||
<label for="cn-name" class="block text-xs text-wp-text2 mb-1">Name</label>
|
||||
<InputText id="cn-name" v-model="name" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="cn-cat" class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
id="cn-cat" v-model="categoryId"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="None" show-clear class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="cn-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
|
||||
<Textarea id="cn-desc" v-model="description" rows="2" class="w-full" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="cn-tags" class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<AutoComplete
|
||||
id="cn-tags"
|
||||
v-model="tags"
|
||||
multiple
|
||||
typeahead
|
||||
:suggestions="tagSuggestions"
|
||||
placeholder="Type a tag and press Enter…"
|
||||
class="w-full"
|
||||
@complete="searchTags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- B) Source / Target wildcards -->
|
||||
<section class="form-section">
|
||||
<h2 class="form-section__label">Source & Target wildcards</h2>
|
||||
<p class="text-xs text-wp-text2 mb-2">
|
||||
Source wildcard's resolved value selects a row of the matrix; target wildcard's options are weighted by the matched cell.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="cn-source" class="block text-xs text-wp-text2 mb-1">Source wildcard</label>
|
||||
<Select
|
||||
id="cn-source"
|
||||
v-model="sourceWildcardId"
|
||||
:options="wildcardOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Pick source"
|
||||
show-clear
|
||||
class="w-full"
|
||||
data-test="source-wildcard-select"
|
||||
aria-label="Source wildcard"
|
||||
/>
|
||||
<p class="text-[11px] text-wp-text3 mt-1">
|
||||
Drives matrix rows ({{ sourceValues.length }} value{{ sourceValues.length === 1 ? '' : 's' }}).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cn-target" class="block text-xs text-wp-text2 mb-1">Target wildcard</label>
|
||||
<Select
|
||||
id="cn-target"
|
||||
v-model="targetWildcardId"
|
||||
:options="wildcardOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Pick target"
|
||||
show-clear
|
||||
class="w-full"
|
||||
data-test="target-wildcard-select"
|
||||
aria-label="Target wildcard"
|
||||
/>
|
||||
<p class="text-[11px] text-wp-text3 mt-1">
|
||||
Drives matrix columns ({{ targetSubCategories.length }} sub-categor{{ targetSubCategories.length === 1 ? 'y' : 'ies' }}).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- C) Matrix grid -->
|
||||
<section class="form-section">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="form-section__label m-0">Rule matrix</h2>
|
||||
<span class="text-[11px] text-wp-text3">Click cycles · cog tunes factor</span>
|
||||
</div>
|
||||
<Card>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="!sourceWildcardId || !targetWildcardId"
|
||||
class="text-sm text-wp-text2 py-6 text-center"
|
||||
data-test="matrix-empty"
|
||||
>
|
||||
Pick a source and target wildcard to populate the matrix.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="sourceValues.length === 0 || targetSubCategories.length === 0"
|
||||
class="text-sm text-wp-text2 py-6 text-center"
|
||||
data-test="matrix-need-subs"
|
||||
>
|
||||
<i class="pi pi-info-circle mr-1" />
|
||||
<span v-if="sourceValues.length === 0">Source wildcard needs at least one option value. </span>
|
||||
<span v-if="targetSubCategories.length === 0">Target wildcard needs at least one sub-category. </span>
|
||||
Add them on the wildcard editor to define rules.
|
||||
</div>
|
||||
<ConstraintMatrixGrid
|
||||
v-else
|
||||
:rows="sourceValues"
|
||||
:cols="targetSubCategories"
|
||||
:model-value="matrix"
|
||||
data-test="matrix-grid"
|
||||
@update:model-value="onMatrixUpdate"
|
||||
/>
|
||||
</template>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- D) Exceptions table -->
|
||||
<section class="form-section">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="form-section__label m-0">Exceptions ({{ exceptions.length }})</h2>
|
||||
<Button
|
||||
label="Add exception"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
severity="primary"
|
||||
data-test="add-exception"
|
||||
@click="addException"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-wp-text2 mb-2">
|
||||
Per-pair overrides for specific option values that the matrix doesn't cover.
|
||||
</p>
|
||||
<table class="w-full text-sm border border-wp-border rounded">
|
||||
<thead>
|
||||
<tr class="bg-wp-bg2">
|
||||
<th class="px-3 py-2 text-left text-wp-text2 text-xs uppercase">Source value</th>
|
||||
<th class="px-3 py-2 text-left text-wp-text2 text-xs uppercase">Target value</th>
|
||||
<th class="px-3 py-2 text-left text-wp-text2 text-xs uppercase w-32">Mode</th>
|
||||
<th class="px-3 py-2 text-left text-wp-text2 text-xs uppercase w-28">Factor</th>
|
||||
<th class="w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(ex, idx) in exceptions" :key="idx" class="border-t border-wp-border">
|
||||
<td class="px-3 py-2">
|
||||
<Select
|
||||
v-model="ex.source"
|
||||
:options="sourceValues.map((v) => ({ label: v, value: v }))"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
editable
|
||||
placeholder="Pick value"
|
||||
class="w-full"
|
||||
aria-label="Exception source value"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<Select
|
||||
v-model="ex.target"
|
||||
:options="targetValues.map((v) => ({ label: v, value: v }))"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
editable
|
||||
placeholder="Pick value"
|
||||
class="w-full"
|
||||
aria-label="Exception target value"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2 w-32">
|
||||
<Select
|
||||
:model-value="ex.mode"
|
||||
:options="MODE_OPTIONS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
aria-label="Exception mode"
|
||||
@update:model-value="(v) => setExceptionMode(idx, v as ConstraintMode)"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2 w-28">
|
||||
<InputNumber
|
||||
v-if="ex.mode === 'boost' || ex.mode === 'reduce'"
|
||||
v-model="ex.factor"
|
||||
:min="0.1"
|
||||
:max="10"
|
||||
:step="0.1"
|
||||
:max-fraction-digits="2"
|
||||
show-buttons
|
||||
button-layout="horizontal"
|
||||
size="small"
|
||||
class="w-full"
|
||||
aria-label="Exception factor"
|
||||
/>
|
||||
<span v-else class="text-wp-text3 font-mono text-xs">—</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="danger"
|
||||
aria-label="Remove exception"
|
||||
@click="removeException(idx)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!exceptions.length">
|
||||
<td colspan="5" class="text-center text-wp-text2 py-4">No exceptions yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- E) Footer -->
|
||||
<div class="form-page__footer">
|
||||
<Button
|
||||
v-if="historyEntries.length"
|
||||
:label="`History (${historyEntries.length})`"
|
||||
icon="pi pi-history"
|
||||
severity="secondary"
|
||||
outlined
|
||||
data-test="history-btn"
|
||||
@click="historyOpen = true"
|
||||
/>
|
||||
<div class="form-page__footer-spacer" />
|
||||
<Button label="Cancel" severity="secondary" outlined @click="router.push('/constraints')" />
|
||||
<Button
|
||||
label="Save"
|
||||
icon="pi pi-check"
|
||||
severity="primary"
|
||||
:loading="saving"
|
||||
data-test="save-btn"
|
||||
@click="save"
|
||||
/>
|
||||
</div>
|
||||
<HistoryPanel
|
||||
:open="historyOpen"
|
||||
:entries="historyEntries"
|
||||
@update:open="(v) => (historyOpen = v)"
|
||||
@restore="applyRestore"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
|
||||
.form-page__header { padding: 24px 24px 0; }
|
||||
.form-page__body { padding: 16px 24px 96px; max-width: 64rem; flex: 1; }
|
||||
.form-page__footer {
|
||||
position: sticky; bottom: 0;
|
||||
background: var(--wp-bg);
|
||||
border-top: 1px solid var(--wp-border);
|
||||
padding: 12px 24px;
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
}
|
||||
.form-page__footer-spacer { flex: 1; }
|
||||
.form-section { margin-bottom: 24px; }
|
||||
.form-section__label {
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--wp-text2);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DerivationEditor — Wave 4 port of `DerivationEditor` in `screens/editors.jsx`.
|
||||
*
|
||||
* Sections:
|
||||
* 1. Identity
|
||||
* 2. Rules list (DerivationRuleCard, with add/remove)
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Textarea from "primevue/textarea";
|
||||
import Select from "primevue/select";
|
||||
import AutoComplete from "primevue/autocomplete";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import EditorFrame from "../components/EditorFrame.vue";
|
||||
import IdentityCard from "../components/IdentityCard.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import DerivationRuleCard from "../components/DerivationRuleCard.vue";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import DerivationRuleCard from "../components/DerivationRuleCard.vue";
|
||||
import HistoryPanel from "../components/HistoryPanel.vue";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import type {
|
||||
DerivationAction,
|
||||
@@ -36,25 +40,9 @@ const rules = ref<DerivationRule[]>([]);
|
||||
const saving = ref(false);
|
||||
const isEdit = computed(() => !!props.id);
|
||||
const historyEntries = ref<ModuleHistoryEntry[]>([]);
|
||||
const historyOpen = ref(false);
|
||||
|
||||
// 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();
|
||||
const known = new Set<string>();
|
||||
for (const m of moduleStore.items) {
|
||||
for (const t of m.tags ?? []) known.add(t);
|
||||
}
|
||||
tagSuggestions.value = Array.from(known)
|
||||
.filter((t) => t.toLowerCase().includes(q) && !tags.value.includes(t))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
let ruleSeq = 0;
|
||||
function newRuleId(): string {
|
||||
ruleSeq += 1;
|
||||
@@ -85,8 +73,6 @@ function migrateRule(raw: unknown): DerivationRule {
|
||||
: [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)
|
||||
@@ -145,7 +131,7 @@ onMounted(async () => {
|
||||
rules.value = incoming.length ? incoming.map(migrateRule) : [];
|
||||
historyEntries.value = readHistory(row.payload);
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Derivation not found", life: 3000 });
|
||||
toast.push({ severity: "error", summary: "Derivation not found" });
|
||||
router.replace("/derivations");
|
||||
}
|
||||
}
|
||||
@@ -154,11 +140,9 @@ 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));
|
||||
}
|
||||
@@ -171,8 +155,7 @@ function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
const p = (entry.payload ?? {}) as Partial<DerivationPayload>;
|
||||
const incoming = Array.isArray(p.rules) ? p.rules : [];
|
||||
rules.value = incoming.length ? incoming.map(migrateRule) : [];
|
||||
historyOpen.value = false;
|
||||
toast.add({
|
||||
toast.push({
|
||||
severity: "info",
|
||||
summary: "Version restored",
|
||||
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
|
||||
@@ -182,7 +165,7 @@ function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
|
||||
toast.push({ severity: "warn", summary: "Name required" });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
@@ -219,145 +202,85 @@ async function save() {
|
||||
payload: newPayload,
|
||||
});
|
||||
}
|
||||
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
|
||||
toast.push({ severity: "success", summary: "Saved", detail: name.value });
|
||||
router.push("/derivations");
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
toast.push({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ rules, addRule, removeRule, historyEntries, historyOpen, applyRestore });
|
||||
function cancel() { router.push("/derivations"); }
|
||||
|
||||
defineExpose({ rules, addRule, removeRule, applyRestore });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-page__header">
|
||||
<RouterLink to="/derivations" class="text-xs text-wp-text2 hover:text-wp-text">
|
||||
<i class="pi pi-angle-left text-xs" /> Derivations
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-semibold m-0 mt-1">{{ isEdit ? "Edit derivation" : "New derivation" }}</h1>
|
||||
</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">
|
||||
<div>
|
||||
<label for="dv-name" class="block text-xs text-wp-text2 mb-1">Name</label>
|
||||
<InputText id="dv-name" v-model="name" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="dv-cat" class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
id="dv-cat" v-model="categoryId"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="None" show-clear class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="dv-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
|
||||
<Textarea id="dv-desc" v-model="description" rows="2" class="w-full" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="dv-tags" class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<AutoComplete
|
||||
id="dv-tags"
|
||||
v-model="tags"
|
||||
multiple
|
||||
typeahead
|
||||
:suggestions="tagSuggestions"
|
||||
placeholder="Type a tag and press Enter…"
|
||||
class="w-full"
|
||||
@complete="searchTags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- B) Rules -->
|
||||
<section class="form-section">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div class="form-page__footer">
|
||||
<Button
|
||||
v-if="historyEntries.length"
|
||||
:label="`History (${historyEntries.length})`"
|
||||
icon="pi pi-history"
|
||||
severity="secondary"
|
||||
outlined
|
||||
data-test="history-btn"
|
||||
@click="historyOpen = true"
|
||||
/>
|
||||
<div class="form-page__footer-spacer" />
|
||||
<Button label="Cancel" severity="secondary" outlined @click="router.push('/derivations')" />
|
||||
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
|
||||
</div>
|
||||
<HistoryPanel
|
||||
:open="historyOpen"
|
||||
:entries="historyEntries"
|
||||
@update:open="(v) => (historyOpen = v)"
|
||||
@restore="applyRestore"
|
||||
<EditorFrame
|
||||
:title="isEdit ? 'Edit derivation' : 'New derivation'"
|
||||
back-route="/derivations"
|
||||
back-label="Derivations"
|
||||
:saving="saving"
|
||||
:history-entries="historyEntries"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@restore="applyRestore"
|
||||
>
|
||||
<IdentityCard
|
||||
:name="name"
|
||||
:description="description"
|
||||
:category-id="categoryId"
|
||||
:tags="tags"
|
||||
@update:name="(v) => (name = v)"
|
||||
@update:description="(v) => (description = v)"
|
||||
@update:category-id="(v) => (categoryId = v)"
|
||||
@update:tags="(v) => (tags = v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card :title="`Rules (${rules.length})`">
|
||||
<template #actions>
|
||||
<Button size="sm" variant="primary" icon="pi pi-plus" data-test="add-rule" @click="addRule">
|
||||
Add rule
|
||||
</Button>
|
||||
</template>
|
||||
<p class="dv-hint">
|
||||
Each rule runs independently. Inside a rule, branches evaluate top-to-bottom — the first matching IF/ELIF wins; the optional ELSE only fires when no branch matched.
|
||||
</p>
|
||||
|
||||
<div v-if="rules.length === 0" class="dv-empty" data-test="rules-empty">
|
||||
No rules yet. Click <strong>Add rule</strong> to start defining IF / ELIF / ELSE behaviour.
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Card>
|
||||
</EditorFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
|
||||
.form-page__header { padding: 24px 24px 0; }
|
||||
.form-page__body { padding: 16px 24px 96px; max-width: 56rem; flex: 1; }
|
||||
.form-page__footer {
|
||||
position: sticky; bottom: 0;
|
||||
background: var(--wp-bg);
|
||||
border-top: 1px solid var(--wp-border);
|
||||
padding: 12px 24px;
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
.dv-hint {
|
||||
font-size: 11.5px;
|
||||
color: var(--wp-text-dim);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.form-page__footer-spacer { flex: 1; }
|
||||
.form-section { margin-bottom: 24px; }
|
||||
.form-section__label {
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--wp-text2);
|
||||
margin: 0 0 8px 0;
|
||||
.dv-empty {
|
||||
font-size: 13px;
|
||||
color: var(--wp-text-muted);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
}
|
||||
.rules-stack {
|
||||
display: flex;
|
||||
@@ -0,0 +1,276 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* FixedEditor — Wave 4 port of `FixedValuesEditor` in `screens/editors.jsx`.
|
||||
*
|
||||
* Sections:
|
||||
* 1. Identity
|
||||
* 2. Values rows ($name + value Textarea + remove)
|
||||
*
|
||||
* Validation enforces unique, identifier-clean `$name` per row.
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import EditorFrame from "../components/EditorFrame.vue";
|
||||
import IdentityCard from "../components/IdentityCard.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import Textarea from "../components/ui/Textarea.vue";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import type { ModuleHistoryEntry } from "../api/types";
|
||||
|
||||
interface NamedValue { id: string; name: string; value: string; }
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const categoryId = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const values = ref<NamedValue[]>([
|
||||
{ id: `val_${Math.random().toString(16).slice(2, 8)}`, name: "", value: "" },
|
||||
{ id: `val_${Math.random().toString(16).slice(2, 8)}`, name: "", value: "" },
|
||||
]);
|
||||
const saving = ref(false);
|
||||
const isEdit = computed(() => !!props.id);
|
||||
const historyEntries = ref<ModuleHistoryEntry[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
|
||||
if (props.id) {
|
||||
try {
|
||||
const row = await moduleStore.get(props.id);
|
||||
name.value = row.name;
|
||||
description.value = row.description;
|
||||
categoryId.value = row.category_id;
|
||||
tags.value = row.tags;
|
||||
const rows = (row.payload as { values?: NamedValue[] }).values ?? [];
|
||||
values.value = rows.map((v) => ({
|
||||
id: v.id,
|
||||
name: (v.name ?? "").replace(/^\$+/, ""),
|
||||
value: v.value ?? "",
|
||||
}));
|
||||
historyEntries.value = readHistory(row.payload);
|
||||
} catch {
|
||||
toast.push({ severity: "error", summary: "Module not found" });
|
||||
router.replace("/fixed-values");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function addValue() {
|
||||
values.value.push({
|
||||
id: `val_${Math.random().toString(16).slice(2, 8)}`,
|
||||
name: "",
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
function removeValue(idx: number) { values.value.splice(idx, 1); }
|
||||
|
||||
function onVarInput(idx: number, raw: string) {
|
||||
values.value[idx].name = (raw ?? "").replace(/[^a-zA-Z0-9_]/g, "");
|
||||
}
|
||||
|
||||
const rowErrors = computed<string[]>(() => {
|
||||
const errs: string[] = [];
|
||||
const counts = new Map<string, number>();
|
||||
for (const v of values.value) {
|
||||
const n = v.name.trim();
|
||||
counts.set(n, (counts.get(n) ?? 0) + 1);
|
||||
}
|
||||
for (const v of values.value) {
|
||||
const n = v.name.trim();
|
||||
if (!n) { errs.push("Required"); continue; }
|
||||
if (!VALID_IDENTIFIER_RE.test(n)) { errs.push("Invalid identifier"); continue; }
|
||||
if ((counts.get(n) ?? 0) > 1) { errs.push("Duplicate name"); continue; }
|
||||
errs.push("");
|
||||
}
|
||||
return errs;
|
||||
});
|
||||
const hasRowErrors = computed(() => rowErrors.value.some((e) => e !== ""));
|
||||
|
||||
function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
name.value = entry.name;
|
||||
description.value = entry.description ?? "";
|
||||
categoryId.value = entry.category_id ?? null;
|
||||
tags.value = entry.tags ? [...entry.tags] : [];
|
||||
const rows = ((entry.payload ?? {}) as { values?: NamedValue[] }).values ?? [];
|
||||
values.value = rows.map((v) => ({
|
||||
id: v.id,
|
||||
name: (v.name ?? "").replace(/^\$+/, ""),
|
||||
value: v.value ?? "",
|
||||
}));
|
||||
toast.push({
|
||||
severity: "info",
|
||||
summary: "Version restored",
|
||||
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
|
||||
life: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
toast.push({ severity: "warn", summary: "Name required" });
|
||||
return;
|
||||
}
|
||||
if (hasRowErrors.value) {
|
||||
toast.push({
|
||||
severity: "warn",
|
||||
summary: "Fix invalid value rows",
|
||||
detail: "Each row needs a unique, valid `$name` identifier.",
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = { values: values.value } as Record<string, unknown>;
|
||||
if (isEdit.value && props.id) {
|
||||
const prev = await moduleStore.get(props.id);
|
||||
const nextHistory = appendSnapshot(
|
||||
{
|
||||
name: prev.name,
|
||||
description: prev.description,
|
||||
category_id: prev.category_id,
|
||||
tags: prev.tags,
|
||||
payload: prev.payload as Record<string, unknown>,
|
||||
},
|
||||
prev.payload as Record<string, unknown>,
|
||||
);
|
||||
await moduleStore.update(props.id, {
|
||||
name: name.value, description: description.value,
|
||||
category_id: categoryId.value, tags: tags.value,
|
||||
payload: { ...payload, history: nextHistory },
|
||||
});
|
||||
historyEntries.value = nextHistory;
|
||||
} else {
|
||||
await moduleStore.create({
|
||||
type: "fixed_values",
|
||||
name: name.value, description: description.value,
|
||||
category_id: categoryId.value, tags: tags.value, payload,
|
||||
});
|
||||
}
|
||||
toast.push({ severity: "success", summary: "Saved", detail: name.value });
|
||||
router.push("/fixed-values");
|
||||
} catch (e) {
|
||||
toast.push({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() { router.push("/fixed-values"); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EditorFrame
|
||||
:title="isEdit ? 'Edit fixed values' : 'New fixed values'"
|
||||
back-route="/fixed-values"
|
||||
back-label="Fixed Values"
|
||||
:saving="saving"
|
||||
:history-entries="historyEntries"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@restore="applyRestore"
|
||||
>
|
||||
<IdentityCard
|
||||
:name="name"
|
||||
:description="description"
|
||||
:category-id="categoryId"
|
||||
:tags="tags"
|
||||
@update:name="(v) => (name = v)"
|
||||
@update:description="(v) => (description = v)"
|
||||
@update:category-id="(v) => (categoryId = v)"
|
||||
@update:tags="(v) => (tags = v)"
|
||||
/>
|
||||
|
||||
<Card :title="`Values (${values.length})`" :padding="false">
|
||||
<template #actions>
|
||||
<Button size="sm" variant="primary" icon="pi pi-plus" data-test="fv-add" @click="addValue">
|
||||
Add value
|
||||
</Button>
|
||||
</template>
|
||||
<table class="wp-table wp-options-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="fv-col-var">Variable</th>
|
||||
<th>Value</th>
|
||||
<th class="fv-col-trash" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(v, idx) in values"
|
||||
:key="v.id"
|
||||
:data-test="`fv-row-${idx}`"
|
||||
:data-invalid="rowErrors[idx] ? 'true' : 'false'"
|
||||
>
|
||||
<td>
|
||||
<div class="wp-input-group">
|
||||
<span class="wp-input-group__addon">$</span>
|
||||
<input
|
||||
class="wp-input"
|
||||
:value="v.name"
|
||||
placeholder="varname"
|
||||
aria-label="Variable name"
|
||||
:data-test="`fv-row-${idx}-name`"
|
||||
@input="onVarInput(idx, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="rowErrors[idx]"
|
||||
class="fv-row__err"
|
||||
:data-test="`fv-row-${idx}-err`"
|
||||
>{{ rowErrors[idx] }}</p>
|
||||
</td>
|
||||
<td>
|
||||
<Textarea
|
||||
v-model="v.value"
|
||||
:rows="2"
|
||||
auto-resize
|
||||
placeholder="value"
|
||||
aria-label="Variable value"
|
||||
:data-test="`fv-row-${idx}-value`"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pi pi-trash"
|
||||
class="wp-btn--danger"
|
||||
aria-label="Remove value"
|
||||
@click="removeValue(idx)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!values.length">
|
||||
<td colspan="3" class="opt-empty">No values yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</EditorFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fv-col-var { width: 220px; }
|
||||
.fv-col-trash { width: 40px; }
|
||||
.fv-row__err {
|
||||
font-size: 11px;
|
||||
color: var(--wp-danger);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.opt-empty {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: var(--wp-text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -1,401 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Button from "primevue/button";
|
||||
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 HistoryPanel from "../components/HistoryPanel.vue";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import type { ModuleHistoryEntry } from "../api/types";
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
interface NamedValue { id: string; name: string; value: string; }
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const categoryId = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const values = ref<NamedValue[]>([]);
|
||||
const saving = ref(false);
|
||||
const isEdit = computed(() => !!props.id);
|
||||
const historyEntries = ref<ModuleHistoryEntry[]>([]);
|
||||
const historyOpen = ref(false);
|
||||
|
||||
// ----- Tag autocomplete (mirrors WildcardForm) -----
|
||||
const tagSuggestions = ref<string[]>([]);
|
||||
function searchTags(event: { query: string }) {
|
||||
const q = event.query.toLowerCase();
|
||||
const known = new Set<string>();
|
||||
for (const m of moduleStore.items) {
|
||||
for (const t of m.tags ?? []) known.add(t);
|
||||
}
|
||||
tagSuggestions.value = Array.from(known)
|
||||
.filter((t) => t.toLowerCase().includes(q) && !tags.value.includes(t))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
|
||||
if (props.id) {
|
||||
try {
|
||||
const row = await moduleStore.get(props.id);
|
||||
name.value = row.name;
|
||||
description.value = row.description;
|
||||
categoryId.value = row.category_id;
|
||||
tags.value = row.tags;
|
||||
const rows = (row.payload as { values?: NamedValue[] }).values ?? [];
|
||||
// Strip any leading "$" that might have been persisted by older shapes —
|
||||
// the variable name is rendered as `$name` via the InputGroupAddon.
|
||||
values.value = rows.map((v) => ({
|
||||
id: v.id,
|
||||
name: (v.name ?? "").replace(/^\$+/, ""),
|
||||
value: v.value ?? "",
|
||||
}));
|
||||
historyEntries.value = readHistory(row.payload);
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Module not found", life: 3000 });
|
||||
router.replace("/fixed-values");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function addValue() {
|
||||
const id = `val_${Math.random().toString(16).slice(2, 8)}`;
|
||||
values.value.push({ id, name: "", value: "" });
|
||||
}
|
||||
function removeValue(idx: number) { values.value.splice(idx, 1); }
|
||||
|
||||
/**
|
||||
* Sanitise a var-name on input — drop everything outside the identifier
|
||||
* charset so users can paste sloppy text and not end up with bindings the
|
||||
* engine tokenizer rejects. Mirrors the prototype's
|
||||
* `e.target.value.replace(/[^a-zA-Z0-9_]/g, "")`.
|
||||
*/
|
||||
function onVarInput(idx: number, raw: string | null | undefined) {
|
||||
const cleaned = (raw ?? "").replace(/[^a-zA-Z0-9_]/g, "");
|
||||
values.value[idx].name = cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-row validity used to drive the inline error chip and the save guard.
|
||||
* - empty → "Required"
|
||||
* - bad ident → "Invalid identifier"
|
||||
* - duplicate → "Duplicate name"
|
||||
* - otherwise → ""
|
||||
*/
|
||||
const rowErrors = computed<string[]>(() => {
|
||||
const errs: string[] = [];
|
||||
const counts = new Map<string, number>();
|
||||
for (const v of values.value) {
|
||||
const n = v.name.trim();
|
||||
counts.set(n, (counts.get(n) ?? 0) + 1);
|
||||
}
|
||||
for (const v of values.value) {
|
||||
const n = v.name.trim();
|
||||
if (!n) { errs.push("Required"); continue; }
|
||||
if (!VALID_IDENTIFIER_RE.test(n)) { errs.push("Invalid identifier"); continue; }
|
||||
if ((counts.get(n) ?? 0) > 1) { errs.push("Duplicate name"); continue; }
|
||||
errs.push("");
|
||||
}
|
||||
return errs;
|
||||
});
|
||||
|
||||
const hasRowErrors = computed(() => rowErrors.value.some((e) => e !== ""));
|
||||
|
||||
function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
name.value = entry.name;
|
||||
description.value = entry.description ?? "";
|
||||
categoryId.value = entry.category_id ?? null;
|
||||
tags.value = entry.tags ? [...entry.tags] : [];
|
||||
const rows = ((entry.payload ?? {}) as { values?: NamedValue[] }).values ?? [];
|
||||
values.value = rows.map((v) => ({
|
||||
id: v.id,
|
||||
name: (v.name ?? "").replace(/^\$+/, ""),
|
||||
value: v.value ?? "",
|
||||
}));
|
||||
historyOpen.value = false;
|
||||
toast.add({
|
||||
severity: "info",
|
||||
summary: "Version restored",
|
||||
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
|
||||
life: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
|
||||
return;
|
||||
}
|
||||
if (hasRowErrors.value) {
|
||||
toast.add({
|
||||
severity: "warn",
|
||||
summary: "Fix invalid value rows",
|
||||
detail: "Each row needs a unique, valid `$name` identifier.",
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = { values: values.value } as Record<string, unknown>;
|
||||
if (isEdit.value && props.id) {
|
||||
const prev = await moduleStore.get(props.id);
|
||||
const nextHistory = appendSnapshot(
|
||||
{
|
||||
name: prev.name,
|
||||
description: prev.description,
|
||||
category_id: prev.category_id,
|
||||
tags: prev.tags,
|
||||
payload: prev.payload as Record<string, unknown>,
|
||||
},
|
||||
prev.payload as Record<string, unknown>,
|
||||
);
|
||||
await moduleStore.update(props.id, {
|
||||
name: name.value, description: description.value,
|
||||
category_id: categoryId.value, tags: tags.value,
|
||||
payload: { ...payload, history: nextHistory },
|
||||
});
|
||||
historyEntries.value = nextHistory;
|
||||
} else {
|
||||
await moduleStore.create({
|
||||
type: "fixed_values",
|
||||
name: name.value, description: description.value,
|
||||
category_id: categoryId.value, tags: tags.value, payload,
|
||||
});
|
||||
}
|
||||
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
|
||||
router.push("/fixed-values");
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-page__header">
|
||||
<RouterLink to="/fixed-values" class="text-xs text-wp-text2 hover:text-wp-text">
|
||||
<i class="pi pi-angle-left text-xs" /> Fixed Values
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-semibold m-0 mt-1">{{ isEdit ? "Edit fixed values" : "New fixed values" }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-page__body">
|
||||
<section class="form-section">
|
||||
<h2 class="form-section__label">Identity</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="fv-name" class="block text-xs text-wp-text2 mb-1">Name</label>
|
||||
<InputText id="fv-name" v-model="name" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="fv-cat" class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
id="fv-cat" v-model="categoryId"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="None" show-clear class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="fv-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
|
||||
<Textarea id="fv-desc" v-model="description" rows="2" class="w-full" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="fv-tags" class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<AutoComplete
|
||||
id="fv-tags"
|
||||
v-model="tags"
|
||||
multiple
|
||||
typeahead
|
||||
:suggestions="tagSuggestions"
|
||||
placeholder="Type a tag and press Enter…"
|
||||
class="w-full"
|
||||
@complete="searchTags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="form-section__label m-0">Values ({{ values.length }})</h2>
|
||||
</div>
|
||||
<p class="text-xs text-wp-text2 mb-2">
|
||||
Each row binds a literal string to <code>$name</code>. Names must match
|
||||
<code>[a-zA-Z_][a-zA-Z0-9_]*</code> and be unique within this module.
|
||||
</p>
|
||||
|
||||
<div v-if="values.length" class="fv-rows">
|
||||
<div
|
||||
v-for="(v, idx) in values"
|
||||
:key="v.id"
|
||||
class="fv-row"
|
||||
:data-test="`fv-row-${idx}`"
|
||||
:data-invalid="rowErrors[idx] ? 'true' : 'false'"
|
||||
>
|
||||
<div class="fv-row__var">
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-dollar" /></InputGroupAddon>
|
||||
<InputText
|
||||
:model-value="v.name"
|
||||
:data-test="`fv-row-${idx}-name`"
|
||||
:class="{ 'p-invalid': !!rowErrors[idx] }"
|
||||
placeholder="varname"
|
||||
aria-label="Variable name"
|
||||
@update:model-value="(val) => onVarInput(idx, val)"
|
||||
/>
|
||||
</InputGroup>
|
||||
<p
|
||||
v-if="rowErrors[idx]"
|
||||
class="fv-row__err"
|
||||
:data-test="`fv-row-${idx}-err`"
|
||||
>
|
||||
{{ rowErrors[idx] }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="fv-row__value">
|
||||
<Textarea
|
||||
v-model="v.value"
|
||||
:rows="2"
|
||||
auto-resize
|
||||
class="w-full"
|
||||
placeholder="value"
|
||||
aria-label="Variable value"
|
||||
:data-test="`fv-row-${idx}-value`"
|
||||
/>
|
||||
</div>
|
||||
<div class="fv-row__remove">
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text rounded size="small" severity="danger"
|
||||
aria-label="Remove value"
|
||||
@click="removeValue(idx)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="fv-add"
|
||||
data-test="fv-add"
|
||||
@click="addValue"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<span>Add value</span>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="form-page__footer">
|
||||
<Button
|
||||
v-if="historyEntries.length"
|
||||
:label="`History (${historyEntries.length})`"
|
||||
icon="pi pi-history"
|
||||
severity="secondary"
|
||||
outlined
|
||||
data-test="history-btn"
|
||||
@click="historyOpen = true"
|
||||
/>
|
||||
<div class="form-page__footer-spacer" />
|
||||
<Button label="Cancel" severity="secondary" outlined @click="router.push('/fixed-values')" />
|
||||
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
|
||||
</div>
|
||||
<HistoryPanel
|
||||
:open="historyOpen"
|
||||
:entries="historyEntries"
|
||||
@update:open="(v) => (historyOpen = v)"
|
||||
@restore="applyRestore"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
|
||||
.form-page__header { padding: 24px 24px 0; }
|
||||
.form-page__body { padding: 16px 24px 96px; max-width: 56rem; flex: 1; }
|
||||
.form-page__footer {
|
||||
position: sticky; bottom: 0;
|
||||
background: var(--wp-bg);
|
||||
border-top: 1px solid var(--wp-border);
|
||||
padding: 12px 24px;
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
}
|
||||
.form-page__footer-spacer { flex: 1; }
|
||||
.form-section { margin-bottom: 24px; }
|
||||
.form-section__label {
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--wp-text2);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
/* Per-row layout: $name | value textarea | trash. */
|
||||
.fv-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.fv-row {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr 36px;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 6px;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.fv-row[data-invalid="true"] {
|
||||
border-color: var(--wp-danger, #dc2626);
|
||||
}
|
||||
.fv-row__var :deep(.p-inputtext) { width: 100%; }
|
||||
.fv-row__err {
|
||||
font-size: 11px;
|
||||
color: var(--wp-danger, #dc2626);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.fv-row__remove {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* Dashed "Add value" button matching prototype affordance. */
|
||||
.fv-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed var(--wp-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--wp-text2);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.fv-add:hover {
|
||||
background: var(--wp-bg2);
|
||||
border-color: var(--wp-text2);
|
||||
color: var(--wp-text);
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* PipelineEditor — Wave 4 port of `PipelineEditor` in `screens/editors.jsx`.
|
||||
*
|
||||
* Sections:
|
||||
* 1. Identity
|
||||
* 2. Steps stack (PipelineSteps + PipelineStepPicker modal)
|
||||
* 3. Resolution preview (synthetic flow)
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Textarea from "primevue/textarea";
|
||||
import Select from "primevue/select";
|
||||
import AutoComplete from "primevue/autocomplete";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import EditorFrame from "../components/EditorFrame.vue";
|
||||
import IdentityCard from "../components/IdentityCard.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import PipelineSteps from "../components/PipelineSteps.vue";
|
||||
import PipelineStepPicker from "../components/PipelineStepPicker.vue";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import { api } from "../api/client";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import PipelineSteps from "../components/PipelineSteps.vue";
|
||||
import PipelineStepPicker from "../components/PipelineStepPicker.vue";
|
||||
import HistoryPanel from "../components/HistoryPanel.vue";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import type {
|
||||
CombinePayload,
|
||||
DerivationPayload,
|
||||
ConstraintPayload,
|
||||
DerivationPayload,
|
||||
ModuleHistoryEntry,
|
||||
ModuleRow,
|
||||
ModuleType,
|
||||
@@ -40,9 +44,7 @@ const steps = ref<PipelineStep[]>([]);
|
||||
const saving = ref(false);
|
||||
const pickerOpen = ref(false);
|
||||
const historyEntries = ref<ModuleHistoryEntry[]>([]);
|
||||
const historyOpen = ref(false);
|
||||
|
||||
// All modules (cross-kind) used by step rows + picker.
|
||||
const allModules = ref<ModuleRow[]>([]);
|
||||
const isEdit = computed(() => !!props.id);
|
||||
|
||||
@@ -64,27 +66,12 @@ const modulesByKind = computed(() => {
|
||||
return groups;
|
||||
});
|
||||
|
||||
const tagSuggestions = ref<string[]>([]);
|
||||
function searchTags(event: { query: string }) {
|
||||
const q = event.query.toLowerCase();
|
||||
const known = new Set<string>();
|
||||
for (const m of moduleStore.items) {
|
||||
for (const t of m.tags ?? []) known.add(t);
|
||||
}
|
||||
for (const m of allModules.value) {
|
||||
for (const t of m.tags ?? []) known.add(t);
|
||||
}
|
||||
tagSuggestions.value = Array.from(known)
|
||||
.filter((t) => t.toLowerCase().includes(q) && !tags.value.includes(t))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
async function loadAllModules() {
|
||||
try {
|
||||
const res = await api.modules.list({});
|
||||
allModules.value = res.items;
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Failed to load modules", detail: String(e), life: 3000 });
|
||||
toast.push({ severity: "error", summary: "Failed to load modules", detail: String(e), life: 3000 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +93,7 @@ onMounted(async () => {
|
||||
}));
|
||||
historyEntries.value = readHistory(row.payload);
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Pipeline not found", life: 3000 });
|
||||
toast.push({ severity: "error", summary: "Pipeline not found" });
|
||||
router.replace("/pipelines");
|
||||
}
|
||||
}
|
||||
@@ -126,8 +113,6 @@ function onPick(mod: ModuleRow) {
|
||||
pickerOpen.value = false;
|
||||
}
|
||||
|
||||
// ------ Resolution preview helpers ----------------------------------
|
||||
|
||||
interface PreviewBinding {
|
||||
text: string;
|
||||
isMutation?: boolean;
|
||||
@@ -220,8 +205,7 @@ function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
enabled: s.enabled !== false,
|
||||
...(s.instance ? { instance: s.instance } : {}),
|
||||
}));
|
||||
historyOpen.value = false;
|
||||
toast.add({
|
||||
toast.push({
|
||||
severity: "info",
|
||||
summary: "Version restored",
|
||||
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
|
||||
@@ -231,7 +215,7 @@ function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
|
||||
toast.push({ severity: "warn", summary: "Name required" });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
@@ -275,165 +259,90 @@ async function save() {
|
||||
payload: newPayload,
|
||||
});
|
||||
}
|
||||
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
|
||||
toast.push({ severity: "success", summary: "Saved", detail: name.value });
|
||||
router.push("/pipelines");
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
toast.push({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() { router.push("/pipelines"); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-page__header">
|
||||
<RouterLink to="/pipelines" class="text-xs text-wp-text2 hover:text-wp-text">
|
||||
<i class="pi pi-angle-left text-xs" /> Pipelines
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-semibold m-0 mt-1">{{ isEdit ? "Edit pipeline" : "New pipeline" }}</h1>
|
||||
</div>
|
||||
<EditorFrame
|
||||
:title="isEdit ? 'Edit pipeline' : 'New pipeline'"
|
||||
back-route="/pipelines"
|
||||
back-label="Pipelines"
|
||||
:saving="saving"
|
||||
:history-entries="historyEntries"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@restore="applyRestore"
|
||||
>
|
||||
<IdentityCard
|
||||
:name="name"
|
||||
:description="description"
|
||||
:category-id="categoryId"
|
||||
:tags="tags"
|
||||
@update:name="(v) => (name = v)"
|
||||
@update:description="(v) => (description = v)"
|
||||
@update:category-id="(v) => (categoryId = v)"
|
||||
@update:tags="(v) => (tags = v)"
|
||||
/>
|
||||
|
||||
<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">
|
||||
<div>
|
||||
<label for="pl-name" class="block text-xs text-wp-text2 mb-1">Name</label>
|
||||
<InputText id="pl-name" v-model="name" class="w-full" />
|
||||
<Card :title="`Modules (${steps.length})`">
|
||||
<template #actions>
|
||||
<span class="wp-dim pl-hint">Resolve top to bottom — each appends to context</span>
|
||||
</template>
|
||||
<PipelineSteps
|
||||
:steps="steps"
|
||||
:modules-by-id="modulesById"
|
||||
:modules-by-kind="modulesByKind"
|
||||
@update:steps="onStepsUpdate"
|
||||
@open-picker="pickerOpen = true"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card v-if="preview.length" title="Resolution preview">
|
||||
<template #actions>
|
||||
<span class="wp-dim pl-hint">Synthetic example — picks first option per wildcard</span>
|
||||
</template>
|
||||
<div class="wp-pl-flow" data-test="pipeline-preview">
|
||||
<div
|
||||
v-for="ps in preview" :key="ps.index"
|
||||
class="wp-pl-flow__step"
|
||||
:data-kind="ps.kind ?? 'unknown'"
|
||||
>
|
||||
<div class="wp-pl-flow__head">
|
||||
<span class="wp-pl-flow__idx">{{ String(ps.index + 1).padStart(2, "0") }}</span>
|
||||
<i :class="previewKindIcon(ps.kind)" class="wp-pl-flow__icon" aria-hidden="true" />
|
||||
<span class="wp-pl-flow__name">{{ ps.name }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label for="pl-cat" class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
id="pl-cat" v-model="categoryId"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="None" show-clear class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="pl-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
|
||||
<Textarea id="pl-desc" v-model="description" rows="2" class="w-full" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="pl-tags" class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<AutoComplete
|
||||
id="pl-tags"
|
||||
v-model="tags"
|
||||
multiple
|
||||
typeahead
|
||||
:suggestions="tagSuggestions"
|
||||
placeholder="Type a tag and press Enter…"
|
||||
class="w-full"
|
||||
@complete="searchTags"
|
||||
/>
|
||||
<div v-if="ps.bindings.length" class="wp-pl-flow__adds">
|
||||
<div
|
||||
v-for="(b, j) in ps.bindings" :key="j"
|
||||
class="wp-pl-flow__binding"
|
||||
:class="{ 'wp-pl-flow__binding--mut': b.isMutation }"
|
||||
>{{ b.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- B) Steps stack -->
|
||||
<section class="form-section">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="form-section__label m-0">Modules ({{ steps.length }})</h2>
|
||||
<span class="text-xs text-wp-text3">Resolve top to bottom — each appends to context</span>
|
||||
</div>
|
||||
<Card>
|
||||
<template #content>
|
||||
<PipelineSteps
|
||||
:steps="steps"
|
||||
:modules-by-id="modulesById"
|
||||
:modules-by-kind="modulesByKind"
|
||||
@update:steps="onStepsUpdate"
|
||||
@open-picker="pickerOpen = true"
|
||||
/>
|
||||
</template>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- D) Resolution preview -->
|
||||
<section v-if="preview.length" class="form-section">
|
||||
<h2 class="form-section__label">Resolution preview</h2>
|
||||
<Card>
|
||||
<template #content>
|
||||
<p class="text-xs text-wp-text3 m-0 mb-2">
|
||||
Synthetic flow — shows the variables each enabled step binds into the resolved context.
|
||||
</p>
|
||||
<div class="wp-pl-flow" data-test="pipeline-preview">
|
||||
<div
|
||||
v-for="ps in preview" :key="ps.index"
|
||||
class="wp-pl-flow__step"
|
||||
:data-kind="ps.kind ?? 'unknown'"
|
||||
>
|
||||
<div class="wp-pl-flow__head">
|
||||
<span class="wp-pl-flow__idx">{{ String(ps.index + 1).padStart(2, "0") }}</span>
|
||||
<i :class="previewKindIcon(ps.kind)" class="wp-pl-flow__icon" aria-hidden="true" />
|
||||
<span class="wp-pl-flow__name">{{ ps.name }}</span>
|
||||
</div>
|
||||
<div v-if="ps.bindings.length" class="wp-pl-flow__adds">
|
||||
<div
|
||||
v-for="(b, j) in ps.bindings" :key="j"
|
||||
class="wp-pl-flow__binding"
|
||||
:class="{ 'wp-pl-flow__binding--mut': b.isMutation }"
|
||||
>{{ b.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- C) Picker modal -->
|
||||
<PipelineStepPicker
|
||||
v-model:visible="pickerOpen"
|
||||
:modules="allModules"
|
||||
:categories="categoryStore.items"
|
||||
@pick="onPick"
|
||||
/>
|
||||
|
||||
<!-- E) Footer -->
|
||||
<div class="form-page__footer">
|
||||
<Button
|
||||
v-if="historyEntries.length"
|
||||
:label="`History (${historyEntries.length})`"
|
||||
icon="pi pi-history"
|
||||
severity="secondary"
|
||||
outlined
|
||||
data-test="history-btn"
|
||||
@click="historyOpen = true"
|
||||
/>
|
||||
<div class="form-page__footer-spacer" />
|
||||
<Button label="Cancel" severity="secondary" outlined @click="router.push('/pipelines')" />
|
||||
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
|
||||
</div>
|
||||
<HistoryPanel
|
||||
:open="historyOpen"
|
||||
:entries="historyEntries"
|
||||
@update:open="(v) => (historyOpen = v)"
|
||||
@restore="applyRestore"
|
||||
/>
|
||||
</div>
|
||||
</EditorFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
|
||||
.form-page__header { padding: 24px 24px 0; }
|
||||
.form-page__body { padding: 16px 24px 96px; max-width: 60rem; flex: 1; }
|
||||
.form-page__footer {
|
||||
position: sticky; bottom: 0;
|
||||
background: var(--wp-bg);
|
||||
border-top: 1px solid var(--wp-border);
|
||||
padding: 12px 24px;
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
}
|
||||
.form-page__footer-spacer { flex: 1; }
|
||||
.form-section { margin-bottom: 24px; }
|
||||
.form-section__label {
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--wp-text2);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.pl-hint { font-size: 11.5px; }
|
||||
.wp-pl-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -475,10 +384,10 @@ async function save() {
|
||||
border-radius: 3px;
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
color: var(--wp-text3);
|
||||
color: var(--wp-text-dim);
|
||||
}
|
||||
.wp-pl-flow__icon {
|
||||
color: var(--step-color, var(--wp-text2));
|
||||
color: var(--step-color, var(--wp-text-muted));
|
||||
font-size: 12px;
|
||||
}
|
||||
.wp-pl-flow__name {
|
||||
@@ -497,5 +406,5 @@ async function save() {
|
||||
color: var(--wp-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.wp-pl-flow__binding--mut { color: var(--wp-text2); font-style: italic; }
|
||||
.wp-pl-flow__binding--mut { color: var(--wp-text-muted); font-style: italic; }
|
||||
</style>
|
||||
@@ -0,0 +1,404 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* WildcardEditor — Wave 4 port of `WildcardEditor` in `screens/editors.jsx`.
|
||||
*
|
||||
* Sections:
|
||||
* 1. Identity (name + category + description + tags + `$varBinding`)
|
||||
* 2. Sub-categories chip list
|
||||
* 3. Options table (weight + value RichTextInput + sub-category Select)
|
||||
*
|
||||
* Save flow appends a snapshot to `payload.history` (utils/history.ts) so
|
||||
* the EditorFrame's history button works on the next mount.
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import EditorFrame from "../components/EditorFrame.vue";
|
||||
import IdentityCard from "../components/IdentityCard.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import Input from "../components/ui/Input.vue";
|
||||
import Select from "../components/ui/Select.vue";
|
||||
import Chip from "../components/ui/Chip.vue";
|
||||
import RichTextInput from "../components/RichTextInput.vue";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import type {
|
||||
CombinePayload,
|
||||
ModuleHistoryEntry,
|
||||
WildcardOption,
|
||||
WildcardPayload,
|
||||
} from "../api/types";
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const categoryId = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const varBinding = ref("");
|
||||
const varBindingError = ref("");
|
||||
const subCategories = ref<string[]>([]);
|
||||
const subDraft = ref("");
|
||||
const options = ref<WildcardOption[]>([
|
||||
{ id: `opt_${Math.random().toString(16).slice(2, 8)}`, value: "", weight: 1 },
|
||||
{ id: `opt_${Math.random().toString(16).slice(2, 8)}`, value: "", weight: 1 },
|
||||
]);
|
||||
const saving = ref(false);
|
||||
const isEdit = computed(() => !!props.id);
|
||||
const historyEntries = ref<ModuleHistoryEntry[]>([]);
|
||||
|
||||
// Suggestions: every other wildcard's binding (excluding self) for `@`-trigger
|
||||
// nested-reference autocomplete.
|
||||
const wcSuggestions = computed<string[]>(() => {
|
||||
const out: string[] = [];
|
||||
for (const m of moduleStore.items) {
|
||||
if (m.type !== "wildcard") continue;
|
||||
if (props.id && m.id === props.id) continue;
|
||||
const p = (m.payload ?? {}) as { var_binding?: string };
|
||||
const b = (p.var_binding && p.var_binding.trim()) || toIdentifier(m.name);
|
||||
if (b && !out.includes(b)) out.push(b);
|
||||
}
|
||||
return out.sort();
|
||||
});
|
||||
|
||||
// Suggestions: union of upstream `$var` names for inline `$`-trigger autocomplete.
|
||||
const varSuggestions = computed<string[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const m of moduleStore.items) {
|
||||
if (props.id && m.id === props.id) continue;
|
||||
if (m.type === "wildcard") {
|
||||
const p = (m.payload ?? {}) as Partial<WildcardPayload>;
|
||||
const b = (p.var_binding && p.var_binding.trim()) || toIdentifier(m.name);
|
||||
if (b && !seen.has(b)) { seen.add(b); out.push(b); }
|
||||
} 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(n); }
|
||||
}
|
||||
} 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(o); }
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
});
|
||||
|
||||
const subCategoryOptions = computed(() => [
|
||||
{ value: "", label: "(none)" },
|
||||
...subCategories.value.map((s) => ({ value: s, label: s })),
|
||||
]);
|
||||
|
||||
const totalWeight = computed(() => {
|
||||
const sum = options.value.reduce((acc, o) => acc + (Number(o.weight) || 0), 0);
|
||||
return sum > 0 ? sum : 1;
|
||||
});
|
||||
|
||||
function probabilityFor(o: WildcardOption): number {
|
||||
return ((Number(o.weight) || 0) / totalWeight.value) * 100;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
|
||||
if (props.id) {
|
||||
try {
|
||||
const row = await moduleStore.get(props.id);
|
||||
name.value = row.name;
|
||||
description.value = row.description;
|
||||
categoryId.value = row.category_id;
|
||||
tags.value = row.tags;
|
||||
const p = row.payload as Partial<WildcardPayload>;
|
||||
options.value = (p.options ?? []).map((o) => ({ ...o }));
|
||||
subCategories.value = [...(p.sub_categories ?? [])];
|
||||
varBinding.value = (p.var_binding && p.var_binding.trim()) || toIdentifier(row.name);
|
||||
historyEntries.value = readHistory(row.payload);
|
||||
} catch {
|
||||
toast.push({ severity: "error", summary: "Wildcard not found" });
|
||||
router.replace("/wildcards");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function addSub() {
|
||||
const s = subDraft.value.trim();
|
||||
if (!s) return;
|
||||
if (subCategories.value.includes(s)) {
|
||||
toast.push({ severity: "warn", summary: "Duplicate sub-category" });
|
||||
return;
|
||||
}
|
||||
subCategories.value.push(s);
|
||||
subDraft.value = "";
|
||||
}
|
||||
|
||||
function removeSub(s: string) {
|
||||
subCategories.value = subCategories.value.filter((x) => x !== s);
|
||||
for (const o of options.value) if (o.sub_category === s) o.sub_category = null;
|
||||
}
|
||||
|
||||
function addOption() {
|
||||
options.value.push({
|
||||
id: `opt_${Math.random().toString(16).slice(2, 8)}`,
|
||||
value: "",
|
||||
weight: 1,
|
||||
});
|
||||
}
|
||||
function removeOption(idx: number) { options.value.splice(idx, 1); }
|
||||
|
||||
function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
name.value = entry.name;
|
||||
description.value = entry.description ?? "";
|
||||
categoryId.value = entry.category_id ?? null;
|
||||
tags.value = entry.tags ? [...entry.tags] : [];
|
||||
const p = (entry.payload ?? {}) as Partial<WildcardPayload>;
|
||||
options.value = (p.options ?? []).map((o) => ({ ...o }));
|
||||
subCategories.value = [...(p.sub_categories ?? [])];
|
||||
varBinding.value = (p.var_binding && p.var_binding.trim()) || toIdentifier(entry.name);
|
||||
toast.push({
|
||||
severity: "info",
|
||||
summary: "Version restored",
|
||||
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
|
||||
life: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
toast.push({ severity: "warn", summary: "Name required" });
|
||||
return;
|
||||
}
|
||||
const finalBinding = varBinding.value.trim() || toIdentifier(name.value);
|
||||
if (!VALID_IDENTIFIER_RE.test(finalBinding)) {
|
||||
varBindingError.value = "Use letters, digits, underscores; must not start with a digit.";
|
||||
toast.push({ severity: "warn", summary: "Invalid variable name" });
|
||||
return;
|
||||
}
|
||||
varBindingError.value = "";
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: WildcardPayload = {
|
||||
options: options.value,
|
||||
sub_categories: subCategories.value,
|
||||
var_binding: finalBinding,
|
||||
};
|
||||
const newPayload = payload as unknown as Record<string, unknown>;
|
||||
if (isEdit.value && props.id) {
|
||||
const prev = await moduleStore.get(props.id);
|
||||
const nextHistory = appendSnapshot(
|
||||
{
|
||||
name: prev.name,
|
||||
description: prev.description,
|
||||
category_id: prev.category_id,
|
||||
tags: prev.tags,
|
||||
payload: prev.payload as Record<string, unknown>,
|
||||
},
|
||||
prev.payload as Record<string, unknown>,
|
||||
);
|
||||
await moduleStore.update(props.id, {
|
||||
name: name.value, description: description.value,
|
||||
category_id: categoryId.value, tags: tags.value,
|
||||
payload: { ...newPayload, history: nextHistory },
|
||||
});
|
||||
historyEntries.value = nextHistory;
|
||||
} else {
|
||||
await moduleStore.create({
|
||||
type: "wildcard",
|
||||
name: name.value, description: description.value,
|
||||
category_id: categoryId.value, tags: tags.value,
|
||||
payload: newPayload,
|
||||
});
|
||||
}
|
||||
toast.push({ severity: "success", summary: "Saved", detail: name.value });
|
||||
router.push("/wildcards");
|
||||
} catch (e) {
|
||||
toast.push({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() { router.push("/wildcards"); }
|
||||
|
||||
defineExpose({ historyEntries, applyRestore });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EditorFrame
|
||||
:title="isEdit ? 'Edit wildcard' : 'New wildcard'"
|
||||
back-route="/wildcards"
|
||||
back-label="Wildcards"
|
||||
:saving="saving"
|
||||
:history-entries="historyEntries"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@restore="applyRestore"
|
||||
>
|
||||
<IdentityCard
|
||||
:name="name"
|
||||
:description="description"
|
||||
:category-id="categoryId"
|
||||
:tags="tags"
|
||||
:var-binding="varBinding"
|
||||
:var-binding-error="varBindingError"
|
||||
@update:name="(v) => (name = v)"
|
||||
@update:description="(v) => (description = v)"
|
||||
@update:category-id="(v) => (categoryId = v)"
|
||||
@update:tags="(v) => (tags = v)"
|
||||
@update:var-binding="(v) => (varBinding = v)"
|
||||
/>
|
||||
|
||||
<Card title="Sub-Categories">
|
||||
<template #actions>
|
||||
<span class="wp-dim wp-text-xs">Optional groupings inside this wildcard</span>
|
||||
</template>
|
||||
<div class="sub-add-row">
|
||||
<Input
|
||||
v-model="subDraft"
|
||||
placeholder="e.g. warm tones"
|
||||
data-test="wc-sub-input"
|
||||
@keydown.enter.prevent="addSub"
|
||||
/>
|
||||
<Button icon="pi pi-plus" data-test="wc-sub-add" @click="addSub">Add</Button>
|
||||
</div>
|
||||
<div v-if="subCategories.length" class="sub-list">
|
||||
<Chip
|
||||
v-for="s in subCategories"
|
||||
:key="s"
|
||||
tone="accent"
|
||||
removable
|
||||
@remove="removeSub(s)"
|
||||
>{{ s }}</Chip>
|
||||
</div>
|
||||
<span v-else class="wp-dim wp-text-xs">No sub-categories yet.</span>
|
||||
</Card>
|
||||
|
||||
<Card :title="`Options (${options.length})`" :padding="false">
|
||||
<template #actions>
|
||||
<Button size="sm" variant="primary" icon="pi pi-plus" data-test="wc-add-opt" @click="addOption">
|
||||
Add option
|
||||
</Button>
|
||||
</template>
|
||||
<table class="wp-table wp-options-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="opt-col-weight">Weight</th>
|
||||
<th>Value</th>
|
||||
<th class="opt-col-sub">Sub-category</th>
|
||||
<th class="opt-col-prob">Probability</th>
|
||||
<th class="opt-col-trash" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(o, i) in options" :key="o.id">
|
||||
<td>
|
||||
<Input
|
||||
:model-value="o.weight"
|
||||
type="number"
|
||||
size="sm"
|
||||
aria-label="Option weight"
|
||||
@update:model-value="(v) => (o.weight = Number(v) || 0)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<RichTextInput
|
||||
v-model="o.value"
|
||||
:ref-suggestions="wcSuggestions"
|
||||
:var-suggestions="varSuggestions"
|
||||
placeholder="value (type @ for nested wildcards · {a|b|c} for inline choices)"
|
||||
aria-label="Option value"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Select
|
||||
:model-value="o.sub_category ?? ''"
|
||||
:options="subCategoryOptions"
|
||||
placeholder="(none)"
|
||||
clearable
|
||||
aria-label="Sub-category for option"
|
||||
@update:model-value="(v) => (o.sub_category = (v as string) || null)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="opt-prob">
|
||||
<div class="opt-prob__bar">
|
||||
<div class="opt-prob__fill" :style="{ width: probabilityFor(o) + '%' }" />
|
||||
</div>
|
||||
<span class="opt-prob__value wp-mono">{{ probabilityFor(o).toFixed(0) }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pi pi-trash"
|
||||
class="wp-btn--danger"
|
||||
aria-label="Remove option"
|
||||
@click="removeOption(i)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!options.length">
|
||||
<td colspan="5" class="opt-empty">No options yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</EditorFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-text-xs { font-size: 11.5px; }
|
||||
.sub-add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.sub-add-row > :first-child { flex: 1; }
|
||||
.sub-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.wp-options-table {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.opt-col-weight { width: 90px; }
|
||||
.opt-col-sub { width: 200px; }
|
||||
.opt-col-prob { width: 130px; }
|
||||
.opt-col-trash { width: 40px; }
|
||||
.opt-empty {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: var(--wp-text-dim);
|
||||
}
|
||||
.opt-prob {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.opt-prob__bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--wp-bg-3);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.opt-prob__fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--wp-accent-500), var(--wp-accent-400));
|
||||
}
|
||||
.opt-prob__value {
|
||||
width: 32px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--wp-text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -1,476 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Button from "primevue/button";
|
||||
import InputText from "primevue/inputtext";
|
||||
import InputGroup from "primevue/inputgroup";
|
||||
import InputGroupAddon from "primevue/inputgroupaddon";
|
||||
import Textarea from "primevue/textarea";
|
||||
import InputNumber from "primevue/inputnumber";
|
||||
import Select from "primevue/select";
|
||||
import AutoComplete from "primevue/autocomplete";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import RichTextInput from "../components/RichTextInput.vue";
|
||||
import HistoryPanel from "../components/HistoryPanel.vue";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import type {
|
||||
CombinePayload,
|
||||
ModuleHistoryEntry,
|
||||
WildcardOption,
|
||||
WildcardPayload,
|
||||
} from "../api/types";
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const categoryId = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const options = ref<WildcardOption[]>([]);
|
||||
const subCategories = ref<string[]>([]);
|
||||
const newSubCategory = ref("");
|
||||
const varBinding = ref("");
|
||||
// Tracks whether the user has manually edited the var_binding field. While
|
||||
// false, the field auto-tracks the (slugified) name; once true we never
|
||||
// clobber the user's value.
|
||||
const varBindingTouched = ref(false);
|
||||
const varBindingError = ref("");
|
||||
const saving = ref(false);
|
||||
const isEdit = computed(() => !!props.id);
|
||||
|
||||
// Per-module version history (max 3). Hydrated from `payload.history` on
|
||||
// load and updated in-place whenever the user saves an edit.
|
||||
const historyEntries = ref<ModuleHistoryEntry[]>([]);
|
||||
const historyOpen = ref(false);
|
||||
|
||||
const subCategoryOptions = computed(() => [
|
||||
{ label: "(none)", value: null as string | null },
|
||||
...subCategories.value.map((s) => ({ label: s, value: s })),
|
||||
]);
|
||||
|
||||
// Suggestions for option-value RichTextInput: every other wildcard's
|
||||
// var_binding (or slug fallback). Self is excluded so authors don't write
|
||||
// a recursive reference by accident. Powers `@`-trigger autocomplete.
|
||||
const otherWildcardBindings = computed<string[]>(() => {
|
||||
const out: string[] = [];
|
||||
for (const m of moduleStore.items) {
|
||||
if (m.type !== "wildcard") continue;
|
||||
if (props.id && m.id === props.id) continue;
|
||||
const p = (m.payload ?? {}) as { var_binding?: string };
|
||||
const binding = (p.var_binding && p.var_binding.trim()) || toIdentifier(m.name);
|
||||
if (binding && !out.includes(binding)) out.push(binding);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// `$varname` suggestions for option-value RichTextInput. Authors can splice
|
||||
// upstream context values into a wildcard option (e.g. `$character_name`).
|
||||
// Pulls the union of:
|
||||
// * other wildcards' `var_binding`
|
||||
// * fixed_values entries' `name`
|
||||
// * combines' `output_var`
|
||||
// Self is excluded so the editor never suggests its own binding.
|
||||
const varSuggestions = computed<string[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const m of moduleStore.items) {
|
||||
if (props.id && m.id === props.id) continue;
|
||||
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(binding); }
|
||||
} 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(n); }
|
||||
}
|
||||
} 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(o); }
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
watch(name, (next) => {
|
||||
if (!varBindingTouched.value) {
|
||||
varBinding.value = toIdentifier(next);
|
||||
}
|
||||
});
|
||||
|
||||
function onVarBindingInput(value: string | undefined): void {
|
||||
varBindingTouched.value = true;
|
||||
varBinding.value = value ?? "";
|
||||
if (varBindingError.value) varBindingError.value = "";
|
||||
}
|
||||
|
||||
function addSubCategory() {
|
||||
const v = newSubCategory.value.trim();
|
||||
if (!v) return;
|
||||
if (subCategories.value.includes(v)) {
|
||||
toast.add({ severity: "warn", summary: "Duplicate sub-category", life: 1500 });
|
||||
return;
|
||||
}
|
||||
subCategories.value.push(v);
|
||||
newSubCategory.value = "";
|
||||
}
|
||||
|
||||
function removeSubCategory(s: string) {
|
||||
subCategories.value = subCategories.value.filter((x) => x !== s);
|
||||
// Also clear it from any option assignment
|
||||
for (const o of options.value) if (o.sub_category === s) o.sub_category = null;
|
||||
}
|
||||
|
||||
const tagSuggestions = ref<string[]>([]);
|
||||
function searchTags(event: { query: string }) {
|
||||
const q = event.query.toLowerCase();
|
||||
const known = new Set<string>();
|
||||
for (const m of moduleStore.items) {
|
||||
for (const t of m.tags ?? []) known.add(t);
|
||||
}
|
||||
tagSuggestions.value = Array.from(known)
|
||||
.filter((t) => t.toLowerCase().includes(q) && !tags.value.includes(t))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
|
||||
if (props.id) {
|
||||
try {
|
||||
const row = await moduleStore.get(props.id);
|
||||
name.value = row.name;
|
||||
description.value = row.description;
|
||||
categoryId.value = row.category_id;
|
||||
tags.value = row.tags;
|
||||
const payload = row.payload as Partial<WildcardPayload>;
|
||||
options.value = payload.options ?? [];
|
||||
subCategories.value = payload.sub_categories ?? [];
|
||||
if (payload.var_binding && payload.var_binding.trim()) {
|
||||
varBinding.value = payload.var_binding;
|
||||
varBindingTouched.value = true;
|
||||
} else {
|
||||
varBinding.value = toIdentifier(row.name);
|
||||
}
|
||||
historyEntries.value = readHistory(row.payload);
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Wildcard not found", life: 3000 });
|
||||
router.replace("/wildcards");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function addOption() {
|
||||
const id = `opt_${Math.random().toString(16).slice(2, 8)}`;
|
||||
options.value.push({ id, value: "", weight: 1 });
|
||||
}
|
||||
function removeOption(idx: number) { options.value.splice(idx, 1); }
|
||||
|
||||
/** Live editor state minus the `history` sidecar — used to capture pre-save snapshots. */
|
||||
function currentPayload(): Record<string, unknown> {
|
||||
const finalBinding = (varBinding.value.trim()) || toIdentifier(name.value);
|
||||
const payload: WildcardPayload = {
|
||||
options: options.value,
|
||||
sub_categories: subCategories.value,
|
||||
var_binding: finalBinding,
|
||||
};
|
||||
return payload as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
name.value = entry.name;
|
||||
description.value = entry.description ?? "";
|
||||
categoryId.value = entry.category_id ?? null;
|
||||
tags.value = entry.tags ? [...entry.tags] : [];
|
||||
const p = (entry.payload ?? {}) as Partial<WildcardPayload>;
|
||||
options.value = (p.options ?? []).map((o) => ({ ...o }));
|
||||
subCategories.value = [...(p.sub_categories ?? [])];
|
||||
if (p.var_binding && p.var_binding.trim()) {
|
||||
varBinding.value = p.var_binding;
|
||||
varBindingTouched.value = true;
|
||||
} else {
|
||||
varBinding.value = toIdentifier(entry.name);
|
||||
varBindingTouched.value = false;
|
||||
}
|
||||
historyOpen.value = false;
|
||||
toast.add({
|
||||
severity: "info",
|
||||
summary: "Version restored",
|
||||
detail: `Restored from ${new Date(entry.saved_at).toLocaleString()}; click Save to commit.`,
|
||||
life: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
|
||||
return;
|
||||
}
|
||||
const finalBinding = (varBinding.value.trim()) || toIdentifier(name.value);
|
||||
if (!VALID_IDENTIFIER_RE.test(finalBinding)) {
|
||||
varBindingError.value = "Use letters, digits, underscores; must not start with a digit.";
|
||||
toast.add({ severity: "warn", summary: "Invalid variable name", life: 2500 });
|
||||
return;
|
||||
}
|
||||
varBindingError.value = "";
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: WildcardPayload = {
|
||||
options: options.value,
|
||||
sub_categories: subCategories.value,
|
||||
var_binding: finalBinding,
|
||||
};
|
||||
const newPayload = payload as unknown as Record<string, unknown>;
|
||||
if (isEdit.value && props.id) {
|
||||
// Capture pre-save state into history sidecar before the new write.
|
||||
const prev = await moduleStore.get(props.id);
|
||||
const nextHistory = appendSnapshot(
|
||||
{
|
||||
name: prev.name,
|
||||
description: prev.description,
|
||||
category_id: prev.category_id,
|
||||
tags: prev.tags,
|
||||
payload: prev.payload as Record<string, unknown>,
|
||||
},
|
||||
prev.payload as Record<string, unknown>,
|
||||
);
|
||||
await moduleStore.update(props.id, {
|
||||
name: name.value, description: description.value,
|
||||
category_id: categoryId.value, tags: tags.value,
|
||||
payload: { ...newPayload, history: nextHistory },
|
||||
});
|
||||
historyEntries.value = nextHistory;
|
||||
} else {
|
||||
await moduleStore.create({
|
||||
type: "wildcard",
|
||||
name: name.value, description: description.value,
|
||||
category_id: categoryId.value, tags: tags.value,
|
||||
payload: newPayload,
|
||||
});
|
||||
}
|
||||
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
|
||||
router.push("/wildcards");
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ historyEntries, historyOpen, applyRestore, currentPayload });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-page__header">
|
||||
<RouterLink to="/wildcards" class="text-xs text-wp-text2 hover:text-wp-text">
|
||||
<i class="pi pi-angle-left text-xs" /> Wildcards
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-semibold m-0 mt-1">{{ isEdit ? "Edit wildcard" : "New wildcard" }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-page__body">
|
||||
<section class="form-section">
|
||||
<h2 class="form-section__label">Identity</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="wc-name" class="block text-xs text-wp-text2 mb-1">Name</label>
|
||||
<InputText id="wc-name" v-model="name" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="wc-var" class="block text-xs text-wp-text2 mb-1">Variable</label>
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-dollar" /></InputGroupAddon>
|
||||
<InputText
|
||||
id="wc-var"
|
||||
:model-value="varBinding"
|
||||
data-test="wc-var-binding"
|
||||
:class="{ 'p-invalid': !!varBindingError }"
|
||||
@update:model-value="onVarBindingInput"
|
||||
/>
|
||||
</InputGroup>
|
||||
<p class="text-xs text-wp-text2 mt-1">
|
||||
Resolved options bind to this variable. Auto-suggested from name; you can edit it.
|
||||
</p>
|
||||
<p v-if="varBindingError" class="text-xs text-wp-danger mt-1" data-test="wc-var-error">
|
||||
{{ varBindingError }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="wc-cat" class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
id="wc-cat" v-model="categoryId"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="None" show-clear class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="wc-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
|
||||
<Textarea id="wc-desc" v-model="description" rows="2" class="w-full" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="wc-tags" class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<AutoComplete
|
||||
id="wc-tags"
|
||||
v-model="tags"
|
||||
multiple
|
||||
typeahead
|
||||
:suggestions="tagSuggestions"
|
||||
placeholder="Type a tag and press Enter…"
|
||||
class="w-full"
|
||||
@complete="searchTags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="form-section__label m-0">Sub-categories</h2>
|
||||
</div>
|
||||
<p class="text-xs text-wp-text2 mb-2">
|
||||
Optional groupings inside this wildcard. Tag each option below with one of these to allow filtering at resolve time.
|
||||
</p>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<InputText
|
||||
v-model="newSubCategory"
|
||||
placeholder="e.g. warm-tones"
|
||||
class="flex-1"
|
||||
aria-label="Sub-category name"
|
||||
@keydown.enter="addSubCategory"
|
||||
/>
|
||||
<Button label="Add" icon="pi pi-plus" size="small" severity="secondary" outlined @click="addSubCategory" />
|
||||
</div>
|
||||
<div v-if="subCategories.length" class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="s in subCategories"
|
||||
:key="s"
|
||||
class="sub-cat-chip"
|
||||
>
|
||||
{{ s }}
|
||||
<i class="pi pi-times cursor-pointer" @click="removeSubCategory(s)" />
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="text-xs text-wp-text3">No sub-categories yet.</p>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="form-section__label m-0">Options</h2>
|
||||
<Button label="Add option" icon="pi pi-plus" size="small" severity="primary" @click="addOption" />
|
||||
</div>
|
||||
<table class="w-full text-sm border border-wp-border rounded">
|
||||
<thead>
|
||||
<tr class="bg-wp-bg2">
|
||||
<th class="px-3 py-2 text-left text-wp-text2 text-xs uppercase w-24">Weight</th>
|
||||
<th class="px-3 py-2 text-left text-wp-text2 text-xs uppercase">Value</th>
|
||||
<th class="px-3 py-2 text-left text-wp-text2 text-xs uppercase w-44">Sub-category</th>
|
||||
<th class="w-16"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(opt, idx) in options" :key="opt.id" class="border-t border-wp-border">
|
||||
<td class="px-3 py-2 w-24">
|
||||
<InputNumber v-model="opt.weight" :min="0" :max="999" size="small" class="w-full" />
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<RichTextInput
|
||||
v-model="opt.value"
|
||||
:ref-suggestions="otherWildcardBindings"
|
||||
:var-suggestions="varSuggestions"
|
||||
placeholder="e.g. red, green, or @hair_color"
|
||||
aria-label="Option value"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2 w-44">
|
||||
<Select
|
||||
v-model="opt.sub_category"
|
||||
:options="subCategoryOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="(none)"
|
||||
class="w-full"
|
||||
aria-label="Sub-category for option"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger"
|
||||
aria-label="Remove option" @click="removeOption(idx)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!options.length">
|
||||
<td colspan="4" class="text-center text-wp-text2 py-4">No options yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-xs text-wp-text2 mt-2">
|
||||
<code>@</code> autocompletes other wildcards.
|
||||
<code>{a|b|c}</code> picks one randomly.
|
||||
<code>$$</code> and <code>@@</code> escape literal characters.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="form-page__footer">
|
||||
<Button
|
||||
v-if="historyEntries.length"
|
||||
:label="`History (${historyEntries.length})`"
|
||||
icon="pi pi-history"
|
||||
severity="secondary"
|
||||
outlined
|
||||
data-test="history-btn"
|
||||
@click="historyOpen = true"
|
||||
/>
|
||||
<div class="form-page__footer-spacer" />
|
||||
<Button label="Cancel" severity="secondary" outlined @click="router.push('/wildcards')" />
|
||||
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
|
||||
</div>
|
||||
<HistoryPanel
|
||||
:open="historyOpen"
|
||||
:entries="historyEntries"
|
||||
@update:open="(v) => (historyOpen = v)"
|
||||
@restore="applyRestore"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
|
||||
.form-page__header { padding: 24px 24px 0; }
|
||||
.form-page__body { padding: 16px 24px 96px; max-width: 56rem; flex: 1; }
|
||||
.form-page__footer {
|
||||
position: sticky; bottom: 0;
|
||||
background: var(--wp-bg);
|
||||
border-top: 1px solid var(--wp-border);
|
||||
padding: 12px 24px;
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
}
|
||||
.form-page__footer-spacer { flex: 1; }
|
||||
.form-section { margin-bottom: 24px; }
|
||||
.form-section__label {
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--wp-text2);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.sub-cat-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 11px;
|
||||
font-size: 11px;
|
||||
background: var(--wp-violet-bg);
|
||||
color: var(--wp-violet);
|
||||
border: 1px solid var(--wp-violet);
|
||||
}
|
||||
.sub-cat-chip i { opacity: 0.7; font-size: 10px; }
|
||||
.sub-cat-chip i:hover { opacity: 1; }
|
||||
</style>
|
||||
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/wp/images/site.webmanifest" />
|
||||
<script type="module" crossorigin src="/wp/assets/manager-Bc7nzdjM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/wp/assets/manager-CS7NgARy.css">
|
||||
<script type="module" crossorigin src="/wp/assets/manager-Da1TVId8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/wp/assets/manager-Bh6ktjk4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user