Made the table's columns sortable

Every column's header is a button that sorts the table by it, and
clicking it again sorts the other way. A column starts with the most
useful first: titles A to Z, the latest dates and highest values, Yes
before No, hidden first, and marks in the menu's order. Statuses sort
in the filters' order rather than alphabetically, and titles by their
words and numbers, ignoring case and punctuation. Rows with no value in
the column (no date, no value, no marks) go last either way, and rows
that tie are newest release first.

The sort is remembered with the other preferences, and is newest
release first until another is chosen, as before. Sorting has moved
out of filterOpportunities, which now keeps the order it's given.
This commit is contained in:
bwilliams committed 2026-10-21 12:47:25 +01:00
1 parent c09c30ae71
commit 5c62376b6e
7 files changed
+417 -56

No files matched your search

+95 -20
View File
@@ -1,13 +1,18 @@
import ArrowDown from 'lucide-solid/icons/arrow-down';
import ArrowUp from 'lucide-solid/icons/arrow-up';
import ChevronsUpDown from 'lucide-solid/icons/chevrons-up-down';
import ExternalLink from 'lucide-solid/icons/external-link';
import Info from 'lucide-solid/icons/info';
import type { JSX } from 'solid-js';
import { createMemo, createSignal, For, Show } from 'solid-js';
import { createMemo, createSignal, For, Match, Show, Switch } from 'solid-js';
import { api } from '@/api/client';
import { errorText } from '@/lib/errors';
import { statusLabel } from '@/lib/filterOpportunities';
import { formatDate, formatMoney } from '@/lib/format';
import { findATenderNoticeUrl, isFromFindATender, latestRelease } from '@/lib/opportunities';
import type { OpportunitySort, SortColumn } from '@/lib/sortOpportunities';
import { nextSort } from '@/lib/sortOpportunities';
import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
import type { Opportunity } from '@/types/Opportunities';
import { MarkMenu } from './MarkMenu';
@@ -17,6 +22,10 @@ import { buttonQuiet, MARK_BG, statusBadge } from './styles';
interface Props {
opportunities: Opportunity[];
annotations: OpportunityAnnotations;
/** The order the opportunities are in, shown on the headers. */
sort: OpportunitySort;
/** Called with the sort a header's click asks for. */
onSort: (next: OpportunitySort) => void;
/** Called with a message when an opportunity's notice couldn't be opened. */
onError: (message: string) => void;
}
@@ -24,11 +33,53 @@ interface Props {
const th =
'sticky top-0 z-10 whitespace-nowrap border-b border-line bg-surface p-3 text-left text-sm font-semibold text-muted';
const td = 'border-b border-line-soft p-3 align-middle';
const sortIcon = { size: 14, strokeWidth: 2, 'aria-hidden': true } as const;
const iconButton =
'flex rounded-md border border-line bg-surface p-1 text-ink hover:enabled:border-muted disabled:opacity-60';
interface HeaderProps {
column: SortColumn;
sort: OpportunitySort;
onSort: (next: OpportunitySort) => void;
class?: string;
children: JSX.Element;
}
// A column's header: a button that sorts the table by the column, or the
// other way if it's sorted by it already.
function SortableHeader(props: HeaderProps): JSX.Element {
const direction = () => (props.sort.column === props.column ? props.sort.direction : undefined);
return (
<th scope="col" class={`${th} ${props.class ?? ''}`} aria-sort={direction()}>
<button
type="button"
class="group inline-flex items-center gap-1 rounded hover:text-ink"
classList={{ 'text-ink': direction() !== undefined }}
onClick={() => props.onSort(nextSort(props.sort, props.column))}
>
{props.children}
<Switch
fallback={
<span class="opacity-40 group-hover:opacity-100">
<ChevronsUpDown {...sortIcon} />
</span>
}
>
<Match when={direction() === 'ascending'}>
<ArrowUp {...sortIcon} />
</Match>
<Match when={direction() === 'descending'}>
<ArrowDown {...sortIcon} />
</Match>
</Switch>
</button>
</th>
);
}
/**
* The table of opportunities, each with its marks, a hide button, a button
* The table of opportunities, sortable by any column, each with its marks, a hide button, a button
* that opens its details and, for those from Find a Tender, a link to its
* latest notice there.
*/
@@ -48,33 +99,57 @@ export function OpportunityTable(props: Props): JSX.Element {
<table class="w-full border-separate border-spacing-0 tabular-nums">
<thead>
<tr>
<th scope="col" class={`${th} min-w-80`}>
<SortableHeader
column="title"
sort={props.sort}
onSort={(next) => props.onSort(next)}
class="min-w-80"
>
Title
</th>
<th scope="col" class={th}>
</SortableHeader>
<SortableHeader column="status" sort={props.sort} onSort={(next) => props.onSort(next)}>
Status
</th>
<th scope="col" class={`${th} text-center`}>
</SortableHeader>
<SortableHeader
column="smeSuitable"
sort={props.sort}
onSort={(next) => props.onSort(next)}
class="text-center"
>
SME suitable
</th>
<th scope="col" class={th}>
</SortableHeader>
<SortableHeader
column="published"
sort={props.sort}
onSort={(next) => props.onSort(next)}
>
Published
</th>
<th scope="col" class={th}>
</SortableHeader>
<SortableHeader column="start" sort={props.sort} onSort={(next) => props.onSort(next)}>
Start
</th>
<th scope="col" class={th}>
</SortableHeader>
<SortableHeader column="end" sort={props.sort} onSort={(next) => props.onSort(next)}>
End
</th>
<th scope="col" class={`${th} text-right`}>
</SortableHeader>
<SortableHeader
column="value"
sort={props.sort}
onSort={(next) => props.onSort(next)}
class="text-right"
>
Value
</th>
<th scope="col" class={th}>
</SortableHeader>
<SortableHeader column="mark" sort={props.sort} onSort={(next) => props.onSort(next)}>
Mark
</th>
<th scope="col" class={`${th} text-center`}>
</SortableHeader>
<SortableHeader
column="hidden"
sort={props.sort}
onSort={(next) => props.onSort(next)}
class="text-center"
>
Hide
</th>
</SortableHeader>
</tr>
</thead>
<tbody class="[&>tr:last-child>td]:border-b-0">