feat(spa): wildcards.vue with full datatable + filter + bulk select

Replace stub Wildcards.vue with full EntityListView-based implementation
featuring columns, row actions, favorite toggle, filter panel, and bulk
delete. Add 3 Vitest tests (62 total). Fix EntityListView favicon img to
use dynamic binding so pnpm build:manager resolves cleanly; add SVG stub
alias in vitest.config.ts for test-time asset resolution.
This commit is contained in:
DumiFlex
2026-04-26 21:29:18 +03:00
parent 5e8d0428d4
commit 22c29aa090
5 changed files with 232 additions and 9 deletions
+80
View File
@@ -0,0 +1,80 @@
import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMemoryHistory, createRouter } from "vue-router";
import PrimeVue from "primevue/config";
import ToastService from "primevue/toastservice";
import ConfirmationService from "primevue/confirmationservice";
vi.mock("../api/client", () => ({
api: {
modules: {
list: vi.fn(),
delete: vi.fn(),
duplicate: vi.fn(),
favorite: vi.fn(),
},
categories: { list: vi.fn().mockResolvedValue({ items: [] }) },
},
ApiError: class extends Error {
constructor(public status: number, message: string) { super(message); }
},
}));
import { api } from "../api/client";
import Wildcards from "../views/Wildcards.vue";
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
beforeEach(() => {
setActivePinia(createPinia());
Object.values(apiMod).forEach((fn) => fn.mockReset());
});
afterEach(() => vi.clearAllMocks());
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/wildcards/new", component: { template: "<div/>" } },
{ path: "/wildcards/:id/edit", name: "wildcards-edit", component: { template: "<div/>" } },
],
});
}
function mountView() {
return mount(Wildcards, {
global: { plugins: [makeRouter(), PrimeVue, ToastService, ConfirmationService] },
});
}
describe("Wildcards.vue", () => {
it("filters store by type=wildcard on mount", async () => {
apiMod.list.mockResolvedValue({ items: [], total: 0 });
mountView();
await flushPromises();
expect(apiMod.list).toHaveBeenCalledWith(expect.objectContaining({ type: "wildcard" }));
});
it("renders rows", async () => {
apiMod.list.mockResolvedValue({
items: [{
id: "wc_a", type: "wildcard", name: "alpha", category_id: null,
tags: [], is_favorite: false, payload: { options: [] }, version: 1,
description: "", created_at: "", updated_at: "",
}],
total: 1,
});
const wrap = mountView();
await flushPromises();
expect(wrap.text()).toContain("alpha");
});
it("empty state shows New Wildcard CTA", async () => {
apiMod.list.mockResolvedValue({ items: [], total: 0 });
const wrap = mountView();
await flushPromises();
expect(wrap.text()).toContain("No wildcards yet");
});
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>

After

Width:  |  Height:  |  Size: 60 B

+5 -1
View File
@@ -32,6 +32,10 @@ const search = ref(props.filter.q ?? "");
const filterPanelOpen = ref(false);
const selected = ref<T[]>([]);
// 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, async (q) => {
props.filter.q = q || undefined;
emit("fetch");
@@ -135,7 +139,7 @@ function confirmBulkDelete() {
<slot name="columns" />
<template #empty>
<div class="text-center p-8">
<img src="/wp/images/favicon.svg" alt="" class="mx-auto mb-3 opacity-70" style="width:64px;height:64px" />
<img :src="faviconUrl" alt="" class="mx-auto mb-3 opacity-70" style="width:64px;height:64px" />
<p class="text-wp-text2 mb-3">{{ emptyMessage }}</p>
<Button :label="newLabel" icon="pi pi-plus" severity="primary" @click="$router.push(newRoute)" />
</div>
+139 -4
View File
@@ -1,9 +1,144 @@
<script setup lang="ts">
import { 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 EntityListView from "../components/EntityListView.vue";
import RelativeDate from "../components/RelativeDate.vue";
import { useModuleStore } from "../stores/moduleStore";
import type { ModuleRow } from "../api/types";
const router = useRouter();
const store = useModuleStore();
const toast = useToast();
onMounted(async () => {
store.filter.type = "wildcard";
await fetch();
});
async function fetch() {
store.filter.type = "wildcard";
try {
await store.fetchAll();
} catch (e) {
toast.add({ severity: "error", summary: "Load failed", detail: String(e), life: 4000 });
}
}
function edit(row: ModuleRow) {
router.push({ name: "wildcards-edit", params: { id: row.id } });
}
async function dup(row: ModuleRow) {
try {
await store.duplicate(row.id);
toast.add({ severity: "success", summary: "Duplicated", detail: row.name, life: 2000 });
} catch (e) {
toast.add({ severity: "error", summary: "Duplicate failed", detail: String(e), life: 4000 });
}
}
async function fav(row: ModuleRow) {
try { await store.toggleFavorite(row.id); }
catch (e) { toast.add({ severity: "error", summary: "Favorite failed", detail: String(e), life: 4000 }); }
}
async function del(row: ModuleRow) {
try {
await store.remove(row.id);
toast.add({ severity: "success", summary: "Deleted", detail: row.name, life: 2000 });
} catch (e) {
toast.add({ severity: "error", summary: "Delete failed", detail: String(e), life: 4000 });
}
}
async function bulkDel(items: ModuleRow[]) {
for (const item of items) await del(item);
}
function optionCount(row: ModuleRow): number {
return ((row.payload as { options?: unknown[] }).options ?? []).length;
}
function validIcon(row: ModuleRow): string {
return optionCount(row) === 0
? "pi pi-exclamation-triangle text-wp-amber"
: "pi pi-check-circle text-wp-green";
}
</script>
<template>
<div class="p-6 text-wp-text">
<h1 class="text-xl font-semibold m-0">Wildcards</h1>
<p class="text-sm text-wp-text2 m-0 mt-2">Stub — wired up in Task 6.</p>
</div>
<EntityListView
title="Wildcards"
subtitle="Wildcard modules pick one weighted option per resolution. Use $variable in prompts."
new-label="New Wildcard"
new-route="/wildcards/new"
:items="store.items"
:loading="store.loading"
:filter="store.filter"
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="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox v-model="filter.favorites" :binary="true" @change="emitFetch" />
Favorites only
</label>
</div>
</template>
<template #columns>
<Column selection-mode="multiple" 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" @click="edit(data)">{{ data.name }}</span>
<span class="text-xs text-wp-text3 font-mono">{{ data.id }}</span>
</div>
</template>
</Column>
<Column header="Items" header-style="width:7rem">
<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="updated_at" header="Updated" sortable header-style="width:10rem">
<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>
</EntityListView>
</template>
+7 -4
View File
@@ -5,11 +5,14 @@ import { resolve } from "node:path";
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
vue: "vue/dist/vue.runtime.esm-bundler.js",
},
alias: [
{ find: "@", replacement: resolve(__dirname, "./src") },
{ find: "vue", replacement: "vue/dist/vue.runtime.esm-bundler.js" },
// Stub absolute public-asset paths that don't exist under jsdom tests.
{ find: /^\/wp\/images\/.*\.svg$/, replacement: resolve(__dirname, "./src/manager/__tests__/stubs/asset.svg") },
],
},
assetsInclude: ["**/*.svg"],
test: {
environment: "jsdom",
globals: true,