bulk request

This commit is contained in:
2026-09-02 16:49:47 +05:30
parent d22b79e109
commit aad72cfe42
9 changed files with 862 additions and 26 deletions

View 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);
});

View 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,
};
}