feat(spa): scaffold combine/derivation/constraint/pipeline list + edit views
Adds four placeholder list pages and matching edit-form stubs to the Vue SPA Manager, mirroring the existing Wildcards/FixedValues pattern: - Combines, Derivations, Constraints, Pipelines list views (DataTable with filters, expansion, click-to-copy id, kind-appropriate columns). - CombineForm/DerivationForm/ConstraintForm/PipelineForm: identity is fully editable; type-specific section is a "Coming soon" Card with a read-only payload JSON pre block. - ModuleType extended to include the four new types and per-type payload interfaces (Combine/Derivation/Constraint/Pipeline) added. - Router: per-kind list/new/edit routes wired up. - AppMenu: Pipelines added at top of Assets, soon flags removed from Combines/Derivations/Constraints (icons updated to match prototype). - Smoke tests (one per list view) confirm store.filter.type is set.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
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";
|
||||
import ConfirmationService from "primevue/confirmationservice";
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
api: {
|
||||
modules: {
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
duplicate: vi.fn(),
|
||||
favorite: vi.fn(),
|
||||
},
|
||||
categories: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
},
|
||||
ApiError: class extends Error {
|
||||
constructor(public status: number, message: string) { super(message); }
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import Combines from "../views/Combines.vue";
|
||||
|
||||
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
Object.values(apiMod).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: { template: "<div/>" } },
|
||||
{ path: "/combines/new", component: { template: "<div/>" } },
|
||||
{ path: "/combines/:id/edit", name: "combines-edit", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return mount(Combines, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService, ConfirmationService] },
|
||||
});
|
||||
}
|
||||
|
||||
describe("Combines.vue", () => {
|
||||
it("filters store by type=combine on mount", async () => {
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
mountView();
|
||||
await flushPromises();
|
||||
expect(apiMod.list).toHaveBeenCalledWith(expect.objectContaining({ type: "combine" }));
|
||||
});
|
||||
|
||||
it("renders rows", async () => {
|
||||
apiMod.list.mockResolvedValue({
|
||||
items: [{
|
||||
id: "cb_a", type: "combine", name: "Subject Phrase", category_id: null,
|
||||
tags: [], is_favorite: false,
|
||||
payload: { template: "$name, $age", output_var: "subject", input_vars: ["$name", "$age"] },
|
||||
version: 1, description: "", created_at: "", updated_at: "",
|
||||
}],
|
||||
total: 1,
|
||||
});
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Subject Phrase");
|
||||
});
|
||||
|
||||
it("empty state shows CTA", async () => {
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("No combines yet");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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";
|
||||
import ConfirmationService from "primevue/confirmationservice";
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
api: {
|
||||
modules: {
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
duplicate: vi.fn(),
|
||||
favorite: vi.fn(),
|
||||
},
|
||||
categories: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
},
|
||||
ApiError: class extends Error {
|
||||
constructor(public status: number, message: string) { super(message); }
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import Constraints from "../views/Constraints.vue";
|
||||
|
||||
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
Object.values(apiMod).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: { template: "<div/>" } },
|
||||
{ path: "/constraints/new", component: { template: "<div/>" } },
|
||||
{ path: "/constraints/:id/edit", name: "constraints-edit", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return mount(Constraints, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService, ConfirmationService] },
|
||||
});
|
||||
}
|
||||
|
||||
describe("Constraints.vue", () => {
|
||||
it("filters store by type=constraint on mount", async () => {
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
mountView();
|
||||
await flushPromises();
|
||||
expect(apiMod.list).toHaveBeenCalledWith(expect.objectContaining({ type: "constraint" }));
|
||||
});
|
||||
|
||||
it("renders rows", async () => {
|
||||
apiMod.list.mockResolvedValue({
|
||||
items: [{
|
||||
id: "cn_a", type: "constraint", name: "Hair × Outfit", category_id: null,
|
||||
tags: [], is_favorite: false,
|
||||
payload: { source_wildcard_id: null, target_wildcard_id: null, matrix: [], exceptions: [] },
|
||||
version: 1, description: "", created_at: "", updated_at: "",
|
||||
}],
|
||||
total: 1,
|
||||
});
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Hair × Outfit");
|
||||
});
|
||||
|
||||
it("empty state shows CTA", async () => {
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("No constraints yet");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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";
|
||||
import ConfirmationService from "primevue/confirmationservice";
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
api: {
|
||||
modules: {
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
duplicate: vi.fn(),
|
||||
favorite: vi.fn(),
|
||||
},
|
||||
categories: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
},
|
||||
ApiError: class extends Error {
|
||||
constructor(public status: number, message: string) { super(message); }
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import Derivations from "../views/Derivations.vue";
|
||||
|
||||
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
Object.values(apiMod).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: { template: "<div/>" } },
|
||||
{ path: "/derivations/new", component: { template: "<div/>" } },
|
||||
{ path: "/derivations/:id/edit", name: "derivations-edit", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return mount(Derivations, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService, ConfirmationService] },
|
||||
});
|
||||
}
|
||||
|
||||
describe("Derivations.vue", () => {
|
||||
it("filters store by type=derivation on mount", async () => {
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
mountView();
|
||||
await flushPromises();
|
||||
expect(apiMod.list).toHaveBeenCalledWith(expect.objectContaining({ type: "derivation" }));
|
||||
});
|
||||
|
||||
it("renders rows", async () => {
|
||||
apiMod.list.mockResolvedValue({
|
||||
items: [{
|
||||
id: "dv_a", type: "derivation", name: "Wet weather", category_id: null,
|
||||
tags: [], is_favorite: false,
|
||||
payload: { rules: [] },
|
||||
version: 1, description: "", created_at: "", updated_at: "",
|
||||
}],
|
||||
total: 1,
|
||||
});
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Wet weather");
|
||||
});
|
||||
|
||||
it("empty state shows CTA", async () => {
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("No derivations yet");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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";
|
||||
import ConfirmationService from "primevue/confirmationservice";
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
api: {
|
||||
modules: {
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
duplicate: vi.fn(),
|
||||
favorite: vi.fn(),
|
||||
},
|
||||
categories: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
},
|
||||
ApiError: class extends Error {
|
||||
constructor(public status: number, message: string) { super(message); }
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import Pipelines from "../views/Pipelines.vue";
|
||||
|
||||
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
Object.values(apiMod).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: { template: "<div/>" } },
|
||||
{ path: "/pipelines/new", component: { template: "<div/>" } },
|
||||
{ path: "/pipelines/:id/edit", name: "pipelines-edit", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return mount(Pipelines, {
|
||||
global: { plugins: [makeRouter(), PrimeVue, ToastService, ConfirmationService] },
|
||||
});
|
||||
}
|
||||
|
||||
describe("Pipelines.vue", () => {
|
||||
it("filters store by type=pipeline on mount", async () => {
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
mountView();
|
||||
await flushPromises();
|
||||
expect(apiMod.list).toHaveBeenCalledWith(expect.objectContaining({ type: "pipeline" }));
|
||||
});
|
||||
|
||||
it("renders rows", async () => {
|
||||
apiMod.list.mockResolvedValue({
|
||||
items: [{
|
||||
id: "pl_a", type: "pipeline", name: "Portrait Default", category_id: null,
|
||||
tags: [], is_favorite: false,
|
||||
payload: { steps: [] },
|
||||
version: 1, description: "", created_at: "", updated_at: "",
|
||||
}],
|
||||
total: 1,
|
||||
});
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Portrait Default");
|
||||
});
|
||||
|
||||
it("empty state shows CTA", async () => {
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("No pipelines yet");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,77 @@
|
||||
export type ModuleType = "wildcard" | "fixed_values";
|
||||
export type ModuleType =
|
||||
| "wildcard"
|
||||
| "fixed_values"
|
||||
| "combine"
|
||||
| "derivation"
|
||||
| "constraint"
|
||||
| "pipeline";
|
||||
|
||||
// ----- Per-type payload shapes (cross-referenced from data.jsx prototype) -----
|
||||
|
||||
export interface CombinePayload {
|
||||
template: string;
|
||||
output_var: string;
|
||||
input_vars: string[];
|
||||
}
|
||||
|
||||
export interface DerivationCondition {
|
||||
kind: "always" | "contains" | "equals" | "absent" | string;
|
||||
var?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface DerivationAction {
|
||||
kind: "append" | "prepend" | "replace" | "remove" | "set" | string;
|
||||
target: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface DerivationBranch {
|
||||
condition: DerivationCondition;
|
||||
action: DerivationAction;
|
||||
}
|
||||
|
||||
export interface DerivationRule {
|
||||
id: string;
|
||||
branches: DerivationBranch[];
|
||||
else?: DerivationAction;
|
||||
}
|
||||
|
||||
export interface DerivationPayload {
|
||||
rules: DerivationRule[];
|
||||
}
|
||||
|
||||
export type ConstraintMatrixCell =
|
||||
| "allow"
|
||||
| "exclude"
|
||||
| "boost"
|
||||
| "reduce"
|
||||
| { mode: string; factor?: number };
|
||||
|
||||
export interface ConstraintException {
|
||||
from: string;
|
||||
to: string;
|
||||
mode: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ConstraintPayload {
|
||||
source_wildcard_id: string | null;
|
||||
target_wildcard_id: string | null;
|
||||
matrix: ConstraintMatrixCell[][];
|
||||
exceptions: ConstraintException[];
|
||||
}
|
||||
|
||||
export interface PipelineStep {
|
||||
id: string;
|
||||
module_id: string;
|
||||
enabled: boolean;
|
||||
instance?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PipelinePayload {
|
||||
steps: PipelineStep[];
|
||||
}
|
||||
|
||||
export interface ModuleRow {
|
||||
id: string;
|
||||
|
||||
@@ -18,11 +18,12 @@ const sections: Section[] = [
|
||||
{
|
||||
label: "Assets",
|
||||
items: [
|
||||
{ label: "Pipelines", icon: "pi pi-list", to: "/pipelines" },
|
||||
{ label: "Wildcards", icon: "pi pi-th-large", to: "/wildcards" },
|
||||
{ label: "Fixed Values", icon: "pi pi-tag", to: "/fixed-values" },
|
||||
{ label: "Constraints", icon: "pi pi-filter", soon: true },
|
||||
{ label: "Combines", icon: "pi pi-link", soon: true },
|
||||
{ label: "Derivations", icon: "pi pi-code", soon: true },
|
||||
{ label: "Combines", icon: "pi pi-share-alt", to: "/combines" },
|
||||
{ label: "Derivations", icon: "pi pi-code", to: "/derivations" },
|
||||
{ label: "Constraints", icon: "pi pi-sitemap", to: "/constraints" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,6 +14,18 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: "fixed-values", name: "fixed-values", component: () => import("../views/FixedValues.vue") },
|
||||
{ path: "fixed-values/new", name: "fixed-values-new", component: () => import("../views/FixedValueForm.vue") },
|
||||
{ path: "fixed-values/:id/edit", name: "fixed-values-edit", component: () => import("../views/FixedValueForm.vue"), props: true },
|
||||
{ path: "combines", name: "combines", component: () => import("../views/Combines.vue") },
|
||||
{ path: "combines/new", name: "combines-new", component: () => import("../views/CombineForm.vue") },
|
||||
{ path: "combines/:id/edit", name: "combines-edit", component: () => import("../views/CombineForm.vue"), props: true },
|
||||
{ path: "derivations", name: "derivations", component: () => import("../views/Derivations.vue") },
|
||||
{ path: "derivations/new", name: "derivations-new", component: () => import("../views/DerivationForm.vue") },
|
||||
{ path: "derivations/:id/edit", name: "derivations-edit", component: () => import("../views/DerivationForm.vue"), props: true },
|
||||
{ path: "constraints", name: "constraints", component: () => import("../views/Constraints.vue") },
|
||||
{ path: "constraints/new", name: "constraints-new", component: () => import("../views/ConstraintForm.vue") },
|
||||
{ path: "constraints/:id/edit", name: "constraints-edit", component: () => import("../views/ConstraintForm.vue"), props: true },
|
||||
{ path: "pipelines", name: "pipelines", component: () => import("../views/Pipelines.vue") },
|
||||
{ path: "pipelines/new", name: "pipelines-new", component: () => import("../views/PipelineForm.vue") },
|
||||
{ path: "pipelines/:id/edit", name: "pipelines-edit", component: () => import("../views/PipelineForm.vue"), props: true },
|
||||
{ path: "categories", name: "categories", component: () => import("../views/Categories.vue") },
|
||||
{ path: "import-export", name: "import-export", component: () => import("../views/ImportExport.vue") },
|
||||
{ path: "test", name: "test", component: () => import("../views/TestRunner.vue") },
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Textarea from "primevue/textarea";
|
||||
import Select from "primevue/select";
|
||||
import AutoComplete from "primevue/autocomplete";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import type { CombinePayload } from "../api/types";
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const categoryId = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const payload = ref<CombinePayload>({ template: "", output_var: "", input_vars: [] });
|
||||
const saving = ref(false);
|
||||
const isEdit = computed(() => !!props.id);
|
||||
|
||||
const tagSuggestions = ref<string[]>([]);
|
||||
function searchTags(event: { query: string }) {
|
||||
const q = event.query.toLowerCase();
|
||||
const known = new Set<string>();
|
||||
for (const m of moduleStore.items) {
|
||||
for (const t of m.tags ?? []) known.add(t);
|
||||
}
|
||||
tagSuggestions.value = Array.from(known)
|
||||
.filter((t) => t.toLowerCase().includes(q) && !tags.value.includes(t))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
|
||||
if (props.id) {
|
||||
try {
|
||||
const row = await moduleStore.get(props.id);
|
||||
name.value = row.name;
|
||||
description.value = row.description;
|
||||
categoryId.value = row.category_id;
|
||||
tags.value = row.tags;
|
||||
const p = row.payload as Partial<CombinePayload>;
|
||||
payload.value = {
|
||||
template: p.template ?? "",
|
||||
output_var: p.output_var ?? "",
|
||||
input_vars: p.input_vars ?? [],
|
||||
};
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Combine not found", life: 3000 });
|
||||
router.replace("/combines");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const body = {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
category_id: categoryId.value,
|
||||
tags: tags.value,
|
||||
payload: payload.value as unknown as Record<string, unknown>,
|
||||
};
|
||||
if (isEdit.value && props.id) {
|
||||
await moduleStore.update(props.id, body);
|
||||
} else {
|
||||
await moduleStore.create({ type: "combine", ...body });
|
||||
}
|
||||
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
|
||||
router.push("/combines");
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-page__header">
|
||||
<RouterLink to="/combines" class="text-xs text-wp-text2 hover:text-wp-text">
|
||||
<i class="pi pi-angle-left text-xs" /> Combines
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-semibold m-0 mt-1">{{ isEdit ? "Edit combine" : "New combine" }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-page__body">
|
||||
<section class="form-section">
|
||||
<h2 class="form-section__label">Identity</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="cb-name" class="block text-xs text-wp-text2 mb-1">Name</label>
|
||||
<InputText id="cb-name" v-model="name" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="cb-cat" class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
id="cb-cat" v-model="categoryId"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="None" show-clear class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="cb-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
|
||||
<Textarea id="cb-desc" v-model="description" rows="2" class="w-full" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="cb-tags" class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<AutoComplete
|
||||
id="cb-tags"
|
||||
v-model="tags"
|
||||
multiple
|
||||
typeahead
|
||||
:suggestions="tagSuggestions"
|
||||
placeholder="Type a tag and press Enter…"
|
||||
class="w-full"
|
||||
@complete="searchTags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h2 class="form-section__label">Template</h2>
|
||||
<Card>
|
||||
<template #content>
|
||||
<p class="text-sm text-wp-text2 m-0 mb-2">
|
||||
Editor coming soon — payload shown below.
|
||||
</p>
|
||||
<pre class="payload-preview" data-test="payload-preview">{{ JSON.stringify(payload, null, 2) }}</pre>
|
||||
</template>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="form-page__footer">
|
||||
<Button label="Cancel" severity="secondary" outlined @click="router.push('/combines')" />
|
||||
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
|
||||
.form-page__header { padding: 24px 24px 0; }
|
||||
.form-page__body { padding: 16px 24px 96px; max-width: 56rem; flex: 1; }
|
||||
.form-page__footer {
|
||||
position: sticky; bottom: 0;
|
||||
background: var(--wp-bg);
|
||||
border-top: 1px solid var(--wp-border);
|
||||
padding: 12px 24px;
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
}
|
||||
.form-section { margin-bottom: 24px; }
|
||||
.form-section__label {
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--wp-text2);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.payload-preview {
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
color: var(--wp-text);
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import Column from "primevue/column";
|
||||
import Button from "primevue/button";
|
||||
import Badge from "primevue/badge";
|
||||
import Checkbox from "primevue/checkbox";
|
||||
import MultiSelect from "primevue/multiselect";
|
||||
import Select from "primevue/select";
|
||||
import EntityListView from "../components/EntityListView.vue";
|
||||
import RelativeDate from "../components/RelativeDate.vue";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import type { CategoryRow, CombinePayload, ModuleRow } from "../api/types";
|
||||
|
||||
const router = useRouter();
|
||||
const store = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
const categoryById = computed(() => {
|
||||
const map = new Map<string, CategoryRow>();
|
||||
for (const c of categoryStore.items) map.set(c.id, c);
|
||||
return map;
|
||||
});
|
||||
|
||||
const allTags = computed(() => {
|
||||
const set = new Set<string>();
|
||||
for (const m of store.items) for (const t of m.tags ?? []) set.add(t);
|
||||
return Array.from(set).sort();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
store.filter.type = "combine";
|
||||
await Promise.all([fetch(), categoryStore.fetchAll()]);
|
||||
});
|
||||
|
||||
async function fetch() {
|
||||
store.filter.type = "combine";
|
||||
try {
|
||||
await store.fetchAll();
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Load failed", detail: String(e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
function edit(row: ModuleRow) {
|
||||
router.push({ name: "combines-edit", params: { id: row.id } });
|
||||
}
|
||||
|
||||
async function copyId(id: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(id);
|
||||
toast.add({ severity: "info", summary: "ID copied", detail: id, life: 1500 });
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Copy failed", life: 2000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function dup(row: ModuleRow) {
|
||||
try {
|
||||
await store.duplicate(row.id);
|
||||
toast.add({ severity: "success", summary: "Duplicated", detail: row.name, life: 2000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Duplicate failed", detail: String(e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function fav(row: ModuleRow) {
|
||||
try { await store.toggleFavorite(row.id); }
|
||||
catch (e) { toast.add({ severity: "error", summary: "Favorite failed", detail: String(e), life: 4000 }); }
|
||||
}
|
||||
|
||||
async function del(row: ModuleRow) {
|
||||
try {
|
||||
await store.remove(row.id);
|
||||
toast.add({ severity: "success", summary: "Deleted", detail: row.name, life: 2000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Delete failed", detail: String(e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDel(items: ModuleRow[]) {
|
||||
for (const item of items) await del(item);
|
||||
}
|
||||
|
||||
function payloadOf(row: ModuleRow): CombinePayload {
|
||||
const p = row.payload as Partial<CombinePayload>;
|
||||
return {
|
||||
template: p.template ?? "",
|
||||
output_var: p.output_var ?? "",
|
||||
input_vars: p.input_vars ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function outputVar(row: ModuleRow): string {
|
||||
const v = payloadOf(row).output_var || "";
|
||||
return v ? `$${v.replace(/^\$/, "")}` : "—";
|
||||
}
|
||||
|
||||
function inputVars(row: ModuleRow): string[] { return payloadOf(row).input_vars; }
|
||||
function inputCount(row: ModuleRow): number { return inputVars(row).length; }
|
||||
function templateOf(row: ModuleRow): string { return payloadOf(row).template; }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EntityListView
|
||||
title="Combines"
|
||||
subtitle="Combine modules merge resolved variables into a new value via templates."
|
||||
new-label="New Combine"
|
||||
new-route="/combines/new"
|
||||
:items="store.items"
|
||||
:loading="store.loading"
|
||||
:filter="store.filter"
|
||||
empty-message="No combines yet. Use these to merge resolved variables into named outputs."
|
||||
@fetch="fetch"
|
||||
@delete="del"
|
||||
@bulk-delete="bulkDel"
|
||||
>
|
||||
<template #filter-panel="{ filter, emitFetch }">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
v-model="filter.category"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="All categories" show-clear class="w-full"
|
||||
aria-label="Filter by category"
|
||||
@change="emitFetch"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<MultiSelect
|
||||
v-model="filter.tags"
|
||||
:options="allTags"
|
||||
placeholder="Any tag" display="chip" filter class="w-full"
|
||||
@change="emitFetch"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2 flex items-center gap-4 pt-1">
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox v-model="filter.favorites" :binary="true" @change="emitFetch" />
|
||||
Favorites only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #columns>
|
||||
<Column selection-mode="multiple" header-style="width:3rem" />
|
||||
<Column expander header-style="width:3rem" />
|
||||
<Column header-style="width:3rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
:icon="data.is_favorite ? 'pi pi-star-fill' : 'pi pi-star'"
|
||||
text rounded size="small"
|
||||
:severity="data.is_favorite ? 'warning' : 'secondary'"
|
||||
aria-label="Toggle favorite"
|
||||
@click.stop="fav(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="name" header="Name" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="wp-kind-combine pi pi-share-alt text-wp-violet" aria-hidden="true" />
|
||||
<div class="flex flex-col">
|
||||
<span class="cursor-pointer font-medium hover:text-white" @click="edit(data)">{{ data.name }}</span>
|
||||
<span
|
||||
class="text-xs text-wp-text3 font-mono cursor-pointer hover:text-wp-text2 select-all"
|
||||
:title="`Click to copy ${data.id}`"
|
||||
@click.stop="copyId(data.id)"
|
||||
>{{ data.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="category_id" header="Category" header-style="width:9rem" sortable>
|
||||
<template #body="{ data }">
|
||||
<span
|
||||
v-if="data.category_id && categoryById.get(data.category_id)"
|
||||
class="category-chip"
|
||||
:style="{ background: categoryById.get(data.category_id)!.color || 'var(--wp-bg3)' }"
|
||||
>
|
||||
{{ categoryById.get(data.category_id)!.name }}
|
||||
</span>
|
||||
<span v-else class="text-wp-text3 text-sm">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Output" header-style="width:10rem">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-wp-violet">{{ outputVar(data) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Inputs" header-style="width:5rem">
|
||||
<template #body="{ data }">
|
||||
<Badge :value="String(inputCount(data))" severity="secondary" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="tags" header="Tags" header-style="width:13rem">
|
||||
<template #body="{ data }">
|
||||
<div v-if="data.tags?.length" class="flex flex-wrap gap-1">
|
||||
<span v-for="(t, i) in data.tags.slice(0, 3)" :key="i" class="tag-chip">{{ t }}</span>
|
||||
<span v-if="data.tags.length > 3" class="text-xs text-wp-text3">+{{ data.tags.length - 3 }}</span>
|
||||
</div>
|
||||
<span v-else class="text-wp-text3 text-sm">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="updated_at" header="Updated" sortable header-style="width:9rem">
|
||||
<template #body="{ data }">
|
||||
<RelativeDate :value="data.updated_at" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Actions" header-style="width:11rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-1" @click.stop>
|
||||
<Button icon="pi pi-pencil" text rounded size="small" aria-label="Edit" @click="edit(data)" />
|
||||
<Button icon="pi pi-copy" text rounded size="small" aria-label="Duplicate" @click="dup(data)" />
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" aria-label="Delete" @click="del(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</template>
|
||||
|
||||
<template #expansion="{ data }">
|
||||
<div class="px-6 py-3">
|
||||
<h5 class="text-xs font-semibold mb-2 uppercase tracking-wider text-wp-text2">
|
||||
Template for <span class="text-wp-rose">{{ data.name }}</span>
|
||||
</h5>
|
||||
<pre v-if="templateOf(data)" class="combine-template font-mono text-xs">{{ templateOf(data) }}</pre>
|
||||
<div v-else class="text-sm text-wp-text3 mb-2">No template defined.</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1">
|
||||
<span class="text-xs text-wp-text3 mr-1">Reads:</span>
|
||||
<span v-if="!inputVars(data).length" class="text-xs text-wp-text3">no inputs</span>
|
||||
<span
|
||||
v-for="(v, i) in inputVars(data)" :key="i"
|
||||
class="input-var-chip font-mono"
|
||||
>{{ v.startsWith("$") ? v : `$${v}` }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</EntityListView>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.category-chip {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.tag-chip {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
background: var(--wp-bg3);
|
||||
color: var(--wp-text2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.combine-template {
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
padding: 8px 10px;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--wp-text);
|
||||
max-width: 720px;
|
||||
}
|
||||
.input-var-chip {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--wp-violet-bg, var(--wp-bg3));
|
||||
color: var(--wp-violet, var(--wp-text2));
|
||||
border: 1px solid var(--wp-violet, var(--wp-border));
|
||||
}
|
||||
.text-wp-violet { color: var(--wp-kind-combine, var(--wp-violet, #b4a0ff)); }
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Textarea from "primevue/textarea";
|
||||
import Select from "primevue/select";
|
||||
import AutoComplete from "primevue/autocomplete";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import type { ConstraintPayload } from "../api/types";
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const categoryId = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const payload = ref<ConstraintPayload>({
|
||||
source_wildcard_id: null,
|
||||
target_wildcard_id: null,
|
||||
matrix: [],
|
||||
exceptions: [],
|
||||
});
|
||||
const saving = ref(false);
|
||||
const isEdit = computed(() => !!props.id);
|
||||
|
||||
const tagSuggestions = ref<string[]>([]);
|
||||
function searchTags(event: { query: string }) {
|
||||
const q = event.query.toLowerCase();
|
||||
const known = new Set<string>();
|
||||
for (const m of moduleStore.items) {
|
||||
for (const t of m.tags ?? []) known.add(t);
|
||||
}
|
||||
tagSuggestions.value = Array.from(known)
|
||||
.filter((t) => t.toLowerCase().includes(q) && !tags.value.includes(t))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
|
||||
if (props.id) {
|
||||
try {
|
||||
const row = await moduleStore.get(props.id);
|
||||
name.value = row.name;
|
||||
description.value = row.description;
|
||||
categoryId.value = row.category_id;
|
||||
tags.value = row.tags;
|
||||
const p = row.payload as Partial<ConstraintPayload>;
|
||||
payload.value = {
|
||||
source_wildcard_id: p.source_wildcard_id ?? null,
|
||||
target_wildcard_id: p.target_wildcard_id ?? null,
|
||||
matrix: p.matrix ?? [],
|
||||
exceptions: p.exceptions ?? [],
|
||||
};
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Constraint not found", life: 3000 });
|
||||
router.replace("/constraints");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const body = {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
category_id: categoryId.value,
|
||||
tags: tags.value,
|
||||
payload: payload.value as unknown as Record<string, unknown>,
|
||||
};
|
||||
if (isEdit.value && props.id) {
|
||||
await moduleStore.update(props.id, body);
|
||||
} else {
|
||||
await moduleStore.create({ type: "constraint", ...body });
|
||||
}
|
||||
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
|
||||
router.push("/constraints");
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-page__header">
|
||||
<RouterLink to="/constraints" class="text-xs text-wp-text2 hover:text-wp-text">
|
||||
<i class="pi pi-angle-left text-xs" /> Constraints
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-semibold m-0 mt-1">{{ isEdit ? "Edit constraint" : "New constraint" }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-page__body">
|
||||
<section class="form-section">
|
||||
<h2 class="form-section__label">Identity</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="cn-name" class="block text-xs text-wp-text2 mb-1">Name</label>
|
||||
<InputText id="cn-name" v-model="name" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="cn-cat" class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
id="cn-cat" v-model="categoryId"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="None" show-clear class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="cn-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
|
||||
<Textarea id="cn-desc" v-model="description" rows="2" class="w-full" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="cn-tags" class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<AutoComplete
|
||||
id="cn-tags"
|
||||
v-model="tags"
|
||||
multiple
|
||||
typeahead
|
||||
:suggestions="tagSuggestions"
|
||||
placeholder="Type a tag and press Enter…"
|
||||
class="w-full"
|
||||
@complete="searchTags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h2 class="form-section__label">Matrix</h2>
|
||||
<Card>
|
||||
<template #content>
|
||||
<p class="text-sm text-wp-text2 m-0 mb-2">
|
||||
Editor coming soon — payload shown below.
|
||||
</p>
|
||||
<pre class="payload-preview" data-test="payload-preview">{{ JSON.stringify(payload, null, 2) }}</pre>
|
||||
</template>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="form-page__footer">
|
||||
<Button label="Cancel" severity="secondary" outlined @click="router.push('/constraints')" />
|
||||
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
|
||||
.form-page__header { padding: 24px 24px 0; }
|
||||
.form-page__body { padding: 16px 24px 96px; max-width: 56rem; flex: 1; }
|
||||
.form-page__footer {
|
||||
position: sticky; bottom: 0;
|
||||
background: var(--wp-bg);
|
||||
border-top: 1px solid var(--wp-border);
|
||||
padding: 12px 24px;
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
}
|
||||
.form-section { margin-bottom: 24px; }
|
||||
.form-section__label {
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--wp-text2);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.payload-preview {
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
color: var(--wp-text);
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,337 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import Column from "primevue/column";
|
||||
import Button from "primevue/button";
|
||||
import Badge from "primevue/badge";
|
||||
import Checkbox from "primevue/checkbox";
|
||||
import MultiSelect from "primevue/multiselect";
|
||||
import Select from "primevue/select";
|
||||
import EntityListView from "../components/EntityListView.vue";
|
||||
import RelativeDate from "../components/RelativeDate.vue";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import type {
|
||||
CategoryRow,
|
||||
ConstraintMatrixCell,
|
||||
ConstraintPayload,
|
||||
ModuleRow,
|
||||
} from "../api/types";
|
||||
|
||||
const router = useRouter();
|
||||
const store = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
const categoryById = computed(() => {
|
||||
const map = new Map<string, CategoryRow>();
|
||||
for (const c of categoryStore.items) map.set(c.id, c);
|
||||
return map;
|
||||
});
|
||||
|
||||
// All loaded modules — used for wildcard-name lookup. We rely on whatever
|
||||
// wildcards happen to be in the moduleStore at the moment; if they're not
|
||||
// present we fall back to the raw id. NOTE: a future moduleStore.lookupById
|
||||
// helper that fetches a single record on-demand would be cleaner.
|
||||
const moduleNameById = computed(() => {
|
||||
const map = new Map<string, ModuleRow>();
|
||||
for (const m of store.items) map.set(m.id, m);
|
||||
return map;
|
||||
});
|
||||
|
||||
const allTags = computed(() => {
|
||||
const set = new Set<string>();
|
||||
for (const m of store.items) for (const t of m.tags ?? []) set.add(t);
|
||||
return Array.from(set).sort();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
store.filter.type = "constraint";
|
||||
await Promise.all([fetch(), categoryStore.fetchAll()]);
|
||||
});
|
||||
|
||||
async function fetch() {
|
||||
store.filter.type = "constraint";
|
||||
try {
|
||||
await store.fetchAll();
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Load failed", detail: String(e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
function edit(row: ModuleRow) {
|
||||
router.push({ name: "constraints-edit", params: { id: row.id } });
|
||||
}
|
||||
|
||||
async function copyId(id: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(id);
|
||||
toast.add({ severity: "info", summary: "ID copied", detail: id, life: 1500 });
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Copy failed", life: 2000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function dup(row: ModuleRow) {
|
||||
try {
|
||||
await store.duplicate(row.id);
|
||||
toast.add({ severity: "success", summary: "Duplicated", detail: row.name, life: 2000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Duplicate failed", detail: String(e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function fav(row: ModuleRow) {
|
||||
try { await store.toggleFavorite(row.id); }
|
||||
catch (e) { toast.add({ severity: "error", summary: "Favorite failed", detail: String(e), life: 4000 }); }
|
||||
}
|
||||
|
||||
async function del(row: ModuleRow) {
|
||||
try {
|
||||
await store.remove(row.id);
|
||||
toast.add({ severity: "success", summary: "Deleted", detail: row.name, life: 2000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Delete failed", detail: String(e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDel(items: ModuleRow[]) {
|
||||
for (const item of items) await del(item);
|
||||
}
|
||||
|
||||
function payloadOf(row: ModuleRow): ConstraintPayload {
|
||||
const p = row.payload as Partial<ConstraintPayload>;
|
||||
return {
|
||||
source_wildcard_id: p.source_wildcard_id ?? null,
|
||||
target_wildcard_id: p.target_wildcard_id ?? null,
|
||||
matrix: p.matrix ?? [],
|
||||
exceptions: p.exceptions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function lookupName(id: string | null): string {
|
||||
if (!id) return "—";
|
||||
return moduleNameById.value.get(id)?.name ?? id;
|
||||
}
|
||||
|
||||
function exceptionCount(row: ModuleRow): number { return payloadOf(row).exceptions.length; }
|
||||
|
||||
interface MatrixSample { source: string; target: string; mode: string; factor: number; }
|
||||
|
||||
function matrixSamples(row: ModuleRow, max = 5): MatrixSample[] {
|
||||
const p = payloadOf(row);
|
||||
const sourceWc = p.source_wildcard_id ? moduleNameById.value.get(p.source_wildcard_id) : undefined;
|
||||
const targetWc = p.target_wildcard_id ? moduleNameById.value.get(p.target_wildcard_id) : undefined;
|
||||
const sourceSubs: string[] =
|
||||
(sourceWc?.payload as { sub_categories?: string[] } | undefined)?.sub_categories ?? [];
|
||||
const targetSubs: string[] =
|
||||
(targetWc?.payload as { sub_categories?: string[] } | undefined)?.sub_categories ?? [];
|
||||
|
||||
const out: MatrixSample[] = [];
|
||||
for (let ti = 0; ti < p.matrix.length && out.length < max; ti++) {
|
||||
const row = p.matrix[ti] ?? [];
|
||||
for (let si = 0; si < row.length && out.length < max; si++) {
|
||||
const cell: ConstraintMatrixCell = row[si];
|
||||
const mode = typeof cell === "string" ? cell : (cell?.mode ?? "allow");
|
||||
const factor =
|
||||
typeof cell === "string"
|
||||
? (cell === "boost" ? 2 : cell === "reduce" ? 0.5 : 1)
|
||||
: (typeof cell?.factor === "number" ? cell.factor : (cell?.mode === "boost" ? 2 : cell?.mode === "reduce" ? 0.5 : 1));
|
||||
// Skip plain "allow" cells from the preview to surface meaningful entries.
|
||||
if (mode === "allow") continue;
|
||||
out.push({
|
||||
source: sourceSubs[si] ?? `col ${si + 1}`,
|
||||
target: targetSubs[ti] ?? `row ${ti + 1}`,
|
||||
mode,
|
||||
factor,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatFactor(f: number): string {
|
||||
if (!Number.isFinite(f)) return "1";
|
||||
if (f >= 10) return f.toFixed(0);
|
||||
return f.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EntityListView
|
||||
title="Constraints"
|
||||
subtitle="Constraint modules apply rules between two wildcards' categories."
|
||||
new-label="New Constraint"
|
||||
new-route="/constraints/new"
|
||||
:items="store.items"
|
||||
:loading="store.loading"
|
||||
:filter="store.filter"
|
||||
empty-message="No constraints yet. Use these to set rules between wildcards."
|
||||
@fetch="fetch"
|
||||
@delete="del"
|
||||
@bulk-delete="bulkDel"
|
||||
>
|
||||
<template #filter-panel="{ filter, emitFetch }">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
v-model="filter.category"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="All categories" show-clear class="w-full"
|
||||
aria-label="Filter by category"
|
||||
@change="emitFetch"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<MultiSelect
|
||||
v-model="filter.tags"
|
||||
:options="allTags"
|
||||
placeholder="Any tag" display="chip" filter class="w-full"
|
||||
@change="emitFetch"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2 flex items-center gap-4 pt-1">
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox v-model="filter.favorites" :binary="true" @change="emitFetch" />
|
||||
Favorites only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #columns>
|
||||
<Column selection-mode="multiple" header-style="width:3rem" />
|
||||
<Column expander header-style="width:3rem" />
|
||||
<Column header-style="width:3rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
:icon="data.is_favorite ? 'pi pi-star-fill' : 'pi pi-star'"
|
||||
text rounded size="small"
|
||||
:severity="data.is_favorite ? 'warning' : 'secondary'"
|
||||
aria-label="Toggle favorite"
|
||||
@click.stop="fav(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="name" header="Name" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="wp-kind-constraint pi pi-sitemap text-wp-amber" aria-hidden="true" />
|
||||
<div class="flex flex-col">
|
||||
<span class="cursor-pointer font-medium hover:text-white" @click="edit(data)">{{ data.name }}</span>
|
||||
<span
|
||||
class="text-xs text-wp-text3 font-mono cursor-pointer hover:text-wp-text2 select-all"
|
||||
:title="`Click to copy ${data.id}`"
|
||||
@click.stop="copyId(data.id)"
|
||||
>{{ data.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="category_id" header="Category" header-style="width:9rem" sortable>
|
||||
<template #body="{ data }">
|
||||
<span
|
||||
v-if="data.category_id && categoryById.get(data.category_id)"
|
||||
class="category-chip"
|
||||
:style="{ background: categoryById.get(data.category_id)!.color || 'var(--wp-bg3)' }"
|
||||
>
|
||||
{{ categoryById.get(data.category_id)!.name }}
|
||||
</span>
|
||||
<span v-else class="text-wp-text3 text-sm">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Source" header-style="width:11rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm">{{ lookupName(payloadOf(data).source_wildcard_id) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Target" header-style="width:11rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm">{{ lookupName(payloadOf(data).target_wildcard_id) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Exceptions" header-style="width:7rem">
|
||||
<template #body="{ data }">
|
||||
<Badge :value="String(exceptionCount(data))" severity="secondary" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="tags" header="Tags" header-style="width:13rem">
|
||||
<template #body="{ data }">
|
||||
<div v-if="data.tags?.length" class="flex flex-wrap gap-1">
|
||||
<span v-for="(t, i) in data.tags.slice(0, 3)" :key="i" class="tag-chip">{{ t }}</span>
|
||||
<span v-if="data.tags.length > 3" class="text-xs text-wp-text3">+{{ data.tags.length - 3 }}</span>
|
||||
</div>
|
||||
<span v-else class="text-wp-text3 text-sm">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="updated_at" header="Updated" sortable header-style="width:9rem">
|
||||
<template #body="{ data }">
|
||||
<RelativeDate :value="data.updated_at" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Actions" header-style="width:11rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-1" @click.stop>
|
||||
<Button icon="pi pi-pencil" text rounded size="small" aria-label="Edit" @click="edit(data)" />
|
||||
<Button icon="pi pi-copy" text rounded size="small" aria-label="Duplicate" @click="dup(data)" />
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" aria-label="Delete" @click="del(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</template>
|
||||
|
||||
<template #expansion="{ data }">
|
||||
<div class="px-6 py-3">
|
||||
<h5 class="text-xs font-semibold mb-2 uppercase tracking-wider text-wp-text2">
|
||||
Sample matrix entries for <span class="text-wp-rose">{{ data.name }}</span>
|
||||
</h5>
|
||||
<div v-if="matrixSamples(data).length === 0" class="text-sm text-wp-text3">
|
||||
No non-default matrix cells defined.
|
||||
</div>
|
||||
<ul v-else class="matrix-list">
|
||||
<li
|
||||
v-for="(s, i) in matrixSamples(data)" :key="i"
|
||||
class="matrix-list__item font-mono text-xs"
|
||||
>
|
||||
<span class="text-wp-text2">{{ s.source }}</span>
|
||||
<span class="text-wp-text3 mx-2">→</span>
|
||||
<span class="text-wp-text2">{{ s.target }}</span>
|
||||
<span class="text-wp-text3 mx-2">:</span>
|
||||
<span class="text-wp-violet">{{ s.mode }}</span>
|
||||
<span v-if="s.mode === 'boost' || s.mode === 'reduce'" class="ml-2 text-wp-text3">
|
||||
×{{ formatFactor(s.factor) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="exceptionCount(data)" class="mt-2 text-xs text-wp-text3">
|
||||
{{ exceptionCount(data) }} per-pair exception{{ exceptionCount(data) === 1 ? "" : "s" }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</EntityListView>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.category-chip {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.tag-chip {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
background: var(--wp-bg3);
|
||||
color: var(--wp-text2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.matrix-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.text-wp-amber { color: var(--wp-kind-constraint, var(--wp-amber, #f7b955)); }
|
||||
.text-wp-violet { color: var(--wp-violet, #b4a0ff); }
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Textarea from "primevue/textarea";
|
||||
import Select from "primevue/select";
|
||||
import AutoComplete from "primevue/autocomplete";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import type { DerivationPayload } from "../api/types";
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const categoryId = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const payload = ref<DerivationPayload>({ rules: [] });
|
||||
const saving = ref(false);
|
||||
const isEdit = computed(() => !!props.id);
|
||||
|
||||
const tagSuggestions = ref<string[]>([]);
|
||||
function searchTags(event: { query: string }) {
|
||||
const q = event.query.toLowerCase();
|
||||
const known = new Set<string>();
|
||||
for (const m of moduleStore.items) {
|
||||
for (const t of m.tags ?? []) known.add(t);
|
||||
}
|
||||
tagSuggestions.value = Array.from(known)
|
||||
.filter((t) => t.toLowerCase().includes(q) && !tags.value.includes(t))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
|
||||
if (props.id) {
|
||||
try {
|
||||
const row = await moduleStore.get(props.id);
|
||||
name.value = row.name;
|
||||
description.value = row.description;
|
||||
categoryId.value = row.category_id;
|
||||
tags.value = row.tags;
|
||||
const p = row.payload as Partial<DerivationPayload>;
|
||||
payload.value = { rules: p.rules ?? [] };
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Derivation not found", life: 3000 });
|
||||
router.replace("/derivations");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const body = {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
category_id: categoryId.value,
|
||||
tags: tags.value,
|
||||
payload: payload.value as unknown as Record<string, unknown>,
|
||||
};
|
||||
if (isEdit.value && props.id) {
|
||||
await moduleStore.update(props.id, body);
|
||||
} else {
|
||||
await moduleStore.create({ type: "derivation", ...body });
|
||||
}
|
||||
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
|
||||
router.push("/derivations");
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-page__header">
|
||||
<RouterLink to="/derivations" class="text-xs text-wp-text2 hover:text-wp-text">
|
||||
<i class="pi pi-angle-left text-xs" /> Derivations
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-semibold m-0 mt-1">{{ isEdit ? "Edit derivation" : "New derivation" }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-page__body">
|
||||
<section class="form-section">
|
||||
<h2 class="form-section__label">Identity</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="dv-name" class="block text-xs text-wp-text2 mb-1">Name</label>
|
||||
<InputText id="dv-name" v-model="name" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="dv-cat" class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
id="dv-cat" v-model="categoryId"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="None" show-clear class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="dv-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
|
||||
<Textarea id="dv-desc" v-model="description" rows="2" class="w-full" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="dv-tags" class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<AutoComplete
|
||||
id="dv-tags"
|
||||
v-model="tags"
|
||||
multiple
|
||||
typeahead
|
||||
:suggestions="tagSuggestions"
|
||||
placeholder="Type a tag and press Enter…"
|
||||
class="w-full"
|
||||
@complete="searchTags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h2 class="form-section__label">Rules</h2>
|
||||
<Card>
|
||||
<template #content>
|
||||
<p class="text-sm text-wp-text2 m-0 mb-2">
|
||||
Editor coming soon — payload shown below.
|
||||
</p>
|
||||
<pre class="payload-preview" data-test="payload-preview">{{ JSON.stringify(payload, null, 2) }}</pre>
|
||||
</template>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="form-page__footer">
|
||||
<Button label="Cancel" severity="secondary" outlined @click="router.push('/derivations')" />
|
||||
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
|
||||
.form-page__header { padding: 24px 24px 0; }
|
||||
.form-page__body { padding: 16px 24px 96px; max-width: 56rem; flex: 1; }
|
||||
.form-page__footer {
|
||||
position: sticky; bottom: 0;
|
||||
background: var(--wp-bg);
|
||||
border-top: 1px solid var(--wp-border);
|
||||
padding: 12px 24px;
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
}
|
||||
.form-section { margin-bottom: 24px; }
|
||||
.form-section__label {
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--wp-text2);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.payload-preview {
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
color: var(--wp-text);
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import Column from "primevue/column";
|
||||
import Button from "primevue/button";
|
||||
import Badge from "primevue/badge";
|
||||
import Checkbox from "primevue/checkbox";
|
||||
import MultiSelect from "primevue/multiselect";
|
||||
import Select from "primevue/select";
|
||||
import EntityListView from "../components/EntityListView.vue";
|
||||
import RelativeDate from "../components/RelativeDate.vue";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import type {
|
||||
CategoryRow,
|
||||
DerivationBranch,
|
||||
DerivationCondition,
|
||||
DerivationAction,
|
||||
DerivationPayload,
|
||||
DerivationRule,
|
||||
ModuleRow,
|
||||
} from "../api/types";
|
||||
|
||||
const router = useRouter();
|
||||
const store = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
const categoryById = computed(() => {
|
||||
const map = new Map<string, CategoryRow>();
|
||||
for (const c of categoryStore.items) map.set(c.id, c);
|
||||
return map;
|
||||
});
|
||||
|
||||
const allTags = computed(() => {
|
||||
const set = new Set<string>();
|
||||
for (const m of store.items) for (const t of m.tags ?? []) set.add(t);
|
||||
return Array.from(set).sort();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
store.filter.type = "derivation";
|
||||
await Promise.all([fetch(), categoryStore.fetchAll()]);
|
||||
});
|
||||
|
||||
async function fetch() {
|
||||
store.filter.type = "derivation";
|
||||
try {
|
||||
await store.fetchAll();
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Load failed", detail: String(e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
function edit(row: ModuleRow) {
|
||||
router.push({ name: "derivations-edit", params: { id: row.id } });
|
||||
}
|
||||
|
||||
async function copyId(id: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(id);
|
||||
toast.add({ severity: "info", summary: "ID copied", detail: id, life: 1500 });
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Copy failed", life: 2000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function dup(row: ModuleRow) {
|
||||
try {
|
||||
await store.duplicate(row.id);
|
||||
toast.add({ severity: "success", summary: "Duplicated", detail: row.name, life: 2000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Duplicate failed", detail: String(e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function fav(row: ModuleRow) {
|
||||
try { await store.toggleFavorite(row.id); }
|
||||
catch (e) { toast.add({ severity: "error", summary: "Favorite failed", detail: String(e), life: 4000 }); }
|
||||
}
|
||||
|
||||
async function del(row: ModuleRow) {
|
||||
try {
|
||||
await store.remove(row.id);
|
||||
toast.add({ severity: "success", summary: "Deleted", detail: row.name, life: 2000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Delete failed", detail: String(e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDel(items: ModuleRow[]) {
|
||||
for (const item of items) await del(item);
|
||||
}
|
||||
|
||||
function rules(row: ModuleRow): DerivationRule[] {
|
||||
return ((row.payload as Partial<DerivationPayload>).rules ?? []);
|
||||
}
|
||||
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}"`;
|
||||
}
|
||||
|
||||
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 ?? ""}"`;
|
||||
}
|
||||
|
||||
function firstBranchPreview(rule: DerivationRule): string {
|
||||
const first: DerivationBranch | undefined = (rule.branches ?? [])[0];
|
||||
if (!first) return "(no branches)";
|
||||
return `IF ${formatCondition(first.condition)} → ${formatAction(first.action)}`;
|
||||
}
|
||||
|
||||
function extraBranches(rule: DerivationRule): number {
|
||||
return Math.max((rule.branches ?? []).length - 1, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EntityListView
|
||||
title="Derivations"
|
||||
subtitle="Derivation modules mutate resolved context with conditional rules."
|
||||
new-label="New Derivation"
|
||||
new-route="/derivations/new"
|
||||
:items="store.items"
|
||||
:loading="store.loading"
|
||||
:filter="store.filter"
|
||||
empty-message="No derivations yet. Use these to apply conditional post-processing rules."
|
||||
@fetch="fetch"
|
||||
@delete="del"
|
||||
@bulk-delete="bulkDel"
|
||||
>
|
||||
<template #filter-panel="{ filter, emitFetch }">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
v-model="filter.category"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="All categories" show-clear class="w-full"
|
||||
aria-label="Filter by category"
|
||||
@change="emitFetch"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<MultiSelect
|
||||
v-model="filter.tags"
|
||||
:options="allTags"
|
||||
placeholder="Any tag" display="chip" filter class="w-full"
|
||||
@change="emitFetch"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2 flex items-center gap-4 pt-1">
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox v-model="filter.favorites" :binary="true" @change="emitFetch" />
|
||||
Favorites only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #columns>
|
||||
<Column selection-mode="multiple" header-style="width:3rem" />
|
||||
<Column expander header-style="width:3rem" />
|
||||
<Column header-style="width:3rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
:icon="data.is_favorite ? 'pi pi-star-fill' : 'pi pi-star'"
|
||||
text rounded size="small"
|
||||
:severity="data.is_favorite ? 'warning' : 'secondary'"
|
||||
aria-label="Toggle favorite"
|
||||
@click.stop="fav(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="name" header="Name" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="wp-kind-derivation pi pi-code text-wp-teal" aria-hidden="true" />
|
||||
<div class="flex flex-col">
|
||||
<span class="cursor-pointer font-medium hover:text-white" @click="edit(data)">{{ data.name }}</span>
|
||||
<span
|
||||
class="text-xs text-wp-text3 font-mono cursor-pointer hover:text-wp-text2 select-all"
|
||||
:title="`Click to copy ${data.id}`"
|
||||
@click.stop="copyId(data.id)"
|
||||
>{{ data.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="category_id" header="Category" header-style="width:9rem" sortable>
|
||||
<template #body="{ data }">
|
||||
<span
|
||||
v-if="data.category_id && categoryById.get(data.category_id)"
|
||||
class="category-chip"
|
||||
:style="{ background: categoryById.get(data.category_id)!.color || 'var(--wp-bg3)' }"
|
||||
>
|
||||
{{ categoryById.get(data.category_id)!.name }}
|
||||
</span>
|
||||
<span v-else class="text-wp-text3 text-sm">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Rules" header-style="width:5rem">
|
||||
<template #body="{ data }">
|
||||
<Badge :value="String(ruleCount(data))" severity="secondary" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="tags" header="Tags" header-style="width:13rem">
|
||||
<template #body="{ data }">
|
||||
<div v-if="data.tags?.length" class="flex flex-wrap gap-1">
|
||||
<span v-for="(t, i) in data.tags.slice(0, 3)" :key="i" class="tag-chip">{{ t }}</span>
|
||||
<span v-if="data.tags.length > 3" class="text-xs text-wp-text3">+{{ data.tags.length - 3 }}</span>
|
||||
</div>
|
||||
<span v-else class="text-wp-text3 text-sm">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="updated_at" header="Updated" sortable header-style="width:9rem">
|
||||
<template #body="{ data }">
|
||||
<RelativeDate :value="data.updated_at" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Actions" header-style="width:11rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-1" @click.stop>
|
||||
<Button icon="pi pi-pencil" text rounded size="small" aria-label="Edit" @click="edit(data)" />
|
||||
<Button icon="pi pi-copy" text rounded size="small" aria-label="Duplicate" @click="dup(data)" />
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" aria-label="Delete" @click="del(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</template>
|
||||
|
||||
<template #expansion="{ data }">
|
||||
<div class="px-6 py-3">
|
||||
<h5 class="text-xs font-semibold mb-2 uppercase tracking-wider text-wp-text2">
|
||||
Rules for <span class="text-wp-rose">{{ data.name }}</span>
|
||||
</h5>
|
||||
<div v-if="ruleCount(data) === 0" class="text-sm text-wp-text3">
|
||||
No rules defined.
|
||||
</div>
|
||||
<ol v-else class="rule-list">
|
||||
<li v-for="(rule, ri) in rules(data)" :key="rule.id ?? ri" class="rule-list__item">
|
||||
<span class="font-mono text-xs">{{ firstBranchPreview(rule) }}</span>
|
||||
<span v-if="extraBranches(rule)" class="ml-2 text-xs text-wp-text3">
|
||||
+{{ 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) }}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</EntityListView>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.category-chip {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.tag-chip {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
background: var(--wp-bg3);
|
||||
color: var(--wp-text2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.rule-list {
|
||||
list-style: decimal;
|
||||
padding-left: 20px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.rule-list__item { color: var(--wp-text); }
|
||||
.text-wp-teal { color: var(--wp-kind-derivation, var(--wp-teal, #5dd2c7)); }
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Textarea from "primevue/textarea";
|
||||
import Select from "primevue/select";
|
||||
import AutoComplete from "primevue/autocomplete";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import type { PipelinePayload } from "../api/types";
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const categoryId = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const payload = ref<PipelinePayload>({ steps: [] });
|
||||
const saving = ref(false);
|
||||
const isEdit = computed(() => !!props.id);
|
||||
|
||||
const tagSuggestions = ref<string[]>([]);
|
||||
function searchTags(event: { query: string }) {
|
||||
const q = event.query.toLowerCase();
|
||||
const known = new Set<string>();
|
||||
for (const m of moduleStore.items) {
|
||||
for (const t of m.tags ?? []) known.add(t);
|
||||
}
|
||||
tagSuggestions.value = Array.from(known)
|
||||
.filter((t) => t.toLowerCase().includes(q) && !tags.value.includes(t))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
|
||||
if (props.id) {
|
||||
try {
|
||||
const row = await moduleStore.get(props.id);
|
||||
name.value = row.name;
|
||||
description.value = row.description;
|
||||
categoryId.value = row.category_id;
|
||||
tags.value = row.tags;
|
||||
const p = row.payload as Partial<PipelinePayload>;
|
||||
payload.value = { steps: p.steps ?? [] };
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Pipeline not found", life: 3000 });
|
||||
router.replace("/pipelines");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!name.value.trim()) {
|
||||
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const body = {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
category_id: categoryId.value,
|
||||
tags: tags.value,
|
||||
payload: payload.value as unknown as Record<string, unknown>,
|
||||
};
|
||||
if (isEdit.value && props.id) {
|
||||
await moduleStore.update(props.id, body);
|
||||
} else {
|
||||
await moduleStore.create({ type: "pipeline", ...body });
|
||||
}
|
||||
toast.add({ severity: "success", summary: "Saved", detail: name.value, life: 2000 });
|
||||
router.push("/pipelines");
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Save failed", detail: String(e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-page__header">
|
||||
<RouterLink to="/pipelines" class="text-xs text-wp-text2 hover:text-wp-text">
|
||||
<i class="pi pi-angle-left text-xs" /> Pipelines
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-semibold m-0 mt-1">{{ isEdit ? "Edit pipeline" : "New pipeline" }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-page__body">
|
||||
<section class="form-section">
|
||||
<h2 class="form-section__label">Identity</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="pl-name" class="block text-xs text-wp-text2 mb-1">Name</label>
|
||||
<InputText id="pl-name" v-model="name" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="pl-cat" class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
id="pl-cat" v-model="categoryId"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="None" show-clear class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="pl-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
|
||||
<Textarea id="pl-desc" v-model="description" rows="2" class="w-full" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="pl-tags" class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<AutoComplete
|
||||
id="pl-tags"
|
||||
v-model="tags"
|
||||
multiple
|
||||
typeahead
|
||||
:suggestions="tagSuggestions"
|
||||
placeholder="Type a tag and press Enter…"
|
||||
class="w-full"
|
||||
@complete="searchTags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h2 class="form-section__label">Steps</h2>
|
||||
<Card>
|
||||
<template #content>
|
||||
<p class="text-sm text-wp-text2 m-0 mb-2">
|
||||
Editor coming soon — payload shown below.
|
||||
</p>
|
||||
<pre class="payload-preview" data-test="payload-preview">{{ JSON.stringify(payload, null, 2) }}</pre>
|
||||
</template>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
|
||||
.form-page__header { padding: 24px 24px 0; }
|
||||
.form-page__body { padding: 16px 24px 96px; max-width: 56rem; flex: 1; }
|
||||
.form-page__footer {
|
||||
position: sticky; bottom: 0;
|
||||
background: var(--wp-bg);
|
||||
border-top: 1px solid var(--wp-border);
|
||||
padding: 12px 24px;
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
}
|
||||
.form-section { margin-bottom: 24px; }
|
||||
.form-section__label {
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--wp-text2);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.payload-preview {
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
color: var(--wp-text);
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import Column from "primevue/column";
|
||||
import Button from "primevue/button";
|
||||
import Badge from "primevue/badge";
|
||||
import Checkbox from "primevue/checkbox";
|
||||
import MultiSelect from "primevue/multiselect";
|
||||
import Select from "primevue/select";
|
||||
import EntityListView from "../components/EntityListView.vue";
|
||||
import RelativeDate from "../components/RelativeDate.vue";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import type {
|
||||
CategoryRow,
|
||||
ModuleRow,
|
||||
ModuleType,
|
||||
PipelinePayload,
|
||||
PipelineStep,
|
||||
} from "../api/types";
|
||||
|
||||
const router = useRouter();
|
||||
const store = useModuleStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
|
||||
const categoryById = computed(() => {
|
||||
const map = new Map<string, CategoryRow>();
|
||||
for (const c of categoryStore.items) map.set(c.id, c);
|
||||
return map;
|
||||
});
|
||||
|
||||
// Used to resolve step.module_id -> name + kind. Best-effort: depends on what's
|
||||
// currently in the moduleStore (which is filtered to type=pipeline on this page).
|
||||
const moduleById = computed(() => {
|
||||
const map = new Map<string, ModuleRow>();
|
||||
for (const m of store.items) map.set(m.id, m);
|
||||
return map;
|
||||
});
|
||||
|
||||
const allTags = computed(() => {
|
||||
const set = new Set<string>();
|
||||
for (const m of store.items) for (const t of m.tags ?? []) set.add(t);
|
||||
return Array.from(set).sort();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
store.filter.type = "pipeline";
|
||||
await Promise.all([fetch(), categoryStore.fetchAll()]);
|
||||
});
|
||||
|
||||
async function fetch() {
|
||||
store.filter.type = "pipeline";
|
||||
try {
|
||||
await store.fetchAll();
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Load failed", detail: String(e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
function edit(row: ModuleRow) {
|
||||
router.push({ name: "pipelines-edit", params: { id: row.id } });
|
||||
}
|
||||
|
||||
async function copyId(id: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(id);
|
||||
toast.add({ severity: "info", summary: "ID copied", detail: id, life: 1500 });
|
||||
} catch {
|
||||
toast.add({ severity: "error", summary: "Copy failed", life: 2000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function dup(row: ModuleRow) {
|
||||
try {
|
||||
await store.duplicate(row.id);
|
||||
toast.add({ severity: "success", summary: "Duplicated", detail: row.name, life: 2000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Duplicate failed", detail: String(e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function fav(row: ModuleRow) {
|
||||
try { await store.toggleFavorite(row.id); }
|
||||
catch (e) { toast.add({ severity: "error", summary: "Favorite failed", detail: String(e), life: 4000 }); }
|
||||
}
|
||||
|
||||
async function del(row: ModuleRow) {
|
||||
try {
|
||||
await store.remove(row.id);
|
||||
toast.add({ severity: "success", summary: "Deleted", detail: row.name, life: 2000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Delete failed", detail: String(e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDel(items: ModuleRow[]) {
|
||||
for (const item of items) await del(item);
|
||||
}
|
||||
|
||||
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 steps(row: ModuleRow): PipelineStep[] {
|
||||
return ((row.payload as Partial<PipelinePayload>).steps ?? []);
|
||||
}
|
||||
function stepCount(row: ModuleRow): number { return steps(row).length; }
|
||||
function enabledCount(row: ModuleRow): number {
|
||||
return steps(row).filter((s) => s.enabled !== false).length;
|
||||
}
|
||||
|
||||
function stepLabel(step: PipelineStep): { name: string; kind: ModuleType | null } {
|
||||
const m = moduleById.value.get(step.module_id);
|
||||
return { name: m?.name ?? step.module_id, kind: m?.type ?? null };
|
||||
}
|
||||
|
||||
function iconFor(kind: ModuleType | null): string {
|
||||
if (!kind) return "pi pi-circle";
|
||||
return KIND_ICON[kind] ?? "pi pi-circle";
|
||||
}
|
||||
|
||||
function kindClass(kind: ModuleType | null): string {
|
||||
return kind ? `wp-kind-${kind}` : "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EntityListView
|
||||
title="Pipelines"
|
||||
subtitle="Pipelines run a sequence of modules in order, accumulating context."
|
||||
new-label="New Pipeline"
|
||||
new-route="/pipelines/new"
|
||||
:items="store.items"
|
||||
:loading="store.loading"
|
||||
:filter="store.filter"
|
||||
empty-message="No pipelines yet. Group modules into reusable presets."
|
||||
@fetch="fetch"
|
||||
@delete="del"
|
||||
@bulk-delete="bulkDel"
|
||||
>
|
||||
<template #filter-panel="{ filter, emitFetch }">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-wp-text2 mb-1">Category</label>
|
||||
<Select
|
||||
v-model="filter.category"
|
||||
:options="categoryStore.items" option-label="name" option-value="id"
|
||||
placeholder="All categories" show-clear class="w-full"
|
||||
aria-label="Filter by category"
|
||||
@change="emitFetch"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-wp-text2 mb-1">Tags</label>
|
||||
<MultiSelect
|
||||
v-model="filter.tags"
|
||||
:options="allTags"
|
||||
placeholder="Any tag" display="chip" filter class="w-full"
|
||||
@change="emitFetch"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2 flex items-center gap-4 pt-1">
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox v-model="filter.favorites" :binary="true" @change="emitFetch" />
|
||||
Favorites only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #columns>
|
||||
<Column selection-mode="multiple" header-style="width:3rem" />
|
||||
<Column expander header-style="width:3rem" />
|
||||
<Column header-style="width:3rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
:icon="data.is_favorite ? 'pi pi-star-fill' : 'pi pi-star'"
|
||||
text rounded size="small"
|
||||
:severity="data.is_favorite ? 'warning' : 'secondary'"
|
||||
aria-label="Toggle favorite"
|
||||
@click.stop="fav(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="name" header="Name" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="wp-kind-pipeline pi pi-list text-wp-pipeline" aria-hidden="true" />
|
||||
<div class="flex flex-col">
|
||||
<span class="cursor-pointer font-medium hover:text-white" @click="edit(data)">{{ data.name }}</span>
|
||||
<span
|
||||
class="text-xs text-wp-text3 font-mono cursor-pointer hover:text-wp-text2 select-all"
|
||||
:title="`Click to copy ${data.id}`"
|
||||
@click.stop="copyId(data.id)"
|
||||
>{{ data.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="category_id" header="Category" header-style="width:9rem" sortable>
|
||||
<template #body="{ data }">
|
||||
<span
|
||||
v-if="data.category_id && categoryById.get(data.category_id)"
|
||||
class="category-chip"
|
||||
:style="{ background: categoryById.get(data.category_id)!.color || 'var(--wp-bg3)' }"
|
||||
>
|
||||
{{ categoryById.get(data.category_id)!.name }}
|
||||
</span>
|
||||
<span v-else class="text-wp-text3 text-sm">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Steps" header-style="width:6rem">
|
||||
<template #body="{ data }">
|
||||
<Badge
|
||||
:value="`${enabledCount(data)} / ${stepCount(data)}`"
|
||||
severity="secondary"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="tags" header="Tags" header-style="width:13rem">
|
||||
<template #body="{ data }">
|
||||
<div v-if="data.tags?.length" class="flex flex-wrap gap-1">
|
||||
<span v-for="(t, i) in data.tags.slice(0, 3)" :key="i" class="tag-chip">{{ t }}</span>
|
||||
<span v-if="data.tags.length > 3" class="text-xs text-wp-text3">+{{ data.tags.length - 3 }}</span>
|
||||
</div>
|
||||
<span v-else class="text-wp-text3 text-sm">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="updated_at" header="Updated" sortable header-style="width:9rem">
|
||||
<template #body="{ data }">
|
||||
<RelativeDate :value="data.updated_at" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Actions" header-style="width:11rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-1" @click.stop>
|
||||
<Button icon="pi pi-pencil" text rounded size="small" aria-label="Edit" @click="edit(data)" />
|
||||
<Button icon="pi pi-copy" text rounded size="small" aria-label="Duplicate" @click="dup(data)" />
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" aria-label="Delete" @click="del(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</template>
|
||||
|
||||
<template #expansion="{ data }">
|
||||
<div class="px-6 py-3">
|
||||
<h5 class="text-xs font-semibold mb-2 uppercase tracking-wider text-wp-text2">
|
||||
Steps for <span class="text-wp-rose">{{ data.name }}</span>
|
||||
</h5>
|
||||
<div v-if="stepCount(data) === 0" class="text-sm text-wp-text3">
|
||||
No steps defined.
|
||||
</div>
|
||||
<ol v-else class="step-list">
|
||||
<li
|
||||
v-for="(step, idx) in steps(data)" :key="step.id ?? idx"
|
||||
class="step-list__item"
|
||||
:class="{ 'step-list__item--disabled': step.enabled === false }"
|
||||
>
|
||||
<span class="step-list__index">{{ idx + 1 }}.</span>
|
||||
<i :class="[iconFor(stepLabel(step).kind), kindClass(stepLabel(step).kind)]" aria-hidden="true" />
|
||||
<span class="step-list__name">{{ stepLabel(step).name }}</span>
|
||||
<span
|
||||
class="step-list__toggle"
|
||||
:title="step.enabled === false ? 'Disabled' : 'Enabled'"
|
||||
:aria-label="step.enabled === false ? 'Disabled' : 'Enabled'"
|
||||
>
|
||||
<i :class="step.enabled === false ? 'pi pi-eye-slash' : 'pi pi-check-circle'" />
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</EntityListView>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.category-chip {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.tag-chip {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
background: var(--wp-bg3);
|
||||
color: var(--wp-text2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.step-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.step-list__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.step-list__item--disabled { opacity: 0.55; }
|
||||
.step-list__index {
|
||||
font-family: var(--wp-font-mono, ui-monospace, monospace);
|
||||
width: 1.5rem;
|
||||
color: var(--wp-text3);
|
||||
}
|
||||
.step-list__name { color: var(--wp-text); }
|
||||
.step-list__toggle { margin-left: auto; color: var(--wp-text3); }
|
||||
.step-list__item--disabled .step-list__toggle { color: var(--wp-amber, #f7b955); }
|
||||
.text-wp-pipeline { color: var(--wp-kind-pipeline, var(--wp-accent, #6aa1ff)); }
|
||||
</style>
|
||||
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/wp/images/site.webmanifest" />
|
||||
<script type="module" crossorigin src="/wp/assets/manager-CwSo2aOA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/wp/assets/manager-DlK5wnm0.css">
|
||||
<script type="module" crossorigin src="/wp/assets/manager-ZcA3OFc-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/wp/assets/manager-D_KNk_uE.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user