feat(spa): real Pipeline editor with steps stack + module picker modal

Replaces the Pipeline form stub with a full editor matching the prototype.
Steps stack supports reorder/toggle/duplicate/remove, picker modal lists
all candidate modules grouped by kind with search and category chips, and
a resolution preview shows the variables each enabled step binds.
This commit is contained in:
DumiFlex
2026-04-27 05:49:10 +03:00
parent 6a5bbb40ef
commit 054a777ec1
7 changed files with 1428 additions and 37 deletions
+108
View File
@@ -0,0 +1,108 @@
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 PipelineForm from "../views/PipelineForm.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: "/pipelines", component: { template: "<div/>" } },
],
});
}
function findByText(wrap: ReturnType<typeof mount>, text: string) {
return wrap.findAll("button").find((b) => b.text().includes(text));
}
describe("PipelineForm.vue", () => {
it("renders 'New pipeline' heading when no id", async () => {
const wrap = mount(PipelineForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
expect(wrap.text()).toContain("New pipeline");
});
it("loads existing module when id prop given", async () => {
apiMod.get.mockResolvedValue({
id: "pl_a", name: "Portrait", description: "desc", category_id: null,
tags: [], type: "pipeline",
payload: { steps: [{ id: "s1", module_id: "wc_a", enabled: true }] },
version: 1, created_at: "", updated_at: "", is_favorite: false,
});
const wrap = mount(PipelineForm, {
props: { id: "pl_a" },
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
expect(wrap.text()).toContain("Edit pipeline");
expect(apiMod.get).toHaveBeenCalledWith("pl_a");
});
it("save without name shows warn toast and skips API", async () => {
const wrap = mount(PipelineForm, {
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 name calls api.modules.create with type=pipeline", async () => {
apiMod.create.mockResolvedValue({
id: "pl_a", type: "pipeline", name: "Portrait",
description: "", category_id: null, tags: [], is_favorite: false,
payload: { steps: [] }, version: 1, created_at: "", updated_at: "",
});
const wrap = mount(PipelineForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const nameInput = wrap.find("#pl-name");
await nameInput.setValue("Portrait");
await nameInput.trigger("input");
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
expect(apiMod.create).toHaveBeenCalledWith(
expect.objectContaining({ type: "pipeline", name: "Portrait" }),
);
const call = apiMod.create.mock.calls[0]?.[0] as { payload: { steps: unknown[] } };
expect(Array.isArray(call.payload.steps)).toBe(true);
});
});
+131
View File
@@ -0,0 +1,131 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import PrimeVue from "primevue/config";
import PipelineSteps from "../components/PipelineSteps.vue";
import type { ModuleRow, ModuleType, PipelineStep } from "../api/types";
function makeModule(over: Partial<ModuleRow>): ModuleRow {
return {
id: "m_a",
type: "wildcard",
name: "alpha",
description: "",
category_id: null,
tags: [],
is_favorite: false,
payload: {},
version: 1,
created_at: "",
updated_at: "",
...over,
};
}
function makeStep(over: Partial<PipelineStep>): PipelineStep {
return { id: "step_aaa", module_id: "m_a", enabled: true, ...over };
}
function buildKindGroups(modules: ModuleRow[]): Record<ModuleType, ModuleRow[]> {
const groups: Record<ModuleType, ModuleRow[]> = {
wildcard: [], fixed_values: [], combine: [],
derivation: [], constraint: [], pipeline: [],
};
for (const m of modules) groups[m.type].push(m);
return groups;
}
function mountSteps(steps: PipelineStep[], modules: ModuleRow[]) {
const modulesById = new Map(modules.map((m) => [m.id, m]));
return mount(PipelineSteps, {
props: {
steps,
modulesById,
modulesByKind: buildKindGroups(modules),
},
global: { plugins: [PrimeVue] },
});
}
describe("PipelineSteps.vue", () => {
it("renders empty state when no steps", () => {
const wrap = mountSteps([], []);
expect(wrap.text()).toContain("No modules yet");
expect(wrap.findAll('[data-test^="pipeline-step-"]').length).toBe(0);
});
it("renders one row per step with name resolved from catalog", () => {
const mod = makeModule({ id: "m_a", name: "Hair Color", type: "wildcard" });
const mod2 = makeModule({ id: "m_b", name: "Outfit", type: "fixed_values" });
const steps = [
makeStep({ id: "s1", module_id: "m_a" }),
makeStep({ id: "s2", module_id: "m_b" }),
];
const wrap = mountSteps(steps, [mod, mod2]);
expect(wrap.findAll('[data-test^="pipeline-step-"]').length).toBe(2);
expect(wrap.text()).toContain("Hair Color");
expect(wrap.text()).toContain("Outfit");
});
it("emits update:steps with reordered list when step moved down", async () => {
const a = makeModule({ id: "m_a", name: "alpha", type: "wildcard" });
const b = makeModule({ id: "m_b", name: "beta", type: "wildcard" });
const steps = [
makeStep({ id: "s1", module_id: "m_a" }),
makeStep({ id: "s2", module_id: "m_b" }),
];
const wrap = mountSteps(steps, [a, b]);
const downBtn = wrap.find('[data-test="step-down-0"]');
await downBtn.trigger("click");
const emitted = wrap.emitted("update:steps");
expect(emitted).toBeTruthy();
const next = (emitted ?? [])[0][0] as PipelineStep[];
expect(next.map((s) => s.module_id)).toEqual(["m_b", "m_a"]);
});
it("emits open-picker event when add button clicked", async () => {
const wrap = mountSteps([], []);
const addBtn = wrap.find('[data-test="pipeline-add-step"]');
await addBtn.trigger("click");
expect(wrap.emitted("open-picker")).toBeTruthy();
});
it("emits update:steps when toggle enabled clicked", async () => {
const mod = makeModule({ id: "m_a", name: "alpha", type: "wildcard" });
const steps = [makeStep({ id: "s1", module_id: "m_a", enabled: true })];
const wrap = mountSteps(steps, [mod]);
await wrap.find('[data-test="step-toggle-0"]').trigger("click");
const emitted = wrap.emitted("update:steps");
expect(emitted).toBeTruthy();
const next = (emitted ?? [])[0][0] as PipelineStep[];
expect(next[0].enabled).toBe(false);
});
it("emits update:steps when remove clicked", async () => {
const mod = makeModule({ id: "m_a", name: "alpha", type: "wildcard" });
const steps = [
makeStep({ id: "s1", module_id: "m_a" }),
makeStep({ id: "s2", module_id: "m_a" }),
];
const wrap = mountSteps(steps, [mod]);
await wrap.find('[data-test="step-remove-0"]').trigger("click");
const emitted = wrap.emitted("update:steps");
expect(emitted).toBeTruthy();
const next = (emitted ?? [])[0][0] as PipelineStep[];
expect(next.length).toBe(1);
expect(next[0].id).toBe("s2");
});
it("emits update:steps with duplicated row when duplicate clicked", async () => {
const mod = makeModule({ id: "m_a", name: "alpha", type: "wildcard" });
const steps = [makeStep({ id: "s1", module_id: "m_a" })];
const wrap = mountSteps(steps, [mod]);
await wrap.find('[data-test="step-duplicate-0"]').trigger("click");
const emitted = wrap.emitted("update:steps");
expect(emitted).toBeTruthy();
const next = (emitted ?? [])[0][0] as PipelineStep[];
expect(next.length).toBe(2);
expect(next[0].module_id).toBe("m_a");
expect(next[1].module_id).toBe("m_a");
expect(next[1].id).not.toBe(next[0].id);
});
});
@@ -0,0 +1,422 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import Dialog from "primevue/dialog";
import InputText from "primevue/inputtext";
import type { CategoryRow, ModuleRow, ModuleType } from "../api/types";
interface Props {
visible: boolean;
modules: ModuleRow[];
categories?: CategoryRow[];
}
const props = withDefaults(defineProps<Props>(), { categories: () => [] });
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "pick", module: ModuleRow): void;
}>();
interface KindMeta {
type: ModuleType | "all";
label: string;
icon: string;
}
const KIND_TABS: KindMeta[] = [
{ type: "all", label: "All", icon: "pi pi-objects-column" },
{ type: "wildcard", label: "Wildcards", icon: "pi pi-th-large" },
{ type: "fixed_values", label: "Fixed Values", icon: "pi pi-tag" },
{ type: "combine", label: "Combines", icon: "pi pi-share-alt" },
{ type: "derivation", label: "Derivations", icon: "pi pi-code" },
{ type: "constraint", label: "Constraints", icon: "pi pi-sitemap" },
];
const KIND_ICON: Record<ModuleType, string> = {
wildcard: "pi pi-th-large",
fixed_values: "pi pi-tag",
combine: "pi pi-share-alt",
derivation: "pi pi-code",
constraint: "pi pi-sitemap",
pipeline: "pi pi-list",
};
const activeTab = ref<ModuleType | "all">("all");
const search = ref("");
watch(() => props.visible, (v) => {
if (v) {
activeTab.value = "all";
search.value = "";
}
});
const counts = computed<Record<string, number>>(() => {
const c: Record<string, number> = { all: 0 };
for (const tab of KIND_TABS) c[tab.type] = 0;
for (const m of props.modules) {
if (m.type === "pipeline") continue;
c.all += 1;
c[m.type] = (c[m.type] ?? 0) + 1;
}
return c;
});
const filtered = computed<ModuleRow[]>(() => {
const q = search.value.trim().toLowerCase();
return props.modules.filter((m) => {
if (m.type === "pipeline") return false;
if (activeTab.value !== "all" && m.type !== activeTab.value) return false;
if (!q) return true;
if (m.name.toLowerCase().includes(q)) return true;
if (m.id.toLowerCase().includes(q)) return true;
if ((m.tags ?? []).some((t) => t.toLowerCase().includes(q))) return true;
return false;
});
});
const categoryById = computed<Map<string, CategoryRow>>(() => {
const m = new Map<string, CategoryRow>();
for (const c of props.categories) m.set(c.id, c);
return m;
});
function pick(m: ModuleRow) {
emit("pick", m);
}
function close() {
emit("update:visible", false);
}
function kindLabel(t: ModuleType): string {
const tab = KIND_TABS.find((x) => x.type === t);
return tab?.label ?? t;
}
</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">
<div class="wp-pl-picker__head">
<i class="pi pi-plus" />
<span class="wp-pl-picker__title">Add module to pipeline</span>
<button
type="button"
class="wp-pl-picker__close"
aria-label="Close picker"
@click="close"
>
<i class="pi pi-times" />
</button>
</div>
<div class="wp-pl-picker__body">
<div class="wp-pl-picker__tabs" role="tablist" aria-label="Module kind">
<button
v-for="tab in KIND_TABS"
:key="tab.type"
type="button"
role="tab"
class="wp-pl-tab"
:data-kind="tab.type"
:data-active="activeTab === tab.type ? '' : null"
:aria-selected="activeTab === tab.type"
:data-test="`picker-tab-${tab.type}`"
@click="activeTab = tab.type"
>
<i :class="tab.icon" />
<span class="wp-pl-tab__label">{{ tab.label }}</span>
<span class="wp-pl-tab__count">{{ counts[tab.type] ?? 0 }}</span>
</button>
</div>
<div class="wp-pl-picker__search">
<span class="wp-pl-picker__searchicon"><i class="pi pi-search" /></span>
<InputText
v-model="search"
placeholder="Search modules…"
class="wp-pl-picker__searchinput"
aria-label="Search modules"
data-test="picker-search"
/>
</div>
<div class="wp-pl-picker__list" data-test="picker-list">
<div v-if="!filtered.length" class="wp-pl-picker__empty">
No modules match.
</div>
<button
v-for="m in filtered"
:key="m.id"
type="button"
class="wp-pl-pickrow"
:data-kind="m.type"
:data-test="`picker-row-${m.id}`"
@click="pick(m)"
>
<span class="wp-pl-pickrow__icon" aria-hidden="true">
<i :class="KIND_ICON[m.type] ?? 'pi pi-circle'" />
</span>
<div class="wp-pl-pickrow__main">
<div class="wp-pl-pickrow__name">{{ m.name }}</div>
<div class="wp-pl-pickrow__id">{{ kindLabel(m.type) }} · {{ m.id }}</div>
</div>
<span
v-if="m.category_id && categoryById.get(m.category_id)"
class="wp-pl-pickrow__cat"
:style="{ background: categoryById.get(m.category_id)!.color || 'var(--wp-bg3)' }"
>{{ categoryById.get(m.category_id)!.name }}</span>
<span class="wp-pl-pickrow__add" aria-hidden="true">
<i class="pi pi-plus" />
</span>
</button>
</div>
</div>
</div>
</Dialog>
</template>
<style scoped>
.wp-pl-picker {
display: flex;
flex-direction: column;
width: 640px;
max-width: 100%;
max-height: 80vh;
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg, 12px);
overflow: hidden;
}
.wp-pl-picker__head {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--wp-border);
background: var(--wp-bg);
}
.wp-pl-picker__title {
font-weight: 600;
flex: 1;
}
.wp-pl-picker__close {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--wp-text2);
cursor: pointer;
font-size: 12px;
padding: 0;
}
.wp-pl-picker__close:hover {
background: var(--wp-bg3);
color: var(--wp-text);
border-color: var(--wp-border);
}
.wp-pl-picker__body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
min-height: 0;
overflow: hidden;
}
.wp-pl-picker__tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex-shrink: 0;
}
.wp-pl-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-bottom: 2px solid transparent;
border-radius: var(--wp-radius-sm, 6px);
color: var(--wp-text2);
font-size: 12px;
font-weight: 500;
cursor: pointer;
flex: 0 1 auto;
min-width: 0;
}
.wp-pl-tab[data-kind="wildcard"][data-active] { border-bottom-color: var(--wp-kind-wildcard); color: var(--wp-kind-wildcard); }
.wp-pl-tab[data-kind="fixed_values"][data-active] { border-bottom-color: var(--wp-kind-fixed); color: var(--wp-kind-fixed); }
.wp-pl-tab[data-kind="combine"][data-active] { border-bottom-color: var(--wp-kind-combine); color: var(--wp-kind-combine); }
.wp-pl-tab[data-kind="derivation"][data-active] { border-bottom-color: var(--wp-kind-derivation); color: var(--wp-kind-derivation); }
.wp-pl-tab[data-kind="constraint"][data-active] { border-bottom-color: var(--wp-kind-constraint); color: var(--wp-kind-constraint); }
.wp-pl-tab[data-kind="all"][data-active] { border-bottom-color: var(--wp-accent-500); color: var(--wp-text); }
.wp-pl-tab:hover {
background: var(--wp-bg3);
color: var(--wp-text);
}
.wp-pl-tab__label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.wp-pl-tab__count {
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 10.5px;
padding: 0 5px;
border-radius: 999px;
background: var(--wp-bg);
border: 1px solid var(--wp-border);
color: var(--wp-text3);
min-width: 18px;
text-align: center;
}
/* Avoid the prototype's input-group flex collapse bug. */
.wp-pl-picker__search {
display: flex;
align-items: stretch;
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm, 6px);
background: var(--wp-bg);
overflow: hidden;
flex-shrink: 0;
min-height: var(--wp-input-h, 38px);
height: 38px;
}
.wp-pl-picker__searchicon {
display: flex; align-items: center; justify-content: center;
padding: 0 12px;
color: var(--wp-text2);
font-size: 14px;
}
.wp-pl-picker__searchinput {
flex: 1;
border: none;
background: transparent;
color: var(--wp-text);
font-size: 13px;
padding: 0 8px;
height: 100%;
}
:deep(.wp-pl-picker__searchinput .p-inputtext),
:deep(input.wp-pl-picker__searchinput) {
border: none;
background: transparent;
height: 100%;
width: 100%;
box-shadow: none;
outline: none;
padding: 0 8px;
color: var(--wp-text);
font-size: 13px;
}
.wp-pl-picker__list {
overflow-y: auto;
overflow-x: hidden;
border: 1px solid var(--wp-border);
border-radius: 8px;
background: var(--wp-bg);
flex: 1;
min-height: 200px;
display: flex;
flex-direction: column;
}
.wp-pl-picker__empty {
padding: 24px;
text-align: center;
font-size: 12px;
color: var(--wp-text3);
}
.wp-pl-pickrow {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
background: var(--wp-bg2);
border: none;
border-bottom: 1px solid var(--wp-border);
cursor: pointer;
text-align: left;
width: 100%;
font-size: 13px;
color: var(--wp-text);
min-width: 0;
}
.wp-pl-pickrow:last-child { border-bottom: none; }
.wp-pl-pickrow:hover { background: var(--wp-bg3); }
.wp-pl-pickrow__icon {
width: 28px; height: 28px;
border-radius: var(--wp-radius-sm, 6px);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
background: var(--wp-bg3);
color: var(--wp-text2);
font-size: 14px;
}
.wp-pl-pickrow[data-kind="wildcard"] .wp-pl-pickrow__icon { background: var(--wp-kind-wildcard-bg); color: var(--wp-kind-wildcard); }
.wp-pl-pickrow[data-kind="fixed_values"] .wp-pl-pickrow__icon { background: var(--wp-kind-fixed-bg); color: var(--wp-kind-fixed); }
.wp-pl-pickrow[data-kind="combine"] .wp-pl-pickrow__icon { background: var(--wp-kind-combine-bg); color: var(--wp-kind-combine); }
.wp-pl-pickrow[data-kind="derivation"] .wp-pl-pickrow__icon { background: var(--wp-kind-derivation-bg); color: var(--wp-kind-derivation); }
.wp-pl-pickrow[data-kind="constraint"] .wp-pl-pickrow__icon { background: var(--wp-kind-constraint-bg); color: var(--wp-kind-constraint); }
.wp-pl-pickrow__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.wp-pl-pickrow__name {
font-weight: 500;
font-size: 13px;
color: var(--wp-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wp-pl-pickrow__id {
font-size: 11px;
font-family: var(--wp-font-mono, ui-monospace, monospace);
color: var(--wp-text2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wp-pl-pickrow__cat {
font-size: 10.5px;
padding: 2px 8px;
border-radius: 9px;
color: #fff;
font-weight: 500;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
}
.wp-pl-pickrow__add {
width: 22px; height: 22px;
border-radius: 4px;
display: flex; align-items: center; justify-content: center;
background: var(--wp-bg3);
color: var(--wp-text2);
font-size: 11px;
flex-shrink: 0;
}
.wp-pl-pickrow:hover .wp-pl-pickrow__add {
background: var(--wp-accent-500, var(--wp-bg3));
color: #fff;
}
</style>
+401
View File
@@ -0,0 +1,401 @@
<script setup lang="ts">
import { computed } from "vue";
import Select from "primevue/select";
import type { ModuleRow, ModuleType, PipelineStep } from "../api/types";
interface Props {
steps: PipelineStep[];
modulesById: Map<string, ModuleRow>;
modulesByKind: Record<ModuleType, ModuleRow[]>;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "update:steps", steps: PipelineStep[]): void;
(e: "open-picker"): void;
}>();
interface KindMeta {
type: ModuleType;
label: string;
icon: string;
routeBase: string;
}
const KIND_META: Record<ModuleType, KindMeta> = {
wildcard: { type: "wildcard", label: "Wildcard", icon: "pi pi-th-large", routeBase: "/wildcards" },
fixed_values: { type: "fixed_values", label: "Fixed Values", icon: "pi pi-tag", routeBase: "/fixed-values" },
combine: { type: "combine", label: "Combine", icon: "pi pi-share-alt", routeBase: "/combines" },
derivation: { type: "derivation", label: "Derivation", icon: "pi pi-code", routeBase: "/derivations" },
constraint: { type: "constraint", label: "Constraint", icon: "pi pi-sitemap", routeBase: "/constraints" },
pipeline: { type: "pipeline", label: "Pipeline", icon: "pi pi-list", routeBase: "/pipelines" },
};
const rows = computed(() =>
props.steps.map((step, idx) => {
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] ?? [] : [];
return { step, idx, mod, kind, meta, sameKindOptions };
}),
);
function emitNew(next: PipelineStep[]) {
emit("update:steps", next);
}
function toggleEnabled(idx: number) {
const next = props.steps.map((s, i) => (i === idx ? { ...s, enabled: !s.enabled } : s));
emitNew(next);
}
function changeModule(idx: number, moduleId: string) {
const next = props.steps.map((s, i) => (i === idx ? { ...s, module_id: moduleId } : s));
emitNew(next);
}
function duplicate(idx: number) {
const src = props.steps[idx];
if (!src) return;
const copy: PipelineStep = {
id: `step_${Math.random().toString(16).slice(2, 8)}`,
module_id: src.module_id,
enabled: src.enabled,
...(src.instance ? { instance: { ...src.instance } } : {}),
};
const next = [...props.steps];
next.splice(idx + 1, 0, copy);
emitNew(next);
}
function remove(idx: number) {
const next = props.steps.filter((_, i) => i !== idx);
emitNew(next);
}
function move(idx: number, dir: -1 | 1) {
const j = idx + dir;
if (j < 0 || j >= props.steps.length) return;
const next = [...props.steps];
[next[idx], next[j]] = [next[j], next[idx]];
emitNew(next);
}
</script>
<template>
<div class="wp-pl-stack" data-test="pipeline-steps">
<div v-if="!steps.length" class="wp-pl-empty">
<i class="pi pi-list" style="font-size:22px;color:var(--wp-text-dim);" />
<div class="wp-pl-empty__title">No modules yet</div>
<div class="wp-pl-empty__hint">Add modules from your library; they'll resolve in order.</div>
</div>
<div
v-for="row in rows"
:key="row.step.id"
class="wp-pl-row"
:class="{ 'wp-pl-row--disabled': !row.step.enabled }"
:data-kind="row.kind ?? 'unknown'"
:data-test="`pipeline-step-${row.idx}`"
>
<div class="wp-pl-row__moves">
<button
type="button"
class="wp-pl-row__movebtn"
:aria-label="`Move step ${row.idx + 1} up`"
:disabled="row.idx === 0"
:data-test="`step-up-${row.idx}`"
@click="move(row.idx, -1)"
>
<i class="pi pi-chevron-up" />
</button>
<button
type="button"
class="wp-pl-row__movebtn"
:aria-label="`Move step ${row.idx + 1} down`"
:disabled="row.idx === steps.length - 1"
:data-test="`step-down-${row.idx}`"
@click="move(row.idx, +1)"
>
<i class="pi pi-chevron-down" />
</button>
</div>
<button
type="button"
class="wp-pl-row__toggle"
:aria-label="row.step.enabled ? `Disable step ${row.idx + 1}` : `Enable step ${row.idx + 1}`"
:aria-pressed="row.step.enabled"
:data-test="`step-toggle-${row.idx}`"
@click="toggleEnabled(row.idx)"
>
<i :class="row.step.enabled ? 'pi pi-eye' : 'pi pi-eye-slash'" />
</button>
<div class="wp-pl-row__icon" aria-hidden="true">
<i :class="row.meta?.icon ?? 'pi pi-circle'" />
</div>
<div class="wp-pl-row__main">
<div class="wp-pl-row__top">
<span class="wp-pl-row__kind">{{ row.meta?.label ?? "Unknown" }}</span>
<span class="wp-pl-row__index">{{ String(row.idx + 1).padStart(2, "0") }}</span>
</div>
<div class="wp-pl-row__name">
<a
v-if="row.mod && row.meta"
:href="`#${row.meta.routeBase}/${row.mod.id}/edit`"
target="_blank"
rel="noopener"
class="wp-pl-row__namelink"
:data-test="`step-name-link-${row.idx}`"
>{{ row.mod.name }}</a>
<span v-else class="wp-pl-row__missing">(missing reference)</span>
</div>
<div class="wp-pl-row__sub">
<Select
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}` } }"
@update:model-value="(v) => changeModule(row.idx, v as string)"
/>
<span v-else class="wp-pl-row__id">{{ row.step.module_id }}</span>
</div>
</div>
<div class="wp-pl-row__actions">
<button
type="button"
class="wp-pl-row__act"
aria-label="Duplicate step"
:data-test="`step-duplicate-${row.idx}`"
@click="duplicate(row.idx)"
>
<i class="pi pi-copy" />
</button>
<button
type="button"
class="wp-pl-row__act wp-pl-row__act--danger"
aria-label="Remove step"
:data-test="`step-remove-${row.idx}`"
@click="remove(row.idx)"
>
<i class="pi pi-trash" />
</button>
</div>
</div>
<button
type="button"
class="wp-pl-add"
data-test="pipeline-add-step"
@click="emit('open-picker')"
>
<i class="pi pi-plus" />
<span>Add module</span>
</button>
</div>
</template>
<style scoped>
.wp-pl-stack {
display: flex;
flex-direction: column;
gap: 6px;
}
.wp-pl-empty {
padding: 28px 16px;
text-align: center;
border: 1px dashed var(--wp-border);
border-radius: var(--wp-radius);
background: var(--wp-bg);
color: var(--wp-text2);
}
.wp-pl-empty__title { font-size: 13px; font-weight: 500; margin-top: 6px; }
.wp-pl-empty__hint { font-size: 12px; color: var(--wp-text3); }
.wp-pl-row {
display: grid;
grid-template-columns: 26px 26px 32px 1fr auto;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-left: 3px solid var(--step-color, var(--wp-accent-500));
border-radius: var(--wp-radius);
transition: background 120ms ease;
}
.wp-pl-row[data-kind="wildcard"] { --step-color: var(--wp-kind-wildcard); }
.wp-pl-row[data-kind="fixed_values"] { --step-color: var(--wp-kind-fixed); }
.wp-pl-row[data-kind="combine"] { --step-color: var(--wp-kind-combine); }
.wp-pl-row[data-kind="derivation"] { --step-color: var(--wp-kind-derivation); }
.wp-pl-row[data-kind="constraint"] { --step-color: var(--wp-kind-constraint); }
.wp-pl-row[data-kind="pipeline"] { --step-color: var(--wp-kind-pipeline); }
.wp-pl-row:hover { background: var(--wp-bg3); }
.wp-pl-row--disabled { opacity: 0.55; }
.wp-pl-row--disabled .wp-pl-row__namelink { text-decoration: line-through; }
.wp-pl-row__moves {
display: flex;
flex-direction: column;
gap: 2px;
}
.wp-pl-row__movebtn {
display: flex; align-items: center; justify-content: center;
width: 22px; height: 14px;
border: 1px solid var(--wp-border);
background: var(--wp-bg);
color: var(--wp-text2);
border-radius: 3px;
cursor: pointer;
font-size: 9px;
padding: 0;
}
.wp-pl-row__movebtn:hover:not(:disabled) {
background: var(--wp-bg3);
color: var(--wp-text);
}
.wp-pl-row__movebtn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.wp-pl-row__toggle {
width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
border: 1px solid var(--wp-border);
background: var(--wp-bg);
border-radius: 4px;
color: var(--wp-text2);
cursor: pointer;
padding: 0;
font-size: 12px;
}
.wp-pl-row__toggle:hover {
background: var(--wp-bg3);
color: var(--wp-text);
}
.wp-pl-row__icon {
width: 32px; height: 32px;
border-radius: var(--wp-radius-sm);
display: flex; align-items: center; justify-content: center;
background: color-mix(in oklab, var(--step-color, var(--wp-accent-500)) 16%, var(--wp-bg3));
color: var(--step-color, var(--wp-text));
flex-shrink: 0;
font-size: 14px;
}
.wp-pl-row__main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.wp-pl-row__top {
display: flex; align-items: center; gap: 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--wp-text2);
font-weight: 500;
}
.wp-pl-row__kind { color: var(--step-color, var(--wp-text2)); }
.wp-pl-row__index {
margin-left: auto;
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 10.5px;
padding: 1px 5px;
border-radius: 3px;
background: var(--wp-bg);
border: 1px solid var(--wp-border);
color: var(--wp-text3);
}
.wp-pl-row__name {
font-weight: 500;
font-size: 13.5px;
color: var(--wp-text);
}
.wp-pl-row__namelink {
color: var(--wp-text);
text-decoration: none;
}
.wp-pl-row__namelink:hover { text-decoration: underline; }
.wp-pl-row__missing { color: var(--wp-danger, #ef4444); font-style: italic; }
.wp-pl-row__sub {
font-size: 11.5px;
color: var(--wp-text2);
}
.wp-pl-row__id {
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 11px;
color: var(--wp-text3);
}
.wp-pl-row__refselect {
width: 100%;
max-width: 320px;
}
.wp-pl-row__actions {
display: flex;
align-items: center;
gap: 2px;
}
.wp-pl-row__act {
width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--wp-text2);
cursor: pointer;
font-size: 12px;
padding: 0;
}
.wp-pl-row__act:hover {
background: var(--wp-bg3);
color: var(--wp-text);
border-color: var(--wp-border);
}
.wp-pl-row__act--danger:hover {
background: color-mix(in oklab, var(--wp-danger, #ef4444) 16%, transparent);
color: #fca5a5;
border-color: color-mix(in oklab, var(--wp-danger, #ef4444) 30%, transparent);
}
.wp-pl-add {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
width: 100%;
border: 1px dashed var(--wp-border-strong, var(--wp-border));
border-radius: var(--wp-radius);
background: transparent;
color: var(--wp-text2);
font-size: 12.5px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
cursor: pointer;
margin-top: 4px;
}
.wp-pl-add:hover {
border-style: solid;
border-color: var(--wp-accent-500, var(--wp-text2));
color: var(--wp-text);
background: var(--wp-bg2);
}
.wp-pl-add .pi { font-size: 11px; }
</style>
+5 -8
View File
@@ -100,20 +100,17 @@ function ruleCount(row: ModuleRow): number { return rules(row).length; }
function formatCondition(c: DerivationCondition | undefined): string {
if (!c) return "?";
if (c.kind === "always") return "always";
const v = c.var ? `$${c.var.replace(/^\$/, "")}` : "$?";
if (c.kind === "absent") return `${v} absent`;
const val = c.value ?? "";
return `${v} ${c.kind} "${val}"`;
return `${v} ${c.op} "${val}"`;
}
function formatAction(a: DerivationAction | undefined): string {
if (!a) return "?";
const verb = (
{ append: "append to", prepend: "prepend to", replace: "replace in", remove: "remove from", set: "set" } as Record<string, string>
)[a.kind] ?? a.kind;
if (a.kind === "remove") return `${verb} ${a.target}`;
return `${verb} ${a.target} = "${a.value ?? ""}"`;
{ append: "append to", prepend: "prepend to", replace: "replace in" } as Record<string, string>
)[a.mode] ?? a.mode;
return `${verb} ${a.target_var} = "${a.value ?? ""}"`;
}
function firstBranchPreview(rule: DerivationRule): string {
@@ -257,7 +254,7 @@ function extraBranches(rule: DerivationRule): number {
+{{ extraBranches(rule) }} more {{ extraBranches(rule) === 1 ? "branch" : "branches" }}
</span>
<span v-if="rule.else" class="ml-2 text-xs text-wp-text3">
· ELSE {{ formatAction(rule.else) }}
· ELSE {{ formatAction(rule.else.action) }}
</span>
</li>
</ol>
+275 -20
View File
@@ -8,9 +8,20 @@ import Textarea from "primevue/textarea";
import Select from "primevue/select";
import AutoComplete from "primevue/autocomplete";
import { useToast } from "primevue/usetoast";
import { api } from "../api/client";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import type { PipelinePayload } from "../api/types";
import PipelineSteps from "../components/PipelineSteps.vue";
import PipelineStepPicker from "../components/PipelineStepPicker.vue";
import type {
CombinePayload,
DerivationPayload,
ConstraintPayload,
ModuleRow,
ModuleType,
PipelinePayload,
PipelineStep,
} from "../api/types";
const props = defineProps<{ id?: string }>();
const router = useRouter();
@@ -22,10 +33,32 @@ const name = ref("");
const description = ref("");
const categoryId = ref<string | null>(null);
const tags = ref<string[]>([]);
const payload = ref<PipelinePayload>({ steps: [] });
const steps = ref<PipelineStep[]>([]);
const saving = ref(false);
const pickerOpen = ref(false);
// All modules (cross-kind) used by step rows + picker.
const allModules = ref<ModuleRow[]>([]);
const isEdit = computed(() => !!props.id);
const modulesById = computed(() => {
const m = new Map<string, ModuleRow>();
for (const row of allModules.value) m.set(row.id, row);
return m;
});
const modulesByKind = computed(() => {
const groups: Record<ModuleType, ModuleRow[]> = {
wildcard: [], fixed_values: [], combine: [],
derivation: [], constraint: [], pipeline: [],
};
for (const row of allModules.value) {
if (row.type === "pipeline") continue;
groups[row.type].push(row);
}
return groups;
});
const tagSuggestions = ref<string[]>([]);
function searchTags(event: { query: string }) {
const q = event.query.toLowerCase();
@@ -33,13 +66,25 @@ function searchTags(event: { query: 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 });
}
}
onMounted(async () => {
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
await Promise.all([categoryStore.fetchAll(), loadAllModules()]);
if (props.id) {
try {
const row = await moduleStore.get(props.id);
@@ -48,7 +93,12 @@ onMounted(async () => {
categoryId.value = row.category_id;
tags.value = row.tags;
const p = row.payload as Partial<PipelinePayload>;
payload.value = { steps: p.steps ?? [] };
steps.value = (p.steps ?? []).map((s) => ({
id: s.id || `step_${Math.random().toString(16).slice(2, 8)}`,
module_id: s.module_id,
enabled: s.enabled !== false,
...(s.instance ? { instance: s.instance } : {}),
}));
} catch {
toast.add({ severity: "error", summary: "Pipeline not found", life: 3000 });
router.replace("/pipelines");
@@ -56,6 +106,102 @@ onMounted(async () => {
}
});
function onStepsUpdate(next: PipelineStep[]) {
steps.value = next;
}
function onPick(mod: ModuleRow) {
const newStep: PipelineStep = {
id: `step_${Math.random().toString(16).slice(2, 8)}`,
module_id: mod.id,
enabled: true,
};
steps.value = [...steps.value, newStep];
pickerOpen.value = false;
}
// ------ Resolution preview helpers ----------------------------------
interface PreviewBinding {
text: string;
isMutation?: boolean;
}
interface PreviewStep {
index: number;
kind: ModuleType | null;
name: string;
bindings: PreviewBinding[];
}
function previewStepBindings(step: PipelineStep): PreviewBinding[] {
const mod = modulesById.value.get(step.module_id);
if (!mod) return [{ text: "(missing module)", isMutation: false }];
const payload = mod.payload as Record<string, unknown>;
const inst = (step.instance ?? {}) as { variable_binding?: string };
if (mod.type === "wildcard") {
const fallback = mod.name.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_|_$/g, "");
const v = inst.variable_binding?.trim() || (payload.variable_binding as string | undefined) || fallback;
return [{ text: `$${v}` }];
}
if (mod.type === "fixed_values") {
const values = (payload.values as { var?: string; name?: string }[] | undefined) ?? [];
return values
.map((row) => (row.var || row.name || "").replace(/^\$/, ""))
.filter(Boolean)
.map((v) => ({ text: `$${v}` }));
}
if (mod.type === "combine") {
const out = (payload as Partial<CombinePayload>).output_var?.replace(/^\$/, "");
return [{ text: out ? `$${out}` : "$output" }];
}
if (mod.type === "derivation") {
const rules = (payload as Partial<DerivationPayload>).rules ?? [];
const targets = new Set<string>();
for (const r of rules) {
for (const b of r.branches ?? []) {
if (b.action?.target_var) targets.add(b.action.target_var);
}
if (r.else?.action?.target_var) targets.add(r.else.action.target_var);
}
if (!targets.size) return [{ text: "mutates $context", isMutation: true }];
return Array.from(targets).map((t) => ({ text: `mutates $${t}`, isMutation: true }));
}
if (mod.type === "constraint") {
const cp = payload as Partial<ConstraintPayload>;
const t = cp.target_wildcard_id;
if (t) return [{ text: `affects $${t}`, isMutation: true }];
return [{ text: "affects (no target)", isMutation: true }];
}
return [];
}
const preview = computed<PreviewStep[]>(() =>
steps.value
.filter((s) => s.enabled)
.map((s, idx) => {
const mod = modulesById.value.get(s.module_id);
return {
index: idx,
kind: mod?.type ?? null,
name: mod?.name ?? "(missing)",
bindings: previewStepBindings(s),
};
}),
);
const KIND_ICON: Record<ModuleType, string> = {
wildcard: "pi pi-th-large",
fixed_values: "pi pi-tag",
combine: "pi pi-share-alt",
derivation: "pi pi-code",
constraint: "pi pi-sitemap",
pipeline: "pi pi-list",
};
function previewKindIcon(k: ModuleType | null): string {
return k ? KIND_ICON[k] : "pi pi-circle";
}
async function save() {
if (!name.value.trim()) {
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
@@ -63,12 +209,20 @@ async function save() {
}
saving.value = true;
try {
const payload: PipelinePayload = {
steps: steps.value.map((s) => ({
id: s.id,
module_id: s.module_id,
enabled: s.enabled,
...(s.instance ? { instance: s.instance } : {}),
})),
};
const body = {
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: payload.value as unknown as Record<string, unknown>,
payload: payload as unknown as Record<string, unknown>,
};
if (isEdit.value && props.id) {
await moduleStore.update(props.id, body);
@@ -95,6 +249,7 @@ async function save() {
</div>
<div class="form-page__body">
<!-- A) Identity -->
<section class="form-section">
<h2 class="form-section__label">Identity</h2>
<div class="grid grid-cols-2 gap-4">
@@ -130,19 +285,67 @@ async function save() {
</div>
</section>
<!-- B) Steps stack -->
<section class="form-section">
<h2 class="form-section__label">Steps</h2>
<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>
<p class="text-sm text-wp-text2 m-0 mb-2">
Editor coming soon — payload shown below.
<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>
<pre class="payload-preview" data-test="payload-preview">{{ JSON.stringify(payload, null, 2) }}</pre>
<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 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" />
@@ -153,7 +356,7 @@ async function save() {
<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__body { padding: 16px 24px 96px; max-width: 60rem; flex: 1; }
.form-page__footer {
position: sticky; bottom: 0;
background: var(--wp-bg);
@@ -167,17 +370,69 @@ async function save() {
letter-spacing: 0.5px; color: var(--wp-text2);
margin: 0 0 8px 0;
}
.payload-preview {
.wp-pl-flow {
display: flex;
flex-direction: column;
gap: 4px;
}
.wp-pl-flow__step {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-left: 3px solid var(--step-color, var(--wp-accent-500));
border-radius: var(--wp-radius);
position: relative;
}
.wp-pl-flow__step[data-kind="wildcard"] { --step-color: var(--wp-kind-wildcard); }
.wp-pl-flow__step[data-kind="fixed_values"] { --step-color: var(--wp-kind-fixed); }
.wp-pl-flow__step[data-kind="combine"] { --step-color: var(--wp-kind-combine); }
.wp-pl-flow__step[data-kind="derivation"] { --step-color: var(--wp-kind-derivation); }
.wp-pl-flow__step[data-kind="constraint"] { --step-color: var(--wp-kind-constraint); }
.wp-pl-flow__step + .wp-pl-flow__step::before {
content: "";
position: absolute;
top: -5px;
left: 18px;
width: 1px;
height: 6px;
background: var(--wp-border);
}
.wp-pl-flow__head {
display: flex; align-items: center; gap: 8px;
color: var(--wp-text);
}
.wp-pl-flow__idx {
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 10.5px;
padding: 1px 5px;
border-radius: 3px;
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 8px 10px;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
color: var(--wp-text);
max-height: 320px;
overflow: auto;
color: var(--wp-text3);
}
.wp-pl-flow__icon {
color: var(--step-color, var(--wp-text2));
font-size: 12px;
}
.wp-pl-flow__name {
font-weight: 500;
font-size: 12.5px;
}
.wp-pl-flow__adds {
display: flex;
flex-direction: column;
gap: 2px;
padding-left: 30px;
}
.wp-pl-flow__binding {
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--wp-text);
line-height: 1.4;
}
.wp-pl-flow__binding--mut { color: var(--wp-text2); font-style: italic; }
</style>
+86 -9
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
@@ -8,8 +8,11 @@ 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 { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
import type { WildcardOption, WildcardPayload } from "../api/types";
const props = defineProps<{ id?: string }>();
const router = useRouter();
@@ -17,15 +20,19 @@ const moduleStore = useModuleStore();
const categoryStore = useCategoryStore();
const toast = useToast();
interface Option { id: string; value: string; weight: number; sub_category?: string | null; }
const name = ref("");
const description = ref("");
const categoryId = ref<string | null>(null);
const tags = ref<string[]>([]);
const options = ref<Option[]>([]);
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);
@@ -34,6 +41,33 @@ const subCategoryOptions = computed(() => [
...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.
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;
});
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;
@@ -72,9 +106,15 @@ onMounted(async () => {
description.value = row.description;
categoryId.value = row.category_id;
tags.value = row.tags;
const payload = row.payload as { options?: Option[]; sub_categories?: string[] };
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);
}
} catch {
toast.add({ severity: "error", summary: "Wildcard not found", life: 3000 });
router.replace("/wildcards");
@@ -93,22 +133,32 @@ async function save() {
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 = {
const payload: WildcardPayload = {
options: options.value,
sub_categories: subCategories.value,
var_binding: finalBinding,
};
if (isEdit.value && props.id) {
await moduleStore.update(props.id, {
name: name.value, description: description.value,
category_id: categoryId.value, tags: tags.value, payload,
category_id: categoryId.value, tags: tags.value,
payload: payload as unknown as Record<string, unknown>,
});
} else {
await moduleStore.create({
type: "wildcard",
name: name.value, description: description.value,
category_id: categoryId.value, tags: tags.value, payload,
category_id: categoryId.value, tags: tags.value,
payload: payload as unknown as Record<string, unknown>,
});
}
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
@@ -138,6 +188,23 @@ async function save() {
<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 name</label>
<InputText
id="wc-var"
:model-value="varBinding"
data-test="wc-var-binding"
class="w-full"
:class="{ 'p-invalid': !!varBindingError }"
@update:model-value="onVarBindingInput"
/>
<p class="text-xs text-wp-text2 mt-1">
Other modules will reference this wildcard's resolved value as <span class="wc-var-mono">${{ varBinding || "name" }}</span>.
</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
@@ -215,7 +282,13 @@ async function save() {
<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"><InputText v-model="opt.value" class="w-full" /></td>
<td class="px-3 py-2">
<RichTextInput
v-model="opt.value"
:ref-suggestions="otherWildcardBindings"
aria-label="Option value"
/>
</td>
<td class="px-3 py-2 w-44">
<Select
v-model="opt.sub_category"
@@ -277,4 +350,8 @@ async function save() {
}
.sub-cat-chip i { opacity: 0.7; font-size: 10px; }
.sub-cat-chip i:hover { opacity: 1; }
.wc-var-mono {
font-family: var(--wp-font-mono, ui-monospace, monospace);
color: var(--wp-accent-text, #c4b5fd);
}
</style>