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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user