feat(spa): tags editor on forms + sort dropdown + category/tags filters
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user