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.
128 lines
4.2 KiB
TypeScript
128 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|