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

+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)}`;
}