Made the classification picker a tree, where ticking a code selects its whole branch

The classifications are grouped by kind (CPV, then Other) and, within
CPV, nested by the CPV hierarchy: each code sits under its nearest
ancestor in the list, so one whose parent isn't in use isn't lost. The
other codes have no hierarchy, so they're listed flat. Branches start
closed; a search shows the matching codes with their ancestors, opened
when no more than 150 match.

Ticking a code selects it and every code beneath it, and ticking a
whole selected branch clears it. The checkboxes are tri-state, showing
a dash when only part of a branch is selected, and a closed branch that
is partly selected says how many codes beneath it are. The selection
above the tree shows a whole selected branch as one chip, with the
number of codes beneath it, and removing that chip clears the branch.

The filter is unchanged: a set of classification ids, so a branch is
the codes in the picker when it's ticked.
This commit is contained in:
bwilliams committed 2026-10-21 12:39:22 +01:00
1 parent 4db268550b
commit ff2aa316b5
4 files changed
+573 -46

No files matched your search

+214 -43
View File
@@ -1,10 +1,105 @@
import ChevronRight from 'lucide-solid/icons/chevron-right';
import type { JSX } from 'solid-js';
import { createMemo, createSignal, createUniqueId, For, Show } from 'solid-js';
import type {
BranchSelection,
ClassificationGroup,
ClassificationNode,
SelectionChip,
} from '@/lib/classifications';
import {
branchIds,
branchSelection,
classificationMatcher,
classificationTree,
countSelected,
filterClassificationTree,
selectionChips,
toggleBranch,
} from '@/lib/classifications';
import { formatCount } from '@/lib/format';
import type { ClassificationCode } from '@/types/Opportunities';
import { fieldLabel, input, linkButton } from './styles';
import { TriStateCheckbox } from './TriStateCheckbox';
const MAX_VISIBLE = 150;
// A search matching more codes than this leaves the tree closed, as opening
// every branch would make a very long list.
const MAX_OPENED = 150;
const toggleButton = 'grid size-5 place-items-center rounded text-muted hover:bg-paper';
const chevron = 'size-4 transition-transform duration-200 motion-reduce:transition-none';
const selectedCount = 'rounded-full bg-accent-soft px-2 text-xs leading-5 text-accent';
interface BranchProps {
node: ClassificationNode;
selected: ReadonlySet<number>;
/** How much of the whole branch under the classification with this id is selected. */
selection: (id: number) => BranchSelection;
/** Called when the checkbox of the classification with this id is ticked or cleared. */
onToggle: (id: number) => void;
/** Whether the branch with this key (a classification id) is open. */
isOpen: (key: string) => boolean;
onToggleOpen: (key: string) => void;
}
// One classification in the tree, with its checkbox (for its whole branch)
// and, when it has codes beneath it, a button to show or hide them.
function ClassificationBranch(props: BranchProps): JSX.Element {
const code = (): ClassificationCode => props.node.code;
const key = (): string => String(code().id);
const hasChildren = (): boolean => props.node.children.length > 0;
const childrenId = createUniqueId();
const selectedBeneath = (): number => countSelected(props.node.children, props.selected);
const selection = (): BranchSelection => props.selection(code().id);
return (
<li>
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-1 rounded px-1 hover:bg-paper">
<Show when={hasChildren()} fallback={<span class="size-5" />}>
<button
type="button"
class={toggleButton}
aria-expanded={props.isOpen(key())}
aria-controls={childrenId}
aria-label={`Codes under ${code().code}`}
onClick={() => props.onToggleOpen(key())}
>
<ChevronRight class={chevron} classList={{ 'rotate-90': props.isOpen(key()) }} />
</button>
</Show>
<label class="grid min-w-0 cursor-pointer grid-cols-[auto_auto_1fr] items-baseline gap-2 py-1">
<TriStateCheckbox
checked={selection() === 'all'}
indeterminate={selection() === 'some'}
onChange={() => props.onToggle(code().id)}
/>
<span class="text-sm text-muted tabular-nums">{code().code}</span>
<span class="truncate">{code().classification}</span>
</label>
<Show when={!props.isOpen(key()) && selection() === 'some' && selectedBeneath() > 0}>
<span class={selectedCount}>{selectedBeneath()} selected</span>
</Show>
</div>
<Show when={hasChildren() && props.isOpen(key())}>
<ul id={childrenId} class="pl-5">
<For each={props.node.children}>
{(child) => (
<ClassificationBranch
node={child}
selected={props.selected}
selection={(id) => props.selection(id)}
onToggle={(id) => props.onToggle(id)}
isOpen={(childKey) => props.isOpen(childKey)}
onToggleOpen={(childKey) => props.onToggleOpen(childKey)}
/>
)}
</For>
</ul>
</Show>
</li>
);
}
interface Props {
classifications: ClassificationCode[];
@@ -16,30 +111,71 @@ interface Props {
class?: string;
}
/** A searchable list of classifications to filter by, with the chosen ones above it. */
/**
* A searchable tree of classifications to filter by, grouped by kind (CPV
* or other) and, within CPV, by the CPV hierarchy; the chosen ones are
* above it. Ticking a classification selects it and every code beneath it.
*/
export function ClassificationPicker(props: Props): JSX.Element {
const [query, setQuery] = createSignal('');
// Group keys and classification ids (as strings) of the open branches.
const [opened, setOpened] = createSignal<ReadonlySet<string>>(new Set());
const inputId = createUniqueId();
const sorted = createMemo(() =>
[...props.classifications].sort((a, b) => a.code.localeCompare(b.code)),
);
const byId = createMemo(() => new Map(sorted().map((c) => [c.id, c])));
const groups = createMemo(() => classificationTree(props.classifications));
// The full tree's nodes by id: a search shows only part of each branch,
// but ticking a classification still selects its whole branch.
const nodesById = createMemo(() => {
const nodes = new Map<number, ClassificationNode>();
const add = (node: ClassificationNode): void => {
nodes.set(node.code.id, node);
node.children.forEach(add);
};
for (const group of groups()) group.nodes.forEach(add);
return nodes;
});
const chips = createMemo(() => selectionChips(groups(), props.selected));
const matches = createMemo(() => {
const q = query().trim().toLocaleLowerCase();
if (!q) return sorted();
return sorted().filter(
(code) =>
code.code.toLocaleLowerCase().startsWith(q) ||
code.classification.toLocaleLowerCase().includes(q),
);
const search = createMemo(() => {
if (!query().trim()) return undefined;
const matches = classificationMatcher(query());
let count = 0;
const found: ClassificationGroup[] = [];
for (const group of groups()) {
const filtered = filterClassificationTree(group.nodes, matches);
count += filtered.count;
if (filtered.nodes.length > 0) found.push({ ...group, nodes: filtered.nodes });
}
return { groups: found, count };
});
const shownGroups = (): ClassificationGroup[] => search()?.groups ?? groups();
const isSearchOpened = (): boolean => (search()?.count ?? Infinity) <= MAX_OPENED;
const isOpen = (key: string): boolean => isSearchOpened() || opened().has(key);
const toggleOpen = (key: string): void => {
const next = new Set(opened());
if (isOpen(key)) next.delete(key);
else next.add(key);
setOpened(next);
};
const selection = (id: number): BranchSelection => {
const node = nodesById().get(id);
return node ? branchSelection(node, props.selected) : 'none';
};
const toggle = (id: number): void => {
const node = nodesById().get(id);
if (node) props.onChange(toggleBranch(node, props.selected));
};
const removeChip = (chip: SelectionChip): void => {
const next = new Set(props.selected);
if (next.has(id)) next.delete(id);
else next.add(id);
for (const id of chip.node && chip.isBranch ? branchIds(chip.node) : [chip.id]) {
next.delete(id);
}
props.onChange(next);
};
@@ -53,19 +189,30 @@ export function ClassificationPicker(props: Props): JSX.Element {
<Show when={props.selected.size > 0}>
<ul class="mb-2 flex flex-wrap gap-1.5" aria-label="Selected classifications">
<For each={[...props.selected]}>
{(id) => {
const code = (): ClassificationCode | undefined => byId().get(id);
<For each={chips()}>
{(chip) => {
const code = (): ClassificationCode | undefined => chip.node?.code;
const beneath = (): number => (chip.node ? branchIds(chip.node).length - 1 : 0);
return (
<li>
<button
type="button"
class="inline-flex max-w-72 items-baseline gap-1.5 rounded-md bg-accent-soft px-2 py-1 text-accent"
onClick={() => toggle(id)}
title="Remove this classification"
onClick={() => removeChip(chip)}
title={
chip.isBranch
? 'Remove this classification and those beneath it'
: 'Remove this classification'
}
>
<span class="text-sm tabular-nums">{code()?.code ?? id}</span>
<span class="text-sm tabular-nums">{code()?.code ?? chip.id}</span>
<span class="truncate">{code()?.classification ?? 'Unknown'}</span>
<Show when={chip.isBranch}>
<span class="text-sm whitespace-nowrap">
+{formatCount(beneath())}
<span class="sr-only"> beneath it</span>
</span>
</Show>
<span aria-hidden="true" class="font-bold">
×
</span>
@@ -108,35 +255,59 @@ export function ClassificationPicker(props: Props): JSX.Element {
aria-label="Classifications"
>
<For
each={matches().slice(0, MAX_VISIBLE)}
each={shownGroups()}
fallback={
<li class="p-1.5 text-sm text-muted">No classifications match “{query()}”.</li>
}
>
{(code) => (
<li>
<label class="grid cursor-pointer grid-cols-[auto_auto_1fr_auto] items-baseline gap-2 rounded px-1.5 py-1 hover:bg-paper">
<input
type="checkbox"
checked={props.selected.has(code.id)}
onChange={() => toggle(code.id)}
/>
<span class="text-sm text-muted tabular-nums">{code.code}</span>
<span class="truncate">{code.classification}</span>
<Show when={code.is_cpv}>
<span class="text-xs text-muted" title="Common Procurement Vocabulary">
CPV
</span>
{(group) => {
const key = (): string => `group:${group.key}`;
const nodesId = createUniqueId();
const selectedInGroup = (): number => countSelected(group.nodes, props.selected);
return (
<li>
<div class="flex items-center gap-1 rounded px-1 hover:bg-paper">
<button
type="button"
class="inline-flex grow items-center gap-1 py-1 text-left font-semibold"
aria-expanded={isOpen(key())}
aria-controls={nodesId}
onClick={() => toggleOpen(key())}
>
<span class="grid size-5 place-items-center text-muted">
<ChevronRight class={chevron} classList={{ 'rotate-90': isOpen(key()) }} />
</span>
<span title={group.title}>{group.label}</span>
</button>
<Show when={!isOpen(key()) && selectedInGroup() > 0}>
<span class={selectedCount}>{selectedInGroup()} selected</span>
</Show>
</div>
<Show when={isOpen(key())}>
<ul id={nodesId} class="pl-5">
<For each={group.nodes}>
{(node) => (
<ClassificationBranch
node={node}
selected={props.selected}
selection={selection}
onToggle={toggle}
isOpen={isOpen}
onToggleOpen={toggleOpen}
/>
)}
</For>
</ul>
</Show>
</label>
</li>
)}
</li>
);
}}
</For>
</ul>
<Show when={matches().length > MAX_VISIBLE}>
<Show when={search() && !isSearchOpened()}>
<p class="mt-1.5 text-sm text-muted">
Showing {MAX_VISIBLE} of {matches().length.toLocaleString('en-GB')}. Type more to narrow
the list.
{formatCount(search()?.count ?? 0)} match, too many to open them all. Type more to
narrow the list, or open the branches you want.
</p>
</Show>
</Show>
+21
View File
@@ -0,0 +1,21 @@
import type { JSX } from 'solid-js';
import { createEffect, splitProps } from 'solid-js';
interface Props extends Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'type' | 'ref'> {
/** Shown as a dash, and announced as mixed, in place of checked. */
indeterminate: boolean;
}
/** A checkbox that can also show a third, indeterminate state. */
export function TriStateCheckbox(props: Props): JSX.Element {
const [local, inputProps] = splitProps(props, ['indeterminate']);
let checkbox!: HTMLInputElement;
// indeterminate is a property with no HTML attribute, so it's set on the
// element rather than in the markup.
createEffect(() => {
checkbox.indeterminate = local.indeterminate;
});
return <input ref={checkbox} type="checkbox" {...inputProps} />;
}
+147 -3
View File
@@ -1,12 +1,23 @@
import { expect, test } from 'vitest';
import type { ClassificationCode } from '@/types/Opportunities';
import { applyClassificationChanges, classificationsInUse } from './classifications';
import type { ClassificationGroup, ClassificationNode } from './classifications';
import {
applyClassificationChanges,
branchSelection,
classificationMatcher,
classificationsInUse,
classificationTree,
countSelected,
filterClassificationTree,
selectionChips,
toggleBranch,
} from './classifications';
import { testOpportunity } from './testOpportunity';
const code = (id: number, value: string): ClassificationCode => ({
const code = (id: number, value: string, isCpv = true): ClassificationCode => ({
id,
is_cpv: true,
is_cpv: isCpv,
code: value,
classification: `Code ${value}`,
});
@@ -39,3 +50,136 @@ test('classificationsInUse keeps those the opportunities have, and the selected
const inUse = classificationsInUse(all, opportunities, new Set([4]));
expect(inUse.map((c) => c.id)).toEqual([1, 3, 4]);
});
// The tree as nested arrays of codes, for comparing shapes.
const shape = (nodes: ClassificationNode[]): unknown[] =>
nodes.map((node) =>
node.children.length > 0 ? [node.code.code, shape(node.children)] : node.code.code,
);
test('classificationTree nests CPV codes under their nearest ancestor in the list', () => {
const groups = classificationTree([
code(1, '72210000'),
code(2, '72000000'),
code(3, '72200000'),
code(4, '72212100'), // its parent 72212000 isn't in the list
code(5, '03000000'),
code(6, '03100000'),
code(7, '45100000'), // no division 45000000
code(8, '45110000'),
]);
expect(groups.map((group) => group.key)).toEqual(['cpv']);
expect(shape(groups[0]?.nodes ?? [])).toEqual([
['03000000', ['03100000']],
['45100000', ['45110000']],
['72000000', [['72200000', [['72210000', ['72212100']]]]]],
]);
});
test('classificationTree puts codes that aren’t CPV in a flat group after CPV', () => {
const groups = classificationTree([
code(1, '90513000-FC01', false),
code(2, '90000000'),
code(3, '85000000-RC04', false),
]);
expect(groups.map((group) => [group.key, shape(group.nodes)])).toEqual([
['cpv', ['90000000']],
['other', ['85000000-RC04', '90513000-FC01']],
]);
});
test('filterClassificationTree keeps matches and their ancestors, and counts matches', () => {
const [cpv] = classificationTree([
code(1, '72000000'),
code(2, '72200000'),
code(3, '72210000'),
code(4, '72300000'),
code(5, '03000000'),
]);
const filtered = filterClassificationTree(cpv?.nodes ?? [], (c) =>
['72210000', '72300000'].includes(c.code),
);
expect(filtered.count).toBe(2);
expect(shape(filtered.nodes)).toEqual([['72000000', [['72200000', ['72210000']], '72300000']]]);
});
test('countSelected counts the selected nodes and codes beneath them', () => {
const [cpv] = classificationTree([
code(1, '72000000'),
code(2, '72200000'),
code(3, '72210000'),
code(4, '72300000'),
code(5, '03000000'),
]);
const selected = new Set([1, 3, 4]);
expect(countSelected(cpv?.nodes ?? [], selected)).toBe(3);
expect(countSelected(cpv?.nodes.find((n) => n.code.id === 1)?.children ?? [], selected)).toBe(2);
});
test('classificationMatcher matches the start of the code or anywhere in the name', () => {
const matches = classificationMatcher(' SoftWare ');
expect(matches({ ...code(1, '48000000'), classification: 'Software packages' })).toBe(true);
expect(matches({ ...code(2, '72000000'), classification: 'IT software services' })).toBe(true);
expect(matches(code(3, '03000000'))).toBe(false);
expect(classificationMatcher('72')(code(4, '72000000'))).toBe(true);
expect(classificationMatcher('2000')({ ...code(5, '72000000'), classification: 'IT' })).toBe(
false,
);
});
// 72000000 > (72200000 > 72210000, 72300000); 03000000.
const branchTree = (): ClassificationGroup[] =>
classificationTree([
code(1, '72000000'),
code(2, '72200000'),
code(3, '72210000'),
code(4, '72300000'),
code(5, '03000000'),
]);
const findNode = (groups: ClassificationGroup[], id: number): ClassificationNode => {
const search = (nodes: ClassificationNode[]): ClassificationNode | undefined =>
nodes.reduce<ClassificationNode | undefined>(
(found, node) => found ?? (node.code.id === id ? node : search(node.children)),
undefined,
);
const node = search(groups.flatMap((group) => group.nodes));
if (!node) throw new Error(`no node ${id}`);
return node;
};
test('branchSelection says whether all, some or none of a branch is selected', () => {
const root = findNode(branchTree(), 1);
expect(branchSelection(root, new Set([5]))).toBe('none');
expect(branchSelection(root, new Set([3]))).toBe('some');
expect(branchSelection(root, new Set([1]))).toBe('some');
expect(branchSelection(root, new Set([1, 2, 3, 4]))).toBe('all');
});
test('toggleBranch selects a branch whole, or clears it when it is all selected', () => {
const root = findNode(branchTree(), 1);
const partial = new Set([3, 5]);
const all = toggleBranch(root, partial);
expect([...all].sort()).toEqual([1, 2, 3, 4, 5]);
expect(partial).toEqual(new Set([3, 5]));
expect(toggleBranch(root, all)).toEqual(new Set([5]));
expect(toggleBranch(findNode(branchTree(), 2), new Set([1]))).toEqual(new Set([1, 2, 3]));
});
test('selectionChips shortens whole branches to one chip and keeps unknown ids', () => {
const groups = branchTree();
const chips = (selected: number[]): unknown[] =>
selectionChips(groups, new Set(selected)).map((chip) => [
chip.id,
chip.node?.code.code,
chip.isBranch,
]);
expect(chips([1, 2, 3, 4, 99])).toEqual([
[1, '72000000', true],
[99, undefined, false],
]);
expect(chips([1, 2, 3, 5])).toEqual([
[5, '03000000', false],
[1, '72000000', false],
[2, '72200000', true],
]);
});
+191
View File
@@ -32,3 +32,194 @@ export function classificationsInUse(
}
return classifications.filter((code) => used.has(code.id));
}
/** A classification in the picker's tree, with the codes beneath it. */
export interface ClassificationNode {
code: ClassificationCode;
children: ClassificationNode[];
}
/** One of the tree's top-level groups, decided by the codes' flags. */
export interface ClassificationGroup {
/** Stable across loads, for remembering which groups are open. */
key: string;
label: string;
/** What the label stands for, where it's an abbreviation. */
title?: string;
nodes: ClassificationNode[];
}
// A CPV code's significant digits: its trailing zeros dropped, keeping at
// least the two of its division. A code's ancestors are the codes whose
// significant digits begin its own (72000000 > 72200000 > 72210000).
const cpvPrefix = (code: string): string => {
const digits = code.replace(/0+$/, '');
return digits.length >= 2 ? digits : code.slice(0, 2);
};
// Nests CPV codes under their nearest ancestor in the list, so a code whose
// parent isn't in use sits under its grandparent, or at the top.
const cpvTree = (codes: readonly ClassificationCode[]): ClassificationNode[] => {
const byPrefix = new Map<string, ClassificationNode>();
const nodes = codes.map((code): ClassificationNode => ({ code, children: [] }));
for (const node of nodes) {
const prefix = cpvPrefix(node.code.code);
// The same code can come with more than one name: the first is the parent.
if (!byPrefix.has(prefix)) byPrefix.set(prefix, node);
}
const roots: ClassificationNode[] = [];
for (const node of nodes) {
const prefix = cpvPrefix(node.code.code);
let parent: ClassificationNode | undefined;
for (let length = prefix.length - 1; length >= 2 && !parent; length--) {
parent = byPrefix.get(prefix.slice(0, length));
}
(parent?.children ?? roots).push(node);
}
return roots;
};
/**
* The classifications as a tree: CPV codes grouped by their place in the
* CPV hierarchy, then the other codes, which have none. Each level is
* ordered by code; groups without codes are left out.
*/
export function classificationTree(
classifications: readonly ClassificationCode[],
): ClassificationGroup[] {
const sorted = [...classifications].sort(
(first, second) => first.code.localeCompare(second.code) || first.id - second.id,
);
const groups: ClassificationGroup[] = [
{
key: 'cpv',
label: 'CPV',
title: 'Common Procurement Vocabulary',
nodes: cpvTree(sorted.filter((code) => code.is_cpv)),
},
{
key: 'other',
label: 'Other',
nodes: sorted.filter((code) => !code.is_cpv).map((code) => ({ code, children: [] })),
},
];
return groups.filter((group) => group.nodes.length > 0);
}
/**
* Whether a classification matches what was typed in the picker: its code
* begins with it, or its name contains it, ignoring case.
*/
export function classificationMatcher(query: string): (code: ClassificationCode) => boolean {
const typed = query.trim().toLocaleLowerCase();
return (code) =>
code.code.toLocaleLowerCase().startsWith(typed) ||
code.classification.toLocaleLowerCase().includes(typed);
}
/**
* The nodes that match, or have a descendant that does, with only those
* descendants beneath them; and how many codes matched.
*/
export function filterClassificationTree(
nodes: readonly ClassificationNode[],
matches: (code: ClassificationCode) => boolean,
): { nodes: ClassificationNode[]; count: number } {
let count = 0;
const kept: ClassificationNode[] = [];
for (const node of nodes) {
const children = filterClassificationTree(node.children, matches);
count += children.count;
const isMatch = matches(node.code);
if (isMatch) count++;
if (isMatch || children.nodes.length > 0)
kept.push({ code: node.code, children: children.nodes });
}
return { nodes: kept, count };
}
/** How many of the nodes, and the codes beneath them, are selected. */
export function countSelected(
nodes: readonly ClassificationNode[],
selected: ReadonlySet<number>,
): number {
return nodes.reduce(
(total, node) =>
total + (selected.has(node.code.id) ? 1 : 0) + countSelected(node.children, selected),
0,
);
}
/** The ids of node and every code beneath it. */
export function branchIds(node: ClassificationNode): number[] {
return [node.code.id, ...node.children.flatMap(branchIds)];
}
/** How much of a branch is selected, for a tri-state checkbox. */
export type BranchSelection = 'all' | 'some' | 'none';
/** Whether all, some or none of node and the codes beneath it are selected. */
export function branchSelection(
node: ClassificationNode,
selected: ReadonlySet<number>,
): BranchSelection {
const ids = branchIds(node);
const count = ids.filter((id) => selected.has(id)).length;
if (count === 0) return 'none';
return count === ids.length ? 'all' : 'some';
}
/**
* The selection after ticking node's checkbox: a branch that is all
* selected is cleared, and any other is selected whole.
*/
export function toggleBranch(node: ClassificationNode, selected: ReadonlySet<number>): Set<number> {
const next = new Set(selected);
const isAll = branchSelection(node, selected) === 'all';
for (const id of branchIds(node)) {
if (isAll) next.delete(id);
else next.add(id);
}
return next;
}
/** One chip in the summary of the selection. */
export interface SelectionChip {
id: number;
/** Undefined when no classification with this id was loaded. */
node: ClassificationNode | undefined;
/** Whether the chip stands for node's whole branch, not only node. */
isBranch: boolean;
}
/**
* The selection, shortened for display: a branch that is all selected is
* one chip, and other selected codes have one each, in tree order. Selected
* ids that aren't in the tree come last.
*/
export function selectionChips(
groups: readonly ClassificationGroup[],
selected: ReadonlySet<number>,
): SelectionChip[] {
const chips: SelectionChip[] = [];
const found = new Set<number>();
const visit = (node: ClassificationNode): void => {
if (node.children.length > 0 && branchSelection(node, selected) === 'all') {
chips.push({ id: node.code.id, node, isBranch: true });
for (const id of branchIds(node)) found.add(id);
return;
}
if (selected.has(node.code.id)) {
chips.push({ id: node.code.id, node, isBranch: false });
found.add(node.code.id);
}
node.children.forEach(visit);
};
for (const group of groups) group.nodes.forEach(visit);
for (const id of selected) {
if (!found.has(id)) chips.push({ id, node: undefined, isBranch: false });
}
return chips;
}