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
44 changes: 23 additions & 21 deletions src/components/Common/FilterBuilder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { computed, ref } from 'vue'
import { onClickOutside, onKeyStroke } from '@vueuse/core'

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

/**
* The `+ Filter` popover.
Expand Down Expand Up @@ -62,14 +68,16 @@ function setMine(value: 'reviewer' | 'creator' | null) {
/**
* 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.
* on flips the direction, because there is no "off" to return to: the explorer
* is always sorted by something (see `DEFAULT_SORT`), so the only question a
* second click can answer is which end you want.
*/
function setSort(value: SortField) {
const off = props.query.sort === value
const active = props.query.sort === value
emit('update', {
...props.query,
sort: off ? null : value,
order: off ? 'desc' : props.query.order,
sort: value,
order: active ? (props.query.order === 'desc' ? 'asc' : 'desc') : 'desc',
page: 1,
})
}
Expand All @@ -80,13 +88,11 @@ function setOrder(order: 'asc' | 'desc') {

/** 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 },
]
: []
const option = sortOption(props.query.sort)
return [
{ value: 'desc' as const, label: option.desc },
{ value: 'asc' as const, label: option.asc },
]
})
</script>

Expand Down Expand Up @@ -196,9 +202,9 @@ const direction = computed(() => {
</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">
<!-- The direction's words come from the column it applies to:
"oudste eerst" beats "oplopend" on a date. -->
<div class="mt-2 flex flex-wrap items-center gap-1.5">
<button
v-for="option in direction"
:key="option.value"
Expand All @@ -217,12 +223,8 @@ const direction = computed(() => {
</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>
Klik dezelfde kolom nog eens om de richting om te draaien.
<template v-if="isDefaultSort(query)">Dit is de standaardvolgorde.</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,
Expand Down
68 changes: 56 additions & 12 deletions src/services/explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,37 @@ export interface ExplorerQuery {
type: number[]
/** Restrict to rows where you are the reviewer or the creator. */
mine: 'reviewer' | 'creator' | null
sort: SortField | null
/** Always set — see `DEFAULT_SORT`. There is no "unsorted". */
sort: SortField
order: 'asc' | 'desc'
/** 1-based. */
page: number
}

/**
* What the explorer sorts by when nobody has said otherwise — including every
* built-in view.
*
* There used to be no answer to that: leaving `sort` off let `GET /inquiry`
* fall back to `coalesce(update_date, create_date) DESC`, which sounds like
* recency and is not. The #973 backfill stamped one identical `update_date`
* onto 20,950 of 26,671 inquiries, so four rows in five sat in a single tie
* group in an order no one chose and `LIMIT`/`OFFSET` does not promise to keep
* stable between two pages. "Alles" only looked sorted.
*
* `document_date` descending instead: newest first, on the one date the table
* actually shows, so the claim can be checked by reading down the Datum column
* rather than taken on faith. The API ends every ordering on the primary key
* (FunderMapsApi #101), which is what makes paging through it total.
*/
export const DEFAULT_SORT: SortField = 'document_date'
export const DEFAULT_ORDER: 'asc' | 'desc' = 'desc'

/** Whether the query still carries the ordering it started with. */
export function isDefaultSort(query: ExplorerQuery): boolean {
return query.sort === DEFAULT_SORT && query.order === DEFAULT_ORDER
}

