Added a dialog with each opportunity's details and releases

Every row has a details icon under the Find a Tender link. It opens a
dialog with the opportunity's details from the list, and fetches the
opportunity with its releases, shown as tabs, newest first. Each tab
summarises the release's tender, awards and contracts, links to its
Find a Tender notice where it is one, and has the release's JSON,
pretty-printed and highlighted, in a section laid out only when it's
opened.

The JSON is highlighted by splitting it into tokens shown as text, not
HTML, as releases come from outside sources. The README's saved
preferences now include the filters.
This commit is contained in:
bwilliams committed 2026-10-19 10:37:27 +01:00
1 parent 6fbf7e56b2
commit f8648d765a
12 files changed
+847 -10

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import type { JSX } from 'solid-js';
import { createMemo, For } from 'solid-js';
import type { JsonTokenKind } from '@/lib/jsonTokens';
import { tokeniseJson } from '@/lib/jsonTokens';
interface Props {
/** JSON text, shown as it is: pretty-print it first. */
json: string;
}
const TOKEN_CLASS: Record<JsonTokenKind, string> = {
key: 'text-code-key',
string: 'text-code-string',
number: 'text-code-number',
literal: 'text-code-literal',
plain: '',
};
/**
* JSON text, syntax highlighted. Each token is a text span, never HTML, as
* releases come from outside sources.
*/
export function JsonView(props: Props): JSX.Element {
const tokens = createMemo(() => tokeniseJson(props.json));
return (
<pre class="max-h-[50vh] overflow-auto rounded-md border border-line-soft bg-paper p-3 font-mono text-[13px] leading-relaxed">
<code>
<For each={tokens()}>
{(token) => <span class={TOKEN_CLASS[token.kind]}>{token.text}</span>}
</For>
</code>
</pre>
);
}
+194
View File
@@ -0,0 +1,194 @@
import X from 'lucide-solid/icons/x';
import type { JSX } from 'solid-js';
import {
createEffect,
createResource,
createUniqueId,
For,
Match,
onCleanup,
Show,
Switch,
} from 'solid-js';
import { api } from '@/api/client';
import { errorText } from '@/lib/errors';
import { statusLabel } from '@/lib/filterOpportunities';
import { formatDate, formatMoney } from '@/lib/format';
import { isFromFindATender } from '@/lib/opportunities';
import type { Opportunity } from '@/types/Opportunities';
import { ReleaseTabs } from './ReleaseTabs';
import { buttonPrimary, statusBadge } from './styles';
interface Props {
/** The opportunity to show, from the list; undefined when the dialog is closed. */
opportunity: Opportunity | undefined;
onClose: () => void;
/** Called with a message when the opportunity's releases didn't load. */
onError: (message: string) => void;
}
const detailList = 'grid grid-cols-[max-content_1fr] gap-x-6 gap-y-1.5 text-sm';
const sectionHeading = 'mb-2 font-semibold';
const yesNo = (value: boolean): string => (value ? 'Yes' : 'No');
/**
* A modal dialog with an opportunity's details, and its releases as tabs.
* The details come from the list, so they show at once; the releases aren't
* in the list, so the opportunity is fetched with them when the dialog
* opens. The native <dialog> keeps focus inside, closes on Escape and
* returns focus to the button that opened it.
*/
export function OpportunityDialog(props: Props): JSX.Element {
let dialog!: HTMLDialogElement;
const titleId = createUniqueId();
let controller: AbortController | undefined;
onCleanup(() => controller?.abort());
const [loaded, { refetch }] = createResource(
() => props.opportunity?.uuid,
(uuid) => {
controller?.abort();
controller = new AbortController();
return api.getOpportunity(uuid, controller.signal);
},
);
// Reading an errored resource throws, so check its state first.
const releases = () => (loaded.state === 'ready' ? (loaded().releases ?? []) : []);
const loadError = () => (loaded.state === 'errored' ? errorText(loaded.error) : undefined);
createEffect(() => {
if (props.opportunity && !dialog.open) dialog.showModal();
else if (!props.opportunity && dialog.open) dialog.close();
});
createEffect(() => {
const err = loadError();
if (err !== undefined) props.onError(`The opportunity's releases didn't load: ${err}`);
});
return (
<dialog
ref={dialog}
aria-labelledby={titleId}
class="m-auto max-h-[90vh] w-[min(64rem,calc(100vw-2rem))] rounded-[10px] border border-line bg-surface p-0 text-ink backdrop:bg-black/50"
onClose={() => props.onClose()}
// A click on the backdrop lands on the dialog itself, outside its content.
onClick={(e) => {
if (e.target === dialog) dialog.close();
}}
>
<Show when={props.opportunity}>
{(opportunity) => (
<div class="flex flex-col gap-6 p-5 sm:p-6">
<header class="flex items-start justify-between gap-4">
<div>
<h2 id={titleId} class="text-xl font-bold">
{opportunity().title || 'Untitled opportunity'}
</h2>
<p class="mt-1 text-muted">{opportunity().buyer_name}</p>
</div>
<button
type="button"
class="rounded-md p-1.5 text-muted hover:text-ink"
aria-label="Close"
onClick={() => dialog.close()}
>
<X size={20} strokeWidth={2} />
</button>
</header>
<section class="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<dl class={detailList}>
<dt class="text-muted">Status</dt>
<dd>
<Show when={opportunity().status} fallback="—">
<span
class={`inline-block rounded px-2 py-0.5 font-semibold ${statusBadge(opportunity().status)}`}
>
{statusLabel(opportunity().status)}
</span>
</Show>
</dd>
<dt class="text-muted">Published</dt>
<dd>{formatDate(opportunity().latest_release_date)}</dd>
<dt class="text-muted">Start</dt>
<dd>{formatDate(opportunity().start_date)}</dd>
<dt class="text-muted">End</dt>
<dd>{formatDate(opportunity().end_date)}</dd>
<dt class="text-muted">Value</dt>
<dd>{formatMoney(opportunity().gross_value)}</dd>
<dt class="text-muted">Procurement method</dt>
<dd>{opportunity().procurement_method || '—'}</dd>
<dt class="text-muted">Category</dt>
<dd>{opportunity().main_category || '—'}</dd>
</dl>
<dl class={detailList}>
<dt class="text-muted">Multiple lots</dt>
<dd>{yesNo(opportunity().multi_lot)}</dd>
<dt class="text-muted">SME suitable</dt>
<dd>{yesNo(opportunity().sme_suitable)}</dd>
<dt class="text-muted">VCSE suitable</dt>
<dd>{yesNo(opportunity().vcse_suitable)}</dd>
<dt class="text-muted">Documents</dt>
<dd>{yesNo(opportunity().has_documents)}</dd>
<dt class="text-muted">Source</dt>
<dd>{opportunity().source}</dd>
<dt class="text-muted">OCID</dt>
<dd class="break-all">{opportunity().uuid}</dd>
</dl>
</section>
<Show when={opportunity().description}>
<section>
<h3 class={sectionHeading}>Description</h3>
<p class="text-sm whitespace-pre-line">{opportunity().description}</p>
</section>
</Show>
<Show when={(opportunity().classifications ?? []).length > 0}>
<section>
<h3 class={sectionHeading}>Classifications</h3>
<ul class="flex flex-col gap-1 text-sm">
<For each={opportunity().classifications ?? []}>
{(code) => (
<li>
<span class="text-muted tabular-nums">{code.code}</span>{' '}
{code.classification}
</li>
)}
</For>
</ul>
</section>
</Show>
<section aria-busy={loaded.loading || undefined}>
<h3 class={sectionHeading}>Releases</h3>
<Switch>
<Match when={loaded.loading}>
<p class="text-sm text-muted">Loading releases…</p>
</Match>
<Match when={loadError()}>
{(err) => (
<div class="text-sm text-danger" role="alert">
<p class="mb-2">The releases didn't load: {err()}</p>
<button type="button" class={buttonPrimary} onClick={() => void refetch()}>
Try again
</button>
</div>
)}
</Match>
<Match when={loaded.state === 'ready'}>
<ReleaseTabs
releases={releases()}
isFromFindATender={isFromFindATender(opportunity())}
/>
</Match>
</Switch>
</section>
</div>
)}
</Show>
</dialog>
);
}
+36 -6
View File
@@ -1,6 +1,7 @@
import ExternalLink from 'lucide-solid/icons/external-link';
import Info from 'lucide-solid/icons/info';
import type { JSX } from 'solid-js';
import { createSignal, For, Show } from 'solid-js';
import { createMemo, createSignal, For, Show } from 'solid-js';
import { api } from '@/api/client';
import { errorText } from '@/lib/errors';
@@ -10,6 +11,7 @@ import { findATenderNoticeUrl, isFromFindATender, latestRelease } from '@/lib/op
import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
import type { Opportunity } from '@/types/Opportunities';
import { MarkMenu } from './MarkMenu';
import { OpportunityDialog } from './OpportunityDialog';
import { buttonQuiet, MARK_BG, statusBadge } from './styles';
interface Props {
@@ -26,10 +28,21 @@ const iconButton =
'flex rounded-md border border-line bg-surface p-1 text-ink hover:enabled:border-muted disabled:opacity-60';
/**
* The table of opportunities, each with its marks, a hide button and, for
* those from Find a Tender, a link to its latest notice there.
* The table of opportunities, each with its marks, a hide button, a button
* that opens its details and, for those from Find a Tender, a link to its
* latest notice there.
*/
export function OpportunityTable(props: Props): JSX.Element {
// One dialog for the table, showing the opportunity chosen by its UUID, so
// it follows the list's updates while it's open.
const [detailsUuid, setDetailsUuid] = createSignal<string>();
const details = createMemo(() => {
const uuid = detailsUuid();
return uuid === undefined
? undefined
: props.opportunities.find((opportunity) => opportunity.uuid === uuid);
});
return (
<div class="overflow-x-auto rounded-[10px] border border-line bg-surface">
<table class="w-full border-separate border-spacing-0 tabular-nums">
@@ -71,11 +84,17 @@ export function OpportunityTable(props: Props): JSX.Element {
opportunity={opportunity}
annotations={props.annotations}
onError={props.onError}
onShowDetails={() => setDetailsUuid(opportunity.uuid)}
/>
)}
</For>
</tbody>
</table>
<OpportunityDialog
opportunity={details()}
onClose={() => setDetailsUuid(undefined)}
onError={props.onError}
/>
</div>
);
}
@@ -84,6 +103,7 @@ interface RowProps {
opportunity: Opportunity;
annotations: OpportunityAnnotations;
onError: (message: string) => void;
onShowDetails: () => void;
}
function OpportunityRow(props: RowProps): JSX.Element {
@@ -135,10 +155,10 @@ function OpportunityRow(props: RowProps): JSX.Element {
<span class="absolute inset-y-0 left-0 flex w-1 flex-col" aria-hidden="true">
<For each={marks()}>{(m) => <span class={`flex-1 ${MARK_BG[m]}`} />}</For>
</span>
{/* The link's place is kept when it's hidden, so every title starts at
the same point. */}
{/* The icons' column is kept when the link is hidden, so every title
starts at the same point. */}
<div class="flex items-start gap-2">
<span class="w-6 shrink-0">
<span class="flex w-6 shrink-0 flex-col gap-1">
<Show when={isFromFindATender(opportunity())}>
<button
type="button"
@@ -152,6 +172,16 @@ function OpportunityRow(props: RowProps): JSX.Element {
<ExternalLink size={14} strokeWidth={2} />
</button>
</Show>
<button
type="button"
class={iconButton}
title="Show the details"
aria-label={`Details of ${opportunityTitle()}`}
aria-haspopup="dialog"
onClick={() => props.onShowDetails()}
>
<Info size={14} strokeWidth={2} />
</button>
</span>
<div>
<span class="block font-semibold" title={opportunity().description || undefined}>
+230
View File
@@ -0,0 +1,230 @@
import ExternalLink from 'lucide-solid/icons/external-link';
import type { JSX } from 'solid-js';
import { createMemo, createSignal, createUniqueId, For, Show } from 'solid-js';
import { statusLabel } from '@/lib/filterOpportunities';
import { formatDate, formatDateTime, formatValue } from '@/lib/format';
import type { ReleaseValue } from '@/lib/ocdsRelease';
import { parseReleaseData, summariseRelease } from '@/lib/ocdsRelease';
import { findATenderNoticeUrl } from '@/lib/opportunities';
import type { OpportunityRelease } from '@/types/Opportunities';
import { JsonView } from './JsonView';
interface Props {
releases: OpportunityRelease[];
/** Whether the releases are Find a Tender notices, so each can link to its notice. */
isFromFindATender: boolean;
}
const tabClass =
'shrink-0 border-b-2 border-transparent px-3 py-2 text-sm whitespace-nowrap text-muted hover:text-ink aria-selected:border-accent aria-selected:font-semibold aria-selected:text-ink';
const sectionHeading = 'mb-1.5 text-sm font-semibold';
const summaryList = 'grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm';
const value = (money: ReleaseValue | undefined): string =>
money ? formatValue(money.amount, money.currency) : '—';
const tagsOf = (release: OpportunityRelease): string =>
(release.release_tags ?? []).map((tag) => tag.tag).join(', ');
interface FieldProps {
term: string;
children: JSX.Element;
}
/** A term and its description in a summary list. */
function Field(props: FieldProps): JSX.Element {
return (
<>
<dt class="text-muted">{props.term}</dt>
<dd>{props.children}</dd>
</>
);
}
interface ReleasePanelProps {
release: OpportunityRelease;
isFromFindATender: boolean;
}
/** One release: its notice, a summary of its tender, awards and contracts, and its JSON. */
function ReleasePanel(props: ReleasePanelProps): JSX.Element {
const parsed = createMemo(() => parseReleaseData(props.release.data));
const summary = createMemo(() => summariseRelease(parsed()));
// The JSON is only laid out once it's opened: a release can be 100KB or more.
const [isJsonOpen, setIsJsonOpen] = createSignal(false);
const json = () => {
const data = parsed();
return data === undefined ? props.release.data : JSON.stringify(data, null, 2);
};
return (
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<dl class={summaryList}>
<Field term="Release">{props.release.release_id}</Field>
<Field term="Published">{formatDateTime(props.release.release_date)}</Field>
<Field term="Tags">{tagsOf(props.release) || '—'}</Field>
</dl>
<Show when={props.isFromFindATender}>
<a
class="inline-flex items-center gap-1.5 text-sm text-accent underline"
href={findATenderNoticeUrl(props.release.release_id)}
target="_blank"
rel="noopener noreferrer"
>
Open notice on Find a Tender
<ExternalLink size={14} strokeWidth={2} />
</a>
</Show>
</div>
<Show when={summary().tender}>
{(tender) => (
<section>
<h4 class={sectionHeading}>Tender</h4>
<dl class={summaryList}>
<Field term="Title">{tender().title || '—'}</Field>
<Field term="Status">{tender().status ? statusLabel(tender().status) : '—'}</Field>
<Field term="Value">{value(tender().value)}</Field>
<Field term="Deadline">{formatDate(tender().deadline)}</Field>
<Field term="Lots">{tender().lotCount || '—'}</Field>
</dl>
</section>
)}
</Show>
<Show when={summary().awards.length > 0}>
<section>
<h4 class={sectionHeading}>Awards</h4>
<ul class="flex flex-col gap-3">
<For each={summary().awards}>
{(award) => (
<li>
<dl class={summaryList}>
<Field term="Award">{award.title || award.id || '—'}</Field>
<Field term="Status">{award.status ? statusLabel(award.status) : '—'}</Field>
<Field term="Suppliers">{award.suppliers.join(', ') || '—'}</Field>
<Field term="Value">{value(award.value)}</Field>
<Field term="Date">{formatDate(award.date)}</Field>
</dl>
</li>
)}
</For>
</ul>
</section>
</Show>
<Show when={summary().contracts.length > 0}>
<section>
<h4 class={sectionHeading}>Contracts</h4>
<ul class="flex flex-col gap-3">
<For each={summary().contracts}>
{(contract) => (
<li>
<dl class={summaryList}>
<Field term="Contract">{contract.title || contract.id || '—'}</Field>
<Field term="Status">
{contract.status ? statusLabel(contract.status) : '—'}
</Field>
<Field term="Value">{value(contract.value)}</Field>
<Field term="Signed">{formatDate(contract.dateSigned)}</Field>
<Field term="Period">
{formatDate(contract.start)} to {formatDate(contract.end)}
</Field>
</dl>
</li>
)}
</For>
</ul>
</section>
</Show>
<details onToggle={(e) => setIsJsonOpen(e.currentTarget.open)}>
<summary class="cursor-pointer text-sm font-semibold">Release JSON</summary>
<div class="mt-2">
<Show when={isJsonOpen()}>
<JsonView json={json()} />
</Show>
</div>
</details>
</div>
);
}
/**
* An opportunity's releases as tabs, newest first, each with a summary and
* its JSON. The tabs follow the ARIA tabs pattern: the arrow keys, Home and
* End move between them, and Tab moves into the open one.
*/
export function ReleaseTabs(props: Props): JSX.Element {
const id = createUniqueId();
const releases = createMemo(() =>
[...props.releases].sort(
(first, second) => Date.parse(second.release_date) - Date.parse(first.release_date),
),
);
const [selected, setSelected] = createSignal(0);
const tabs: HTMLButtonElement[] = [];
const select = (index: number): void => {
const count = releases().length;
const next = (index + count) % count;
setSelected(next);
tabs[next]?.focus();
};
const onKeyDown = (event: KeyboardEvent): void => {
const moves: Record<string, number> = {
ArrowRight: selected() + 1,
ArrowLeft: selected() - 1,
Home: 0,
End: releases().length - 1,
};
const index = moves[event.key];
if (index === undefined) return;
event.preventDefault();
select(index);
};
return (
<Show
when={releases().length > 0}
fallback={<p class="text-sm text-muted">This opportunity has no releases.</p>}
>
<div
role="tablist"
aria-label="Releases"
class="flex overflow-x-auto border-b border-line"
onKeyDown={onKeyDown}
>
<For each={releases()}>
{(release, index) => (
<button
ref={(tab) => (tabs[index()] = tab)}
type="button"
role="tab"
id={`${id}-tab-${index()}`}
class={tabClass}
aria-selected={selected() === index()}
aria-controls={`${id}-panel`}
tabindex={selected() === index() ? 0 : -1}
onClick={() => setSelected(index())}
>
{formatDateTime(release.release_date)}
<Show when={tagsOf(release)}>
<span class="ml-1.5 font-normal text-muted">{tagsOf(release)}</span>
</Show>
</button>
)}
</For>
</div>
<div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-tab-${selected()}`}>
<Show when={releases()[selected()]} keyed>
{(release) => (
<ReleasePanel release={release} isFromFindATender={props.isFromFindATender} />
)}
</Show>
</div>
</Show>
);
}