feat(spa): fixedvalues.vue per-type list page

This commit is contained in:
DumiFlex
2026-04-26 21:32:38 +03:00
parent 22c29aa090
commit 32f0513420
2 changed files with 208 additions and 4 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 FixedValues from "../views/FixedValues.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: "/fixed-values/new", component: { template: "<div/>" } },
{ path: "/fixed-values/:id/edit", name: "fixed-values-edit", component: { template: "<div/>" } },
],
});
}
function mountView() {
return mount(FixedValues, {
global: { plugins: [makeRouter(), PrimeVue, ToastService, ConfirmationService] },
});
}
describe("FixedValues.vue", () => {
it("filters store by type=fixed_values on mount", async () => {
apiMod.list.mockResolvedValue({ items: [], total: 0 });
mountView();
await flushPromises();
expect(apiMod.list).toHaveBeenCalledWith(expect.objectContaining({ type: "fixed_values" }));
});
it("renders rows", async () => {
apiMod.list.mockResolvedValue({
items: [{
id: "fv_a", type: "fixed_values", name: "lens", category_id: null,
tags: [], is_favorite: false, payload: { values: [] }, version: 1,
description: "", created_at: "", updated_at: "",
}],
total: 1,
});
const wrap = mountView();
await flushPromises();
expect(wrap.text()).toContain("lens");
});
it("empty state shows CTA", async () => {
apiMod.list.mockResolvedValue({ items: [], total: 0 });
const wrap = mountView();
await flushPromises();
expect(wrap.text()).toContain("No fixed values yet");
});
});
+128 -4
View File
@@ -1,9 +1,133 @@
<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 = "fixed_values";
await fetch();
});
async function fetch() {
store.filter.type = "fixed_values";
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: "fixed-values-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 valueCount(row: ModuleRow): number {
return ((row.payload as { values?: unknown[] }).values ?? []).length;
}
</script>
<template>
<div class="p-6 text-wp-text">
<h1 class="text-xl font-semibold m-0">Fixed Values</h1>
<p class="text-sm text-wp-text2 m-0 mt-2">Stub — wired up in Task 7.</p>
</div>
<EntityListView
title="Fixed Values"
subtitle="Fixed-value modules emit one or more named string variables, set explicitly."
new-label="New Fixed Values"
new-route="/fixed-values/new"
:items="store.items"
:loading="store.loading"
:filter="store.filter"
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="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="Values" header-style="width:7rem">
<template #body="{ data }">
<Badge :value="String(valueCount(data))" severity="secondary" />
</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>