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:
1 parent
c09c30ae71
commit
5c62376b6e
7 files changed
+417
-56
No files matched your search
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user