From ff2aa316b5513e436a81244927f2fef2f8558bc3 Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Wed, 21 Oct 2026 12:39:22 +0100 Subject: [PATCH] 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. --- web/src/components/ClassificationPicker.tsx | 257 ++++++++++++++++---- web/src/components/TriStateCheckbox.tsx | 21 ++ web/src/lib/classifications.test.ts | 150 +++++++++++- web/src/lib/classifications.ts | 191 +++++++++++++++ 4 files changed, 573 insertions(+), 46 deletions(-) create mode 100644 web/src/components/TriStateCheckbox.tsx diff --git a/web/src/components/ClassificationPicker.tsx b/web/src/components/ClassificationPicker.tsx index 1d2b12b..59b7c1b 100644 --- a/web/src/components/ClassificationPicker.tsx +++ b/web/src/components/ClassificationPicker.tsx @@ -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; + /** 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 ( +
  • +
    + }> + + + + 0}> + {selectedBeneath()} selected + +
    + +
      + + {(child) => ( + props.selection(id)} + onToggle={(id) => props.onToggle(id)} + isOpen={(childKey) => props.isOpen(childKey)} + onToggleOpen={(childKey) => props.onToggleOpen(childKey)} + /> + )} + +
    +
    +
  • + ); +} 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>(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(); + 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 { 0}>
      - - {(id) => { - const code = (): ClassificationCode | undefined => byId().get(id); + + {(chip) => { + const code = (): ClassificationCode | undefined => chip.node?.code; + const beneath = (): number => (chip.node ? branchIds(chip.node).length - 1 : 0); return (
    • } > - {(code) => ( -
    • -
    • +
      + + 0}> + {selectedInGroup()} selected + +
      + +
        + + {(node) => ( + + )} + +
      - -
    • - )} + + ); + }}
    - MAX_VISIBLE}> +

    - 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.

    diff --git a/web/src/components/TriStateCheckbox.tsx b/web/src/components/TriStateCheckbox.tsx new file mode 100644 index 0000000..0152fbc --- /dev/null +++ b/web/src/components/TriStateCheckbox.tsx @@ -0,0 +1,21 @@ +import type { JSX } from 'solid-js'; +import { createEffect, splitProps } from 'solid-js'; + +interface Props extends Omit, '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 ; +} diff --git a/web/src/lib/classifications.test.ts b/web/src/lib/classifications.test.ts index ff79816..1b33fe3 100644 --- a/web/src/lib/classifications.test.ts +++ b/web/src/lib/classifications.test.ts @@ -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( + (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], + ]); +}); diff --git a/web/src/lib/classifications.ts b/web/src/lib/classifications.ts index 84afa18..d297267 100644 --- a/web/src/lib/classifications.ts +++ b/web/src/lib/classifications.ts @@ -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(); + 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 { + 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, +): 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): Set { + 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, +): SelectionChip[] { + const chips: SelectionChip[] = []; + const found = new Set(); + 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; +}