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

+10
View File
@@ -8,6 +8,7 @@
"name": "job-search-frontend",
"version": "0.1.0",
"dependencies": {
"lucide-solid": "^1.52.0",
"solid-js": "^1.9.3"
},
"devDependencies": {
@@ -4033,6 +4034,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-solid": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/lucide-solid/-/lucide-solid-1.52.0.tgz",
"integrity": "sha512-uesU2FyxqeSmPNxdMkbjL20ZpTEu1U0WS3kXXsuPrd3aku8vVyPmz9+/d1PO8G6PvKKVaVH3MDZmL5gLO6kYSg==",
"license": "ISC",
"peerDependencies": {
"solid-js": "^1.4.7"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1
View File
@@ -18,6 +18,7 @@
"sync": "node scripts/syncDist.ts"
},
"dependencies": {
"lucide-solid": "^1.52.0",
"solid-js": "^1.9.3"
},
"devDependencies": {
+9 -1
View File
@@ -67,7 +67,15 @@ export function App(): JSX.Element {
/>
<main>
<Switch fallback={<OpportunityTable opportunities={rows()} annotations={annotations} />}>
<Switch
fallback={
<OpportunityTable
opportunities={rows()}
annotations={annotations}
onError={toasts.show}
/>
}
>
<Match when={opportunities.isLoading() && opportunities.items().length === 0}>
<p class={`${statePanel} text-muted`}>Loading opportunities…</p>
</Match>
+22
View File
@@ -106,6 +106,28 @@ describe('listChangedOpportunities', () => {
});
});
describe('getOpportunity', () => {
test('fetches the opportunity by its encoded UUID', async () => {
const { api } = await loadClient();
respond = () => json(testOpportunity('ocds-a/1'));
const opportunity = await api.getOpportunity('ocds-a/1');
expect(opportunity.uuid).toBe('ocds-a/1');
expect(sent.map((request) => request.url)).toEqual(['/api/opportunities/ocds-a%2F1']);
});
test('an unknown opportunity is an ApiError with status 404', async () => {
const client = await loadClient();
respond = () => json({ status: 404, title: 'Not Found' }, 404);
const err: unknown = await client.api.getOpportunity('ocds-1').catch((e: unknown) => e);
expect(err).toBeInstanceOf(client.ApiError);
expect(err).toMatchObject({ status: 404 });
});
});
describe('setMark', () => {
const marks = { ...testOpportunity('ocds-1').marks, won: true };
const csrf = (token: string): Response => json({ token, header: 'X-Csrf-Token' });
+6
View File
@@ -13,6 +13,7 @@ import type {
*
* GET {base}/opportunities?offset=&limit= -> OpportunityPage
* GET {base}/opportunities?changed_after={batch} -> OpportunityPage (changed since)
* GET {base}/opportunities/{uuid} -> Opportunity (with its releases)
* GET {base}/classifications -> ClassificationCode[]
* PUT {base}/opportunities/{uuid}/marks/{mark} -> OpportunityMarks (sets it)
* DELETE {base}/opportunities/{uuid}/marks/{mark} -> OpportunityMarks (clears it)
@@ -137,6 +138,11 @@ export const api = {
return { opportunities: page.opportunities, batch: page.batch };
},
/** 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 });
},
/** Every classification some opportunity has, ordered by code. */
listClassifications(signal?: AbortSignal): Promise<ClassificationCode[]> {
return request<ClassificationCode[]>('/classifications', { signal });
+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
+42 -1
View File
@@ -1,6 +1,12 @@
import { expect, test } from 'vitest';
import { mergeOpportunities } from './opportunities';
import type { OpportunityRelease } from '@/types/Opportunities';
import {
findATenderNoticeUrl,
isFromFindATender,
latestRelease,
mergeOpportunities,
} from './opportunities';
import { testOpportunity } from './testOpportunity';
test('replaces opportunities in place and adds new ones at the end', () => {
@@ -16,3 +22,38 @@ test('replaces opportunities in place and adds new ones at the end', () => {
]);
expect(list[0]?.title).toBe('Opportunity a');
});
const release = (releaseId: string, releaseDate: string): OpportunityRelease => ({
uuid: 'a',
release_id: releaseId,
release_date: releaseDate,
release_tags: null,
data: '{}',
});
test('only Find a Tender opportunities are from Find a Tender', () => {
expect(isFromFindATender(testOpportunity('a'))).toBe(true);
expect(isFromFindATender(testOpportunity('a', { source: 'FTSMock' }))).toBe(false);
});
test('the latest release is the newest, wherever it is in the list', () => {
const opportunity = testOpportunity('a', {
releases: [
release('001-2026', '2026-10-01T09:00:00Z'),
release('003-2026', '2026-10-09T19:21:17+01:00'),
release('002-2026', '2026-10-05T12:00:00Z'),
],
});
expect(latestRelease(opportunity)?.release_id).toBe('003-2026');
});
test('an opportunity without releases has no latest release', () => {
expect(latestRelease(testOpportunity('a', { releases: null }))).toBeUndefined();
expect(latestRelease(testOpportunity('a', { releases: [] }))).toBeUndefined();
});
test('a notice’s URL is its release ID on Find a Tender', () => {
expect(findATenderNoticeUrl('095868-2026')).toBe(
'https://www.find-tender.service.gov.uk/notice/095868-2026',
);
});
+25 -1
View File
@@ -1,4 +1,4 @@
import type { Opportunity } from '@/types/Opportunities';
import type { Opportunity, OpportunityRelease } from '@/types/Opportunities';
/**
* The opportunities with changed merged in: each replaces the opportunity
@@ -12,3 +12,27 @@ export function mergeOpportunities(
for (const opportunity of changed) byUuid.set(opportunity.uuid, opportunity);
return [...byUuid.values()];
}
/** The source the backend gives opportunities fetched from Find a Tender. */
const FIND_A_TENDER_SOURCE = 'FindATender';
/** Whether the opportunity was fetched from Find a Tender, so its releases are notices there. */
export function isFromFindATender(opportunity: Opportunity): boolean {
return opportunity.source === FIND_A_TENDER_SOURCE;
}
/** The opportunity's newest release, or undefined if it has none. */
export function latestRelease(opportunity: Opportunity): OpportunityRelease | undefined {
let latest: OpportunityRelease | undefined;
for (const release of opportunity.releases ?? []) {
if (!latest || Date.parse(release.release_date) > Date.parse(latest.release_date)) {
latest = release;
}
}
return latest;
}
/** The page of a Find a Tender notice; a Find a Tender release's ID is its notice's. */
export function findATenderNoticeUrl(releaseId: string): string {
return `https://www.find-tender.service.gov.uk/notice/${encodeURIComponent(releaseId)}`;
}