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:
1 parent
4db268550b
commit
ff2aa316b5
4 files changed
+573
-46
No files matched your search
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user