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; +}