feat(spa): tags editor on forms + sort dropdown + category/tags filters

This commit is contained in:
DumiFlex
2026-04-26 22:25:42 +03:00
parent 133c8df491
commit 0722da2a98
7 changed files with 200 additions and 17 deletions
@@ -11,6 +11,7 @@ vi.mock("../api/client", () => ({
create: vi.fn(),
get: vi.fn(),
update: vi.fn(),
list: vi.fn().mockResolvedValue({ items: [], total: 0 }),
},
categories: { list: vi.fn().mockResolvedValue({ items: [] }) },
},
@@ -25,6 +26,7 @@ const apiCat = api.categories as unknown as Record<string, ReturnType<typeof vi.
beforeEach(() => {
setActivePinia(createPinia());
Object.values(apiMod).forEach((fn) => fn.mockReset());
apiMod.list.mockResolvedValue({ items: [], total: 0 });
apiCat.list.mockResolvedValue({ items: [] });
});
afterEach(() => {
+74 -3
View File
@@ -5,10 +5,24 @@ import Button from "primevue/button";
import InputText from "primevue/inputtext";
import IconField from "primevue/iconfield";
import InputIcon from "primevue/inputicon";
import Select from "primevue/select";
import { useConfirm } from "primevue/useconfirm";
import ConfirmDialog from "primevue/confirmdialog";
interface Filter { q?: string; favorites?: boolean; }
interface Filter {
q?: string;
favorites?: boolean;
category?: string;
tags?: string[];
sortBy?: string;
}
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 props = defineProps<{
title: string;
@@ -46,6 +60,8 @@ const activeFilterCount = computed(() => {
let n = 0;
if (props.filter.q) n++;
if (props.filter.favorites) n++;
if (props.filter.category) n++;
if (props.filter.tags?.length) n++;
return n;
});
@@ -53,9 +69,40 @@ function clearFilters() {
search.value = "";
props.filter.q = undefined;
props.filter.favorites = false;
props.filter.category = undefined;
props.filter.tags = [];
emit("fetch");
}
const sortedItems = 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)));
}
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;
});
function rowClass(row: T): string {
return (row as T & { is_favorite?: boolean }).is_favorite ? "wp-row-favorite" : "";
}
@@ -98,6 +145,20 @@ function confirmBulkDelete() {
outlined size="small"
@click="filterPanelOpen = !filterPanelOpen"
/>
<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="ml-auto text-xs text-wp-text2">{{ items.length }} item(s)</span>
</div>
@@ -115,6 +176,14 @@ function confirmBulkDelete() {
favorites only
<i class="pi pi-times" @click="filter.favorites = false; emit('fetch')" />
</span>
<span v-if="filter.category" class="filter-chip filter-chip--category">
category: {{ filter.category }}
<i class="pi pi-times" @click="filter.category = undefined; emit('fetch')" />
</span>
<span v-for="t in filter.tags" :key="t" class="filter-chip filter-chip--tag">
tag: {{ t }}
<i class="pi pi-times" @click="filter.tags = filter.tags!.filter((x) => x !== t); emit('fetch')" />
</span>
<Button label="Clear all" text size="small" @click="clearFilters" />
</div>
@@ -134,8 +203,8 @@ function confirmBulkDelete() {
<DataTable
v-model:selection="selected"
v-model:expandedRows="expandedRows"
:value="items" :loading="loading" data-key="id"
:rows="20" :paginator="items.length > 20"
:value="sortedItems" :loading="loading" data-key="id"
:rows="20" :paginator="sortedItems.length > 20"
:row-class="rowClass"
>
<slot name="columns" />
@@ -172,6 +241,8 @@ function confirmBulkDelete() {
}
.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 i { cursor: pointer; opacity: 0.7; }
.filter-chip i:hover { opacity: 1; }
.bulk-bar {
+27 -1
View File
@@ -7,6 +7,7 @@ import InputGroup from "primevue/inputgroup";
import InputGroupAddon from "primevue/inputgroupaddon";
import Textarea from "primevue/textarea";
import Select from "primevue/select";
import AutoComplete from "primevue/autocomplete";
import { useToast } from "primevue/usetoast";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
@@ -27,8 +28,20 @@ const values = ref<NamedValue[]>([]);
const saving = ref(false);
const isEdit = computed(() => !!props.id);
const tagSuggestions = ref<string[]>([]);
function searchTags(event: { query: string }) {
const q = event.query.toLowerCase();
const known = new Set<string>();
for (const m of moduleStore.items) {
for (const t of m.tags ?? []) known.add(t);
}
tagSuggestions.value = Array.from(known)
.filter((t) => t.toLowerCase().includes(q) && !tags.value.includes(t))
.slice(0, 10);
}
onMounted(async () => {
await categoryStore.fetchAll();
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
if (props.id) {
try {
const row = await moduleStore.get(props.id);
@@ -109,6 +122,19 @@ async function save() {
<label for="fv-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
<Textarea id="fv-desc" v-model="description" rows="2" class="w-full" />
</div>
<div class="col-span-2">
<label for="fv-tags" class="block text-xs text-wp-text2 mb-1">Tags</label>
<AutoComplete
id="fv-tags"
v-model="tags"
multiple
typeahead
:suggestions="tagSuggestions"
placeholder="Type a tag and press Enter…"
class="w-full"
@complete="searchTags"
/>
</div>
</div>
</section>
+34 -5
View File
@@ -6,6 +6,8 @@ import Column from "primevue/column";
import Button from "primevue/button";
import Badge from "primevue/badge";
import Checkbox from "primevue/checkbox";
import MultiSelect from "primevue/multiselect";
import Select from "primevue/select";
import EntityListView from "../components/EntityListView.vue";
import RelativeDate from "../components/RelativeDate.vue";
import { useModuleStore } from "../stores/moduleStore";
@@ -23,6 +25,12 @@ const categoryById = computed(() => {
return map;
});
const allTags = computed(() => {
const set = new Set<string>();
for (const m of store.items) for (const t of m.tags ?? []) set.add(t);
return Array.from(set).sort();
});
onMounted(async () => {
store.filter.type = "fixed_values";
await Promise.all([fetch(), categoryStore.fetchAll()]);
@@ -101,11 +109,32 @@ function topValues(row: ModuleRow): NamedValue[] { return values(row).slice(0, 3
@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 class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs text-wp-text2 mb-1">Category</label>
<Select
v-model="filter.category"
:options="categoryStore.items" option-label="name" option-value="id"
placeholder="All categories" show-clear class="w-full"
aria-label="Filter by category"
@change="emitFetch"
/>
</div>
<div>
<label class="block text-xs text-wp-text2 mb-1">Tags</label>
<MultiSelect
v-model="filter.tags"
:options="allTags"
placeholder="Any tag" display="chip" filter class="w-full"
@change="emitFetch"
/>
</div>
<div class="col-span-2 flex items-center gap-4 pt-1">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox v-model="filter.favorites" :binary="true" @change="emitFetch" />
Favorites only
</label>
</div>
</div>
</template>
+27 -1
View File
@@ -6,6 +6,7 @@ import InputText from "primevue/inputtext";
import Textarea from "primevue/textarea";
import InputNumber from "primevue/inputnumber";
import Select from "primevue/select";
import AutoComplete from "primevue/autocomplete";
import { useToast } from "primevue/usetoast";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
@@ -26,8 +27,20 @@ const options = ref<Option[]>([]);
const saving = ref(false);
const isEdit = computed(() => !!props.id);
const tagSuggestions = ref<string[]>([]);
function searchTags(event: { query: string }) {
const q = event.query.toLowerCase();
const known = new Set<string>();
for (const m of moduleStore.items) {
for (const t of m.tags ?? []) known.add(t);
}
tagSuggestions.value = Array.from(known)
.filter((t) => t.toLowerCase().includes(q) && !tags.value.includes(t))
.slice(0, 10);
}
onMounted(async () => {
await categoryStore.fetchAll();
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
if (props.id) {
try {
const row = await moduleStore.get(props.id);
@@ -108,6 +121,19 @@ async function save() {
<label for="wc-desc" class="block text-xs text-wp-text2 mb-1">Description</label>
<Textarea id="wc-desc" v-model="description" rows="2" class="w-full" />
</div>
<div class="col-span-2">
<label for="wc-tags" class="block text-xs text-wp-text2 mb-1">Tags</label>
<AutoComplete
id="wc-tags"
v-model="tags"
multiple
typeahead
:suggestions="tagSuggestions"
placeholder="Type a tag and press Enter…"
class="w-full"
@complete="searchTags"
/>
</div>
</div>
</section>
+34 -5
View File
@@ -6,6 +6,8 @@ import Column from "primevue/column";
import Button from "primevue/button";
import Badge from "primevue/badge";
import Checkbox from "primevue/checkbox";
import MultiSelect from "primevue/multiselect";
import Select from "primevue/select";
import EntityListView from "../components/EntityListView.vue";
import RelativeDate from "../components/RelativeDate.vue";
import { useModuleStore } from "../stores/moduleStore";
@@ -23,6 +25,12 @@ const categoryById = computed(() => {
return map;
});
const allTags = computed(() => {
const set = new Set<string>();
for (const m of store.items) for (const t of m.tags ?? []) set.add(t);
return Array.from(set).sort();
});
onMounted(async () => {
store.filter.type = "wildcard";
await Promise.all([fetch(), categoryStore.fetchAll()]);
@@ -109,11 +117,32 @@ function validIcon(row: ModuleRow): string {
@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 class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs text-wp-text2 mb-1">Category</label>
<Select
v-model="filter.category"
:options="categoryStore.items" option-label="name" option-value="id"
placeholder="All categories" show-clear class="w-full"
aria-label="Filter by category"
@change="emitFetch"
/>
</div>
<div>
<label class="block text-xs text-wp-text2 mb-1">Tags</label>
<MultiSelect
v-model="filter.tags"
:options="allTags"
placeholder="Any tag" display="chip" filter class="w-full"
@change="emitFetch"
/>
</div>
<div class="col-span-2 flex items-center gap-4 pt-1">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox v-model="filter.favorites" :binary="true" @change="emitFetch" />
Favorites only
</label>
</div>
</div>
</template>
+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-DE2EWJFs.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-DO0xdhRG.css">
<script type="module" crossorigin src="/wp/assets/manager-DjdGrtNo.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-DzNhfNc6.css">
</head>
<body>
<div id="app"></div>