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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>
|
||||
|
After Width: | Height: | Size: 60 B |
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user