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:
DumiFlex
2026-04-27 03:57:19 +03:00
parent a16c5324e2
commit 8c03e72d69
16 changed files with 2396 additions and 6 deletions
+81
View File
@@ -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");
});
});
+81
View File
@@ -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");
});
});
+81
View File
@@ -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");
});
});
+81
View File
@@ -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");
});
});
+74 -1
View File
@@ -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;
+4 -3
View File
@@ -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" },
],
},
{
+12
View File
@@ -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") },
+187
View File
@@ -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>
+284
View File
@@ -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>
+193
View File
@@ -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>
+337
View File
@@ -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>
+183
View File
@@ -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>
+296
View File
@@ -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>
+183
View File
@@ -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>
+317
View File
@@ -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
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
<link rel="manifest" href="/wp/images/site.webmanifest" />
<script type="module" crossorigin src="/wp/assets/manager-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>