Added a link from each opportunity to its latest Find a Tender notice

Opportunities from Find a Tender have a link icon before their title.
Clicking it fetches the opportunity with its releases (the list leaves
them out) and opens its newest release's notice,
www.find-tender.service.gov.uk/notice/<release_id>, in a new tab. The
tab is opened on the click and pointed at the notice when the request
returns, so it isn't blocked as a popup; if the request fails, or there
are no releases, it's closed and a toast says why. Opportunities from
other sources have no link for now, but keep its space so the titles
line up.

The icon is lucide-solid's, added for it at 1.52.0.
This commit is contained in:
bwilliams committed 2026-10-19 09:38:14 +01:00
1 parent a6630f112f
commit e98f18b790
8 files changed
+186 -10

No files matched your search

+71 -7
View File
@@ -1,8 +1,12 @@
import ExternalLink from 'lucide-solid/icons/external-link';
import type { JSX } from 'solid-js';
import { For } from 'solid-js';
import { createSignal, For, Show } 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 { findATenderNoticeUrl, isFromFindATender, latestRelease } from '@/lib/opportunities';
import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
import type { Opportunity } from '@/types/Opportunities';
import { MarkMenu } from './MarkMenu';
@@ -11,13 +15,20 @@ import { buttonQuiet, MARK_BG, statusBadge } from './styles';
interface Props {
opportunities: Opportunity[];
annotations: OpportunityAnnotations;
/** Called with a message when an opportunity's notice couldn't be opened. */
onError: (message: string) => void;
}
const th =
'sticky top-0 z-10 whitespace-nowrap border-b border-line bg-surface p-3 text-left text-sm font-semibold text-muted';
const td = 'border-b border-line-soft p-3 align-middle';
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 and a hide button. */
/**
* 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.
*/
export function OpportunityTable(props: Props): JSX.Element {
return (
<div class="overflow-x-auto rounded-[10px] border border-line bg-surface">
@@ -56,7 +67,11 @@ export function OpportunityTable(props: Props): JSX.Element {
<tbody class="[&>tr:last-child>td]:border-b-0">
<For each={props.opportunities}>
{(opportunity) => (
<OpportunityRow opportunity={opportunity} annotations={props.annotations} />
<OpportunityRow
opportunity={opportunity}
annotations={props.annotations}
onError={props.onError}
/>
)}
</For>
</tbody>
@@ -68,6 +83,7 @@ export function OpportunityTable(props: Props): JSX.Element {
interface RowProps {
opportunity: Opportunity;
annotations: OpportunityAnnotations;
onError: (message: string) => void;
}
function OpportunityRow(props: RowProps): JSX.Element {
@@ -75,6 +91,33 @@ function OpportunityRow(props: RowProps): JSX.Element {
const annotation = () => props.annotations.get(opportunity().uuid);
const marks = () => annotation().marks;
const hidden = () => annotation().hidden;
const opportunityTitle = () => opportunity().title || 'Untitled opportunity';
const [isOpening, setIsOpening] = createSignal(false);
// The list doesn't include releases, so the opportunity is fetched with
// them when the link is clicked.
const openNotice = async (): Promise<void> => {
// The tab is opened now, while the click allows it: one opened after the
// request would be blocked as a popup.
const tab = window.open('', '_blank');
setIsOpening(true);
try {
const release = latestRelease(await api.getOpportunity(opportunity().uuid));
if (!release) throw new Error('it has no releases');
const url = findATenderNoticeUrl(release.release_id);
if (tab) {
tab.opener = null;
tab.location.href = url;
} else {
window.open(url, '_blank', 'noopener');
}
} catch (err) {
tab?.close();
props.onError(`The notice for ${opportunityTitle()} didn't open: ${errorText(err)}`);
} finally {
setIsOpening(false);
}
};
return (
<tr
@@ -92,10 +135,31 @@ 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>
<span class="block font-semibold" title={opportunity().description || undefined}>
{opportunity().title || 'Untitled opportunity'}
</span>
<span class="mt-0.5 block text-sm text-muted">{opportunity().buyer_name}</span>
{/* The link's place is kept when it's hidden, so every title starts at
the same point. */}
<div class="flex items-start gap-2">
<span class="w-6 shrink-0">
<Show when={isFromFindATender(opportunity())}>
<button
type="button"
class={iconButton}
title="Open the latest notice on Find a Tender"
aria-label={`Open the latest Find a Tender notice for ${opportunityTitle()}`}
aria-busy={isOpening() || undefined}
disabled={isOpening()}
onClick={() => void openNotice()}
>
<ExternalLink size={14} strokeWidth={2} />
</button>
</Show>
</span>
<div>
<span class="block font-semibold" title={opportunity().description || undefined}>
{opportunityTitle()}
</span>
<span class="mt-0.5 block text-sm text-muted">{opportunity().buyer_name}</span>
</div>
</div>
</td>
<td class={td}>
<span