Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 97 additions & 3 deletions src/components/Common/FilterBuilder.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { onClickOutside, onKeyStroke } from '@vueuse/core'

import { INQUIRY_TYPE_LABELS, STATUS_META } from '@/services/inquiryEnums'
import type { ExplorerQuery } from '@/services/explorer'
import { SORT_OPTIONS, sortOption, type ExplorerQuery, type SortField } from '@/services/explorer'

/**
* The `+ Filter` popover.
Expand All @@ -16,6 +16,13 @@ import type { ExplorerQuery } from '@/services/explorer'
* Everything applies immediately. There is no Apply button, because the result
* count is right next to the popover and watching it move is the fastest way to
* tell whether you asked the right question.
*
* Sorting lives here too, under a rule of its own — one column at a time,
* unlike the filters above it. The table headers can already sort, but only on
* the six columns that happen to be on screen and only by cycling
* asc → desc → off, which is three clicks to say "op opsteller, Z → A". This
* says it in two, names both directions, and covers the columns the table does
* not show.
*/
const props = defineProps<{ query: ExplorerQuery }>()

Expand Down Expand Up @@ -51,6 +58,36 @@ function setType(value: number) {
function setMine(value: 'reviewer' | 'creator' | null) {
emit('update', { ...props.query, mine: props.query.mine === value ? null : value, page: 1 })
}

/**
* Picking a column sorts it descending — newest, highest, most recent first is
* what people mean by "sorteer op datum". Picking the column it already sorts
* on turns sorting off again, so the same button is both the on and the off.
*/
function setSort(value: SortField) {
const off = props.query.sort === value
emit('update', {
...props.query,
sort: off ? null : value,
order: off ? 'desc' : props.query.order,
page: 1,
})
}

function setOrder(order: 'asc' | 'desc') {
emit('update', { ...props.query, order, page: 1 })
}

/** The active column's own words for its two directions. */
const direction = computed(() => {
const option = props.query.sort ? sortOption(props.query.sort) : null
return option
? [
{ value: 'desc' as const, label: option.desc },
{ value: 'asc' as const, label: option.asc },
]
: []
})
</script>

<template>
Expand All @@ -61,7 +98,7 @@ function setMine(value: 'reviewer' | 'creator' | null) {
:aria-expanded="open"
@click="open = !open"
>
+ Filter
Filteren &amp; sorteren
</button>

<div
Expand Down Expand Up @@ -135,6 +172,63 @@ function setMine(value: 'reviewer' | 'creator' | null) {
so this one narrows the page you are looking at, not the query. -->
<p class="text-sm mt-2 text-label">Type filtert de zichtbare pagina, niet de hele set.</p>
</section>

<!-- Sorting is a different verb from filtering, so it sits below a rule
rather than as a fourth chip group. Unlike the sets above it, this
one is single-choice: the API sorts on one column. -->
<section class="border-t border-divider pt-3.5">
<h3 class="studio-label mb-2">SORTEREN OP</h3>
<div class="flex flex-wrap gap-1.5">
<button
v-for="option in SORT_OPTIONS"
:key="option.value"
type="button"
class="text-base rounded-full border px-2.5 py-1 font-medium"
:class="
query.sort === option.value
? 'border-blue-border bg-blue-tint text-blue-ink'
: 'border-line bg-surface text-muted hover:border-line-hover'
"
:aria-pressed="query.sort === option.value"
@click="setSort(option.value)"
>
{{ option.label }}
</button>
</div>

<!-- The direction only exists once a column does, and its words come
from that column: "oudste eerst" beats "oplopend" on a date. -->
<div v-if="direction.length" class="mt-2 flex flex-wrap items-center gap-1.5">
<button
v-for="option in direction"
:key="option.value"
type="button"
class="text-base rounded-full border px-2.5 py-1 font-medium"
:class="
query.order === option.value
? 'border-blue-border bg-blue-tint text-blue-ink'
: 'border-line bg-surface text-muted hover:border-line-hover'
"
:aria-pressed="query.order === option.value"
@click="setOrder(option.value)"
>
{{ option.label }}
</button>
</div>

<p class="text-sm mt-2 text-label">
<template v-if="query.sort">
Klik dezelfde kolom nog eens om terug te gaan naar de standaardvolgorde.
</template>
<template v-else>
Zonder keuze houdt de API haar eigen volgorde aan (laatst gewijzigd eerst).
</template>
</p>
<p v-if="query.sort === 'type' || query.sort === 'status'" class="text-sm mt-1 text-label">
Type en status volgen de volgorde van de database-enum: dat groepeert de rijen,
maar rangschikt ze niet.
</p>
</section>
</div>
</div>
</template>
9 changes: 5 additions & 4 deletions src/components/Common/FilterChip.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<script setup lang="ts">
/**
* An active filter, shown as `key: value ×`.
* An active part of the question, shown as `key: value ×`.
*
* Filters that live only inside a dropdown are filters people forget they set,
* and then report the list as broken. A chip per filter makes the current
* question visible and removable in one click.
* and then report the list as broken. A chip per filter — and per sort, which
* has the same problem — makes the current question visible and removable in
* one click.
*/
defineProps<{ label: string; value: string }>()

Expand All @@ -20,7 +21,7 @@ defineEmits<{ remove: [] }>()
<button
type="button"
class="ml-0.5 leading-none text-blue-soft hover:text-blue-ink"
:aria-label="`Filter ${label} verwijderen`"
:aria-label="`${label} verwijderen`"
@click="$emit('remove')"
>
×
Expand Down
79 changes: 69 additions & 10 deletions src/services/explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,53 @@ function ints(raw: unknown, valid: (n: number) => boolean): number[] {
return out
}

const SORT_FIELDS: readonly SortField[] = [
'id',
'document_name',
'type',
'document_date',
'creator',
'reviewer',
'status',
/* -------------------------------------------------------------- sort fields */

export interface SortOption {
value: SortField
label: string
/** What the two directions actually mean for this column. */
asc: string
desc: string
}

/**
* The sortable columns, in the order the filter popover offers them.
*
* Each direction gets a label of its own rather than a shared
* "oplopend / aflopend": on a date column that is a word people have to
* translate into "oudste eerst" before they can use it, and translating a
* control is how you end up clicking it twice to find out what it does.
*
* `creator` and `reviewer` sort on the user's e-mail address server-side —
* which is the string the table shows, so A→Z means what it looks like.
* `type` and `status` sort on the PostgreSQL enum's declaration order: that
* groups the rows, but the order between groups carries no meaning (in
* `report.audit_status`, `rejected` precedes `pending_review`). Said out loud
* in the popover rather than dressed up as a ranking.
*/
export const SORT_OPTIONS: readonly SortOption[] = [
{ value: 'document_date', label: 'Datum', asc: 'oudste eerst', desc: 'nieuwste eerst' },
{ value: 'id', label: 'ID', asc: 'laagste eerst', desc: 'hoogste eerst' },
{ value: 'document_name', label: 'Naam', asc: 'A → Z', desc: 'Z → A' },
{ value: 'type', label: 'Type', asc: 'oplopend', desc: 'aflopend' },
{ value: 'status', label: 'Status', asc: 'oplopend', desc: 'aflopend' },
{ value: 'creator', label: 'Opsteller', asc: 'A → Z', desc: 'Z → A' },
{ value: 'reviewer', label: 'Beoordelaar', asc: 'A → Z', desc: 'Z → A' },
]

const SORT_FIELDS: readonly SortField[] = SORT_OPTIONS.map((option) => option.value)

export function sortOption(field: SortField): SortOption {
return SORT_OPTIONS.find((option) => option.value === field) ?? SORT_OPTIONS[0]
}

/** `datum · nieuwste eerst` — the sort as one readable phrase. */
export function describeSort(sort: SortField, order: 'asc' | 'desc'): string {
const option = sortOption(sort)
return `${option.label.toLowerCase()} · ${order === 'asc' ? option.asc : option.desc}`
}

export function parseQuery(raw: Record<string, unknown>): ExplorerQuery {
const page = Number(raw.page)
const sort = SORT_FIELDS.find((field) => field === raw.sort) ?? null
Expand Down Expand Up @@ -195,7 +232,7 @@ const MINE_LABELS: Record<'reviewer' | 'creator', string> = {
creator: 'ik stelde op',
}

/** One removable chip per active filter — see `FilterChip`. */
/** One removable chip per active filter, plus one for the sort — see `FilterChip`. */
export function chipsFor(query: ExplorerQuery): Chip[] {
const chips: Chip[] = []

Expand Down Expand Up @@ -226,6 +263,19 @@ export function chipsFor(query: ExplorerQuery): Chip[] {
})
}

// The sort is not a filter, but it belongs here for the same reason the
// filters do: it changes which rows you see first, it survives in a shared
// link, and half of what you can sort on (opsteller) has no column in the
// table to carry an arrow. Clearing it returns to the API's own ordering.
if (query.sort) {
chips.push({
id: 'sort',
label: 'sortering',
value: describeSort(query.sort, query.order),
clear: (q) => ({ ...q, sort: null, order: 'desc', page: 1 }),
})
}

return chips
}

Expand Down Expand Up @@ -258,7 +308,16 @@ export function saveView(label: string, query: ExplorerQuery): SavedView {
// updates the view instead of stacking two tabs with the same name.
key: `custom:${label.toLowerCase().replace(/\s+/g, '-')}`,
label,
query: { q: query.q, status: query.status, type: query.type, mine: query.mine },
// The sort travels with the view: "te controleren, oudste eerst" is one
// saved question, not a filter plus a setting you have to reapply.
query: {
q: query.q,
status: query.status,
type: query.type,
mine: query.mine,
sort: query.sort,
order: query.order,
},
builtin: false,
}
const next = [...customViews().filter((v) => v.key !== view.key), view]
Expand Down
9 changes: 6 additions & 3 deletions src/views/InquiryListView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,15 @@ const sortField = computed(() =>
Object.keys(SORT_KEYS).find((field) => SORT_KEYS[field] === query.value.sort) ?? null,
)

// asc → desc → back to default recency ordering.
// desc → asc → back to default recency ordering. Descending first, because
// that is what "sorteer op datum" means, and because the filter popover's sort
// section starts there too — two controls writing one piece of state should not
// disagree about which way the first click points.
function onSort(field: string) {
const key = SORT_KEYS[field]
if (!key) return
if (query.value.sort !== key) push({ ...query.value, sort: key, order: 'asc', page: 1 })
else if (query.value.order === 'asc') push({ ...query.value, order: 'desc', page: 1 })
if (query.value.sort !== key) push({ ...query.value, sort: key, order: 'desc', page: 1 })
else if (query.value.order === 'desc') push({ ...query.value, order: 'asc', page: 1 })
else push({ ...query.value, sort: null, order: 'desc', page: 1 })
}

Expand Down
Loading