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:
DumiFlex
2026-04-27 19:18:49 +03:00
parent df5d38d625
commit 84fc961fc2
28 changed files with 2524 additions and 3141 deletions
@@ -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);
});
});
@@ -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");
});
});
@@ -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");
});
});
+95
View File
@@ -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);
});
});
+129
View File
@@ -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");
});
});
@@ -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" }),
@@ -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;
});
+2 -3
View File
@@ -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"
+13 -19
View File
@@ -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>
+163 -202
View File
@@ -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;
+124
View File
@@ -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>
+5 -8
View File
@@ -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>
+211
View File
@@ -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>
+40 -19
View File
@@ -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%;
+5 -5
View File
@@ -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
View File
@@ -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") },
+327
View File
@@ -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>
-639
View File
@@ -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>
+452
View File
@@ -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>
-551
View File
@@ -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 &amp; 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;
+276
View File
@@ -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>
-401
View File
@@ -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>
+404
View File
@@ -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>
-476
View File
@@ -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
View File
@@ -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>