/**
* How many rows a page of the explorer holds.
*
Expand All @@ -47,7 +72,15 @@ export interface ExplorerQuery {
export const PAGE_SIZE = 20

export function emptyQuery(): ExplorerQuery {
return { q: '', status: [], type: [], mine: null, sort: null, order: 'desc', page: 1 }
return {
q: '',
status: [],
type: [],
mine: null,
sort: DEFAULT_SORT,
order: DEFAULT_ORDER,
page: 1,
}
}

export interface SavedView {
Expand Down Expand Up @@ -156,7 +189,9 @@ export function describeSort(sort: SortField, order: 'asc' | 'desc'): string {

export function parseQuery(raw: Record<string, unknown>): ExplorerQuery {
const page = Number(raw.page)
const sort = SORT_FIELDS.find((field) => field === raw.sort) ?? null
// An unknown or absent `?sort=` lands on the default rather than on nothing:
// a link someone hand-edited should show a sorted list, not an arbitrary one.
const sort = SORT_FIELDS.find((field) => field === raw.sort) ?? DEFAULT_SORT
return {
q: typeof raw.q === 'string' ? raw.q : '',
status: ints(raw.status, (n) => statusMeta(n).label !== 'Onbekend'),
Expand All @@ -179,7 +214,7 @@ export function toRouteQuery(query: ExplorerQuery, viewKey: string): LocationQue
if (query.status.length) out.status = query.status.join(',')
if (query.type.length) out.type = query.type.join(',')
if (query.mine) out.mine = query.mine
if (query.sort) {
if (!isDefaultSort(query)) {
out.sort = query.sort
out.order = query.order
}
Expand All @@ -189,7 +224,12 @@ export function toRouteQuery(query: ExplorerQuery, viewKey: string): LocationQue

/** A saved view's stored shape, merged over the empty query. */
export function fromView(view: SavedView): ExplorerQuery {
return { ...emptyQuery(), ...view.query }
const merged = { ...emptyQuery(), ...view.query }
// Views saved before the explorer had a default ordering stored `sort: null`,
// and they live in someone's `localStorage` rather than in a table we can
// migrate. Anything the sort no longer recognises falls back to the default.
const sort = SORT_FIELDS.find((field) => field === merged.sort)
return { ...merged, sort: sort ?? DEFAULT_SORT, order: merged.order === 'asc' ? 'asc' : 'desc' }
}

/* -------------------------------------------------------------- API mapping */
Expand All @@ -211,10 +251,11 @@ export function toListOpts(query: ExplorerQuery, userId: string | null): IInquir
if (query.status.length) opts.status = [...query.status]
if (query.mine === 'creator' && userId) opts.creator = userId
if (query.mine === 'reviewer' && userId) opts.reviewer = userId
if (query.sort) {
opts.sort = query.sort
opts.order = query.order
}
// Always sent, default included. Letting the parameter fall away would hand
// the ordering back to the endpoint's fallback, which is the one thing this
// module exists to stop happening — see `DEFAULT_SORT`.
opts.sort = query.sort
opts.order = query.order
return opts
}

Expand Down Expand Up @@ -275,13 +316,16 @@ 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) {
// table to carry an arrow.
//
// Only once it differs from the default, though. A chip that is present on
// every screen is furniture, and its × would be a button that does nothing.
if (!isDefaultSort(query)) {
chips.push({
id: 'sort',
label: 'sortering',
value: describeSort(query.sort, query.order),
clear: (q) => ({ ...q, sort: null, order: 'desc', page: 1 }),
clear: (q) => ({ ...q, sort: DEFAULT_SORT, order: DEFAULT_ORDER, page: 1 }),
})
}

Expand Down
7 changes: 7 additions & 0 deletions src/services/worklist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import type { IInquiryListOpts } from '@/services/fundermaps/endpoints/inquiry'
import { DEFAULT_ORDER, DEFAULT_SORT } from '@/services/explorer'
import { AUDIT_STATUS } from '@/services/inquiryEnums'

/** Which side of the dossier the lane puts you on. */
Expand Down Expand Up @@ -74,6 +75,12 @@ export function laneQuery(lane: Lane, userId: string): IInquiryListOpts {
status: lane.statuses,
...(lane.role === 'reviewer' ? { reviewer: userId } : { creator: userId }),
limit: LANE_FETCH,
// Sorted for the same reason the explorer is, and it shows more here: a
// lane displays the first eight of the fifty it fetches, so without an
// ordering those eight are eight arbitrary rows out of a tie group the
// #973 backfill made 20,950 rows wide. Newest first — see `DEFAULT_SORT`.
sort: DEFAULT_SORT,
order: DEFAULT_ORDER,
}
}

Expand Down
13 changes: 7 additions & 6 deletions src/views/InquiryListView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -182,16 +182,17 @@ const sortField = computed(() =>
Object.keys(SORT_KEYS).find((field) => SORT_KEYS[field] === query.value.sort) ?? null,
)

// 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.
// A new column sorts descending; the same column again flips the direction.
// There is no third click that switches sorting off, because the explorer is
// always sorted by something — see `DEFAULT_SORT`. Descending first, because
// that is what "sorteer op datum" means, and because the filter popover 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: '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 })
else push({ ...query.value, order: query.value.order === 'desc' ? 'asc' : 'desc', page: 1 })
}

const selectedIds = ref<Set<string | number>>(new Set())
Expand Down
Loading