Replaced the Title filter with a general search, run by the backend
GET /api/opportunities/search?q= returns the UUIDs of the matching opportunities, and the frontend filters its list by them. Words without a prompt search the title and description; title:, buyer:, buyer_id: and cpv: search only their own field, and a leading ! leaves out what a term matches. Repeats of one prompt are OR, everything else AND. CPV terms are checked per code: cpv:72 !cpv:7221 needs a code starting 72 that doesn't start 7221. Unknown prompts are reported, and every response carries the help that the search box's "?" panel shows. Migration 00011 adds the description and buyer name to the full-text index, and indexes CPV descriptions.
This commit is contained in:
1 parent
0a777792d8
commit
b0230e6847
29 files changed
+1913
-43
No files matched your search
+3
-2
@@ -1,8 +1,9 @@
|
||||
# JobSearch - Frontend
|
||||
|
||||
A Vite + SolidJS + Tailwind CSS (v4) + TypeScript table of opportunities with
|
||||
title search, status, mark and classification filters, and per-opportunity
|
||||
mark and hide controls.
|
||||
a general search (run by the backend, with title:, buyer:, buyer_id: and cpv:
|
||||
prompts), status, mark and classification filters, and per-opportunity mark
|
||||
and hide controls.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
+5
-2
@@ -22,6 +22,7 @@ import { readStored, STORAGE_KEYS, writeStored } from '@/lib/storage';
|
||||
import { createLiveUpdates } from '@/primitives/createLiveUpdates';
|
||||
import { createClassifications, createOpportunities } from '@/primitives/createOpportunities';
|
||||
import { createOpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
|
||||
import { createOpportunitySearch } from '@/primitives/createOpportunitySearch';
|
||||
import { createTheme } from '@/primitives/createTheme';
|
||||
import { createToasts } from '@/primitives/createToasts';
|
||||
|
||||
@@ -44,7 +45,8 @@ export function App(): JSX.Element {
|
||||
const opportunities = createOpportunities(releasedSince);
|
||||
const classifications = createClassifications();
|
||||
const annotations = createOpportunityAnnotations(opportunities.items, toasts.show);
|
||||
createLiveUpdates({ opportunities, classifications, annotations });
|
||||
const search = createOpportunitySearch(() => filters().search, releasedSince, toasts.show);
|
||||
createLiveUpdates({ opportunities, classifications, annotations, search });
|
||||
const theme = createTheme();
|
||||
|
||||
const statuses = createMemo(() => statusOptions(opportunities.items()));
|
||||
@@ -62,7 +64,7 @@ export function App(): JSX.Element {
|
||||
// re-runs this.
|
||||
const rows = createMemo(() =>
|
||||
sortOpportunities(
|
||||
filterOpportunities(opportunities.items(), filters(), annotations.get),
|
||||
filterOpportunities(opportunities.items(), filters(), annotations.get, search.matches()),
|
||||
sort(),
|
||||
annotations.get,
|
||||
),
|
||||
@@ -107,6 +109,7 @@ export function App(): JSX.Element {
|
||||
filters={filters()}
|
||||
onChange={setFilters}
|
||||
onClear={clear}
|
||||
search={search}
|
||||
statuses={statuses()}
|
||||
classifications={pickable()}
|
||||
isLoadingClassifications={classifications.isLoading()}
|
||||
|
||||
@@ -131,6 +131,41 @@ describe('listChangedOpportunities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchOpportunities', () => {
|
||||
test('sends the box’s text as typed, encoded, with released_from', async () => {
|
||||
const { api } = await loadClient();
|
||||
const found = {
|
||||
uuids: ['ocds-1'],
|
||||
matches_all: false,
|
||||
unknown_prompts: [],
|
||||
prompts: [{ name: 'title', help: 'The title only.' }],
|
||||
syntax: [],
|
||||
};
|
||||
respond = () => json(found);
|
||||
|
||||
const result = await api.searchOpportunities(
|
||||
'title:"road repairs" !cpv:72&x',
|
||||
'2026-10-11T23:00:00.000Z',
|
||||
);
|
||||
|
||||
expect(sent[0]?.url).toBe(
|
||||
'/api/opportunities/search?q=title%3A%22road%20repairs%22%20!cpv%3A72%26x' +
|
||||
'&released_from=2026-10-11T23%3A00%3A00.000Z',
|
||||
);
|
||||
expect(result).toEqual(found);
|
||||
});
|
||||
|
||||
test('leaves out released_from when there is no bound', async () => {
|
||||
const { api } = await loadClient();
|
||||
respond = () =>
|
||||
json({ uuids: [], matches_all: true, unknown_prompts: [], prompts: [], syntax: [] });
|
||||
|
||||
await api.searchOpportunities('', '');
|
||||
|
||||
expect(sent[0]?.url).toBe('/api/opportunities/search?q=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOpportunity', () => {
|
||||
test('fetches the opportunity by its encoded UUID', async () => {
|
||||
const { api } = await loadClient();
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
Opportunity,
|
||||
OpportunityMarks,
|
||||
OpportunityPage,
|
||||
OpportunitySearch,
|
||||
Problem,
|
||||
} from '@/types/Opportunities';
|
||||
|
||||
@@ -14,6 +15,8 @@ import type {
|
||||
* GET {base}/opportunities?offset=&limit= -> OpportunityPage
|
||||
* GET {base}/opportunities?changed_after={batch} -> OpportunityPage (changed since)
|
||||
* both with &released_from={RFC 3339 time}: only those released since
|
||||
* GET {base}/opportunities/search?q={text} -> OpportunitySearch
|
||||
* also with &released_from
|
||||
* GET {base}/opportunities/{uuid} -> Opportunity (with its releases)
|
||||
* GET {base}/classifications -> ClassificationCode[]
|
||||
* PUT {base}/opportunities/{uuid}/marks/{mark} -> OpportunityMarks (sets it)
|
||||
@@ -149,6 +152,22 @@ export const api = {
|
||||
return { opportunities: page.opportunities, batch: page.batch };
|
||||
},
|
||||
|
||||
/**
|
||||
* Searches the opportunities released at or after releasedFrom ("" for
|
||||
* any) with query, the search box's text as typed: the backend parses it.
|
||||
* A blank query matches all of them, and still returns the search's help.
|
||||
*/
|
||||
searchOpportunities(
|
||||
query: string,
|
||||
releasedFrom: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OpportunitySearch> {
|
||||
return request<OpportunitySearch>(
|
||||
`/opportunities/search?q=${encodeURIComponent(query)}${releasedFromParam(releasedFrom)}`,
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
|
||||
/** One opportunity with all of its releases. An unknown UUID is an ApiError with status 404. */
|
||||
getOpportunity(uuid: string, signal?: AbortSignal): Promise<Opportunity> {
|
||||
return request<Opportunity>(opportunityPath(uuid), { signal });
|
||||
|
||||
@@ -9,16 +9,20 @@ import {
|
||||
statusLabel,
|
||||
} from '@/lib/filterOpportunities';
|
||||
import { readStored, STORAGE_KEYS, writeStored } from '@/lib/storage';
|
||||
import type { OpportunitySearchResults } from '@/primitives/createOpportunitySearch';
|
||||
import type { OpportunityMark } from '@/types/Annotations';
|
||||
import { MARK_LABELS, OPPORTUNITY_MARKS } from '@/types/Annotations';
|
||||
import type { ClassificationCode } from '@/types/Opportunities';
|
||||
import { ClassificationPicker } from './ClassificationPicker';
|
||||
import { SearchField } from './SearchField';
|
||||
import { buttonQuiet, fieldLabel, input, MARK_BG } from './styles';
|
||||
|
||||
interface Props {
|
||||
filters: OpportunityFilters;
|
||||
onChange: (next: OpportunityFilters) => void;
|
||||
onClear: () => void;
|
||||
/** The search that filters.search runs. */
|
||||
search: OpportunitySearchResults;
|
||||
statuses: string[];
|
||||
classifications: ClassificationCode[];
|
||||
isLoadingClassifications: boolean;
|
||||
@@ -43,11 +47,10 @@ function toggled<T>(set: ReadonlySet<T>, value: T): Set<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The collapsible panel of filters (title, status, marks, classification,
|
||||
* The collapsible panel of filters (search, status, marks, classification,
|
||||
* hidden), with the count of opportunities shown.
|
||||
*/
|
||||
export function FilterPanel(props: Props): JSX.Element {
|
||||
const titleId = createUniqueId();
|
||||
const daysId = createUniqueId();
|
||||
const bodyId = createUniqueId();
|
||||
|
||||
@@ -151,19 +154,11 @@ export function FilterPanel(props: Props): JSX.Element {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class={fieldLabel} for={titleId}>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id={titleId}
|
||||
type="search"
|
||||
class={`${input} px-3 py-2.5 text-base`}
|
||||
placeholder="Search opportunity titles"
|
||||
value={props.filters.title}
|
||||
onInput={(e) => update({ title: e.currentTarget.value })}
|
||||
/>
|
||||
</div>
|
||||
<SearchField
|
||||
value={props.filters.search}
|
||||
onInput={(search) => update({ search })}
|
||||
search={props.search}
|
||||
/>
|
||||
|
||||
<fieldset class="m-0 min-w-0 border-0 p-0">
|
||||
<legend class={fieldLabel}>Status</legend>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import CircleQuestionMark from 'lucide-solid/icons/circle-question-mark';
|
||||
import type { JSX } from 'solid-js';
|
||||
import { createSignal, createUniqueId, For, Match, Show, Switch } from 'solid-js';
|
||||
|
||||
import { promptText, unknownPromptsText } from '@/lib/search';
|
||||
import type { OpportunitySearchResults } from '@/primitives/createOpportunitySearch';
|
||||
import { input, linkButton } from './styles';
|
||||
|
||||
interface Props {
|
||||
/** The box's text. */
|
||||
value: string;
|
||||
onInput: (value: string) => void;
|
||||
search: OpportunitySearchResults;
|
||||
}
|
||||
|
||||
const helpButton =
|
||||
'flex rounded-md border border-line bg-surface p-1 text-ink hover:border-muted aria-expanded:border-ink';
|
||||
const helpTerm = 'font-mono text-[13px] whitespace-nowrap';
|
||||
|
||||
/**
|
||||
* The general search box, with its help (from the backend, behind the "?"
|
||||
* button), a note on prompts the search ignored, and the search's errors.
|
||||
*/
|
||||
export function SearchField(props: Props): JSX.Element {
|
||||
const inputId = createUniqueId();
|
||||
const helpId = createUniqueId();
|
||||
const noteId = createUniqueId();
|
||||
const [isHelpOpen, setIsHelpOpen] = createSignal(false);
|
||||
|
||||
const unknownPrompts = (): string => {
|
||||
const unknown = props.search.unknownPrompts();
|
||||
return unknown.length === 0
|
||||
? ''
|
||||
: unknownPromptsText(unknown, props.search.help()?.prompts ?? []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-center gap-1.5">
|
||||
<label class="text-sm font-semibold" for={inputId}>
|
||||
Search
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class={helpButton}
|
||||
aria-label="Search help"
|
||||
title="Search help"
|
||||
aria-expanded={isHelpOpen()}
|
||||
aria-controls={helpId}
|
||||
onClick={() => setIsHelpOpen(!isHelpOpen())}
|
||||
>
|
||||
<CircleQuestionMark size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
id={inputId}
|
||||
type="search"
|
||||
class={`${input} px-3 py-2.5 text-base`}
|
||||
placeholder="Words, “phrases” and prompts such as title:"
|
||||
value={props.value}
|
||||
onInput={(e) => props.onInput(e.currentTarget.value)}
|
||||
aria-describedby={noteId}
|
||||
aria-busy={props.search.isSearching() || undefined}
|
||||
/>
|
||||
|
||||
<Show when={props.search.error()}>
|
||||
{(err) => (
|
||||
<p class="mt-1.5 text-sm text-danger" role="alert">
|
||||
The search didn't run: {err().message}{' '}
|
||||
<button type="button" class={linkButton} onClick={() => props.search.refresh()}>
|
||||
Try again
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
<p id={noteId} class="mt-1.5 text-sm text-muted empty:hidden">
|
||||
{unknownPrompts()}
|
||||
</p>
|
||||
|
||||
<div
|
||||
id={helpId}
|
||||
class="mt-2 rounded-md border border-line-soft bg-paper p-3 text-sm"
|
||||
hidden={!isHelpOpen()}
|
||||
>
|
||||
<Switch fallback={<p class="text-muted">Loading the search help…</p>}>
|
||||
<Match when={props.search.help()}>
|
||||
{(help) => (
|
||||
<>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
|
||||
<For each={help().syntax}>
|
||||
{(syntax) => (
|
||||
<>
|
||||
<dt class={helpTerm}>{syntax.example}</dt>
|
||||
<dd>{syntax.help}</dd>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</dl>
|
||||
<h3 class="mt-3 mb-1.5 font-semibold">Prompts</h3>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
|
||||
<For each={help().prompts}>
|
||||
{(prompt) => (
|
||||
<>
|
||||
<dt class={helpTerm}>{promptText(prompt.name)}</dt>
|
||||
<dd>{prompt.help}</dd>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.search.helpError()}>
|
||||
{(err) => (
|
||||
<p class="text-danger" role="alert">
|
||||
The search help didn't load: {err().message}{' '}
|
||||
<button type="button" class={linkButton} onClick={() => props.search.reloadHelp()}>
|
||||
Try again
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,10 +43,16 @@ const annotations: Record<string, Annotation> = {
|
||||
const annotationFor = (uuid: string): Annotation =>
|
||||
annotations[uuid] ?? { uuid, marks: [], hidden: false };
|
||||
|
||||
const filtered = (filters: Partial<OpportunityFilters>): string[] =>
|
||||
filterOpportunities(opportunities, { ...emptyFilters(), ...filters }, annotationFor).map(
|
||||
(opportunity) => opportunity.uuid,
|
||||
);
|
||||
const filtered = (
|
||||
filters: Partial<OpportunityFilters>,
|
||||
searchMatches?: ReadonlySet<string>,
|
||||
): string[] =>
|
||||
filterOpportunities(
|
||||
opportunities,
|
||||
{ ...emptyFilters(), ...filters },
|
||||
annotationFor,
|
||||
searchMatches,
|
||||
).map((opportunity) => opportunity.uuid);
|
||||
|
||||
describe('filterOpportunities', () => {
|
||||
test('hides hidden opportunities unless asked, keeping the order given', () => {
|
||||
@@ -55,7 +61,6 @@ describe('filterOpportunities', () => {
|
||||
});
|
||||
|
||||
test.each<{ name: string; filters: Partial<OpportunityFilters>; want: string[] }>([
|
||||
{ name: 'title, ignoring case and spaces', filters: { title: ' CLOUD ' }, want: ['a', 'c'] },
|
||||
{ name: 'statuses', filters: { statuses: new Set(['active', 'planned']) }, want: ['a', 'c'] },
|
||||
{ name: 'a mark', filters: { marks: new Set(['interested']) }, want: ['a'] },
|
||||
{ name: 'no marks', filters: { marks: new Set([NOT_MARKED]) }, want: ['c'] },
|
||||
@@ -65,18 +70,24 @@ describe('filterOpportunities', () => {
|
||||
expect(filtered(filters)).toEqual(want);
|
||||
});
|
||||
|
||||
test('keeps only the search’s matches, when it has some', () => {
|
||||
expect(filtered({}, new Set(['c', 'x']))).toEqual(['c']);
|
||||
expect(filtered({}, new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
test('combines filters', () => {
|
||||
expect(filtered({ title: 'cloud', smeSuitable: true })).toEqual(['a']);
|
||||
expect(filtered({ title: 'road', showHidden: true, smeSuitable: true })).toEqual([]);
|
||||
expect(filtered({ smeSuitable: true }, new Set(['a', 'c']))).toEqual(['a']);
|
||||
expect(filtered({ showHidden: true, smeSuitable: true }, new Set(['b']))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test('activeFilterCount counts the filters in use', () => {
|
||||
expect(activeFilterCount(emptyFilters())).toBe(0);
|
||||
expect(
|
||||
activeFilterCount({ ...emptyFilters(), title: ' ', smeSuitable: true, showHidden: true }),
|
||||
activeFilterCount({ ...emptyFilters(), search: ' ', smeSuitable: true, showHidden: true }),
|
||||
).toBe(2);
|
||||
expect(activeFilterCount({ ...emptyFilters(), releasedWithinDays: 30 })).toBe(1);
|
||||
expect(activeFilterCount({ ...emptyFilters(), search: 'title:cloud' })).toBe(1);
|
||||
});
|
||||
|
||||
test('releasedFrom is the start of the day that many days ago, in UK time', () => {
|
||||
@@ -104,7 +115,7 @@ describe('stored filters', () => {
|
||||
test('read back as they were stored', () => {
|
||||
const filters: OpportunityFilters = {
|
||||
releasedWithinDays: 30,
|
||||
title: 'cloud',
|
||||
search: 'cloud !cpv:72',
|
||||
statuses: new Set(['active', 'withdrawn']),
|
||||
marks: new Set(['interested', NOT_MARKED]),
|
||||
classificationIds: new Set([1, 42]),
|
||||
@@ -122,11 +133,16 @@ describe('stored filters', () => {
|
||||
expect(parseFilters(text)).toEqual(emptyFilters());
|
||||
});
|
||||
|
||||
test('an older version’s title filter is kept as search words', () => {
|
||||
expect(parseFilters(JSON.stringify({ title: 'road repairs' })).search).toBe('road repairs');
|
||||
expect(parseFilters(JSON.stringify({ title: 'road', search: 'cloud' })).search).toBe('cloud');
|
||||
});
|
||||
|
||||
test('fields that aren’t valid keep their starting values', () => {
|
||||
const filters = parseFilters(
|
||||
JSON.stringify({
|
||||
releasedWithinDays: 0,
|
||||
title: 5,
|
||||
search: 5,
|
||||
statuses: 'active',
|
||||
marks: ['won', 'not-a-mark'],
|
||||
classificationIds: [3, '4', 1.5],
|
||||
|
||||
@@ -18,7 +18,11 @@ export interface OpportunityFilters {
|
||||
* here.
|
||||
*/
|
||||
releasedWithinDays: number;
|
||||
title: string;
|
||||
/**
|
||||
* The general search box's text, as typed. The backend runs the search
|
||||
* (see createOpportunitySearch), and its matches are applied here.
|
||||
*/
|
||||
search: string;
|
||||
/** Empty means every status. */
|
||||
statuses: ReadonlySet<string>;
|
||||
/** Empty means any marks. Otherwise an opportunity needs at least one of these. */
|
||||
@@ -37,7 +41,7 @@ export interface OpportunityFilters {
|
||||
*/
|
||||
export const emptyFilters = (): OpportunityFilters => ({
|
||||
releasedWithinDays: DEFAULT_RELEASED_WITHIN_DAYS,
|
||||
title: '',
|
||||
search: '',
|
||||
statuses: new Set(),
|
||||
marks: new Set(),
|
||||
classificationIds: new Set(),
|
||||
@@ -91,7 +95,12 @@ export function parseFilters(text: string | null): OpportunityFilters {
|
||||
releasedWithinDays: isDays(stored.releasedWithinDays)
|
||||
? stored.releasedWithinDays
|
||||
: filters.releasedWithinDays,
|
||||
title: isString(stored.title) ? stored.title : filters.title,
|
||||
// Older versions stored a title filter; its text is kept as search words.
|
||||
search: isString(stored.search)
|
||||
? stored.search
|
||||
: isString(stored.title)
|
||||
? stored.title
|
||||
: filters.search,
|
||||
statuses: statuses ? new Set(statuses) : filters.statuses,
|
||||
marks: marks ? new Set(marks) : filters.marks,
|
||||
classificationIds: classificationIds ? new Set(classificationIds) : filters.classificationIds,
|
||||
@@ -107,7 +116,7 @@ export function parseFilters(text: string | null): OpportunityFilters {
|
||||
export const activeFilterCount = (filters: OpportunityFilters): number =>
|
||||
[
|
||||
filters.releasedWithinDays !== DEFAULT_RELEASED_WITHIN_DAYS,
|
||||
filters.title.trim() !== '',
|
||||
filters.search.trim() !== '',
|
||||
filters.statuses.size > 0,
|
||||
filters.marks.size > 0,
|
||||
filters.classificationIds.size > 0,
|
||||
@@ -128,18 +137,21 @@ export function releasedFrom(days: number, now: Date = new Date()): string {
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - days).toISOString();
|
||||
}
|
||||
|
||||
/** The opportunities that pass filters, in the order given. */
|
||||
/**
|
||||
* The opportunities that pass filters, in the order given. searchMatches is
|
||||
* the UUIDs the search found, or undefined when it doesn't filter (no
|
||||
* search, or one with nothing to search for).
|
||||
*/
|
||||
export function filterOpportunities(
|
||||
opportunities: readonly Opportunity[],
|
||||
filters: OpportunityFilters,
|
||||
annotationFor: (uuid: string) => Annotation,
|
||||
searchMatches: ReadonlySet<string> | undefined,
|
||||
): Opportunity[] {
|
||||
const query = filters.title.trim().toLocaleLowerCase();
|
||||
|
||||
return opportunities.filter((opportunity) => {
|
||||
const annotation = annotationFor(opportunity.uuid);
|
||||
if (!filters.showHidden && annotation.hidden) return false;
|
||||
if (query && !opportunity.title.toLocaleLowerCase().includes(query)) return false;
|
||||
if (searchMatches && !searchMatches.has(opportunity.uuid)) return false;
|
||||
if (filters.statuses.size > 0 && !filters.statuses.has(opportunity.status)) return false;
|
||||
if (filters.smeSuitable && !opportunity.sme_suitable) return false;
|
||||
if (filters.marks.size > 0) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { promptText, unknownPromptsText } from './search';
|
||||
|
||||
const prompts = [
|
||||
{ name: 'title', help: '' },
|
||||
{ name: 'buyer', help: '' },
|
||||
{ name: 'cpv', help: '' },
|
||||
];
|
||||
|
||||
test('promptText is the prompt as typed', () => {
|
||||
expect(promptText('buyer_id')).toBe('buyer_id:');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: 'one unknown prompt',
|
||||
unknown: ['foo'],
|
||||
want: '“foo:” isn’t a prompt, so it was ignored. The prompts are title:, buyer: and cpv:.',
|
||||
},
|
||||
{
|
||||
name: 'several, as typed',
|
||||
unknown: ['foo', 'Bar'],
|
||||
want: '“foo:” and “Bar:” aren’t prompts, so they were ignored. The prompts are title:, buyer: and cpv:.',
|
||||
},
|
||||
])('unknownPromptsText names $name, then the prompts', ({ unknown, want }) => {
|
||||
expect(unknownPromptsText(unknown, prompts)).toBe(want);
|
||||
});
|
||||
|
||||
test('unknownPromptsText leaves out the prompts before the help has loaded', () => {
|
||||
expect(unknownPromptsText(['foo'], [])).toBe('“foo:” isn’t a prompt, so it was ignored.');
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { SearchPrompt } from '@/types/Opportunities';
|
||||
|
||||
const LIST_FORMAT = new Intl.ListFormat('en-GB', { type: 'conjunction' });
|
||||
|
||||
/** A prompt's name as it's typed in the search box: "title:". */
|
||||
export const promptText = (name: string): string => `${name}:`;
|
||||
|
||||
/**
|
||||
* What the search box says about prompts it doesn't know (their names as
|
||||
* typed, without the colon), which the search ignored: they're named, then
|
||||
* the prompts there are, if they're known yet.
|
||||
*/
|
||||
export function unknownPromptsText(
|
||||
unknown: readonly string[],
|
||||
prompts: readonly SearchPrompt[],
|
||||
): string {
|
||||
const names = LIST_FORMAT.format(unknown.map((name) => `“${promptText(name)}”`));
|
||||
const ignored =
|
||||
unknown.length === 1
|
||||
? `${names} isn’t a prompt, so it was ignored.`
|
||||
: `${names} aren’t prompts, so they were ignored.`;
|
||||
if (prompts.length === 0) return ignored;
|
||||
return `${ignored} The prompts are ${LIST_FORMAT.format(prompts.map((p) => promptText(p.name)))}.`;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { onCleanup } from 'solid-js';
|
||||
import { connectEvents } from '@/api/events';
|
||||
import { applyClassificationChanges } from '@/lib/classifications';
|
||||
import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
|
||||
import type { OpportunitySearchResults } from '@/primitives/createOpportunitySearch';
|
||||
import type { ServerEvent } from '@/types/Events';
|
||||
import type { ClassificationCode } from '@/types/Opportunities';
|
||||
import type { ApiList, OpportunityList } from './createOpportunities';
|
||||
@@ -12,6 +13,7 @@ export interface LiveData {
|
||||
opportunities: OpportunityList;
|
||||
classifications: ApiList<ClassificationCode>;
|
||||
annotations: OpportunityAnnotations;
|
||||
search: OpportunitySearchResults;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,10 +23,12 @@ export interface LiveData {
|
||||
* - ClassificationsUpdated changes the classifications in place.
|
||||
* - OpportunityMarksUpdated sets the opportunity's marks.
|
||||
* - OpportunitiesUpdated catches the opportunities up: the changed ones are
|
||||
* fetched and merged in, as the event only names them.
|
||||
* fetched and merged in, as the event only names them. The search runs
|
||||
* again, as they may match it now, or no longer.
|
||||
*
|
||||
* After the connection drops and is made again, events may have been missed:
|
||||
* the opportunities catch up, and the classifications are reloaded.
|
||||
* the opportunities catch up, the search runs again, and the classifications
|
||||
* are reloaded.
|
||||
*/
|
||||
export function createLiveUpdates(data: LiveData): void {
|
||||
const handle = (event: ServerEvent): void => {
|
||||
@@ -39,6 +43,7 @@ export function createLiveUpdates(data: LiveData): void {
|
||||
break;
|
||||
case 'OpportunitiesUpdated':
|
||||
data.opportunities.catchUp();
|
||||
data.search.refresh();
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -47,6 +52,7 @@ export function createLiveUpdates(data: LiveData): void {
|
||||
onEvent: handle,
|
||||
onReconnect: () => {
|
||||
data.opportunities.catchUp();
|
||||
data.search.refresh();
|
||||
data.classifications.reload();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { createRoot, createSignal } from 'solid-js';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type * as clientModule from '@/api/client';
|
||||
import type { OpportunitySearch } from '@/types/Opportunities';
|
||||
import type { OpportunitySearchResults } from './createOpportunitySearch';
|
||||
import { createOpportunitySearch } from './createOpportunitySearch';
|
||||
|
||||
const requests = vi.hoisted(() => ({
|
||||
searchOpportunities:
|
||||
vi.fn<
|
||||
(query: string, releasedFrom: string, signal?: AbortSignal) => Promise<OpportunitySearch>
|
||||
>(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/client', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof clientModule>()),
|
||||
api: requests,
|
||||
}));
|
||||
|
||||
/** A search the test answers when it chooses. */
|
||||
interface SentSearch {
|
||||
query: string;
|
||||
releasedFrom: string;
|
||||
signal: AbortSignal | undefined;
|
||||
resolve: (found: OpportunitySearch) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}
|
||||
|
||||
const HELP = {
|
||||
prompts: [{ name: 'title', help: 'The title only.' }],
|
||||
syntax: [{ example: '!cloud', help: 'Not cloud.' }],
|
||||
};
|
||||
|
||||
const found = (...uuids: string[]): OpportunitySearch => ({
|
||||
uuids,
|
||||
matches_all: false,
|
||||
unknown_prompts: [],
|
||||
...HELP,
|
||||
});
|
||||
|
||||
let sent: SentSearch[];
|
||||
let errors: string[];
|
||||
let search: OpportunitySearchResults;
|
||||
let dispose: () => void;
|
||||
let setFilters: (filters: { search: string; days: number }) => void;
|
||||
let setReleasedFrom: (from: string) => void;
|
||||
|
||||
/** The searches sent with a query: the help's has none. */
|
||||
const searches = (): SentSearch[] => sent.filter((s) => s.query !== '');
|
||||
|
||||
/** Lets pending promises, Solid's effects and timers up to ms run. */
|
||||
const wait = async (ms = 0): Promise<void> => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
};
|
||||
|
||||
/** Starts the search with a stored query, and answers the help's request. */
|
||||
async function start(query: string): Promise<void> {
|
||||
createRoot((disposeRoot) => {
|
||||
dispose = disposeRoot;
|
||||
const [filters, setStored] = createSignal({ search: query, days: 7 });
|
||||
const [releasedFrom, setFrom] = createSignal('2026-10-12T00:00:00Z');
|
||||
setFilters = setStored;
|
||||
setReleasedFrom = setFrom;
|
||||
search = createOpportunitySearch(
|
||||
() => filters().search,
|
||||
releasedFrom,
|
||||
(message) => errors.push(message),
|
||||
);
|
||||
});
|
||||
sent.find((s) => s.query === '')?.resolve({ ...found(), matches_all: true });
|
||||
await wait();
|
||||
}
|
||||
|
||||
const type = (text: string): void => setFilters({ search: text, days: 7 });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
sent = [];
|
||||
errors = [];
|
||||
requests.searchOpportunities.mockImplementation(
|
||||
(query, releasedFrom, signal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
sent.push({ query, releasedFrom, signal, resolve, reject });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dispose();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('loads the help at the start, with a blank query and no bound', async () => {
|
||||
await start('');
|
||||
|
||||
expect(sent.map((s) => [s.query, s.releasedFrom])).toEqual([['', '']]);
|
||||
expect(search.help()).toEqual(HELP);
|
||||
expect(search.matches()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('runs a stored search at once, and keeps its matches', async () => {
|
||||
await start('cloud');
|
||||
|
||||
expect(searches().map((s) => [s.query, s.releasedFrom])).toEqual([
|
||||
['cloud', '2026-10-12T00:00:00Z'],
|
||||
]);
|
||||
expect(search.isSearching()).toBe(true);
|
||||
searches()[0]?.resolve(found('a', 'b'));
|
||||
await wait();
|
||||
|
||||
expect([...(search.matches() ?? [])]).toEqual(['a', 'b']);
|
||||
expect(search.isSearching()).toBe(false);
|
||||
});
|
||||
|
||||
test('waits for typing to pause, then searches for the latest text', async () => {
|
||||
await start('');
|
||||
|
||||
type('c');
|
||||
await wait(200);
|
||||
type('cloud');
|
||||
await wait(299);
|
||||
expect(searches()).toHaveLength(0);
|
||||
expect(search.isSearching()).toBe(true);
|
||||
|
||||
await wait(1);
|
||||
expect(searches().map((s) => s.query)).toEqual(['cloud']);
|
||||
});
|
||||
|
||||
test('aborts a search under way for the next, and ignores its late answer', async () => {
|
||||
await start('cloud');
|
||||
type('road');
|
||||
await wait(300);
|
||||
|
||||
expect(searches()[0]?.signal?.aborted).toBe(true);
|
||||
searches()[0]?.resolve(found('old'));
|
||||
searches()[1]?.resolve(found('new'));
|
||||
await wait();
|
||||
|
||||
expect([...(search.matches() ?? [])]).toEqual(['new']);
|
||||
});
|
||||
|
||||
test('doesn’t filter when nothing in the box can be searched for', async () => {
|
||||
await start('title:');
|
||||
searches()[0]?.resolve({ ...found(), matches_all: true, unknown_prompts: ['foo'] });
|
||||
await wait();
|
||||
|
||||
expect(search.matches()).toBeUndefined();
|
||||
expect(search.unknownPrompts()).toEqual(['foo']);
|
||||
});
|
||||
|
||||
test('a blank box clears the matches at once, without a request', async () => {
|
||||
await start('cloud');
|
||||
searches()[0]?.resolve(found('a'));
|
||||
await wait();
|
||||
|
||||
type(' ');
|
||||
await wait(300);
|
||||
|
||||
expect(searches()).toHaveLength(1);
|
||||
expect(search.matches()).toBeUndefined();
|
||||
expect(search.isSearching()).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps the last matches after a failure, and refresh tries again', async () => {
|
||||
await start('cloud');
|
||||
searches()[0]?.resolve(found('a'));
|
||||
await wait();
|
||||
type('road');
|
||||
await wait(300);
|
||||
searches()[1]?.reject(new Error('offline'));
|
||||
await wait();
|
||||
|
||||
expect([...(search.matches() ?? [])]).toEqual(['a']);
|
||||
expect(search.error()?.message).toBe('offline');
|
||||
expect(errors).toEqual(["The search didn't run: offline"]);
|
||||
|
||||
search.refresh();
|
||||
searches()[2]?.resolve(found('b'));
|
||||
await wait();
|
||||
expect(searches()[2]?.query).toBe('road');
|
||||
expect([...(search.matches() ?? [])]).toEqual(['b']);
|
||||
expect(search.error()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('runs again at once for a new releasedFrom, but not for other filters', async () => {
|
||||
await start('cloud');
|
||||
|
||||
setFilters({ search: 'cloud', days: 30 });
|
||||
await wait(300);
|
||||
expect(searches()).toHaveLength(1);
|
||||
|
||||
setReleasedFrom('2026-10-01T00:00:00Z');
|
||||
await wait();
|
||||
expect(searches().map((s) => s.releasedFrom)).toEqual([
|
||||
'2026-10-12T00:00:00Z',
|
||||
'2026-10-01T00:00:00Z',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a search mends help that didn’t load', async () => {
|
||||
createRoot((disposeRoot) => {
|
||||
dispose = disposeRoot;
|
||||
const [query, setQuery] = createSignal('');
|
||||
setFilters = (filters) => setQuery(filters.search);
|
||||
search = createOpportunitySearch(
|
||||
query,
|
||||
() => '',
|
||||
(message) => errors.push(message),
|
||||
);
|
||||
});
|
||||
sent[0]?.reject(new Error('offline'));
|
||||
await wait();
|
||||
expect(search.helpError()?.message).toBe('offline');
|
||||
expect(search.help()).toBeUndefined();
|
||||
|
||||
type('cloud');
|
||||
await wait(300);
|
||||
searches()[0]?.resolve(found());
|
||||
await wait();
|
||||
|
||||
expect(search.help()).toEqual(HELP);
|
||||
expect(search.helpError()).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js';
|
||||
|
||||
import { api } from '@/api/client';
|
||||
import { errorText } from '@/lib/errors';
|
||||
import type { OpportunitySearch, SearchExample, SearchPrompt } from '@/types/Opportunities';
|
||||
|
||||
/** How long typing has to pause for before the search runs, in milliseconds. */
|
||||
const SEARCH_DELAY = 300;
|
||||
|
||||
/** The search box's help, from the backend, which parses the search. */
|
||||
export interface SearchHelp {
|
||||
prompts: SearchPrompt[];
|
||||
/** Examples of the syntax, in the order to show them. */
|
||||
syntax: SearchExample[];
|
||||
}
|
||||
|
||||
/** The general search, run by the backend, and its help. */
|
||||
export interface OpportunitySearchResults {
|
||||
/**
|
||||
* The UUIDs the last search found, or undefined when the search doesn't
|
||||
* filter: the box is blank, nothing in it can be searched for, or no
|
||||
* search has finished yet. After a failure, the last search's.
|
||||
*/
|
||||
matches: () => ReadonlySet<string> | undefined;
|
||||
/** The last search's prompts the backend doesn't know, as typed, without the colon. */
|
||||
unknownPrompts: () => string[];
|
||||
/** Whether a search is waiting for typing to pause, or running. */
|
||||
isSearching: () => boolean;
|
||||
/** Why the last search failed, until one succeeds or the box is cleared. */
|
||||
error: () => Error | undefined;
|
||||
/** Runs the search again now, unless the box is blank: to retry, or as opportunities changed. */
|
||||
refresh: () => void;
|
||||
/** The help, or undefined until it has loaded. */
|
||||
help: () => SearchHelp | undefined;
|
||||
/** Why the help didn't load, until it does. */
|
||||
helpError: () => Error | undefined;
|
||||
reloadHelp: () => void;
|
||||
}
|
||||
|
||||
const toError = (err: unknown): Error => (err instanceof Error ? err : new Error(errorText(err)));
|
||||
|
||||
/**
|
||||
* Searches the opportunities released at or after releasedFrom with query,
|
||||
* the search box's text, as either changes: at once for a new releasedFrom
|
||||
* or the first query, and after a pause in typing for the others. A search
|
||||
* under way is aborted by the next. A failure is shown with onError, and
|
||||
* the last search's matches are kept. The help is loaded once, at the
|
||||
* start.
|
||||
*/
|
||||
export function createOpportunitySearch(
|
||||
query: () => string,
|
||||
releasedFrom: () => string,
|
||||
onError: (message: string) => void,
|
||||
): OpportunitySearchResults {
|
||||
const [result, setResult] = createSignal<OpportunitySearch>();
|
||||
const [isSearching, setIsSearching] = createSignal(false);
|
||||
const [error, setError] = createSignal<Error>();
|
||||
const [help, setHelp] = createSignal<SearchHelp>();
|
||||
const [helpError, setHelpError] = createSignal<Error>();
|
||||
let controller: AbortController | undefined;
|
||||
let helpController: AbortController | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
onCleanup(() => {
|
||||
clearTimeout(timer);
|
||||
controller?.abort();
|
||||
helpController?.abort();
|
||||
});
|
||||
|
||||
const keepHelp = (found: OpportunitySearch): void => {
|
||||
setHelp({ prompts: found.prompts, syntax: found.syntax });
|
||||
setHelpError(undefined);
|
||||
};
|
||||
|
||||
async function search(): Promise<void> {
|
||||
clearTimeout(timer);
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const found = await api.searchOpportunities(query(), releasedFrom(), signal);
|
||||
if (signal.aborted) return;
|
||||
setResult(found);
|
||||
setError(undefined);
|
||||
// Every result carries the help, so a search mends a help that didn't load.
|
||||
keepHelp(found);
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
const failure = toError(err);
|
||||
setError(failure);
|
||||
onError(`The search didn't run: ${failure.message}`);
|
||||
} finally {
|
||||
if (!signal.aborted) setIsSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
const clear = (): void => {
|
||||
clearTimeout(timer);
|
||||
controller?.abort();
|
||||
setResult(undefined);
|
||||
setError(undefined);
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
createEffect(
|
||||
on([query, releasedFrom], ([text, from], previous) => {
|
||||
// query may be read from a larger value, such as all the filters, which
|
||||
// changes when neither does.
|
||||
if (previous && previous[0] === text && previous[1] === from) return;
|
||||
if (text.trim() === '') {
|
||||
clear();
|
||||
} else if (previous && previous[0] !== text) {
|
||||
clearTimeout(timer);
|
||||
setIsSearching(true);
|
||||
timer = setTimeout(() => void search(), SEARCH_DELAY);
|
||||
} else {
|
||||
void search();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
async function loadHelp(): Promise<void> {
|
||||
helpController?.abort();
|
||||
helpController = new AbortController();
|
||||
const { signal } = helpController;
|
||||
try {
|
||||
const found = await api.searchOpportunities('', '', signal);
|
||||
if (!signal.aborted) keepHelp(found);
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
const failure = toError(err);
|
||||
setHelpError(failure);
|
||||
onError(`The search help didn't load: ${failure.message}`);
|
||||
}
|
||||
}
|
||||
void loadHelp();
|
||||
|
||||
const matches = createMemo(() => {
|
||||
const found = result();
|
||||
return found && !found.matches_all ? new Set(found.uuids) : undefined;
|
||||
});
|
||||
|
||||
return {
|
||||
matches,
|
||||
unknownPrompts: () => result()?.unknown_prompts ?? [],
|
||||
isSearching,
|
||||
error,
|
||||
refresh: () => {
|
||||
if (query().trim() !== '') void search();
|
||||
},
|
||||
help,
|
||||
helpError,
|
||||
reloadHelp: () => void loadHelp(),
|
||||
};
|
||||
}
|
||||
@@ -41,5 +41,12 @@ export type OpportunityReleaseTag = Schemas['types.OpportunityReleaseTag'];
|
||||
/** A page of GET /api/opportunities. */
|
||||
export type OpportunityPage = Schemas['api.OpportunityPage'];
|
||||
|
||||
/** GET /api/opportunities/search's result: the matches, and the search's help. */
|
||||
export type OpportunitySearch = Schemas['api.OpportunitySearch'];
|
||||
/** A search prompt (title:, cpv:, ...) and what it searches. */
|
||||
export type SearchPrompt = Schemas['search.Prompt'];
|
||||
/** An example of the search's syntax, and what it means. */
|
||||
export type SearchExample = Schemas['search.Example'];
|
||||
|
||||
/** An RFC 9457 problem details body, as the API returns for errors. */
|
||||
export type Problem = Schemas['httpx.Problem'];
|
||||
@@ -140,6 +140,77 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/opportunities/search": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Search opportunities
|
||||
* @description The UUIDs of the opportunities matching a general search,
|
||||
* in no particular order. q is words and "quoted phrases",
|
||||
* each optionally with a prompt (title:, buyer:, buyer_id:,
|
||||
* cpv:) and a leading ! to leave out what it matches; prompts
|
||||
* and syntax explain it. A search with nothing to search for
|
||||
* (blank, or only unknown prompts) has matches_all set and no
|
||||
* uuids. unknown_prompts lists the prompts that aren't known,
|
||||
* as typed; their terms are ignored. released_from (RFC 3339,
|
||||
* inclusive) leaves out opportunities whose latest release is
|
||||
* before it.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description The search */
|
||||
q?: string;
|
||||
/** @description Only opportunities whose latest release is at or after this time */
|
||||
released_from?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["api.OpportunitySearch"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid released_from */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["httpx.Problem"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["httpx.Problem"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/opportunities/{uuid}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -351,6 +422,13 @@ export interface components {
|
||||
offset: number;
|
||||
opportunities: components["schemas"]["types.Opportunity"][];
|
||||
};
|
||||
"api.OpportunitySearch": {
|
||||
matches_all: boolean;
|
||||
prompts: components["schemas"]["search.Prompt"][];
|
||||
syntax: components["schemas"]["search.Example"][];
|
||||
unknown_prompts: string[];
|
||||
uuids: string[];
|
||||
};
|
||||
"httpx.Problem": {
|
||||
detail?: string;
|
||||
instance?: string;
|
||||
@@ -359,6 +437,14 @@ export interface components {
|
||||
title: string;
|
||||
type?: string;
|
||||
};
|
||||
"search.Example": {
|
||||
example: string;
|
||||
help: string;
|
||||
};
|
||||
"search.Prompt": {
|
||||
help: string;
|
||||
name: string;
|
||||
};
|
||||
"types.ClassificationCode": {
|
||||
classification: string;
|
||||
code: string;
|
||||
|
||||
Reference in new issue
Block a user