The classification filter now offers only the loaded opportunities' classifications

It lists the classifications the opportunities in the days window have,
not every one stored, plus any already chosen, so a selection stays
visible and can be removed when nothing in the window has it. The other
filters don't narrow it, so choosing a status doesn't make
classifications vanish.
This commit is contained in:
bwilliams committed 2026-10-19 10:19:34 +01:00
1 parent 0819fb442b
commit 6fbf7e56b2
4 files changed
+46 -5

No files matched your search

+3 -2
View File
@@ -69,8 +69,9 @@ again.
The list is fetched a page (100) at a time until there are no more. The marks
are `interested`, `skipped`, `bid_submitted`, `lost`, `won` and `hidden`;
Won and Lost exclude each other (409). The classifications are the ones the
filter offers: only those some opportunity has. The client fetches the CSRF
Won and Lost exclude each other (409). The classifications are every one some
stored opportunity has; the filter offers only those the loaded opportunities
(the days window) have, and any already chosen. The client fetches the CSRF
token when it first needs it, and a new one if a request is refused for it.
## Where things are
+12 -1
View File
@@ -6,6 +6,7 @@ import { OpportunityTable } from '@/components/OpportunityTable';
import { buttonPrimary } from '@/components/styles';
import { ThemeToggle } from '@/components/ThemeToggle';
import { ToastList } from '@/components/ToastList';
import { classificationsInUse } from '@/lib/classifications';
import type { OpportunityFilters } from '@/lib/filterOpportunities';
import {
emptyFilters,
@@ -44,6 +45,16 @@ export function App(): JSX.Element {
const theme = createTheme();
const statuses = createMemo(() => statusOptions(opportunities.items()));
// Only the classifications of the opportunities loaded (the days window),
// not those the other filters leave: choosing a status shouldn't make
// classifications vanish from the picker.
const pickable = createMemo(() =>
classificationsInUse(
classifications.items(),
opportunities.items(),
filters().classificationIds,
),
);
// annotations.get reads from a store, so hiding an opportunity re-runs this.
const rows = createMemo(() =>
filterOpportunities(opportunities.items(), filters(), annotations.get),
@@ -89,7 +100,7 @@ export function App(): JSX.Element {
onChange={setFilters}
onClear={clear}
statuses={statuses()}
classifications={classifications.items()}
classifications={pickable()}
isLoadingClassifications={classifications.isLoading()}
classificationsError={classifications.error()}
onRetryClassifications={classifications.reload}
+13 -1
View File
@@ -1,7 +1,8 @@
import { expect, test } from 'vitest';
import type { ClassificationCode } from '@/types/Opportunities';
import { applyClassificationChanges } from './classifications';
import { applyClassificationChanges, classificationsInUse } from './classifications';
import { testOpportunity } from './testOpportunity';
const code = (id: number, value: string): ClassificationCode => ({
id,
@@ -27,3 +28,14 @@ test('an added code replaces one with the same id', () => {
});
expect(after).toEqual([code(1, '03000001')]);
});
test('classificationsInUse keeps those the opportunities have, and the selected ones', () => {
const all = [code(1, '03000000'), code(2, '45000000'), code(3, '72000000'), code(4, '90000000')];
const opportunities = [
testOpportunity('a', { classifications: [code(3, '72000000')] }),
testOpportunity('b', { classifications: [code(1, '03000000'), code(3, '72000000')] }),
testOpportunity('c', { classifications: null }),
];
const inUse = classificationsInUse(all, opportunities, new Set([4]));
expect(inUse.map((c) => c.id)).toEqual([1, 3, 4]);
});
+18 -1
View File
@@ -1,5 +1,5 @@
import type { ClassificationsUpdatedPayload } from '@/types/Events';
import type { ClassificationCode } from '@/types/Opportunities';
import type { ClassificationCode, Opportunity } from '@/types/Opportunities';
/**
* The classifications after change: without the removed ids, with the added
@@ -15,3 +15,20 @@ export function applyClassificationChanges(
(first, second) => first.code.localeCompare(second.code) || first.id - second.id,
);
}
/**
* The classifications some of the opportunities have, and those whose ids
* are selected, so a selection stays visible (and can be removed) when no
* opportunity loaded has it.
*/
export function classificationsInUse(
classifications: readonly ClassificationCode[],
opportunities: readonly Opportunity[],
selected: ReadonlySet<number>,
): ClassificationCode[] {
const used = new Set(selected);
for (const opportunity of opportunities) {
for (const code of opportunity.classifications ?? []) used.add(code.id);
}
return classifications.filter((code) => used.has(code.id));
}