feat(spa): port prototype ModuleListView + drop PrimeVue from list screens (Wave 3)

Replaces the PrimeVue-DataTable-backed EntityListView with a 1:1 port of the
prototype's ModuleListView from screens/lists.jsx. Uses raw <table> markup +
the new ui/* primitives (Button, Checkbox, Chip, Select). Migrates all 6 list
screens (Wildcards, FixedValues, Combines, Derivations, Constraints, Pipelines)
to consume the new shell. Adds an extended ModuleListView.test.ts covering
search, filter panel toggle, pagination, chip removal, and row expansion.
This commit is contained in:
DumiFlex
2026-04-27 18:52:46 +03:00
parent 30fed89ce3
commit df5d38d625
12 changed files with 2450 additions and 1823 deletions
@@ -1,212 +0,0 @@
import { mount, flushPromises } from "@vue/test-utils";
import { reactive } from "vue";
import { setActivePinia, createPinia } from "pinia";
import { beforeEach, describe, expect, it } from "vitest";
import { createMemoryHistory, createRouter } from "vue-router";
import PrimeVue from "primevue/config";
import ToastService from "primevue/toastservice";
import ConfirmationService from "primevue/confirmationservice";
import Column from "primevue/column";
import EntityListView from "../components/EntityListView.vue";
interface Row {
id: string;
name: string;
updated_at: string;
is_favorite?: boolean;
tags?: string[];
}
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/things/new", component: { template: "<div/>" } },
],
});
}
function makeItems(n: number): Row[] {
// Newest first sort baseline — generate descending updated_at strings.
return Array.from({ length: n }, (_, i) => ({
id: `r_${String(i).padStart(3, "0")}`,
name: `Row ${i}`,
updated_at: `2025-01-${String(31 - (i % 28)).padStart(2, "0")}T00:00:00Z`,
tags: i % 2 === 0 ? ["even"] : ["odd"],
is_favorite: i === 0,
}));
}
interface Filter {
q?: string;
favorites?: boolean;
category?: string;
tags?: string[];
sortBy?: string;
}
function mountView(opts: {
items: Row[];
filter?: Filter;
extraFilters?: { key: string; label: string; check: (r: Row) => boolean }[];
} = { items: [] }) {
const filter = reactive<Filter>(opts.filter ?? {});
const wrap = mount(EntityListView<Row>, {
props: {
title: "Things",
subtitle: "All the things",
newLabel: "New Thing",
newRoute: "/things/new",
items: opts.items,
loading: false,
filter,
emptyMessage: "No things yet.",
extraFilters: opts.extraFilters,
},
global: {
plugins: [makeRouter(), PrimeVue, ToastService, ConfirmationService],
components: { Column },
},
slots: {
columns: `<Column field="name" header="Name" />`,
},
});
return { wrap, filter };
}
beforeEach(() => {
setActivePinia(createPinia());
});
describe("EntityListView.vue", () => {
it("renders title, subtitle, and empty-state CTA when no items + no filters", async () => {
const { wrap } = mountView({ items: [] });
await flushPromises();
expect(wrap.text()).toContain("Things");
expect(wrap.text()).toContain("All the things");
expect(wrap.text()).toContain("No things yet.");
});
it("renders rows when items provided", async () => {
const { wrap } = mountView({ items: makeItems(3) });
await flushPromises();
expect(wrap.text()).toContain("Row 0");
expect(wrap.text()).toContain("Row 1");
expect(wrap.text()).toContain("Row 2");
});
it("hides pagination footer when items count below smallest page size", async () => {
const { wrap } = mountView({ items: makeItems(5) });
await flushPromises();
expect(wrap.find(".wp-pagination").exists()).toBe(false);
});
it("shows pagination footer + correct counts when more than 10 items", async () => {
const { wrap } = mountView({ items: makeItems(20) });
await flushPromises();
expect(wrap.find(".wp-pagination").exists()).toBe(true);
// Default pageSize = 15, total = 20, so first page shows 1–15 of 20.
const text = wrap.find(".wp-pagination").text();
expect(text).toContain("1");
expect(text).toContain("15");
expect(text).toContain("20");
});
it("disables prev/first on page 1 and enables next/last", async () => {
const { wrap } = mountView({ items: makeItems(40) });
await flushPromises();
const buttons = wrap.find(".wp-pager").findAll("button");
// Order: first, prev, next, last (label spans between prev and next).
expect(buttons[0].attributes("disabled")).toBeDefined();
expect(buttons[1].attributes("disabled")).toBeDefined();
expect(buttons[2].attributes("disabled")).toBeUndefined();
expect(buttons[3].attributes("disabled")).toBeUndefined();
});
it("disables next/last after navigating to last page", async () => {
const { wrap } = mountView({ items: makeItems(40) });
await flushPromises();
const inst = wrap.vm as unknown as { goToPage: (n: number) => void };
inst.goToPage(99);
await flushPromises();
const buttons = wrap.find(".wp-pager").findAll("button");
expect(buttons[0].attributes("disabled")).toBeUndefined();
expect(buttons[1].attributes("disabled")).toBeUndefined();
expect(buttons[2].attributes("disabled")).toBeDefined();
expect(buttons[3].attributes("disabled")).toBeDefined();
});
it("active filter count includes category, favorites, and each tag", async () => {
const { wrap } = mountView({
items: makeItems(3),
filter: { category: "cat-1", favorites: true, tags: ["a", "b"] },
});
await flushPromises();
expect(wrap.find(".wp-filter-count").exists()).toBe(true);
expect(wrap.find(".wp-filter-count").text()).toBe("4");
});
it("'No matches' empty state shows when filters active and result empty", async () => {
const { wrap } = mountView({
items: [],
filter: { q: "nothing matches" },
});
await flushPromises();
expect(wrap.text()).toContain("No matches");
expect(wrap.text()).not.toContain("No things yet.");
});
it("clear-all button removes filter chips", async () => {
const { wrap, filter } = mountView({
items: makeItems(3),
filter: { favorites: true, tags: ["a"], category: "cat-1" },
});
await flushPromises();
expect(wrap.findAll(".filter-chip").length).toBeGreaterThan(0);
const clearBtn = wrap.findAll("button").find((b) => b.text() === "Clear all");
expect(clearBtn).toBeDefined();
if (!clearBtn) throw new Error("missing clear button");
await clearBtn.trigger("click");
await flushPromises();
expect(filter.favorites).toBe(false);
expect(filter.category).toBeUndefined();
expect(filter.tags).toEqual([]);
expect(wrap.findAll(".filter-chip").length).toBe(0);
});
it("removing a tag chip removes only that tag", async () => {
const { wrap, filter } = mountView({
items: makeItems(3),
filter: { tags: ["a", "b"] },
});
await flushPromises();
const tagChips = wrap.findAll(".filter-chip--tag");
expect(tagChips.length).toBe(2);
// Click the close button of the first tag chip.
await tagChips[0].find(".filter-chip__close").trigger("click");
await flushPromises();
expect(filter.tags).toEqual(["b"]);
expect(wrap.findAll(".filter-chip--tag").length).toBe(1);
});
it("renders extraFilters as toggle chips inside the filter panel", async () => {
const { wrap } = mountView({
items: makeItems(4),
extraFilters: [
{ key: "fav", label: "Favorited", check: (r: Row) => !!r.is_favorite },
],
});
await flushPromises();
// Open the filter panel.
const filtersBtn = wrap.findAll("button").find((b) => b.text().includes("Filters"));
expect(filtersBtn).toBeDefined();
if (!filtersBtn) throw new Error("missing filters button");
await filtersBtn.trigger("click");
await flushPromises();
expect(wrap.find(".wp-extra-chip").exists()).toBe(true);
expect(wrap.find(".wp-extra-chip").text()).toContain("Favorited");
// Count badge reflects matches in items (1 of 4 favorited).
expect(wrap.find(".wp-extra-chip__count").text()).toBe("1");
});
});
@@ -0,0 +1,199 @@
import { mount, flushPromises } from "@vue/test-utils";
import { reactive, h } from "vue";
import { setActivePinia, createPinia } from "pinia";
import { beforeEach, describe, expect, it } from "vitest";
import { createMemoryHistory, createRouter } from "vue-router";
import ModuleListView from "../components/ModuleListView.vue";
interface Row {
id: string;
name: string;
updated_at: string;
is_favorite?: boolean;
tags?: string[];
}
interface Filter {
q?: string;
favorites?: boolean;
category?: string | null;
tags?: string[];
sortBy?: string;
}
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/things/new", component: { template: "<div/>" } },
],
});
}
function makeItems(n: number): Row[] {
// Newest first sort baseline — generate descending updated_at strings.
return Array.from({ length: n }, (_, i) => ({
id: `r_${String(i).padStart(3, "0")}`,
name: `Row ${i}`,
updated_at: `2025-01-${String(31 - (i % 28)).padStart(2, "0")}T00:00:00Z`,
tags: i % 2 === 0 ? ["even"] : ["odd"],
is_favorite: i === 0,
}));
}
function mountView(opts: {
items: Row[];
filter?: Filter;
extraFilters?: { key: string; label: string; check: (r: Row) => boolean }[];
} = { items: [] }) {
const filter = reactive<Filter>(opts.filter ?? {});
const wrap = mount(ModuleListView<Row>, {
props: {
title: "Things",
subtitle: "All the things",
newLabel: "New Thing",
newRoute: "/things/new",
items: opts.items,
loading: false,
filter,
emptyMessage: "No things yet.",
extraFilters: opts.extraFilters,
midCols: 0,
},
global: { plugins: [makeRouter()] },
slots: {
// No mid-cols — every visible row is just the built-in name + tags + updated.
"columns-head": () => null,
columns: () => null,
actions: ({ row }: { row: Row }) =>
h("button", { class: "wp-test-edit", "data-id": row.id }, "Edit"),
expansion: ({ row }: { row: Row }) =>
h("div", { class: "wp-test-expand" }, `Expanded: ${row.name}`),
},
});
return { wrap, filter };
}
beforeEach(() => {
setActivePinia(createPinia());
});
describe("ModuleListView.vue", () => {
it("renders title, subtitle, and empty-state CTA", async () => {
const { wrap } = mountView({ items: [] });
await flushPromises();
expect(wrap.text()).toContain("Things");
expect(wrap.text()).toContain("All the things");
expect(wrap.text()).toContain("No things yet.");
});
it("renders rows when items provided", async () => {
const { wrap } = mountView({ items: makeItems(3) });
await flushPromises();
expect(wrap.text()).toContain("Row 0");
expect(wrap.text()).toContain("Row 1");
expect(wrap.text()).toContain("Row 2");
});
it("search input updates filter.q and emits fetch", async () => {
const { wrap, filter } = mountView({ items: makeItems(3) });
await flushPromises();
const input = wrap.find('input[aria-label="Search"]');
expect(input.exists()).toBe(true);
await input.setValue("hello");
expect(filter.q).toBe("hello");
expect(wrap.emitted("fetch")).toBeTruthy();
});
it("toggling Filters opens the panel", async () => {
const { wrap } = mountView({
items: makeItems(3),
extraFilters: [{ key: "fav", label: "Favorited", check: (r) => !!r.is_favorite }],
});
await flushPromises();
expect(wrap.find(".wp-filter-panel").exists()).toBe(false);
const filtersBtn = wrap.findAll("button").find((b) => b.text().includes("Filters"));
expect(filtersBtn).toBeDefined();
if (!filtersBtn) throw new Error("missing filters button");
await filtersBtn.trigger("click");
await flushPromises();
expect(wrap.find(".wp-filter-panel").exists()).toBe(true);
// Extra filter chip is rendered inside.
expect(wrap.find(".wp-filter-toggle").exists()).toBe(true);
expect(wrap.find(".wp-filter-toggle").text()).toContain("Favorited");
});
it("paginates 20 items into pages of 10 then 10 when pageSize=10", async () => {
const { wrap } = mountView({ items: makeItems(20) });
await flushPromises();
// `defineExpose` re-wraps refs as primitives on `wrap.vm`, so reach into
// the setup state via `$.exposed` to mutate them through real refs.
interface Exposed {
page: { value: number };
pageSize: { value: number };
paged: { value: Row[] };
totalPages: { value: number };
goToPage: (n: number) => void;
}
const exposed = (wrap.vm.$ as unknown as { exposed: Exposed }).exposed;
exposed.pageSize.value = 10;
await flushPromises();
expect(exposed.totalPages.value).toBe(2);
expect(exposed.paged.value.length).toBe(10);
exposed.goToPage(2);
await flushPromises();
expect(exposed.paged.value.length).toBe(10);
// Total of 20 unique items rendered across pages.
const seen = new Set(exposed.paged.value.map((r) => r.id));
exposed.goToPage(1);
await flushPromises();
exposed.paged.value.forEach((r) => seen.add(r.id));
expect(seen.size).toBe(20);
});
it("active-filter chip removes its filter on click", async () => {
const { wrap, filter } = mountView({
items: makeItems(3),
filter: { favorites: true, tags: ["even"] },
});
await flushPromises();
const chips = wrap.findAll(".wp-chip");
expect(chips.length).toBeGreaterThan(0);
// Click the favorites chip's close button.
const favChip = chips.find((c) => c.text().includes("Favorites only"));
expect(favChip).toBeDefined();
if (!favChip) throw new Error("missing favorite chip");
await favChip.find(".wp-chip__close").trigger("click");
await flushPromises();
expect(filter.favorites).toBe(false);
});
it("expansion toggles when chevron clicked", async () => {
const { wrap } = mountView({ items: makeItems(3) });
await flushPromises();
expect(wrap.find(".wp-test-expand").exists()).toBe(false);
const expandBtn = wrap.find(".wp-row-expand-btn");
expect(expandBtn.exists()).toBe(true);
await expandBtn.trigger("click");
await flushPromises();
expect(wrap.find(".wp-test-expand").exists()).toBe(true);
expect(wrap.find(".wp-test-expand").text()).toContain("Expanded: Row 0");
// Toggle off.
await expandBtn.trigger("click");
await flushPromises();
expect(wrap.find(".wp-test-expand").exists()).toBe(false);
});
it("hides pagination when items count below smallest page size (10)", async () => {
const { wrap } = mountView({ items: makeItems(5) });
await flushPromises();
expect(wrap.find(".wp-page-pager").exists()).toBe(false);
});
it("shows pagination when items > 10", async () => {
const { wrap } = mountView({ items: makeItems(20) });
await flushPromises();
expect(wrap.find(".wp-page-pager").exists()).toBe(true);
});
});
-681
View File
@@ -1,681 +0,0 @@
<script setup lang="ts" generic="T extends { id: string }">
import { computed, ref, watch } from "vue";
import DataTable from "primevue/datatable";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import InputGroup from "primevue/inputgroup";
import InputGroupAddon from "primevue/inputgroupaddon";
import Select from "primevue/select";
import { useConfirm } from "primevue/useconfirm";
import ConfirmDialog from "primevue/confirmdialog";
interface Filter {
q?: string;
favorites?: boolean;
category?: string;
tags?: string[];
sortBy?: string;
}
interface ExtraFilter {
key: string;
label: string;
check: (item: T) => boolean;
}
const SORT_OPTIONS = [
{ label: "Updated — newest", value: "updated-desc" },
{ label: "Updated — oldest", value: "updated-asc" },
{ label: "Name A → Z", value: "name-asc" },
{ label: "Name Z → A", value: "name-desc" },
];
const PAGE_SIZE_OPTIONS = [
{ label: "10", value: 10 },
{ label: "15", value: 15 },
{ label: "25", value: 25 },
{ label: "50", value: 50 },
];
const props = defineProps<{
title: string;
subtitle: string;
newLabel: string;
newRoute: string;
items: T[];
loading: boolean;
filter: Filter;
emptyMessage: string;
extraFilters?: ExtraFilter[];
}>();
const emit = defineEmits<{
fetch: [];
delete: [item: T];
bulkDelete: [items: T[]];
}>();
const confirm = useConfirm();
const search = ref(props.filter.q ?? "");
const filterPanelOpen = ref(false);
const selected = ref<T[]>([]);
const expandedRows = ref<Record<string, boolean>>({});
const activeExtras = ref<string[]>([]);
const page = ref(1);
const pageSize = ref(15);
// Public asset served by aiohttp at runtime — use dynamic binding so Vite
// does not try to inline/copy the file during the manager SPA build.
const faviconUrl = "/wp/images/favicon.svg";
watch(search, (q) => {
props.filter.q = q || undefined;
emit("fetch");
});
const activeFilterCount = computed(() => {
let n = 0;
if (props.filter.favorites) n++;
if (props.filter.category) n++;
if (props.filter.tags?.length) n += props.filter.tags.length;
n += activeExtras.value.length;
return n;
});
const hasActiveFilters = computed(
() => activeFilterCount.value > 0 || !!props.filter.q,
);
function clearFilters() {
search.value = "";
props.filter.q = undefined;
props.filter.favorites = false;
props.filter.category = undefined;
props.filter.tags = [];
activeExtras.value = [];
emit("fetch");
}
function removeTag(t: string) {
props.filter.tags = (props.filter.tags ?? []).filter((x) => x !== t);
emit("fetch");
}
function removeExtra(key: string) {
activeExtras.value = activeExtras.value.filter((k) => k !== key);
}
const filteredItems = computed(() => {
let list = [...props.items] as (T & {
name?: string;
updated_at?: string;
tags?: string[];
})[];
// Client-side tags filter (server has no tag query yet).
if (props.filter.tags?.length) {
const wanted = new Set(props.filter.tags);
list = list.filter((m) => (m.tags ?? []).some((t) => wanted.has(t)));
}
// Apply extra (kind-specific) filters via passed predicates.
const extras = props.extraFilters;
if (activeExtras.value.length && extras?.length) {
const tests = activeExtras.value
.map((k) => extras.find((f) => f.key === k))
.filter((f): f is ExtraFilter => !!f);
list = list.filter((m) => tests.every((f) => f.check(m as unknown as T)));
}
switch (props.filter.sortBy) {
case "name-asc":
list.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
break;
case "name-desc":
list.sort((a, b) => (b.name ?? "").localeCompare(a.name ?? ""));
break;
case "updated-asc":
list.sort((a, b) => (a.updated_at ?? "").localeCompare(b.updated_at ?? ""));
break;
case "updated-desc":
default:
list.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
break;
}
return list;
});
// Reset to page 1 whenever any filter input or page size changes.
watch(
() => [
props.filter.q,
props.filter.category,
props.filter.favorites,
props.filter.tags?.length ?? 0,
activeExtras.value.length,
props.filter.sortBy,
pageSize.value,
],
() => {
page.value = 1;
},
);
const totalPages = computed(() =>
Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)),
);
const safePage = computed(() => Math.min(page.value, totalPages.value));
const pageStart = computed(() => (safePage.value - 1) * pageSize.value);
const pageEnd = computed(() =>
Math.min(pageStart.value + pageSize.value, filteredItems.value.length),
);
const pagedItems = computed(() =>
filteredItems.value.slice(pageStart.value, pageStart.value + pageSize.value),
);
const showPagination = computed(
() => filteredItems.value.length > 0 && filteredItems.value.length > Math.min(...PAGE_SIZE_OPTIONS.map((o) => o.value)),
);
function goToPage(p: number) {
page.value = Math.min(Math.max(1, p), totalPages.value);
}
function rowClass(row: T): string {
return (row as T & { is_favorite?: boolean }).is_favorite ? "wp-row-favorite" : "";
}
function confirmBulkDelete() {
confirm.require({
message: `Delete ${selected.value.length} selected items?`,
header: "Confirm bulk delete",
icon: "pi pi-exclamation-triangle",
acceptClass: "p-button-danger",
accept: () => {
emit("bulkDelete", [...(selected.value as T[])]);
selected.value = [];
},
});
}
defineExpose({
// Exposed for tests + parent screens that want to drive pagination/filters.
page,
pageSize,
goToPage,
activeFilterCount,
filteredItems,
pagedItems,
totalPages,
});
</script>
<template>
<div class="wp-page p-6 text-wp-text">
<ConfirmDialog />
<div class="wp-page__header">
<div class="wp-page__title-wrap">
<h1 class="wp-page__title">{{ title }}</h1>
<p class="wp-page__subtitle">{{ subtitle }}</p>
</div>
<div class="wp-page__actions">
<slot name="toolbar-actions" />
<Button
:label="newLabel" icon="pi pi-plus" severity="primary"
@click="$router.push(newRoute)"
/>
</div>
</div>
<!-- Toolbar -->
<div class="wp-toolbar">
<InputGroup class="wp-toolbar__search">
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="search" placeholder="Search by name, id, tag…" />
</InputGroup>
<Button
:severity="filterPanelOpen ? 'primary' : 'secondary'"
:outlined="filterPanelOpen"
text
size="small"
aria-label="Filters"
@click="filterPanelOpen = !filterPanelOpen"
>
<span class="flex items-center gap-1">
<i class="pi pi-filter" />
<span>Filters</span>
<span v-if="activeFilterCount > 0" class="wp-filter-count">{{ activeFilterCount }}</span>
</span>
</Button>
<Select
:model-value="filter.sortBy ?? 'updated-desc'"
:options="SORT_OPTIONS" option-label="label" option-value="value"
size="small" class="w-44"
aria-label="Sort"
@update:model-value="(v: string) => { filter.sortBy = v; }"
>
<template #value="{ value }">
<span class="text-xs text-wp-text2">
<i class="pi pi-sort-alt mr-1" />
{{ SORT_OPTIONS.find((o) => o.value === value)?.label ?? "Sort" }}
</span>
</template>
</Select>
<span class="wp-toolbar__count">
{{ filteredItems.length }} / {{ items.length }} items
</span>
</div>
<!-- Filter panel -->
<div v-if="filterPanelOpen" class="wp-filter-panel">
<slot
name="filter-panel"
:filter="filter"
:emit-fetch="() => emit('fetch')"
/>
<div v-if="extraFilters?.length" class="wp-filter-extras">
<span class="wp-filter-extras__label">Syntax</span>
<div class="wp-filter-extras__chips">
<button
v-for="f in extraFilters" :key="f.key" type="button"
class="wp-extra-chip"
:data-active="activeExtras.includes(f.key) ? '' : null"
@click="activeExtras.includes(f.key)
? removeExtra(f.key)
: (activeExtras = [...activeExtras, f.key])"
>
{{ f.label }}
<span class="wp-extra-chip__count">
{{ items.filter((x) => f.check(x as unknown as T)).length }}
</span>
</button>
</div>
</div>
<div v-if="hasActiveFilters" class="wp-filter-panel__footer">
<Button label="Clear filters" icon="pi pi-times" text size="small" @click="clearFilters" />
</div>
</div>
<!-- Bulk-select bar (above active-filter chips when present) -->
<div v-if="selected.length" class="bulk-bar">
<span class="text-sm">
<strong class="text-wp-accent">{{ selected.length }}</strong> selected
</span>
<div class="flex gap-2">
<Button label="Clear" text size="small" @click="selected = []" />
<Button
label="Delete selected" icon="pi pi-trash" severity="danger" outlined size="small"
@click="confirmBulkDelete"
/>
</div>
</div>
<!-- Active-filter chips row -->
<div v-if="hasActiveFilters" class="wp-active-filters">
<span class="text-xs text-wp-text2">Active:</span>
<span v-if="filter.q" class="filter-chip filter-chip--search">
name: {{ filter.q }}
<button type="button" class="filter-chip__close" aria-label="Clear search" @click="search = ''">
<i class="pi pi-times" />
</button>
</span>
<span v-if="filter.favorites" class="filter-chip filter-chip--favorite">
Favorites only
<button
type="button" class="filter-chip__close" aria-label="Clear favorites filter"
@click="filter.favorites = false; emit('fetch')"
>
<i class="pi pi-times" />
</button>
</span>
<span v-if="filter.category" class="filter-chip filter-chip--category">
category: {{ filter.category }}
<button
type="button" class="filter-chip__close" aria-label="Clear category filter"
@click="filter.category = undefined; emit('fetch')"
>
<i class="pi pi-times" />
</button>
</span>
<span v-for="t in filter.tags ?? []" :key="t" class="filter-chip filter-chip--tag">
tag: {{ t }}
<button
type="button" class="filter-chip__close"
:aria-label="`Remove tag ${t}`"
@click="removeTag(t)"
>
<i class="pi pi-times" />
</button>
</span>
<span
v-for="k in activeExtras" :key="k"
class="filter-chip filter-chip--extra"
>
{{ extraFilters?.find((f) => f.key === k)?.label ?? k }}
<button
type="button" class="filter-chip__close"
:aria-label="`Remove ${k} filter`"
@click="removeExtra(k)"
>
<i class="pi pi-times" />
</button>
</span>
<Button label="Clear all" text size="small" class="ml-1" @click="clearFilters" />
</div>
<DataTable
v-model:selection="selected"
v-model:expandedRows="expandedRows"
:value="pagedItems" :loading="loading" data-key="id"
:row-class="rowClass"
>
<slot name="columns" />
<template v-if="$slots.expansion" #expansion="slotProps">
<slot name="expansion" v-bind="slotProps" />
</template>
<template #empty>
<div class="wp-empty">
<template v-if="hasActiveFilters">
<div class="wp-empty__icon">
<i class="pi pi-search" />
</div>
<p class="text-wp-text font-semibold m-0">No matches</p>
<p class="text-wp-text2 m-0 mt-1">Try clearing filters, or change the search query.</p>
<Button label="Clear filters" icon="pi pi-times" text size="small" class="mt-2" @click="clearFilters" />
</template>
<template v-else>
<img :src="faviconUrl" alt="" class="wp-empty__logo" />
<p class="text-wp-text2 mb-3">{{ emptyMessage }}</p>
<Button :label="newLabel" icon="pi pi-plus" severity="primary" @click="$router.push(newRoute)" />
</template>
</div>
</template>
</DataTable>
<!-- Pagination footer -->
<div v-if="showPagination" class="wp-pagination">
<span class="wp-pagination__range">
{{ pageStart + 1 }}–{{ pageEnd }} of {{ filteredItems.length }}
</span>
<div class="wp-spacer" />
<span class="wp-pagination__per-page-label">Per page</span>
<Select
v-model="pageSize"
:options="PAGE_SIZE_OPTIONS" option-label="label" option-value="value"
size="small" class="wp-pagination__per-page"
aria-label="Items per page"
/>
<div class="wp-pager">
<Button
icon="pi pi-angle-double-left" text size="small"
aria-label="First page"
:disabled="safePage === 1"
@click="goToPage(1)"
/>
<Button
icon="pi pi-angle-left" text size="small"
aria-label="Previous page"
:disabled="safePage === 1"
@click="goToPage(safePage - 1)"
/>
<span class="wp-pager__label">Page {{ safePage }} / {{ totalPages }}</span>
<Button
icon="pi pi-angle-right" text size="small"
aria-label="Next page"
:disabled="safePage === totalPages"
@click="goToPage(safePage + 1)"
/>
<Button
icon="pi pi-angle-double-right" text size="small"
aria-label="Last page"
:disabled="safePage === totalPages"
@click="goToPage(totalPages)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.wp-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.wp-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.wp-page__title-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.wp-page__title {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
}
.wp-page__subtitle {
color: var(--wp-text-muted);
font-size: 12.5px;
margin: 0;
max-width: 720px;
}
.wp-page__actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.wp-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wp-toolbar__search {
flex: 1;
min-width: 240px;
max-width: 440px;
}
.wp-toolbar__search :deep(.p-inputtext) {
height: 38px;
}
.wp-toolbar__count {
font-size: 11.5px;
color: var(--wp-text-dim);
margin-left: auto;
}
.wp-filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 10.5px;
background: var(--wp-accent);
color: #fff;
margin-left: 2px;
}
.wp-filter-panel {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.wp-filter-panel__footer {
display: flex;
justify-content: flex-end;
}
.wp-filter-extras__label {
display: block;
font-size: 11px;
color: var(--wp-text-muted);
margin-bottom: 6px;
}
.wp-filter-extras__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.wp-extra-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 11px;
font-size: 11.5px;
border: 1px solid var(--wp-border);
background: var(--wp-bg2);
color: var(--wp-text-muted);
cursor: pointer;
transition: all 0.12s ease;
}
.wp-extra-chip:hover {
background: var(--wp-bg3);
color: var(--wp-text);
}
.wp-extra-chip[data-active] {
background: var(--wp-accent-glow);
border-color: var(--wp-accent);
color: var(--wp-accent);
}
.wp-extra-chip__count {
font-size: 10.5px;
color: var(--wp-text-dim);
margin-left: 2px;
}
.wp-active-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 11px;
font-size: 11px;
border: 1px solid var(--wp-border);
background: var(--wp-bg2);
}
.filter-chip--search { color: var(--wp-accent); border-color: var(--wp-accent); background: var(--wp-accent-glow); }
.filter-chip--favorite { color: var(--wp-amber); border-color: var(--wp-amber); }
.filter-chip--category { color: var(--wp-violet); border-color: var(--wp-violet); background: var(--wp-violet-bg); }
.filter-chip--tag { color: var(--wp-teal); border-color: var(--wp-teal); background: var(--wp-teal-bg); }
.filter-chip--extra { color: var(--wp-accent); border-color: var(--wp-accent); background: var(--wp-accent-glow); }
.filter-chip__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.7;
border-radius: 999px;
}
.filter-chip__close:hover { opacity: 1; }
.filter-chip__close:focus-visible {
outline: 1px solid currentColor;
outline-offset: 1px;
}
.filter-chip__close .pi { font-size: 10px; }
.bulk-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--wp-accent-glow);
border: 1px solid var(--wp-accent);
border-radius: var(--wp-radius);
padding: 8px 12px;
}
.wp-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 32px 16px;
text-align: center;
}
.wp-empty__icon {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 999px;
background: var(--wp-bg3);
color: var(--wp-text-muted);
font-size: 18px;
margin-bottom: 6px;
}
.wp-empty__logo {
width: 64px;
height: 64px;
margin-bottom: 12px;
opacity: 0.7;
}
.wp-pagination {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
}
.wp-spacer { flex: 1; }
.wp-pagination__range {
font-size: 12px;
color: var(--wp-text-dim);
font-variant-numeric: tabular-nums;
}
.wp-pagination__per-page-label {
font-size: 11.5px;
color: var(--wp-text-dim);
}
.wp-pagination__per-page {
width: 92px;
}
.wp-pager {
display: inline-flex;
align-items: center;
gap: 2px;
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: 7px;
padding: 2px;
}
.wp-pager__label {
padding: 0 10px;
font-size: 11.5px;
color: var(--wp-text-muted);
font-variant-numeric: tabular-nums;
min-width: 90px;
text-align: center;
}
:deep(.wp-row-favorite) {
border-left: 2px solid var(--wp-amber);
}
</style>
+831
View File
@@ -0,0 +1,831 @@
<script setup lang="ts" generic="T extends { id: string; name?: string; updated_at?: string; tags?: string[]; is_favorite?: boolean }">
/**
* ModuleListView — faithful 1:1 port of the prototype `ModuleListView` from
* `docs/design-handoff/wildcardpipeline/project/screens/lists.jsx`.
*
* Replaces the previous EntityListView (which wrapped PrimeVue DataTable). This
* version uses raw `<table>` markup + the `ui/*` primitives so every list
* screen renders the same way the React prototype does.
*
* Per-screen extension points are slots:
* - `header-actions` — extra buttons in the page header
* - `filter-panel` — kind-specific filter controls (category, tags, …)
* - `columns-head` — `<th>` cells (rendered after the built-in
* select / expand / favorite / name columns)
* - `columns` — `<td>` cells for each row, mirrors columns-head
* - `actions` — trailing per-row action buttons
* - `expansion` — body of the expanded-row drawer
*/
import { computed, ref, watch, useSlots } from "vue";
import { useRouter } from "vue-router";
import Button from "./ui/Button.vue";
import Checkbox from "./ui/Checkbox.vue";
import Chip from "./ui/Chip.vue";
import Select from "./ui/Select.vue";
import RelativeDate from "./RelativeDate.vue";
interface Filter {
q?: string;
favorites?: boolean;
category?: string | null;
tags?: string[];
sortBy?: string;
}
interface ExtraFilter<R> {
key: string;
label: string;
check: (item: R) => boolean;
}
interface Props {
title: string;
subtitle: string;
newLabel: string;
newRoute: string;
newIcon?: string;
items: T[];
loading?: boolean;
filter: Filter;
emptyMessage: string;
extraFilters?: ExtraFilter<T>[];
/** Number of caller-provided columns between built-in `name` col and `tags`. */
midCols?: number;
/** Whether the column block emits the favorite-star slot column. */
showFavorite?: boolean;
/** Whether the column block emits the tags column (default true). */
showTags?: boolean;
/** Whether the column block emits the updated-at column (default true). */
showUpdated?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
newIcon: "pi pi-plus",
midCols: 0,
showFavorite: true,
showTags: true,
showUpdated: true,
});
const emit = defineEmits<{
(e: "fetch"): void;
(e: "delete", item: T): void;
(e: "bulk-delete", items: T[]): void;
}>();
const slots = useSlots();
const router = useRouter();
const SORT_OPTIONS = [
{ value: "updated-desc", label: "Updated — newest" },
{ value: "updated-asc", label: "Updated — oldest" },
{ value: "name-asc", label: "Name A → Z" },
{ value: "name-desc", label: "Name Z → A" },
];
const PAGE_SIZE_OPTIONS = [
{ value: 10, label: "10" },
{ value: 15, label: "15" },
{ value: 25, label: "25" },
{ value: 50, label: "50" },
];
const filtersOpen = ref(false);
const selected = ref<Set<string>>(new Set());
const expanded = ref<Set<string>>(new Set());
const extraActive = ref<Record<string, boolean>>({});
const page = ref(1);
const pageSize = ref(15);
// Sync `extraActive` keys with current `extraFilters` so we never read from
// removed keys after the parent swaps the array.
watch(
() => props.extraFilters?.map((f) => f.key) ?? [],
(keys) => {
const next: Record<string, boolean> = {};
for (const k of keys) next[k] = !!extraActive.value[k];
extraActive.value = next;
},
{ immediate: true },
);
const activeExtras = computed(() =>
(props.extraFilters ?? []).filter((f) => extraActive.value[f.key]),
);
const activeFilterCount = computed(() => {
let n = 0;
if (props.filter.favorites) n++;
if (props.filter.category) n++;
n += props.filter.tags?.length ?? 0;
n += activeExtras.value.length;
return n;
});
const hasActiveFilters = computed(
() => activeFilterCount.value > 0 || !!props.filter.q,
);
const filteredItems = computed(() => {
let out = [...props.items];
// Client-side tag filter (server has no tag query yet).
if (props.filter.tags?.length) {
const wanted = new Set(props.filter.tags);
out = out.filter((m) => (m.tags ?? []).some((t) => wanted.has(t)));
}
// Extra (kind-specific) filters.
const extras = activeExtras.value;
if (extras.length > 0) {
out = out.filter((m) => extras.every((f) => f.check(m)));
}
switch (props.filter.sortBy) {
case "name-asc":
out.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
break;
case "name-desc":
out.sort((a, b) => (b.name ?? "").localeCompare(a.name ?? ""));
break;
case "updated-asc":
out.sort((a, b) => (a.updated_at ?? "").localeCompare(b.updated_at ?? ""));
break;
case "updated-desc":
default:
out.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
break;
}
return out;
});
// Reset to page 1 whenever any input that drives `filteredItems` changes.
watch(
() => [
props.filter.q,
props.filter.category,
props.filter.favorites,
props.filter.tags?.length ?? 0,
activeExtras.value.length,
props.filter.sortBy,
pageSize.value,
],
() => {
page.value = 1;
},
);
const totalPages = computed(() =>
Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)),
);
const safePage = computed(() => Math.min(page.value, totalPages.value));
const pageStart = computed(() => (safePage.value - 1) * pageSize.value);
const pageEnd = computed(() =>
Math.min(pageStart.value + pageSize.value, filteredItems.value.length),
);
const paged = computed(() =>
filteredItems.value.slice(pageStart.value, pageStart.value + pageSize.value),
);
// Total count of <th> / <td> cells per row — used for empty-row + expansion
// `colspan`. Keep in sync with the table head/body markup below:
// 1 select + 1 expand + 1 favorite (optional) + 1 name + N mid-cols
// + 1 tags (optional) + 1 updated (optional) + 1 actions.
const totalCols = computed(() => {
let c = 1 + 1 + 1; // select + expand + name
if (props.showFavorite) c += 1;
c += props.midCols;
if (props.showTags) c += 1;
if (props.showUpdated) c += 1;
c += 1; // actions
return c;
});
const showPagination = computed(
() =>
filteredItems.value.length > 0 &&
filteredItems.value.length >
Math.min(...PAGE_SIZE_OPTIONS.map((o) => o.value)),
);
const allSelected = computed({
get(): boolean {
return paged.value.length > 0 && paged.value.every((r) => selected.value.has(r.id));
},
set(v: boolean) {
const next = new Set(selected.value);
if (v) for (const r of paged.value) next.add(r.id);
else for (const r of paged.value) next.delete(r.id);
selected.value = next;
},
});
function toggleSelect(row: T) {
const next = new Set(selected.value);
if (next.has(row.id)) next.delete(row.id);
else next.add(row.id);
selected.value = next;
}
function isSelected(row: T): boolean {
return selected.value.has(row.id);
}
function toggleExpand(id: string) {
const next = new Set(expanded.value);
if (next.has(id)) next.delete(id);
else next.add(id);
expanded.value = next;
}
function isExpanded(id: string): boolean {
return expanded.value.has(id);
}
function clearFilters() {
props.filter.q = "";
props.filter.favorites = false;
props.filter.category = null;
props.filter.tags = [];
for (const k of Object.keys(extraActive.value)) extraActive.value[k] = false;
emit("fetch");
}
function removeTag(t: string) {
props.filter.tags = (props.filter.tags ?? []).filter((x) => x !== t);
emit("fetch");
}
function emitFetch() {
emit("fetch");
}
function bulkDelete() {
const items = props.items.filter((i) => selected.value.has(i.id));
if (items.length === 0) return;
emit("bulk-delete", items);
selected.value = new Set();
}
function onNew() {
router.push(props.newRoute);
}
function matchCount(ef: ExtraFilter<T>): number {
return props.items.filter((m) => ef.check(m)).length;
}
const sortBy = computed({
get(): string | number | null {
return props.filter.sortBy ?? "updated-desc";
},
set(v: string | number | null) {
props.filter.sortBy = (v as string | undefined) ?? undefined;
},
});
const pageSizeModel = computed({
get(): string | number | null {
return pageSize.value;
},
set(v: string | number | null) {
pageSize.value = typeof v === "number" ? v : Number(v ?? 15);
},
});
function goToPage(p: number) {
page.value = Math.min(Math.max(1, p), totalPages.value);
}
defineExpose({
page,
pageSize,
goToPage,
filteredItems,
paged,
totalPages,
activeFilterCount,
});
</script>
<template>
<div class="wp-page wp-page--fill">
<!-- Header -->
<div class="wp-page__header">
<div class="wp-page__title-wrap">
<h1 class="wp-page__title">{{ title }}</h1>
<p class="wp-page__subtitle">{{ subtitle }}</p>
</div>
<div class="wp-page__actions">
<slot name="header-actions" />
<Button variant="primary" :icon="newIcon" @click="onNew">{{ newLabel }}</Button>
</div>
</div>
<!-- Toolbar -->
<div class="wp-page-toolbar">
<div class="wp-page-toolbar__search wp-input-group">
<span class="wp-input-group__addon"><i class="pi pi-search" aria-hidden="true" /></span>
<input
class="wp-input"
placeholder="Search by name, id, tag…"
:value="filter.q ?? ''"
aria-label="Search"
@input="(e) => { filter.q = (e.target as HTMLInputElement).value; emitFetch(); }"
/>
</div>
<Button
:variant="filtersOpen ? 'outline' : 'secondary'"
:icon="filtersOpen ? 'pi pi-filter-fill' : 'pi pi-filter'"
aria-label="Filters"
@click="filtersOpen = !filtersOpen"
>
<span class="wp-page-toolbar__filters-label">
Filters<span v-if="activeFilterCount" class="wp-filter-count">{{ activeFilterCount }}</span>
</span>
</Button>
<div class="wp-page-toolbar__sort">
<Select v-model="sortBy" :options="SORT_OPTIONS" aria-label="Sort" size="sm" />
</div>
<span class="wp-toolbar__count">{{ filteredItems.length }} / {{ items.length }} items</span>
</div>
<!-- Filter panel -->
<Transition name="filter-collapse">
<div v-if="filtersOpen" class="wp-filter-panel">
<slot name="filter-panel" :filter="filter" :emit-fetch="emitFetch" />
<div v-if="extraFilters?.length" class="wp-filter-panel__extra">
<span class="wp-filter-panel__extra-label">Syntax</span>
<div class="wp-filter-panel__extra-chips">
<button
v-for="ef in extraFilters" :key="ef.key"
type="button"
class="wp-filter-toggle"
:data-active="extraActive[ef.key] ? 'true' : 'false'"
@click="extraActive[ef.key] = !extraActive[ef.key]"
>
{{ ef.label }}
<span class="wp-filter-toggle__count">{{ matchCount(ef) }}</span>
</button>
</div>
</div>
<div v-if="hasActiveFilters" class="wp-filter-panel__footer">
<button type="button" class="wp-link" @click="clearFilters">Clear filters</button>
</div>
</div>
</Transition>
<!-- Bulk-select bar -->
<div v-if="selected.size" class="wp-bulk-bar">
<span class="wp-bulk-bar__count">
<strong>{{ selected.size }}</strong> selected
</span>
<span class="wp-spacer" />
<Button variant="ghost" size="sm" @click="selected = new Set()">Clear</Button>
<Button variant="danger" size="sm" icon="pi pi-trash" @click="bulkDelete">Delete</Button>
</div>
<!-- Active-filter chips row -->
<div v-if="hasActiveFilters" class="wp-active-filters">
<span class="wp-active-filters__label">Active:</span>
<Chip
v-if="filter.q"
tone="accent"
removable
@remove="() => { filter.q = ''; emitFetch(); }"
>
Search: {{ filter.q }}
</Chip>
<Chip
v-if="filter.favorites"
tone="warn"
removable
@remove="() => { filter.favorites = false; emitFetch(); }"
>
Favorites only
</Chip>
<Chip
v-if="filter.category"
tone="info"
removable
@remove="() => { filter.category = null; emitFetch(); }"
>
Category: {{ filter.category }}
</Chip>
<Chip
v-for="t in filter.tags ?? []" :key="t"
tone="success"
removable
@remove="removeTag(t)"
>
{{ t }}
</Chip>
<Chip
v-for="ef in activeExtras" :key="ef.key"
tone="accent"
removable
@remove="extraActive[ef.key] = false"
>
{{ ef.label }}
</Chip>
<button type="button" class="wp-link" @click="clearFilters">Clear all</button>
</div>
<!-- Table -->
<div class="wp-table-wrap wp-table-wrap--scroll">
<table class="wp-table wp-table--sticky-head">
<thead>
<tr>
<th class="wp-table__select"><Checkbox v-model="allSelected" aria-label="Select all" /></th>
<th class="wp-table__expand-col" />
<th v-if="showFavorite" class="wp-table__fav-col" />
<th class="wp-sortable">Name</th>
<slot name="columns-head" />
<th v-if="showTags">Tags</th>
<th v-if="showUpdated" class="wp-sortable wp-table__updated-col">Updated</th>
<th class="wp-table__actions-col" style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<template v-for="row in paged" :key="row.id">
<tr
:data-expanded="isExpanded(row.id) ? 'true' : 'false'"
:class="{ 'wp-table__row--selected': isSelected(row), 'wp-row-favorite': row.is_favorite }"
>
<td @click.stop>
<Checkbox
:model-value="isSelected(row)"
aria-label="Select row"
@update:model-value="() => toggleSelect(row)"
/>
</td>
<td>
<button
type="button"
class="wp-row-expand-btn"
:aria-expanded="isExpanded(row.id)"
aria-label="Toggle row"
@click="toggleExpand(row.id)"
>
<i :class="isExpanded(row.id) ? 'pi pi-chevron-down' : 'pi pi-chevron-right'" />
</button>
</td>
<td v-if="showFavorite">
<slot name="favorite" :row="row">
<button
type="button"
class="wp-row-fav-btn"
:data-on="row.is_favorite ? 'true' : 'false'"
aria-label="Toggle favorite"
@click.stop="$emit && $emit"
>
<i :class="row.is_favorite ? 'pi pi-star-fill' : 'pi pi-star'" />
</button>
</slot>
</td>
<td>
<slot name="name" :row="row">
<div class="wp-row-name">
<span class="wp-row-name__text">{{ row.name }}</span>
<span class="wp-id">{{ row.id }}</span>
</div>
</slot>
</td>
<slot name="columns" :row="row" />
<td v-if="showTags">
<div v-if="(row.tags ?? []).length" class="wp-row-tags">
<span v-for="(t, i) in (row.tags ?? []).slice(0, 3)" :key="i" class="wp-row-tag">{{ t }}</span>
<span v-if="(row.tags ?? []).length > 3" class="wp-row-tag-more">+{{ (row.tags ?? []).length - 3 }}</span>
</div>
<span v-else class="wp-dim">—</span>
</td>
<td v-if="showUpdated">
<RelativeDate :value="row.updated_at" />
</td>
<td class="wp-table__actions-col" style="text-align:right" @click.stop>
<div class="wp-row-actions">
<slot name="actions" :row="row" />
</div>
</td>
</tr>
<tr v-if="isExpanded(row.id) && slots.expansion" class="wp-row-expand-row">
<td :colspan="totalCols" style="padding:0">
<div class="wp-row-expand">
<slot name="expansion" :row="row" />
</div>
</td>
</tr>
</template>
<tr v-if="!paged.length">
<td :colspan="totalCols">
<div v-if="!hasActiveFilters" class="wp-empty">
<div class="wp-empty__icon"><i class="pi pi-inbox" /></div>
<p class="wp-empty__msg">{{ emptyMessage }}</p>
<Button variant="primary" :icon="newIcon" @click="onNew">{{ newLabel }}</Button>
</div>
<div v-else class="wp-empty wp-empty--no-match">
<div class="wp-empty__icon"><i class="pi pi-search" /></div>
<p class="wp-empty__msg">No matches.</p>
<button type="button" class="wp-link" @click="clearFilters">Clear filters</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="showPagination" class="wp-page-pager wp-pagination">
<span class="wp-pagination__range">
{{ pageStart + 1 }}–{{ pageEnd }} of {{ filteredItems.length }}
</span>
<span class="wp-spacer" />
<span class="wp-pagination__per-page-label">Per page</span>
<div class="wp-pagination__per-page">
<Select v-model="pageSizeModel" :options="PAGE_SIZE_OPTIONS" aria-label="Items per page" size="sm" />
</div>
<div class="wp-pager">
<button
type="button" class="wp-icon-btn"
aria-label="First page"
:disabled="safePage === 1"
@click="goToPage(1)"
><i class="pi pi-angle-double-left" /></button>
<button
type="button" class="wp-icon-btn"
aria-label="Previous page"
:disabled="safePage === 1"
@click="goToPage(safePage - 1)"
><i class="pi pi-angle-left" /></button>
<span class="wp-pager__label">Page {{ safePage }} / {{ totalPages }}</span>
<button
type="button" class="wp-icon-btn"
aria-label="Next page"
:disabled="safePage === totalPages"
@click="goToPage(safePage + 1)"
><i class="pi pi-angle-right" /></button>
<button
type="button" class="wp-icon-btn"
aria-label="Last page"
:disabled="safePage === totalPages"
@click="goToPage(totalPages)"
><i class="pi pi-angle-double-right" /></button>
</div>
</div>
</div>
</template>
<style scoped>
.wp-page-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wp-page-toolbar__search {
flex: 1;
min-width: 240px;
max-width: 440px;
}
.wp-page-toolbar__sort { width: 180px; }
.wp-page-toolbar__filters-label { display: inline-flex; align-items: center; gap: 4px; }
.wp-filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 10.5px;
background: var(--wp-accent-500);
color: #fff;
margin-left: 4px;
}
.wp-filter-panel {
background: var(--wp-bg-2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.wp-filter-panel__extra-label {
display: block;
font-size: 11px;
color: var(--wp-text-muted);
margin-bottom: 6px;
}
.wp-filter-panel__extra-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.wp-filter-panel__footer {
display: flex;
justify-content: flex-end;
}
.wp-filter-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 11px;
font-size: 11.5px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-2);
color: var(--wp-text-muted);
cursor: pointer;
transition: all 0.12s ease;
}
.wp-filter-toggle:hover {
background: var(--wp-bg-3);
color: var(--wp-text);
}
.wp-filter-toggle[data-active="true"] {
background: color-mix(in oklab, var(--wp-accent-500) 22%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500) 45%, transparent);
color: var(--wp-accent-text);
}
.wp-filter-toggle__count {
font-size: 10.5px;
color: var(--wp-text-dim);
margin-left: 2px;
}
.wp-bulk-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: color-mix(in oklab, var(--wp-accent-500) 12%, transparent);
border: 1px solid color-mix(in oklab, var(--wp-accent-500) 35%, transparent);
border-radius: var(--wp-radius);
}
.wp-bulk-bar__count { font-size: 12.5px; }
.wp-active-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.wp-active-filters__label {
font-size: 11.5px;
color: var(--wp-text-dim);
}
.wp-link {
background: transparent;
border: none;
padding: 0;
font-size: 11.5px;
color: var(--wp-accent-text);
cursor: pointer;
text-decoration: underline;
}
.wp-link:hover { color: var(--wp-accent-text-strong); }
.wp-table__select { width: 32px; padding-right: 0; }
.wp-table__expand-col { width: 28px; padding: 0; }
.wp-table__fav-col { width: 28px; padding: 0; }
.wp-table__updated-col { width: 100px; }
.wp-table__actions-col { width: 130px; }
.wp-table__row--selected > td { background: color-mix(in oklab, var(--wp-accent-500) 8%, transparent) !important; }
.wp-row-expand-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
color: var(--wp-text-muted);
display: inline-flex;
align-items: center;
justify-content: center;
}
.wp-row-expand-btn:hover { color: var(--wp-text); }
.wp-row-expand-btn .pi { font-size: 11px; }
.wp-row-fav-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
color: var(--wp-text-dim);
display: inline-flex;
align-items: center;
justify-content: center;
}
.wp-row-fav-btn[data-on="true"] { color: var(--wp-warn, #fcd34d); }
.wp-row-fav-btn .pi { font-size: 13px; }
.wp-row-name {
display: flex;
flex-direction: column;
gap: 2px;
}
.wp-row-name__text {
font-weight: 500;
}
.wp-row-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.wp-row-tag {
font-size: 10px;
padding: 1px 6px;
background: var(--wp-bg-3);
color: var(--wp-text2, var(--wp-text-muted));
border-radius: 3px;
}
.wp-row-tag-more {
font-size: 10px;
color: var(--wp-text-dim);
}
.wp-row-actions {
display: inline-flex;
gap: 2px;
justify-content: flex-end;
}
.wp-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 32px 16px;
text-align: center;
}
.wp-empty__icon {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 999px;
background: var(--wp-bg-3);
color: var(--wp-text-muted);
font-size: 18px;
margin-bottom: 6px;
}
.wp-empty__msg { color: var(--wp-text-muted); margin: 0 0 6px; }
.wp-page-pager {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
}
.wp-pagination__range {
font-size: 12px;
color: var(--wp-text-dim);
font-variant-numeric: tabular-nums;
}
.wp-pagination__per-page-label {
font-size: 11.5px;
color: var(--wp-text-dim);
}
.wp-pagination__per-page { width: 92px; }
.wp-icon-btn {
background: transparent;
border: none;
cursor: pointer;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--wp-text-muted);
display: inline-flex;
align-items: center;
justify-content: center;
}
.wp-icon-btn:hover:not(:disabled) {
background: var(--wp-bg-3);
color: var(--wp-text);
}
.wp-icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.filter-collapse-enter-active,
.filter-collapse-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.filter-collapse-enter-from,
.filter-collapse-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.wp-row-favorite > td:first-child {
border-left: 2px solid var(--wp-warn, #f7b955);
}
</style>
+6 -2
View File
@@ -5,9 +5,13 @@ import type { ModuleCreateInput, ModuleRow, ModuleType, ModuleUpdateInput } from
interface Filter {
type?: ModuleType;
category?: string;
category?: string | null;
q?: string;
favorites?: boolean;
/** Client-side AND-mode tag filter — server has no tag query yet. */
tags?: string[];
/** Client-side sort key. One of "updated-desc" / "updated-asc" / "name-asc" / "name-desc". */
sortBy?: string;
}
export const useModuleStore = defineStore("modules", () => {
@@ -20,7 +24,7 @@ export const useModuleStore = defineStore("modules", () => {
try {
const params: Record<string, string | boolean | undefined> = {};
if (filter.type) params.type = filter.type;
if (filter.category) params.category = filter.category;
if (filter.category) params.category = filter.category ?? undefined;
if (filter.q) params.q = filter.q;
if (filter.favorites) params.favorites = true;
const res = await api.modules.list(params);
+223 -146
View File
@@ -2,14 +2,10 @@
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 ModuleListView from "../components/ModuleListView.vue";
import Button from "../components/ui/Button.vue";
import Checkbox from "../components/ui/Checkbox.vue";
import Select from "../components/ui/Select.vue";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import type { CategoryRow, CombinePayload, ModuleRow } from "../api/types";
@@ -31,6 +27,11 @@ const allTags = computed(() => {
return Array.from(set).sort();
});
const categoryOptions = computed(() => [
{ value: null, label: "All categories" },
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
]);
onMounted(async () => {
store.filter.type = "combine";
await Promise.all([fetch(), categoryStore.fetchAll()]);
@@ -85,6 +86,11 @@ async function bulkDel(items: ModuleRow[]) {
for (const item of items) await del(item);
}
function toggleTag(t: string, currentTags: string[] | undefined): string[] {
const cur = currentTags ?? [];
return cur.includes(t) ? cur.filter((x) => x !== t) : [...cur, t];
}
function payloadOf(row: ModuleRow): CombinePayload {
const p = row.payload as Partial<CombinePayload>;
return {
@@ -102,149 +108,215 @@ function outputVar(row: ModuleRow): string {
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; }
interface TemplatePart { kind: "key" | "var" | "text"; text: string; }
function templateParts(row: ModuleRow): TemplatePart[] {
const tpl = templateOf(row);
if (!tpl) return [];
const parts = tpl.split(/(\{[^}]+\}|\$[a-z_][a-z0-9_]*)/i);
return parts
.filter((p) => p !== "")
.map((p) => {
if (/^\{[^}]+\}$/.test(p)) return { kind: "key" as const, text: p };
if (/^\$[a-z_]/i.test(p)) return { kind: "var" as const, text: p };
return { kind: "text" as const, text: p };
});
}
</script>
<template>
<EntityListView
<ModuleListView
title="Combines"
subtitle="Combine modules merge resolved variables into a new value via templates."
subtitle="Combine modules merge upstream resolved values via a template, storing into a new variable for downstream."
new-label="New Combine"
new-route="/combines/new"
:items="store.items"
:loading="store.loading"
:filter="store.filter"
:mid-cols="3"
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>
<div class="wp-filters-grid">
<div class="wp-field">
<label class="wp-field__label">Category</label>
<Select
v-model="filter.category"
:options="categoryStore.items" option-label="name" option-value="id"
placeholder="All categories" show-clear class="w-full"
:model-value="filter.category ?? null"
:options="categoryOptions"
placeholder="Any category"
aria-label="Filter by category"
@change="emitFetch"
@update:model-value="(v) => { filter.category = v as string | null; 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
<div class="wp-field">
<label class="wp-field__label">Favorites</label>
<label class="wp-fav-toggle">
<Checkbox
:model-value="!!filter.favorites"
@update:model-value="(v) => { filter.favorites = v; emitFetch(); }"
/>
<span>Favorites only</span>
</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 class="wp-field wp-field--full">
<label class="wp-field__label">
Tags{{ filter.tags?.length ? ` (${filter.tags.length})` : "" }}
</label>
<div v-if="!allTags.length" class="wp-dim wp-tags-empty">No tags in this collection.</div>
<div v-else class="wp-tags-row">
<button
v-for="t in allTags" :key="t"
type="button"
class="wp-tag-chip"
:data-active="(filter.tags ?? []).includes(t) ? 'true' : 'false'"
@click="filter.tags = toggleTag(t, filter.tags); emitFetch()"
>
{{ t }}
</button>
</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 #favorite="{ row }">
<button
type="button"
class="wp-row-fav-btn"
:data-on="row.is_favorite ? 'true' : 'false'"
:aria-label="row.is_favorite ? 'Unfavorite' : 'Favorite'"
@click.stop="fav(row)"
>
<i :class="row.is_favorite ? 'pi pi-star-fill' : 'pi pi-star'" />
</button>
</template>
<template #name="{ row }">
<div class="wp-row-name">
<span class="wp-row-name__text" @click="edit(row)">{{ row.name }}</span>
<span
class="wp-id"
:title="`Click to copy ${row.id}`"
@click.stop="copyId(row.id)"
>{{ row.id }}</span>
</div>
</template>
<template #columns-head>
<th style="width: 130px">Category</th>
<th style="width: 140px">Output</th>
<th style="width: 70px">Inputs</th>
</template>
<template #columns="{ row }">
<td>
<span
v-if="row.category_id && categoryById.get(row.category_id)"
class="category-chip"
:style="{ background: categoryById.get(row.category_id)!.color || 'var(--wp-bg-3)' }"
>
{{ categoryById.get(row.category_id)!.name }}
</span>
<span v-else class="wp-dim">—</span>
</td>
<td><span class="wp-mono wp-output-var">{{ outputVar(row) }}</span></td>
<td><span class="wp-mono">{{ inputCount(row) }}</span></td>
</template>
<template #actions="{ row }">
<Button variant="ghost" size="sm" icon="pi pi-pencil" aria-label="Edit" @click="edit(row)" />
<Button variant="ghost" size="sm" icon="pi pi-clone" aria-label="Duplicate" @click="dup(row)" />
<Button variant="ghost" size="sm" icon="pi pi-trash" aria-label="Delete" @click="del(row)" />
</template>
<template #expansion="{ row }">
<div class="wp-row-expand__title">Template</div>
<div v-if="templateOf(row)" class="wp-snippet">
<template v-for="(p, i) in templateParts(row)" :key="i">
<span v-if="p.kind === 'key'" class="wp-token-key">{{ p.text }}</span>
<span v-else-if="p.kind === 'var'" class="wp-token-var">{{ p.text }}</span>
<span v-else>{{ p.text }}</span>
</template>
</div>
<div v-else class="wp-dim">No template defined.</div>
<div class="wp-input-vars">
<span class="wp-dim wp-input-vars__label">Reads:</span>
<span v-if="!inputVars(row).length" class="wp-dim">no inputs</span>
<span
v-for="(v, i) in inputVars(row)" :key="i"
class="wp-input-var-chip"
>
<i :class="v.startsWith('@') ? 'pi pi-th-large' : 'pi pi-tag'" />
{{ v }}
</span>
</div>
</template>
</ModuleListView>
</template>
<style scoped>
.wp-filters-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.wp-field--full { grid-column: 1 / -1; }
.wp-field__label {
display: block;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--wp-text-muted);
font-weight: 600;
margin-bottom: 5px;
}
.wp-fav-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
cursor: pointer;
user-select: none;
}
.wp-tags-row { display: flex; flex-wrap: wrap; gap: 6px; }
.wp-tags-empty { font-size: 12px; }
.wp-tag-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
background: var(--wp-bg-3);
color: var(--wp-text-muted);
border: 1px solid var(--wp-border);
cursor: pointer;
}
.wp-tag-chip[data-active="true"] {
background: color-mix(in oklab, var(--wp-accent-500) 22%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500) 45%, transparent);
color: var(--wp-accent-text);
}
.wp-row-fav-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
color: var(--wp-text-dim);
display: inline-flex;
align-items: center;
}
.wp-row-fav-btn[data-on="true"] { color: var(--wp-warn, #fcd34d); }
.wp-row-fav-btn .pi { font-size: 13px; }
.wp-row-name { display: flex; flex-direction: column; gap: 2px; }
.wp-row-name__text { font-weight: 500; cursor: pointer; }
.wp-id {
font-family: var(--wp-font-mono);
font-size: 10.5px;
color: var(--wp-text-dim);
cursor: pointer;
user-select: all;
}
.category-chip {
display: inline-block;
font-size: 11px;
@@ -254,31 +326,36 @@ function templateOf(row: ModuleRow): string { return payloadOf(row).template; }
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;
.wp-output-var { color: var(--wp-accent-text, #c4b5fd); }
.wp-row-expand__title {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--wp-text-dim);
margin-bottom: 8px;
font-weight: 600;
}
.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;
.wp-input-vars {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
margin-top: 8px;
}
.input-var-chip {
.wp-input-vars__label { font-size: 11.5px; margin-right: 4px; }
.wp-input-var-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
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));
background: color-mix(in oklab, var(--wp-accent-500) 14%, transparent);
color: var(--wp-accent-text);
border: 1px solid color-mix(in oklab, var(--wp-accent-500) 35%, transparent);
font-family: var(--wp-font-mono);
}
.text-wp-violet { color: var(--wp-kind-combine, var(--wp-violet, #b4a0ff)); }
.wp-input-var-chip .pi { font-size: 9.5px; }
</style>
+197 -158
View File
@@ -2,14 +2,10 @@
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 ModuleListView from "../components/ModuleListView.vue";
import Button from "../components/ui/Button.vue";
import Checkbox from "../components/ui/Checkbox.vue";
import Select from "../components/ui/Select.vue";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import type {
@@ -31,10 +27,8 @@ const categoryById = computed(() => {
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.
// Best-effort module-by-id lookup. Only resolves wildcards already loaded into
// the moduleStore — see Constraints.vue port note in v1 for context.
const moduleNameById = computed(() => {
const map = new Map<string, ModuleRow>();
for (const m of store.items) map.set(m.id, m);
@@ -47,6 +41,11 @@ const allTags = computed(() => {
return Array.from(set).sort();
});
const categoryOptions = computed(() => [
{ value: null, label: "All categories" },
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
]);
onMounted(async () => {
store.filter.type = "constraint";
await Promise.all([fetch(), categoryStore.fetchAll()]);
@@ -101,9 +100,13 @@ async function bulkDel(items: ModuleRow[]) {
for (const item of items) await del(item);
}
function toggleTag(t: string, currentTags: string[] | undefined): string[] {
const cur = currentTags ?? [];
return cur.includes(t) ? cur.filter((x) => x !== t) : [...cur, t];
}
function payloadOf(row: ModuleRow): ConstraintPayload {
const p = row.payload as Partial<ConstraintPayload> & {
// Backwards-compat: tolerate older array-of-arrays payloads silently.
matrix?: ConstraintMatrix | unknown[];
};
const matrix: ConstraintMatrix =
@@ -143,7 +146,6 @@ function matrixSamples(row: ModuleRow, max = 5): MatrixSample[] {
: mode === "reduce"
? 0.5
: 1;
// Skip plain "allow" cells from the preview to surface meaningful entries.
if (mode === "allow") continue;
out.push({ source: sourceVal, target: targetSub, mode, factor });
}
@@ -159,179 +161,216 @@ function formatFactor(f: number): string {
</script>
<template>
<EntityListView
<ModuleListView
title="Constraints"
subtitle="Constraint modules apply rules between two wildcards' categories."
subtitle="Constraints set rules between two wildcards' sub-categories — exclude, boost, or reduce specific combinations, with per-pair exceptions."
new-label="New Constraint"
new-route="/constraints/new"
:items="store.items"
:loading="store.loading"
:filter="store.filter"
:mid-cols="4"
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>
<div class="wp-filters-grid">
<div class="wp-field">
<label class="wp-field__label">Category</label>
<Select
v-model="filter.category"
:options="categoryStore.items" option-label="name" option-value="id"
placeholder="All categories" show-clear class="w-full"
:model-value="filter.category ?? null"
:options="categoryOptions"
placeholder="Any category"
aria-label="Filter by category"
@change="emitFetch"
@update:model-value="(v) => { filter.category = v as string | null; 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
<div class="wp-field">
<label class="wp-field__label">Favorites</label>
<label class="wp-fav-toggle">
<Checkbox
:model-value="!!filter.favorites"
@update:model-value="(v) => { filter.favorites = v; emitFetch(); }"
/>
<span>Favorites only</span>
</label>
</div>
<div class="wp-field wp-field--full">
<label class="wp-field__label">
Tags{{ filter.tags?.length ? ` (${filter.tags.length})` : "" }}
</label>
<div v-if="!allTags.length" class="wp-dim wp-tags-empty">No tags in this collection.</div>
<div v-else class="wp-tags-row">
<button
v-for="t in allTags" :key="t"
type="button"
class="wp-tag-chip"
:data-active="(filter.tags ?? []).includes(t) ? 'true' : 'false'"
@click="filter.tags = toggleTag(t, filter.tags); emitFetch()"
>
{{ t }}
</button>
</div>
</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 }}
<template #favorite="{ row }">
<button
type="button"
class="wp-row-fav-btn"
:data-on="row.is_favorite ? 'true' : 'false'"
:aria-label="row.is_favorite ? 'Unfavorite' : 'Favorite'"
@click.stop="fav(row)"
>
<i :class="row.is_favorite ? 'pi pi-star-fill' : 'pi pi-star'" />
</button>
</template>
<template #name="{ row }">
<div class="wp-row-name">
<span class="wp-row-name__text" @click="edit(row)">{{ row.name }}</span>
<span
class="wp-id"
:title="`Click to copy ${row.id}`"
@click.stop="copyId(row.id)"
>{{ row.id }}</span>
</div>
</template>
<template #columns-head>
<th style="width: 130px">Category</th>
<th style="width: 130px">Source</th>
<th style="width: 130px">Target</th>
<th style="width: 90px">Exceptions</th>
</template>
<template #columns="{ row }">
<td>
<span
v-if="row.category_id && categoryById.get(row.category_id)"
class="category-chip"
:style="{ background: categoryById.get(row.category_id)!.color || 'var(--wp-bg-3)' }"
>
{{ categoryById.get(row.category_id)!.name }}
</span>
<span v-else class="wp-dim">—</span>
</td>
<td><span class="wp-cn-name">{{ lookupName(payloadOf(row).source_wildcard_id) }}</span></td>
<td><span class="wp-cn-name">{{ lookupName(payloadOf(row).target_wildcard_id) }}</span></td>
<td><span class="wp-mono">{{ exceptionCount(row) }}</span></td>
</template>
<template #actions="{ row }">
<Button variant="ghost" size="sm" icon="pi pi-pencil" aria-label="Edit" @click="edit(row)" />
<Button variant="ghost" size="sm" icon="pi pi-clone" aria-label="Duplicate" @click="dup(row)" />
<Button variant="ghost" size="sm" icon="pi pi-trash" aria-label="Delete" @click="del(row)" />
</template>
<template #expansion="{ row }">
<div class="wp-row-expand__title">
Sample matrix entries for <span class="wp-row-expand__name">{{ (row.name ?? '').toUpperCase() }}</span>
</div>
<div v-if="!matrixSamples(row).length" class="wp-dim">No non-default matrix cells defined.</div>
<ul v-else class="wp-matrix-list">
<li
v-for="(s, i) in matrixSamples(row)" :key="i"
class="wp-matrix-item"
>
<span class="wp-cn-name">{{ s.source }}</span>
<span class="wp-dim wp-matrix-arrow">→</span>
<span class="wp-cn-name">{{ s.target }}</span>
<span class="wp-dim wp-matrix-sep">:</span>
<span class="wp-token-key">{{ s.mode }}</span>
<span v-if="s.mode === 'boost' || s.mode === 'reduce'" class="wp-dim">
×{{ formatFactor(s.factor) }}
</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>
</li>
</ul>
<div v-if="exceptionCount(row)" class="wp-dim wp-matrix-meta">
{{ exceptionCount(row) }} per-pair exception{{ exceptionCount(row) === 1 ? "" : "s" }}
</div>
</template>
</EntityListView>
</ModuleListView>
</template>
<style scoped>
.category-chip {
display: inline-block;
.wp-filters-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.wp-field--full { grid-column: 1 / -1; }
.wp-field__label {
display: block;
font-size: 11px;
padding: 2px 8px;
border-radius: 9px;
color: #fff;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--wp-text-muted);
font-weight: 600;
margin-bottom: 5px;
}
.wp-fav-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
cursor: pointer;
user-select: none;
}
.wp-tags-row { display: flex; flex-wrap: wrap; gap: 6px; }
.wp-tags-empty { font-size: 12px; }
.wp-tag-chip {
display: inline-flex; align-items: center;
padding: 2px 8px; border-radius: 999px; font-size: 11px;
background: var(--wp-bg-3); color: var(--wp-text-muted);
border: 1px solid var(--wp-border);
cursor: pointer;
}
.wp-tag-chip[data-active="true"] {
background: color-mix(in oklab, var(--wp-accent-500) 22%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500) 45%, transparent);
color: var(--wp-accent-text);
}
.wp-row-fav-btn {
background: transparent; border: none; cursor: pointer; padding: 4px;
color: var(--wp-text-dim);
display: inline-flex; align-items: center;
}
.wp-row-fav-btn[data-on="true"] { color: var(--wp-warn, #fcd34d); }
.wp-row-fav-btn .pi { font-size: 13px; }
.wp-row-name { display: flex; flex-direction: column; gap: 2px; }
.wp-row-name__text { font-weight: 500; cursor: pointer; }
.wp-id {
font-family: var(--wp-font-mono);
font-size: 10.5px;
color: var(--wp-text-dim);
cursor: pointer;
user-select: all;
}
.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;
.wp-cn-name { font-size: 12px; color: var(--wp-text); }
.wp-row-expand__title {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--wp-text-dim);
margin-bottom: 8px;
font-weight: 600;
}
.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); }
.wp-row-expand__name { color: var(--wp-accent-text, #c4b5fd); }
.wp-matrix-list {
list-style: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 2px;
}
.wp-matrix-item { font-family: var(--wp-font-mono); font-size: 11.5px; }
.wp-matrix-arrow, .wp-matrix-sep { margin: 0 6px; }
.wp-matrix-meta { margin-top: 8px; font-size: 11px; }
</style>
+218 -159
View File
@@ -2,21 +2,16 @@
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 ModuleListView from "../components/ModuleListView.vue";
import Button from "../components/ui/Button.vue";
import Checkbox from "../components/ui/Checkbox.vue";
import Select from "../components/ui/Select.vue";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import type {
CategoryRow,
DerivationBranch,
DerivationCondition,
DerivationAction,
DerivationCondition,
DerivationPayload,
DerivationRule,
ModuleRow,
@@ -39,6 +34,11 @@ const allTags = computed(() => {
return Array.from(set).sort();
});
const categoryOptions = computed(() => [
{ value: null, label: "All categories" },
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
]);
onMounted(async () => {
store.filter.type = "derivation";
await Promise.all([fetch(), categoryStore.fetchAll()]);
@@ -93,177 +93,233 @@ async function bulkDel(items: ModuleRow[]) {
for (const item of items) await del(item);
}
function toggleTag(t: string, currentTags: string[] | undefined): string[] {
const cur = currentTags ?? [];
return cur.includes(t) ? cur.filter((x) => x !== t) : [...cur, t];
}
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 "?";
const v = c.var ? `$${c.var.replace(/^\$/, "")}` : "$?";
const val = c.value ?? "";
return `${v} ${c.op} "${val}"`;
const VERB: Record<string, string> = {
append: "append to",
prepend: "prepend to",
replace: "replace in",
};
function actionVerb(mode: string | undefined): string {
return VERB[mode ?? ""] ?? (mode ?? "?");
}
function formatAction(a: DerivationAction | undefined): string {
if (!a) return "?";
const verb = (
{ append: "append to", prepend: "prepend to", replace: "replace in" } as Record<string, string>
)[a.mode] ?? a.mode;
return `${verb} ${a.target_var} = "${a.value ?? ""}"`;
interface CondView { var?: string; op?: string; value?: string; }
function condView(c: DerivationCondition | undefined): CondView | null {
if (!c) return null;
return { var: c.var, op: c.op, value: c.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);
function actView(a: DerivationAction | undefined): { verb: string; target: string; value: string } | null {
if (!a) return null;
return { verb: actionVerb(a.mode), target: a.target_var, value: a.value ?? "" };
}
</script>
<template>
<EntityListView
<ModuleListView
title="Derivations"
subtitle="Derivation modules mutate resolved context with conditional rules."
subtitle="Derivations mutate the resolved context post-resolution — append, replace, or remove tokens conditionally."
new-label="New Derivation"
new-route="/derivations/new"
:items="store.items"
:loading="store.loading"
:filter="store.filter"
:mid-cols="2"
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>
<div class="wp-filters-grid">
<div class="wp-field">
<label class="wp-field__label">Category</label>
<Select
v-model="filter.category"
:options="categoryStore.items" option-label="name" option-value="id"
placeholder="All categories" show-clear class="w-full"
:model-value="filter.category ?? null"
:options="categoryOptions"
placeholder="Any category"
aria-label="Filter by category"
@change="emitFetch"
@update:model-value="(v) => { filter.category = v as string | null; 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
<div class="wp-field">
<label class="wp-field__label">Favorites</label>
<label class="wp-fav-toggle">
<Checkbox
:model-value="!!filter.favorites"
@update:model-value="(v) => { filter.favorites = v; emitFetch(); }"
/>
<span>Favorites only</span>
</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 class="wp-field wp-field--full">
<label class="wp-field__label">
Tags{{ filter.tags?.length ? ` (${filter.tags.length})` : "" }}
</label>
<div v-if="!allTags.length" class="wp-dim wp-tags-empty">No tags in this collection.</div>
<div v-else class="wp-tags-row">
<button
v-for="t in allTags" :key="t"
type="button"
class="wp-tag-chip"
:data-active="(filter.tags ?? []).includes(t) ? 'true' : 'false'"
@click="filter.tags = toggleTag(t, filter.tags); emitFetch()"
>
{{ t }}
</button>
</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.action) }}
</span>
</li>
</ol>
</div>
</template>
</EntityListView>
<template #favorite="{ row }">
<button
type="button"
class="wp-row-fav-btn"
:data-on="row.is_favorite ? 'true' : 'false'"
:aria-label="row.is_favorite ? 'Unfavorite' : 'Favorite'"
@click.stop="fav(row)"
>
<i :class="row.is_favorite ? 'pi pi-star-fill' : 'pi pi-star'" />
</button>
</template>
<template #name="{ row }">
<div class="wp-row-name">
<span class="wp-row-name__text" @click="edit(row)">{{ row.name }}</span>
<span
class="wp-id"
:title="`Click to copy ${row.id}`"
@click.stop="copyId(row.id)"
>{{ row.id }}</span>
</div>
</template>
<template #columns-head>
<th style="width: 130px">Category</th>
<th style="width: 70px">Rules</th>
</template>
<template #columns="{ row }">
<td>
<span
v-if="row.category_id && categoryById.get(row.category_id)"
class="category-chip"
:style="{ background: categoryById.get(row.category_id)!.color || 'var(--wp-bg-3)' }"
>
{{ categoryById.get(row.category_id)!.name }}
</span>
<span v-else class="wp-dim">—</span>
</td>
<td><span class="wp-mono">{{ ruleCount(row) }}</span></td>
</template>
<template #actions="{ row }">
<Button variant="ghost" size="sm" icon="pi pi-pencil" aria-label="Edit" @click="edit(row)" />
<Button variant="ghost" size="sm" icon="pi pi-clone" aria-label="Duplicate" @click="dup(row)" />
<Button variant="ghost" size="sm" icon="pi pi-trash" aria-label="Delete" @click="del(row)" />
</template>
<template #expansion="{ row }">
<div class="wp-row-expand__title">Rules ({{ ruleCount(row) }})</div>
<div v-if="!ruleCount(row)" class="wp-dim">No rules defined.</div>
<div v-else class="wp-snippet wp-rules-snippet">
<div v-for="(rule, ri) in rules(row)" :key="rule.id ?? ri" class="wp-rule-block">
<div class="wp-token-com wp-rule-block__head"># rule {{ ri + 1 }}</div>
<div v-for="(b, bi) in rule.branches ?? []" :key="bi">
<span class="wp-token-key">{{ bi === 0 ? "IF" : "ELIF" }}</span>
<template v-if="condView(b.condition)">
<span> @{{ condView(b.condition)!.var || "?" }}</span>
<span> {{ condView(b.condition)!.op }}</span>
<span class="wp-token-str"> "{{ condView(b.condition)!.value }}"</span>
</template>
<span v-else><em class="wp-dim">always</em></span>
<span class="wp-token-com"> · </span>
<span class="wp-token-key">THEN</span>
<template v-if="actView(b.action)">
<span> {{ actView(b.action)!.verb }} {{ actView(b.action)!.target }}</span>
<span class="wp-token-str"> "{{ actView(b.action)!.value }}"</span>
</template>
</div>
<div v-if="rule.else">
<span class="wp-token-key">ELSE</span>
<template v-if="actView(rule.else.action)">
<span> {{ actView(rule.else.action)!.verb }} {{ actView(rule.else.action)!.target }}</span>
<span class="wp-token-str"> "{{ actView(rule.else.action)!.value }}"</span>
</template>
</div>
</div>
</div>
</template>
</ModuleListView>
</template>
<style scoped>
.wp-filters-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.wp-field--full { grid-column: 1 / -1; }
.wp-field__label {
display: block;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--wp-text-muted);
font-weight: 600;
margin-bottom: 5px;
}
.wp-fav-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
cursor: pointer;
user-select: none;
}
.wp-tags-row { display: flex; flex-wrap: wrap; gap: 6px; }
.wp-tags-empty { font-size: 12px; }
.wp-tag-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
background: var(--wp-bg-3);
color: var(--wp-text-muted);
border: 1px solid var(--wp-border);
cursor: pointer;
}
.wp-tag-chip[data-active="true"] {
background: color-mix(in oklab, var(--wp-accent-500) 22%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500) 45%, transparent);
color: var(--wp-accent-text);
}
.wp-row-fav-btn {
background: transparent; border: none; cursor: pointer; padding: 4px;
color: var(--wp-text-dim);
display: inline-flex; align-items: center;
}
.wp-row-fav-btn[data-on="true"] { color: var(--wp-warn, #fcd34d); }
.wp-row-fav-btn .pi { font-size: 13px; }
.wp-row-name { display: flex; flex-direction: column; gap: 2px; }
.wp-row-name__text { font-weight: 500; cursor: pointer; }
.wp-id {
font-family: var(--wp-font-mono);
font-size: 10.5px;
color: var(--wp-text-dim);
cursor: pointer;
user-select: all;
}
.category-chip {
display: inline-block;
font-size: 11px;
@@ -273,21 +329,24 @@ function extraBranches(rule: DerivationRule): number {
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;
.wp-row-expand__title {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--wp-text-dim);
margin-bottom: 8px;
font-weight: 600;
}
.rule-list {
list-style: decimal;
padding-left: 20px;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
.wp-rules-snippet {
max-width: 720px;
line-height: 1.7;
}
.wp-rule-block { margin-bottom: 8px; }
.wp-rule-block:last-child { margin-bottom: 0; }
.wp-rule-block__head {
font-size: 10.5px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.rule-list__item { color: var(--wp-text); }
.text-wp-teal { color: var(--wp-kind-derivation, var(--wp-teal, #5dd2c7)); }
</style>
+184 -124
View File
@@ -2,14 +2,10 @@
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 ModuleListView from "../components/ModuleListView.vue";
import Button from "../components/ui/Button.vue";
import Checkbox from "../components/ui/Checkbox.vue";
import Select from "../components/ui/Select.vue";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import type { ModuleRow, CategoryRow } from "../api/types";
@@ -31,6 +27,11 @@ const allTags = computed(() => {
return Array.from(set).sort();
});
const categoryOptions = computed(() => [
{ value: null, label: "All categories" },
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
]);
onMounted(async () => {
store.filter.type = "fixed_values";
await Promise.all([fetch(), categoryStore.fetchAll()]);
@@ -85,17 +86,22 @@ async function bulkDel(items: ModuleRow[]) {
for (const item of items) await del(item);
}
function toggleTag(t: string, currentTags: string[] | undefined): string[] {
const cur = currentTags ?? [];
return cur.includes(t) ? cur.filter((x) => x !== t) : [...cur, t];
}
interface NamedValue { id: string; name: string; value: string; }
function values(row: ModuleRow): NamedValue[] {
return ((row.payload as { values?: NamedValue[] }).values ?? []);
}
function valueCount(row: ModuleRow): number { return values(row).length; }
function topValues(row: ModuleRow): NamedValue[] { return values(row).slice(0, 3); }
function topValues(row: ModuleRow): NamedValue[] { return values(row).slice(0, 4); }
</script>
<template>
<EntityListView
<ModuleListView
title="Fixed Values"
subtitle="Fixed-value modules emit one or more named string variables, set explicitly."
new-label="New Fixed Values"
@@ -103,133 +109,183 @@ function topValues(row: ModuleRow): NamedValue[] { return values(row).slice(0, 3
:items="store.items"
:loading="store.loading"
:filter="store.filter"
:mid-cols="2"
empty-message="No fixed values yet. Use these to lock variables to specific strings."
@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>
<div class="wp-filters-grid">
<div class="wp-field">
<label class="wp-field__label">Category</label>
<Select
v-model="filter.category"
:options="categoryStore.items" option-label="name" option-value="id"
placeholder="All categories" show-clear class="w-full"
:model-value="filter.category ?? null"
:options="categoryOptions"
placeholder="Any category"
aria-label="Filter by category"
@change="emitFetch"
@update:model-value="(v) => { filter.category = v as string | null; 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
<div class="wp-field">
<label class="wp-field__label">Favorites</label>
<label class="wp-fav-toggle">
<Checkbox
:model-value="!!filter.favorites"
@update:model-value="(v) => { filter.favorites = v; emitFetch(); }"
/>
<span>Favorites only</span>
</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 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 class="wp-field wp-field--full">
<label class="wp-field__label">
Tags{{ filter.tags?.length ? ` (${filter.tags.length})` : "" }}
</label>
<div v-if="!allTags.length" class="wp-dim wp-tags-empty">No tags in this collection.</div>
<div v-else class="wp-tags-row">
<button
v-for="t in allTags" :key="t"
type="button"
class="wp-tag-chip"
:data-active="(filter.tags ?? []).includes(t) ? 'true' : 'false'"
@click="filter.tags = toggleTag(t, filter.tags); emitFetch()"
>
{{ t }}
</button>
</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="Values" header-style="width:5rem">
<template #body="{ data }">
<Badge :value="String(valueCount(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">
First values for <span class="text-wp-rose">{{ data.name }}</span>
</h5>
<div v-if="topValues(data).length === 0" class="text-sm text-wp-text3">
No values defined.
</div>
<div v-else class="flex flex-col gap-1">
<div v-for="(v, i) in topValues(data)" :key="i" class="flex items-center gap-3 text-sm">
<span class="font-mono text-wp-violet">${{ v.name.replace(/^\$/, "") || "?" }}</span>
<span class="text-wp-text2">=</span>
<span class="font-mono text-wp-text">{{ v.value || "(empty)" }}</span>
</div>
<span v-if="valueCount(data) > 3" class="text-xs text-wp-text3 mt-1">
… and {{ valueCount(data) - 3 }} more
</span>
</div>
</div>
</template>
</EntityListView>
<template #favorite="{ row }">
<button
type="button"
class="wp-row-fav-btn"
:data-on="row.is_favorite ? 'true' : 'false'"
:aria-label="row.is_favorite ? 'Unfavorite' : 'Favorite'"
@click.stop="fav(row)"
>
<i :class="row.is_favorite ? 'pi pi-star-fill' : 'pi pi-star'" />
</button>
</template>
<template #name="{ row }">
<div class="wp-row-name">
<span class="wp-row-name__text" @click="edit(row)">{{ row.name }}</span>
<span
class="wp-id"
:title="`Click to copy ${row.id}`"
@click.stop="copyId(row.id)"
>{{ row.id }}</span>
</div>
</template>
<template #columns-head>
<th style="width: 130px">Category</th>
<th style="width: 70px">Values</th>
</template>
<template #columns="{ row }">
<td>
<span
v-if="row.category_id && categoryById.get(row.category_id)"
class="category-chip"
:style="{ background: categoryById.get(row.category_id)!.color || 'var(--wp-bg-3)' }"
>
{{ categoryById.get(row.category_id)!.name }}
</span>
<span v-else class="wp-dim">—</span>
</td>
<td><span class="wp-mono">{{ valueCount(row) }}</span></td>
</template>
<template #actions="{ row }">
<Button variant="ghost" size="sm" icon="pi pi-pencil" aria-label="Edit" @click="edit(row)" />
<Button variant="ghost" size="sm" icon="pi pi-clone" aria-label="Duplicate" @click="dup(row)" />
<Button variant="ghost" size="sm" icon="pi pi-trash" aria-label="Delete" @click="del(row)" />
</template>
<template #expansion="{ row }">
<div class="wp-row-expand__title">
First values for <span class="wp-row-expand__name">{{ (row.name ?? '').toUpperCase() }}</span>
</div>
<div v-if="!values(row).length" class="wp-dim">No values defined.</div>
<div v-else class="wp-snippet">
<div v-for="(v, i) in topValues(row)" :key="i">
<span class="wp-token-var">${{ (v.name ?? '').replace(/^\$/, '') || "?" }}</span>
<span class="wp-token-com"> = </span>
<span class="wp-token-str">"{{ v.value }}"</span>
</div>
</div>
<div v-if="valueCount(row) > 4" class="wp-dim wp-opts-more">
… and {{ valueCount(row) - 4 }} more
</div>
</template>
</ModuleListView>
</template>
<style scoped>
.wp-filters-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.wp-field--full { grid-column: 1 / -1; }
.wp-field__label {
display: block;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--wp-text-muted);
font-weight: 600;
margin-bottom: 5px;
}
.wp-fav-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
cursor: pointer;
user-select: none;
}
.wp-tags-row { display: flex; flex-wrap: wrap; gap: 6px; }
.wp-tags-empty { font-size: 12px; }
.wp-tag-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
background: var(--wp-bg-3);
color: var(--wp-text-muted);
border: 1px solid var(--wp-border);
cursor: pointer;
transition: all 0.12s ease;
}
.wp-tag-chip[data-active="true"] {
background: color-mix(in oklab, var(--wp-accent-500) 22%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500) 45%, transparent);
color: var(--wp-accent-text);
}
.wp-row-fav-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
color: var(--wp-text-dim);
display: inline-flex;
align-items: center;
}
.wp-row-fav-btn[data-on="true"] { color: var(--wp-warn, #fcd34d); }
.wp-row-fav-btn .pi { font-size: 13px; }
.wp-row-name { display: flex; flex-direction: column; gap: 2px; }
.wp-row-name__text { font-weight: 500; cursor: pointer; }
.wp-id {
font-family: var(--wp-font-mono);
font-size: 10.5px;
color: var(--wp-text-dim);
cursor: pointer;
user-select: all;
}
.category-chip {
display: inline-block;
font-size: 11px;
@@ -239,11 +295,15 @@ function topValues(row: ModuleRow): NamedValue[] { return values(row).slice(0, 3
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;
.wp-row-expand__title {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--wp-text-dim);
margin-bottom: 8px;
font-weight: 600;
}
.wp-row-expand__name { color: var(--wp-accent-text, #c4b5fd); }
.wp-opts-more { margin-top: 8px; font-size: 11.5px; }
</style>
+295 -166
View File
@@ -2,14 +2,10 @@
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 ModuleListView from "../components/ModuleListView.vue";
import Button from "../components/ui/Button.vue";
import Checkbox from "../components/ui/Checkbox.vue";
import Select from "../components/ui/Select.vue";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import type {
@@ -31,8 +27,6 @@ const categoryById = computed(() => {
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);
@@ -45,6 +39,11 @@ const allTags = computed(() => {
return Array.from(set).sort();
});
const categoryOptions = computed(() => [
{ value: null, label: "All categories" },
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
]);
onMounted(async () => {
store.filter.type = "pipeline";
await Promise.all([fetch(), categoryStore.fetchAll()]);
@@ -99,6 +98,11 @@ async function bulkDel(items: ModuleRow[]) {
for (const item of items) await del(item);
}
function toggleTag(t: string, currentTags: string[] | undefined): string[] {
const cur = currentTags ?? [];
return cur.includes(t) ? cur.filter((x) => x !== t) : [...cur, t];
}
const KIND_ICON: Record<ModuleType, string> = {
wildcard: "pi pi-th-large",
fixed_values: "pi pi-tag",
@@ -108,13 +112,28 @@ const KIND_ICON: Record<ModuleType, string> = {
pipeline: "pi pi-list",
};
const KIND_LABEL: Record<ModuleType, string> = {
wildcard: "Wildcard",
fixed_values: "Fixed Values",
combine: "Combine",
derivation: "Derivation",
constraint: "Constraint",
pipeline: "Pipeline",
};
const KIND_COLOR_VAR: Record<ModuleType, string> = {
wildcard: "var(--wp-kind-wildcard, #c026d3)",
fixed_values: "var(--wp-kind-fixed_values, #ec4899)",
combine: "var(--wp-kind-combine, #8b5cf6)",
derivation: "var(--wp-kind-derivation, #14b8a6)",
constraint: "var(--wp-kind-constraint, #f59e0b)",
pipeline: "var(--wp-kind-pipeline, #6aa1ff)",
};
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);
@@ -126,192 +145,302 @@ function iconFor(kind: ModuleType | null): string {
return KIND_ICON[kind] ?? "pi pi-circle";
}
function kindClass(kind: ModuleType | null): string {
return kind ? `wp-kind-${kind}` : "";
interface MixCount { kind: ModuleType; count: number; label: string; color: string; icon: string; }
function mixCounts(row: ModuleRow): MixCount[] {
const counts: Partial<Record<ModuleType, number>> = {};
for (const s of steps(row)) {
const m = moduleById.value.get(s.module_id);
const k = m?.type;
if (!k) continue;
counts[k] = (counts[k] ?? 0) + 1;
}
const order: ModuleType[] = ["wildcard", "fixed_values", "combine", "constraint", "derivation"];
return order
.filter((k) => counts[k])
.map((k) => ({
kind: k,
count: counts[k]!,
label: KIND_LABEL[k],
color: KIND_COLOR_VAR[k],
icon: KIND_ICON[k],
}));
}
function pad2(n: number): string { return String(n + 1).padStart(2, "0"); }
</script>
<template>
<EntityListView
<ModuleListView
title="Pipelines"
subtitle="Pipelines run a sequence of modules in order, accumulating context."
subtitle="Pipelines are ordered presets of modules. They run top-to-bottom — each step appends to the resolved context the next step sees."
new-label="New Pipeline"
new-route="/pipelines/new"
:items="store.items"
:loading="store.loading"
:filter="store.filter"
:mid-cols="3"
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>
<div class="wp-filters-grid">
<div class="wp-field">
<label class="wp-field__label">Category</label>
<Select
v-model="filter.category"
:options="categoryStore.items" option-label="name" option-value="id"
placeholder="All categories" show-clear class="w-full"
:model-value="filter.category ?? null"
:options="categoryOptions"
placeholder="Any category"
aria-label="Filter by category"
@change="emitFetch"
@update:model-value="(v) => { filter.category = v as string | null; 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
<div class="wp-field">
<label class="wp-field__label">Favorites</label>
<label class="wp-fav-toggle">
<Checkbox
:model-value="!!filter.favorites"
@update:model-value="(v) => { filter.favorites = v; emitFetch(); }"
/>
<span>Favorites only</span>
</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'"
<div class="wp-field wp-field--full">
<label class="wp-field__label">
Tags{{ filter.tags?.length ? ` (${filter.tags.length})` : "" }}
</label>
<div v-if="!allTags.length" class="wp-dim wp-tags-empty">No tags in this collection.</div>
<div v-else class="wp-tags-row">
<button
v-for="t in allTags" :key="t"
type="button"
class="wp-tag-chip"
:data-active="(filter.tags ?? []).includes(t) ? 'true' : 'false'"
@click="filter.tags = toggleTag(t, filter.tags); emitFetch()"
>
<i :class="step.enabled === false ? 'pi pi-eye-slash' : 'pi pi-check-circle'" />
</span>
</li>
</ol>
{{ t }}
</button>
</div>
</div>
</div>
</template>
</EntityListView>
<template #favorite="{ row }">
<button
type="button"
class="wp-row-fav-btn"
:data-on="row.is_favorite ? 'true' : 'false'"
:aria-label="row.is_favorite ? 'Unfavorite' : 'Favorite'"
@click.stop="fav(row)"
>
<i :class="row.is_favorite ? 'pi pi-star-fill' : 'pi pi-star'" />
</button>
</template>
<template #name="{ row }">
<div class="wp-row-name">
<span class="wp-row-name__text" @click="edit(row)">{{ row.name }}</span>
<span
class="wp-id"
:title="`Click to copy ${row.id}`"
@click.stop="copyId(row.id)"
>{{ row.id }}</span>
</div>
</template>
<template #columns-head>
<th style="width: 130px">Category</th>
<th style="width: 70px">Steps</th>
<th style="width: 140px">Mix</th>
</template>
<template #columns="{ row }">
<td>
<span
v-if="row.category_id && categoryById.get(row.category_id)"
class="category-chip"
:style="{ background: categoryById.get(row.category_id)!.color || 'var(--wp-bg-3)' }"
>
{{ categoryById.get(row.category_id)!.name }}
</span>
<span v-else class="wp-dim">—</span>
</td>
<td><span class="wp-mono">{{ stepCount(row) }}</span></td>
<td>
<div class="wp-mix-cell">
<span
v-for="m in mixCounts(row)" :key="m.kind"
class="wp-mix-chip"
:title="`${m.count} × ${m.label}`"
:style="{ color: m.color, background: `color-mix(in oklab, ${m.color} 14%, transparent)` }"
>
<i :class="m.icon" />
{{ m.count }}
</span>
</div>
</td>
</template>
<template #actions="{ row }">
<Button variant="ghost" size="sm" icon="pi pi-pencil" aria-label="Edit" @click="edit(row)" />
<Button variant="ghost" size="sm" icon="pi pi-clone" aria-label="Duplicate" @click="dup(row)" />
<Button variant="ghost" size="sm" icon="pi pi-trash" aria-label="Delete" @click="del(row)" />
</template>
<template #expansion="{ row }">
<div class="wp-row-expand__title">
{{ stepCount(row) }} step{{ stepCount(row) === 1 ? "" : "s" }} — top to bottom
</div>
<div v-if="!stepCount(row)" class="wp-dim">No steps defined.</div>
<div v-else class="wp-step-list">
<div
v-for="(step, idx) in steps(row).slice(0, 6)" :key="step.id ?? idx"
class="wp-step"
:class="{ 'wp-step--disabled': step.enabled === false }"
>
<span class="wp-step__index">{{ pad2(idx) }}</span>
<span
class="wp-step__icon"
:style="{ color: stepLabel(step).kind ? KIND_COLOR_VAR[stepLabel(step).kind!] : 'var(--wp-text-dim)',
background: stepLabel(step).kind ? `color-mix(in oklab, ${KIND_COLOR_VAR[stepLabel(step).kind!]} 18%, transparent)` : 'transparent' }"
>
<i :class="iconFor(stepLabel(step).kind)" />
</span>
<span class="wp-step__kind">
{{ stepLabel(step).kind ? KIND_LABEL[stepLabel(step).kind!] : "—" }}
</span>
<span class="wp-step__name">{{ stepLabel(step).name }}</span>
<i
v-if="step.enabled === false"
class="pi pi-eye-slash wp-step__toggle wp-step__toggle--off"
title="Disabled"
/>
<i
v-else
class="pi pi-check-circle wp-step__toggle"
title="Enabled"
/>
</div>
</div>
<div v-if="stepCount(row) > 6" class="wp-dim wp-opts-more">
… and {{ stepCount(row) - 6 }} more
</div>
</template>
</ModuleListView>
</template>
<style scoped>
.category-chip {
display: inline-block;
.wp-filters-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.wp-field--full { grid-column: 1 / -1; }
.wp-field__label {
display: block;
font-size: 11px;
padding: 2px 8px;
border-radius: 9px;
color: #fff;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--wp-text-muted);
font-weight: 600;
margin-bottom: 5px;
}
.wp-fav-toggle {
display: inline-flex; align-items: center;
gap: 8px; font-size: 12.5px;
cursor: pointer; user-select: none;
}
.wp-tags-row { display: flex; flex-wrap: wrap; gap: 6px; }
.wp-tags-empty { font-size: 12px; }
.wp-tag-chip {
display: inline-flex; align-items: center;
padding: 2px 8px; border-radius: 999px; font-size: 11px;
background: var(--wp-bg-3); color: var(--wp-text-muted);
border: 1px solid var(--wp-border);
cursor: pointer;
}
.wp-tag-chip[data-active="true"] {
background: color-mix(in oklab, var(--wp-accent-500) 22%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500) 45%, transparent);
color: var(--wp-accent-text);
}
.wp-row-fav-btn {
background: transparent; border: none; cursor: pointer; padding: 4px;
color: var(--wp-text-dim);
display: inline-flex; align-items: center;
}
.wp-row-fav-btn[data-on="true"] { color: var(--wp-warn, #fcd34d); }
.wp-row-fav-btn .pi { font-size: 13px; }
.wp-row-name { display: flex; flex-direction: column; gap: 2px; }
.wp-row-name__text { font-weight: 500; cursor: pointer; }
.wp-id {
font-family: var(--wp-font-mono);
font-size: 10.5px; color: var(--wp-text-dim);
cursor: pointer; user-select: all;
}
.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 {
.wp-mix-cell { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }
.wp-mix-chip {
display: inline-flex; align-items: center; gap: 3px;
padding: 2px 6px; border-radius: 5px;
font-size: 11px;
font-family: var(--wp-font-mono);
}
.wp-mix-chip .pi { font-size: 9px; }
.wp-row-expand__title {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--wp-text-dim);
margin-bottom: 8px;
font-weight: 600;
}
.wp-step-list {
display: grid;
gap: 4px;
max-width: 540px;
}
.wp-step {
display: flex; align-items: center; gap: 8px;
padding: 5px 10px; border-radius: 6px;
background: var(--wp-bg-2);
border: 1px solid var(--wp-border);
font-size: 12px; min-width: 0;
}
.wp-step--disabled { opacity: 0.55; }
.wp-step__index {
font-family: var(--wp-font-mono);
font-size: 10px;
padding: 1px 6px;
background: var(--wp-bg3);
color: var(--wp-text2);
border-radius: 3px;
color: var(--wp-text-dim);
width: 18px;
}
.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;
.wp-step__icon {
width: 18px; height: 18px;
border-radius: 4px;
display: grid; place-items: center;
font-size: 10px;
flex-shrink: 0;
}
.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);
.wp-step__kind {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--wp-text-dim);
flex-shrink: 0;
}
.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)); }
.wp-step__name {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.wp-step__toggle { color: var(--wp-text-dim); margin-left: auto; }
.wp-step__toggle--off { color: var(--wp-warn, #fcd34d); }
.wp-opts-more { margin-top: 6px; font-size: 11.5px; }
</style>
+295 -173
View File
@@ -2,15 +2,11 @@
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 ModuleListView from "../components/ModuleListView.vue";
import RichTextPreview from "../components/RichTextPreview.vue";
import Button from "../components/ui/Button.vue";
import Checkbox from "../components/ui/Checkbox.vue";
import Select from "../components/ui/Select.vue";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import type { ModuleRow, CategoryRow } from "../api/types";
@@ -37,6 +33,11 @@ const allTags = computed(() => {
return Array.from(set).sort();
});
const categoryOptions = computed(() => [
{ value: null, label: "All categories" },
...categoryStore.items.map((c) => ({ value: c.id, label: c.name })),
]);
// Reactive ref-graph computed off the store. Rebuilds on every change since
// `getWildcardSyntax` is cheap (linear over tokens) and graphs are small.
const wildcardGraph = computed(() => buildWildcardGraph(store.items));
@@ -62,17 +63,17 @@ function syntaxView(row: ModuleRow): SyntaxView {
const extraFilters = computed(() => [
{
key: "has-refs",
label: "Has nested refs",
label: "Uses nested refs",
check: (m: ModuleRow) => getWildcardSyntax(m).hasRefs,
},
{
key: "has-inline",
label: "Has inline choices",
label: "Has inline {a|b|c}",
check: (m: ModuleRow) => getWildcardSyntax(m).hasInline,
},
{
key: "is-referenced",
label: "Is referenced",
label: "Referenced by others",
check: (m: ModuleRow) => {
const inc = wildcardGraph.value.incoming.get(wildcardVarName(m));
return !!inc && inc.size > 0;
@@ -134,6 +135,11 @@ async function bulkDel(items: ModuleRow[]) {
for (const item of items) await del(item);
}
function toggleTag(t: string, currentTags: string[] | undefined): string[] {
const cur = currentTags ?? [];
return cur.includes(t) ? cur.filter((x) => x !== t) : [...cur, t];
}
interface WildcardOption { id: string; value: string; weight: number; }
function options(row: ModuleRow): WildcardOption[] {
@@ -141,18 +147,20 @@ function options(row: ModuleRow): WildcardOption[] {
}
function optionCount(row: ModuleRow): number { return options(row).length; }
function topOptions(row: ModuleRow): WildcardOption[] {
return [...options(row)].sort((a, b) => (b.weight ?? 1) - (a.weight ?? 1)).slice(0, 3);
return [...options(row)].sort((a, b) => (b.weight ?? 1) - (a.weight ?? 1)).slice(0, 4);
}
function validIcon(row: ModuleRow): string {
return optionCount(row) === 0
? "pi pi-exclamation-triangle text-wp-amber"
: "pi pi-check-circle text-wp-green";
function totalWeight(row: ModuleRow): number {
return options(row).reduce((a, b) => a + (b.weight ?? 1), 0) || 1;
}
function isValid(row: ModuleRow): boolean {
return options(row).every((o) => !!o.value);
}
</script>
<template>
<EntityListView
<ModuleListView
title="Wildcards"
subtitle="Wildcard modules pick one weighted option per resolution. Use $variable in prompts."
new-label="New Wildcard"
@@ -161,171 +169,261 @@ function validIcon(row: ModuleRow): string {
:loading="store.loading"
:filter="store.filter"
:extra-filters="extraFilters"
:mid-cols="4"
empty-message="No wildcards yet. Create your first to start building dynamic prompts."
@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>
<div class="wp-filters-grid">
<div class="wp-field">
<label class="wp-field__label">Category</label>
<Select
v-model="filter.category"
:options="categoryStore.items" option-label="name" option-value="id"
placeholder="All categories" show-clear class="w-full"
:model-value="filter.category ?? null"
:options="categoryOptions"
placeholder="Any category"
aria-label="Filter by category"
@change="emitFetch"
@update:model-value="(v) => { filter.category = v as string | null; 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
<div class="wp-field">
<label class="wp-field__label">
Favorites
</label>
<label class="wp-fav-toggle">
<Checkbox
:model-value="!!filter.favorites"
@update:model-value="(v) => { filter.favorites = v; emitFetch(); }"
/>
<span>Favorites only</span>
</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 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>
</template>
</Column>
<Column header="Syntax" header-style="width:8rem">
<template #body="{ data }">
<template v-for="v in [syntaxView(data)]" :key="data.id">
<div
v-if="v.outgoing.length || v.incoming.length || v.hasInline"
class="wp-syntax-cell"
<div class="wp-field wp-field--full">
<label class="wp-field__label">
Tags{{ filter.tags?.length ? ` (${filter.tags.length})` : "" }}
</label>
<div v-if="!allTags.length" class="wp-dim wp-tags-empty">No tags in this collection.</div>
<div v-else class="wp-tags-row">
<button
v-for="t in allTags" :key="t"
type="button"
class="wp-tag-chip"
:data-active="(filter.tags ?? []).includes(t) ? 'true' : 'false'"
@click="filter.tags = toggleTag(t, filter.tags); emitFetch()"
>
<span
v-if="v.outgoing.length"
class="wp-syntax-pill wp-syntax-pill--ref"
:title="`References ${v.outgoing.length} wildcard${v.outgoing.length === 1 ? '' : 's'}: ${v.outgoing.join(', ')}`"
>
<i class="pi pi-arrow-right-arrow-left" />
{{ v.outgoing.length }}
</span>
<span
v-if="v.hasInline"
class="wp-syntax-pill wp-syntax-pill--dp"
title="Contains inline {a|b|c} alternatives"
>{{ "{ }" }}</span>
<span
v-if="v.incoming.length"
class="wp-syntax-pill wp-syntax-pill--in"
:title="`Referenced by ${v.incoming.length} wildcard${v.incoming.length === 1 ? '' : 's'}: ${v.incoming.join(', ')}`"
>
<i class="pi pi-arrow-left" />
{{ v.incoming.length }}
</span>
</div>
<span v-else class="text-wp-text3 text-sm">—</span>
</template>
</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="Items" header-style="width:5rem">
<template #body="{ data }">
<Badge :value="String(optionCount(data))" severity="secondary" />
</template>
</Column>
<Column header="Valid" header-style="width:5rem">
<template #body="{ data }">
<i :class="validIcon(data)" :title="optionCount(data) === 0 ? 'No options' : 'Valid'" />
</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>
{{ t }}
</button>
</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">
Top options for <span class="text-wp-violet">{{ data.name }}</span>
</h5>
<div v-if="topOptions(data).length === 0" class="text-sm text-wp-text3">
No options defined.
</div>
<div v-else class="flex flex-col gap-1">
<div v-for="(opt, i) in topOptions(data)" :key="i" class="flex items-center gap-3 text-sm">
<Badge :value="String(opt.weight)" severity="secondary" class="min-w-8" />
<RichTextPreview v-if="opt.value" :value="opt.value" />
<span v-else class="font-mono text-wp-text3">(empty)</span>
</div>
<span v-if="optionCount(data) > 3" class="text-xs text-wp-text3 mt-1">
… and {{ optionCount(data) - 3 }} more
</span>
</div>
</div>
</template>
</EntityListView>
<template #favorite="{ row }">
<button
type="button"
class="wp-row-fav-btn"
:data-on="row.is_favorite ? 'true' : 'false'"
:aria-label="row.is_favorite ? 'Unfavorite' : 'Favorite'"
@click.stop="fav(row)"
>
<i :class="row.is_favorite ? 'pi pi-star-fill' : 'pi pi-star'" />
</button>
</template>
<template #name="{ row }">
<div class="wp-row-name">
<span class="wp-row-name__text" @click="edit(row)">{{ row.name }}</span>
<span
class="wp-id"
:title="`Click to copy ${row.id}`"
@click.stop="copyId(row.id)"
>{{ row.id }}</span>
</div>
</template>
<template #columns-head>
<th style="width: 130px">Category</th>
<th style="width: 60px">Items</th>
<th style="width: 84px">Syntax</th>
<th style="width: 60px">Valid</th>
</template>
<template #columns="{ row }">
<td>
<span
v-if="row.category_id && categoryById.get(row.category_id)"
class="category-chip"
:style="{ background: categoryById.get(row.category_id)!.color || 'var(--wp-bg-3)' }"
>
{{ categoryById.get(row.category_id)!.name }}
</span>
<span v-else class="wp-dim">—</span>
</td>
<td><span class="wp-mono">{{ optionCount(row) }}</span></td>
<td>
<template v-for="v in [syntaxView(row)]" :key="row.id">
<div
v-if="v.outgoing.length || v.incoming.length || v.hasInline"
class="wp-syntax-cell"
>
<span
v-if="v.outgoing.length"
class="wp-syntax-pill wp-syntax-pill--ref"
:title="`Nests ${v.outgoing.length} other wildcard${v.outgoing.length === 1 ? '' : 's'}`"
>
<i class="pi pi-arrow-right" /> {{ v.outgoing.length }}
</span>
<span
v-if="v.incoming.length"
class="wp-syntax-pill wp-syntax-pill--in"
:title="`Referenced by ${v.incoming.length} wildcard${v.incoming.length === 1 ? '' : 's'}`"
>
<i class="pi pi-arrow-left" /> {{ v.incoming.length }}
</span>
<span
v-if="v.hasInline"
class="wp-syntax-pill wp-syntax-pill--dp"
title="Contains inline {a|b|c} alternatives"
>{{ "{ }" }}</span>
</div>
<span v-else class="wp-dim">—</span>
</template>
</td>
<td>
<i
v-if="isValid(row)"
class="pi pi-check-circle"
style="color: var(--wp-success, #86efac);"
title="Valid"
/>
<i
v-else
class="pi pi-exclamation-triangle"
style="color: var(--wp-warn, #fcd34d);"
title="Empty option present"
/>
</td>
</template>
<template #actions="{ row }">
<Button variant="ghost" size="sm" icon="pi pi-pencil" aria-label="Edit" @click="edit(row)" />
<Button variant="ghost" size="sm" icon="pi pi-clone" aria-label="Duplicate" @click="dup(row)" />
<Button variant="ghost" size="sm" icon="pi pi-trash" aria-label="Delete" @click="del(row)" />
</template>
<template #expansion="{ row }">
<div class="wp-row-expand__title">
Top options for <span class="wp-row-expand__name">{{ (row.name ?? '').toUpperCase() }}</span>
</div>
<div v-if="!options(row).length" class="wp-dim">No options defined.</div>
<div v-else class="wp-opts-grid">
<template v-for="(opt, i) in topOptions(row)" :key="i">
<span class="wp-mono wp-dim">{{ opt.weight ?? 1 }}×</span>
<span class="wp-mono wp-opts-grid__val">
<RichTextPreview v-if="opt.value" :value="opt.value" />
<span v-else class="wp-dim">(empty)</span>
</span>
<div class="wp-opts-grid__bar">
<div
class="wp-opts-grid__bar-fill"
:style="{ width: `${((opt.weight ?? 1) / totalWeight(row)) * 100}%` }"
/>
</div>
</template>
</div>
<div v-if="optionCount(row) > 4" class="wp-dim wp-opts-more">
… and {{ optionCount(row) - 4 }} more
</div>
</template>
</ModuleListView>
</template>
<style scoped>
.wp-filters-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.wp-field--full { grid-column: 1 / -1; }
.wp-field__label {
display: block;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--wp-text-muted);
font-weight: 600;
margin-bottom: 5px;
}
.wp-fav-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
cursor: pointer;
user-select: none;
}
.wp-tags-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.wp-tags-empty { font-size: 12px; }
.wp-tag-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
background: var(--wp-bg-3);
color: var(--wp-text-muted);
border: 1px solid var(--wp-border);
cursor: pointer;
transition: all 0.12s ease;
}
.wp-tag-chip:hover {
color: var(--wp-text);
background: var(--wp-bg-4);
}
.wp-tag-chip[data-active="true"] {
background: color-mix(in oklab, var(--wp-accent-500) 22%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500) 45%, transparent);
color: var(--wp-accent-text);
}
.wp-row-fav-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
color: var(--wp-text-dim);
display: inline-flex;
align-items: center;
justify-content: center;
}
.wp-row-fav-btn[data-on="true"] { color: var(--wp-warn, #fcd34d); }
.wp-row-fav-btn .pi { font-size: 13px; }
.wp-row-name {
display: flex;
flex-direction: column;
gap: 2px;
}
.wp-row-name__text {
font-weight: 500;
cursor: pointer;
}
.wp-row-name__text:hover { color: var(--wp-text); }
.wp-id {
font-family: var(--wp-font-mono);
font-size: 10.5px;
color: var(--wp-text-dim);
cursor: pointer;
user-select: all;
}
.wp-id:hover { color: var(--wp-text-muted); }
.category-chip {
display: inline-block;
font-size: 11px;
@@ -335,15 +433,7 @@ function validIcon(row: ModuleRow): string {
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;
}
/* Syntax indicator pills (mirrors design-handoff styles.css). */
.wp-syntax-cell {
display: inline-flex;
gap: 4px;
@@ -366,21 +456,53 @@ function validIcon(row: ModuleRow): string {
}
.wp-syntax-pill .pi { font-size: 9.5px; }
.wp-syntax-pill--ref {
/* outgoing nested @refs — pink/magenta to match @ref token color */
color: var(--wp-kind-wildcard, #f0abfc);
background: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 14%, transparent);
border-color: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 28%, transparent);
}
.wp-syntax-pill--in {
/* incoming references — accent (purple) */
color: var(--wp-accent-text, #c4b5fd);
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 14%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 32%, transparent);
}
.wp-syntax-pill--dp {
/* inline {a|b|c} — amber/yellow to match dp-brace token color */
color: var(--wp-warn, #fcd34d);
background: color-mix(in oklab, var(--wp-warn, #facc15) 14%, transparent);
border-color: color-mix(in oklab, var(--wp-warn, #facc15) 30%, transparent);
}
.wp-row-expand__title {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--wp-text-dim);
margin-bottom: 8px;
font-weight: 600;
}
.wp-row-expand__name { color: var(--wp-accent-text, #c4b5fd); }
.wp-opts-grid {
display: grid;
grid-template-columns: 30px 1fr 120px;
gap: 4px 12px;
max-width: 760px;
font-size: 12.5px;
align-items: center;
}
.wp-opts-grid__val {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wp-opts-grid__bar {
background: var(--wp-bg-3);
height: 6px;
border-radius: 999px;
overflow: hidden;
}
.wp-opts-grid__bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--wp-accent-500, #8b5cf6), var(--wp-accent-400, #a78bfa));
}
.wp-opts-more { margin-top: 8px; font-size: 11.5px; }
</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-CDUtSLHq.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-D3FbmiU9.css">
<script type="module" crossorigin src="/wp/assets/manager-Bc7nzdjM.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-CS7NgARy.css">
</head>
<body>
<div id="app"></div>