🚧 Add possbility to sort and filter on table headers

This commit is contained in:
Jonas Bengtsson
2026-09-05 18:44:16 +02:00
parent 6795547c47
commit 3c4b5563ea
7 changed files with 524 additions and 28 deletions
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
// Sortable + filterable table header: click the label to cycle
// asc -> desc -> default; the funnel opens a popover with free-text search
// and (for facet columns) checkboxes over the occurring values.
import { onBeforeUnmount, onMounted } from 'vue'
import { ArrowDown, ArrowUp, ArrowUpDown, ListFilter, X } from 'lucide-vue-next'
const props = defineProps<{
label: string
name: string
// sorting (optional): current state + toggle
sortable?: boolean
sortKey?: string | null
sortDir?: 'asc' | 'desc'
onSort?: (name: string) => void
// filtering (optional): 'text', 'facet' or 'text+facet'
filterKind?: 'text' | 'facet' | 'text+facet'
filterText?: string
onFilterText?: (name: string, text: string) => void
filterChecked?: Set<string>
onFilterCheck?: (name: string, value: string, checked: boolean) => void
// occurring values with counts (facets); may be filtered by the text box
facetValues?: { value: string; count: number }[]
open?: boolean
onToggleOpen?: (name: string) => void
}>()
const emit = defineEmits<{
(e: 'update:open', name: string): void
}>()
function togglePopover() {
props.onToggleOpen?.(props.name)
}
function onCheckbox(value: string, event: Event) {
props.onFilterCheck?.(props.name, value, (event.target as HTMLInputElement).checked)
}
// close on outside click
function outside(e: MouseEvent) {
const pop = document.getElementById(`filter-pop-${props.name}`)
const btn = document.getElementById(`filter-btn-${props.name}`)
if (pop && !pop.contains(e.target as Node) && btn && !btn.contains(e.target as Node)) {
if (props.open) props.onToggleOpen?.(props.name)
}
}
onMounted(() => document.addEventListener('click', outside))
onBeforeUnmount(() => document.removeEventListener('click', outside))
</script>
<template>
<th style="position: relative">
<div class="row" style="gap: 0.3rem; flex-wrap: nowrap">
<span
v-if="sortable"
style="cursor: pointer; user-select: none"
@click="onSort?.(name)"
>
{{ label }}
<ArrowUp v-if="sortKey === name && sortDir === 'asc'" :size="13" />
<ArrowDown v-else-if="sortKey === name && sortDir === 'desc'" :size="13" />
<ArrowUpDown v-else :size="13" style="opacity: 0.4" />
</span>
<span v-else>{{ label }}</span>
<button
v-if="filterKind"
:id="`filter-btn-${name}`"
class="ghost th-filter-btn"
style="padding: 0.1rem 0.25rem"
:title="`Filter ${label}`"
@click.stop="togglePopover"
>
<ListFilter :size="13" />
</button>
</div>
<div v-if="open" :id="`filter-pop-${name}`" class="filter-pop">
<input
v-if="filterKind === 'text' || filterKind === 'text+facet'"
class="mono"
:value="filterText"
placeholder="contains..."
style="width: 100%; margin-bottom: 0.4rem"
@input="onFilterText?.(name, ($event.target as HTMLInputElement).value)"
/>
<div
v-if="filterKind === 'facet' || filterKind === 'text+facet'"
class="facet-list"
>
<label
v-for="v in facetValues"
:key="v.value"
class="row facet-row"
style="gap: 0.4rem; flex-wrap: nowrap"
>
<input
type="checkbox"
style="width: auto"
:checked="filterChecked?.has(v.value) ?? false"
@change="onCheckbox(v.value, $event)"
/>
<span class="mono" style="flex: 1">{{ v.value }}</span>
<span class="muted">{{ v.count }}</span>
</label>
</div>
<div class="row" style="justify-content: flex-end; margin-top: 0.4rem">
<button class="ghost" style="padding: 0.15rem 0.5rem" @click="onFilterText?.(name, '')">
<X :size="12" /> clear
</button>
</div>
</div>
</th>
</template>
<style scoped>
.th-filter-btn {
color: var(--muted);
}
.filter-pop {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 30;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow-md);
padding: 0.6rem;
min-width: 14rem;
max-width: 20rem;
text-align: left;
}
.facet-list {
max-height: 14rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.facet-row {
font-size: 0.82rem;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
// Client-side table sorting + filtering, shared by the fully-loaded tables
// (Devices, Users). The paginated Messages view talks to the API instead —
// only the popover/header components are shared with it.
//
// A column is configured with:
// get: row -> value
// sort: 'string' | 'number' (default 'string')
// filter: 'text' | 'facet' | 'text+facet' (default: no filtering)
import { computed, reactive, ref, type Ref } from 'vue'
export type FilterKind = 'text' | 'facet' | 'text+facet'
export interface ColumnConfig<T> {
get: (row: T) => string | number
sort?: 'string' | 'number'
filter?: FilterKind
}
export interface ColumnState {
text: string
checked: Set<string>
open: boolean
}
export function useTable<T>(rows: Ref<T[]>, columns: Record<string, ColumnConfig<T>>) {
const state = reactive<Record<string, ColumnState>>({})
const sortKey = ref<string | null>(null)
const sortDir = ref<'asc' | 'desc'>('asc')
function col(name: string): ColumnState {
if (!state[name]) state[name] = { text: '', checked: new Set(), open: false }
return state[name]
}
function toggleSort(name: string): void {
if (sortKey.value === null || sortKey.value !== name) {
sortKey.value = name
sortDir.value = 'asc'
} else if (sortDir.value === 'asc') {
sortDir.value = 'desc'
} else {
sortKey.value = null // third click: back to default order
}
}
function distinctValues(name: string): { value: string; count: number }[] {
const cfg = columns[name]
const counts = new Map<string, number>()
for (const row of rows.value) {
const v = String(cfg.get(row))
counts.set(v, (counts.get(v) ?? 0) + 1)
}
return [...counts.entries()]
.map(([value, count]) => ({ value, count }))
.sort((a, b) => a.value.localeCompare(b.value))
}
function passesFilter(name: string, value: string): boolean {
const cfg = columns[name]
const st = state[name]
if (!cfg.filter || !st) return true
if (st.text && !value.toLowerCase().includes(st.text.toLowerCase())) return false
if (st.checked.size > 0 && !st.checked.has(value)) return false
return true
}
const sortedFilteredRows = computed(() => {
let out = rows.value.filter((row) => {
for (const name of Object.keys(columns)) {
const cfg = columns[name]
if (!cfg.filter) continue
if (!passesFilter(name, String(cfg.get(row)))) return false
}
return true
})
if (sortKey.value !== null) {
const cfg = columns[sortKey.value]
const dir = sortDir.value === 'asc' ? 1 : -1
out = [...out].sort((a, b) => {
const va = cfg.get(a)
const vb = cfg.get(b)
if (cfg.sort === 'number') return ((va as number) - (vb as number)) * dir
return String(va).localeCompare(String(vb)) * dir
})
}
return out
})
function clearColumn(name: string): void {
const st = state[name]
if (!st) return
st.text = ''
st.checked = new Set()
}
function activeFilterCount(): number {
return Object.values(state).filter((st) => st.text !== '' || st.checked.size > 0).length
}
return {
col,
state,
sortKey,
sortDir,
toggleSort,
distinctValues,
sortedFilteredRows,
clearColumn,
activeFilterCount,
}
}
+49 -3
View File
@@ -3,6 +3,8 @@ import { onMounted, ref } from 'vue'
import { Cpu, Plus, Trash2 } from 'lucide-vue-next'
import { api } from '../api'
import type { Device } from '../api'
import { useTable } from '../composables/useTable'
import TableHeader from '../components/TableHeader.vue'
import { canEdit, showError, toast } from '../store'
const devices = ref<Device[]>([])
@@ -10,6 +12,23 @@ const loaded = ref(false)
const newImei = ref('')
const newName = ref('')
const table = useTable(devices, {
imei: { get: (d) => d.imei, filter: 'text' },
device: { get: (d) => d.device, filter: 'text+facet' },
})
function onFilterText(name: string, text: string) {
table.col(name).text = text
}
function onFilterCheck(name: string, value: string, checked: boolean) {
const st = table.col(name)
if (checked) st.checked.add(value)
else st.checked.delete(value)
}
function onToggleOpen(name: string) {
table.col(name).open = !table.col(name).open
}
async function load() {
try {
devices.value = await api<Device[]>('/api/devices')
@@ -69,13 +88,40 @@ async function removeDevice(d: Device) {
<table v-if="devices.length">
<thead>
<tr>
<th>IMEI</th>
<th>Device</th>
<TableHeader
label="IMEI"
name="imei"
sortable
:sort-key="table.sortKey.value"
:sort-dir="table.sortDir.value"
:on-sort="table.toggleSort"
filter-kind="text"
:filter-text="table.col('imei').text"
:on-filter-text="onFilterText"
:open="table.col('imei').open"
:on-toggle-open="onToggleOpen"
/>
<TableHeader
label="Device"
name="device"
sortable
:sort-key="table.sortKey.value"
:sort-dir="table.sortDir.value"
:on-sort="table.toggleSort"
filter-kind="text+facet"
:filter-text="table.col('device').text"
:on-filter-text="onFilterText"
:facet-values="table.distinctValues('device')"
:filter-checked="table.col('device').checked"
:on-filter-check="onFilterCheck"
:open="table.col('device').open"
:on-toggle-open="onToggleOpen"
/>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="d in devices" :key="d.imei">
<tr v-for="d in table.sortedFilteredRows.value" :key="d.imei">
<td class="mono">
<router-link :to="`/devices/${d.imei}`">{{ d.imei }}</router-link>
</td>
+106 -13
View File
@@ -2,18 +2,29 @@
import { onMounted, ref } from 'vue'
import { Inbox } from 'lucide-vue-next'
import { api } from '../api'
import type { MessagePage } from '../api'
import type { Device, MessagePage } from '../api'
import { formatTime } from '../format'
import { showError } from '../store'
import TableHeader from '../components/TableHeader.vue'
const page = ref<MessagePage | null>(null)
const loading = ref(false)
const offset = ref(0)
const limit = 25
// filters
const imei = ref('')
const device = ref('')
// the full device population for the facet lists (not just the loaded page)
const devices = ref<Device[]>([])
// server-side sort + filters (mirrored into query params on load)
const sort = ref('')
const imeiChecked = ref(new Set<string>())
const deviceChecked = ref(new Set<string>())
const imeiText = ref('')
const deviceText = ref('')
const popOpen = ref<string | null>(null)
let textTimer: number | undefined
// date filters (the Received column's filter)
const from = ref('')
const to = ref('')
@@ -21,8 +32,11 @@ function buildQuery(): string {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset.value))
if (imei.value.trim()) params.set('imei', imei.value.trim())
if (device.value.trim()) params.set('device', device.value.trim())
if (sort.value) params.set('sort', sort.value)
for (const v of imeiChecked.value) params.append('imei', v)
for (const v of deviceChecked.value) params.append('device', v)
if (imeiText.value.trim()) params.set('imei_like', imeiText.value.trim())
if (deviceText.value.trim()) params.set('device_like', deviceText.value.trim())
if (from.value) params.set('from', new Date(from.value).toISOString())
if (to.value) params.set('to', new Date(to.value).toISOString())
return `/api/messages?${params.toString()}`
@@ -44,6 +58,43 @@ function applyFilters() {
load()
}
function toggleSort(name: string) {
// cycle asc -> desc -> default for the given column key
const current = sort.value
if (current !== name && current !== `-${name}`) sort.value = name
else if (current === name) sort.value = `-${name}`
else sort.value = ''
applyFilters()
}
function sortState(name: string): '' | 'asc' | 'desc' {
if (sort.value === name) return 'asc'
if (sort.value === `-${name}`) return 'desc'
return ''
}
function facetValues(kind: 'imei' | 'device'): { value: string; count: number }[] {
// values from the full device list ("might occur"), not just the loaded page
if (kind === 'imei') return devices.value.map((d) => ({ value: d.imei, count: 1 }))
return devices.value.map((d) => ({ value: d.device, count: 1 }))
}
function onCheck(kind: 'imei' | 'device', value: string, checked: boolean) {
const target = kind === 'imei' ? imeiChecked : deviceChecked
if (checked) target.value.add(value)
else target.value.delete(value)
applyFilters()
}
function onText(kind: 'imei' | 'device', text: string) {
if (kind === 'imei') imeiText.value = text
else deviceText.value = text
// server-side filtering: reload shortly after typing stops (the
// client-side tables filter reactively; this one needs a query)
window.clearTimeout(textTimer)
textTimer = window.setTimeout(applyFilters, 400)
}
function prev() {
offset.value = Math.max(0, offset.value - limit)
load()
@@ -54,20 +105,25 @@ function next() {
load()
}
onMounted(load)
onMounted(async () => {
load()
try {
devices.value = await api<Device[]>('/api/devices')
} catch {
// facet lists just stay empty; the text filters still work
}
})
</script>
<template>
<h1>Messages</h1>
<form class="card row" @submit.prevent="applyFilters">
<input v-model="imei" placeholder="IMEI" style="max-width: 12rem" />
<input v-model="device" placeholder="Device name" style="max-width: 12rem" />
<label class="muted">from</label>
<input v-model="from" type="datetime-local" style="max-width: 14rem" />
<label class="muted">to</label>
<input v-model="to" type="datetime-local" style="max-width: 14rem" />
<button class="primary" type="submit">Filter</button>
<button class="primary" type="submit">Apply</button>
</form>
<div class="card">
@@ -84,9 +140,46 @@ onMounted(load)
<table>
<thead>
<tr>
<th>Received</th>
<th>IMEI</th>
<th>Device</th>
<TableHeader
label="Received"
name="received_at"
sortable
:sort-key="sortState('received_at') ? 'received_at' : null"
:sort-dir="sortState('received_at') === 'desc' ? 'desc' : 'asc'"
:on-sort="() => toggleSort('received_at')"
/>
<TableHeader
label="IMEI"
name="imei"
sortable
:sort-key="sortState('imei') ? 'imei' : null"
:sort-dir="sortState('imei') === 'desc' ? 'desc' : 'asc'"
:on-sort="() => toggleSort('imei')"
filter-kind="text+facet"
:filter-text="imeiText"
:on-filter-text="(_n, t) => onText('imei', t)"
:facet-values="facetValues('imei')"
:filter-checked="imeiChecked"
:on-filter-check="(n, v, c) => onCheck('imei', v, c)"
:open="popOpen === 'imei'"
:on-toggle-open="() => (popOpen = popOpen === 'imei' ? null : 'imei')"
/>
<TableHeader
label="Device"
name="device"
sortable
:sort-key="sortState('device') ? 'device' : null"
:sort-dir="sortState('device') === 'desc' ? 'desc' : 'asc'"
:on-sort="() => toggleSort('device')"
filter-kind="text+facet"
:filter-text="deviceText"
:on-filter-text="(_n, t) => onText('device', t)"
:facet-values="facetValues('device')"
:filter-checked="deviceChecked"
:on-filter-check="(n, v, c) => onCheck('device', v, c)"
:open="popOpen === 'device'"
:on-toggle-open="() => (popOpen = popOpen === 'device' ? null : 'device')"
/>
<th>Payload + attributes</th>
</tr>
</thead>
+47 -3
View File
@@ -2,6 +2,8 @@
import { onMounted, ref } from 'vue'
import { Plus, Trash2, UserRound } from 'lucide-vue-next'
import { api } from '../api'
import { useTable } from '../composables/useTable'
import TableHeader from '../components/TableHeader.vue'
import { canEdit, isAdmin, showError, toast, ui } from '../store'
const users = ref<{ username: string; role: string }[]>([])
@@ -11,6 +13,23 @@ const newPassword = ref('')
// superusers may only create 'user' accounts (server-enforced); admins any.
const newRole = ref('user')
const table = useTable(users, {
username: { get: (u) => u.username, filter: 'text' },
role: { get: (u) => u.role, filter: 'facet' },
})
function onFilterText(name: string, text: string) {
table.col(name).text = text
}
function onFilterCheck(name: string, value: string, checked: boolean) {
const st = table.col(name)
if (checked) st.checked.add(value)
else st.checked.delete(value)
}
function onToggleOpen(name: string) {
table.col(name).open = !table.col(name).open
}
async function load() {
try {
users.value = await api<{ username: string; role: string }[]>('/api/users')
@@ -93,13 +112,38 @@ async function removeUser(u: { username: string; role: string }) {
<table v-if="users.length">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<TableHeader
label="Username"
name="username"
sortable
:sort-key="table.sortKey.value"
:sort-dir="table.sortDir.value"
:on-sort="table.toggleSort"
filter-kind="text"
:filter-text="table.col('username').text"
:on-filter-text="onFilterText"
:open="table.col('username').open"
:on-toggle-open="onToggleOpen"
/>
<TableHeader
label="Role"
name="role"
sortable
:sort-key="table.sortKey.value"
:sort-dir="table.sortDir.value"
:on-sort="table.toggleSort"
filter-kind="facet"
:facet-values="table.distinctValues('role')"
:filter-checked="table.col('role').checked"
:on-filter-check="onFilterCheck"
:open="table.col('role').open"
:on-toggle-open="onToggleOpen"
/>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.username">
<tr v-for="u in table.sortedFilteredRows.value" :key="u.username">
<td class="mono">{{ u.username }}</td>
<td>
<span class="badge" :class="{ ok: u.role === 'admin' }">{{ u.role }}</span>