bulk request
This commit is contained in:
60
src/components/useSelection.test.ts
Normal file
60
src/components/useSelection.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
|
||||
/*
|
||||
The selection logic, lifted out of the hook so it can be tested without React.
|
||||
|
||||
The rule that matters: a bulk action must only ever touch rows the person could
|
||||
see when they chose them. Approving stock moves it, so acting on a row hidden
|
||||
behind a filter is not a cosmetic bug.
|
||||
*/
|
||||
|
||||
function chosenOf(visible: readonly number[], picked: ReadonlySet<number>) {
|
||||
return visible.filter((id) => picked.has(id));
|
||||
}
|
||||
|
||||
function afterToggleAll(visible: readonly number[], picked: ReadonlySet<number>) {
|
||||
const next = new Set(picked);
|
||||
const everyVisibleChosen = visible.length > 0 && visible.every((id) => next.has(id));
|
||||
for (const id of visible) {
|
||||
if (everyVisibleChosen) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
test('a bulk action never touches a row that was filtered away', () => {
|
||||
// Ticked while the list showed everything, then the list was narrowed.
|
||||
const picked = new Set([1, 2, 3]);
|
||||
assert.deepEqual(chosenOf([2], picked), [2]);
|
||||
});
|
||||
|
||||
test('narrowing and widening again does not untick the work', () => {
|
||||
// The hidden ids stay in the set; they are simply not acted on while hidden.
|
||||
const picked = new Set([1, 2, 3]);
|
||||
assert.deepEqual(chosenOf([1, 2, 3], picked), [1, 2, 3]);
|
||||
});
|
||||
|
||||
test('select all covers only what is on screen', () => {
|
||||
const next = afterToggleAll([2, 3], new Set());
|
||||
assert.deepEqual([...next].sort(), [2, 3]);
|
||||
});
|
||||
|
||||
test('select all a second time clears exactly what it added', () => {
|
||||
const picked = afterToggleAll([2, 3], new Set([9]));
|
||||
const cleared = afterToggleAll([2, 3], picked);
|
||||
// 9 was chosen elsewhere and is not on screen, so it survives.
|
||||
assert.deepEqual([...cleared], [9]);
|
||||
});
|
||||
|
||||
test('select all on an empty list does nothing', () => {
|
||||
assert.equal(afterToggleAll([], new Set()).size, 0);
|
||||
});
|
||||
|
||||
test('the header is only fully ticked when every visible row is', () => {
|
||||
const visible = [1, 2];
|
||||
const partial = chosenOf(visible, new Set([1]));
|
||||
assert.equal(partial.length === visible.length, false);
|
||||
const full = chosenOf(visible, new Set([1, 2]));
|
||||
assert.equal(full.length === visible.length, true);
|
||||
});
|
||||
88
src/components/useSelection.ts
Normal file
88
src/components/useSelection.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Which rows a person has ticked, and the header checkbox that follows.
|
||||
*
|
||||
* Shared by the three screens that grew a bulk action — importing from the
|
||||
* catalogue, requesting stock, and deciding requests — because the fiddly parts
|
||||
* are the same every time and getting them subtly different between screens is
|
||||
* how a merchant learns to distrust the tick boxes.
|
||||
*
|
||||
* ── The part that is easy to get wrong ──────────────────────────────────────
|
||||
*
|
||||
* A selection is kept against the ROWS CURRENTLY VISIBLE. Filter a list down,
|
||||
* tick everything, clear the filter, and press the button: a naive
|
||||
* implementation acts on rows the person could not see when they chose. So
|
||||
* "select all" only ever covers what is on screen, and `chosen` is intersected
|
||||
* with the visible ids before it is handed back.
|
||||
*
|
||||
* Ids that scroll out of view are NOT dropped from the set, because narrowing a
|
||||
* search and widening it again should not silently untick the work. They are
|
||||
* simply not acted on while they are hidden.
|
||||
*/
|
||||
export interface Selection {
|
||||
/** Visible ids that are ticked — what a bulk action should act on. */
|
||||
chosen: number[];
|
||||
count: number;
|
||||
has: (id: number) => boolean;
|
||||
toggle: (id: number) => void;
|
||||
/** Tick or untick everything currently visible. */
|
||||
toggleAll: () => void;
|
||||
clear: () => void;
|
||||
/** Every visible row is ticked. Drives the header checkbox. */
|
||||
allChosen: boolean;
|
||||
/** Some but not all — the indeterminate state. */
|
||||
someChosen: boolean;
|
||||
}
|
||||
|
||||
export function useSelection(visibleIds: readonly number[]): Selection {
|
||||
const [picked, setPicked] = useState<ReadonlySet<number>>(() => new Set());
|
||||
|
||||
const chosen = useMemo(
|
||||
() => visibleIds.filter((id) => picked.has(id)),
|
||||
[visibleIds, picked],
|
||||
);
|
||||
|
||||
const toggle = useCallback((id: number) => {
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const allChosen = visibleIds.length > 0 && chosen.length === visibleIds.length;
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
const everyVisibleChosen =
|
||||
visibleIds.length > 0 && visibleIds.every((id) => next.has(id));
|
||||
for (const id of visibleIds) {
|
||||
if (everyVisibleChosen) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [visibleIds]);
|
||||
|
||||
const clear = useCallback(() => setPicked(new Set()), []);
|
||||
|
||||
return {
|
||||
chosen,
|
||||
count: chosen.length,
|
||||
has: (id: number) => picked.has(id),
|
||||
toggle,
|
||||
toggleAll,
|
||||
clear,
|
||||
allChosen,
|
||||
someChosen: chosen.length > 0 && !allChosen,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user