diff --git a/src/components/Common/FilterBuilder.vue b/src/components/Common/FilterBuilder.vue index 15e1484..5f344ab 100644 --- a/src/components/Common/FilterBuilder.vue +++ b/src/components/Common/FilterBuilder.vue @@ -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. @@ -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, }) } @@ -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 }, + ] }) @@ -196,9 +202,9 @@ const direction = computed(() => { - -
+ +

- - + Klik dezelfde kolom nog eens om de richting om te draaien. +

Type en status volgen de volgorde van de database-enum: dat groepeert de rijen, diff --git a/src/services/explorer.ts b/src/services/explorer.ts index 4efb288..bfae5c3 100644 --- a/src/services/explorer.ts +++ b/src/services/explorer.ts @@ -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. * @@ -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 { @@ -156,7 +189,9 @@ export function describeSort(sort: SortField, order: 'asc' | 'desc'): string { export function parseQuery(raw: Record): 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'), @@ -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 } @@ -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 */ @@ -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 } @@ -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 }), }) } diff --git a/src/services/worklist.ts b/src/services/worklist.ts index 0d2b696..5365dc6 100644 --- a/src/services/worklist.ts +++ b/src/services/worklist.ts @@ -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. */ @@ -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, } } diff --git a/src/views/InquiryListView.vue b/src/views/InquiryListView.vue index a08266f..b7b1153 100644 --- a/src/views/InquiryListView.vue +++ b/src/views/InquiryListView.vue @@ -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>(new Set())