From 64886a2f1eeff501dc22ae21f2a4a0ecc676722f Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Wed, 21 Oct 2026 14:14:47 +0100 Subject: [PATCH] Added links to EU Supply tenders, as there are to Find a Tender notices --- web/src/components/OpportunityDialog.tsx | 7 +-- web/src/components/OpportunityTable.tsx | 37 ++++++++------ web/src/components/ReleaseTabs.tsx | 39 ++++++++------- web/src/lib/opportunities.test.ts | 47 ++++++++++++++++-- web/src/lib/opportunities.ts | 63 +++++++++++++++++++++--- 5 files changed, 143 insertions(+), 50 deletions(-) diff --git a/web/src/components/OpportunityDialog.tsx b/web/src/components/OpportunityDialog.tsx index c34025e..78f36f3 100644 --- a/web/src/components/OpportunityDialog.tsx +++ b/web/src/components/OpportunityDialog.tsx @@ -15,7 +15,7 @@ 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 { noticeSite } from '@/lib/opportunities'; import type { Opportunity } from '@/types/Opportunities'; import { ReleaseTabs } from './ReleaseTabs'; import { buttonPrimary, statusBadge } from './styles'; @@ -179,10 +179,7 @@ export function OpportunityDialog(props: Props): JSX.Element { )} - + diff --git a/web/src/components/OpportunityTable.tsx b/web/src/components/OpportunityTable.tsx index 188b4bb..34e9afd 100644 --- a/web/src/components/OpportunityTable.tsx +++ b/web/src/components/OpportunityTable.tsx @@ -10,7 +10,8 @@ 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 { NoticeSite } from '@/lib/opportunities'; +import { latestRelease, noticeSite } from '@/lib/opportunities'; import type { OpportunitySort, SortColumn } from '@/lib/sortOpportunities'; import { nextSort } from '@/lib/sortOpportunities'; import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations'; @@ -80,7 +81,7 @@ function SortableHeader(props: HeaderProps): JSX.Element { /** * The table of opportunities, sortable by any column, each with its marks, a hide button, a button - * that opens its details and, for those from Find a Tender, a link to its + * that opens its details and, for those from Find a Tender or EU Supply, a link to its * latest notice there. */ export function OpportunityTable(props: Props): JSX.Element { @@ -187,11 +188,12 @@ function OpportunityRow(props: RowProps): JSX.Element { const marks = () => annotation().marks; const hidden = () => annotation().hidden; const opportunityTitle = () => opportunity().title || 'Untitled opportunity'; + const site = (): NoticeSite | undefined => noticeSite(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 => { + const openNotice = async (site: NoticeSite): Promise => { // 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'); @@ -199,7 +201,8 @@ function OpportunityRow(props: RowProps): JSX.Element { try { const release = latestRelease(await api.getOpportunity(opportunity().uuid)); if (!release) throw new Error('it has no releases'); - const url = findATenderNoticeUrl(release.release_id); + const url = site.urlFor(release); + if (!url) throw new Error(`its latest release has no ${site.name} address`); if (tab) { tab.opener = null; tab.location.href = url; @@ -244,18 +247,20 @@ function OpportunityRow(props: RowProps): JSX.Element { > - - + + {(site) => ( + + )}
diff --git a/web/src/components/ReleaseTabs.tsx b/web/src/components/ReleaseTabs.tsx index 32e7968..eb3ab6b 100644 --- a/web/src/components/ReleaseTabs.tsx +++ b/web/src/components/ReleaseTabs.tsx @@ -6,14 +6,14 @@ 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 { NoticeSite } 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; + /** The site the releases are notices on, so each can link to its notice; undefined if none. */ + site: NoticeSite | undefined; } const tabClass = @@ -44,7 +44,7 @@ function Field(props: FieldProps): JSX.Element { interface ReleasePanelProps { release: OpportunityRelease; - isFromFindATender: boolean; + site: NoticeSite | undefined; } /** One release: its notice, a summary of its tender, awards and contracts, and its JSON. */ @@ -53,6 +53,11 @@ function ReleasePanel(props: ReleasePanelProps): JSX.Element { 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 notice = () => { + const site = props.site; + const url = site?.urlFor(props.release); + return site && url ? { name: site.name, url } : undefined; + }; const json = () => { const data = parsed(); return data === undefined ? props.release.data : JSON.stringify(data, null, 2); @@ -66,16 +71,18 @@ function ReleasePanel(props: ReleasePanelProps): JSX.Element { {formatDateTime(props.release.release_date)} {tagsOf(props.release) || '—'} - - - Open notice on Find a Tender - - + + {(notice) => ( + + Open notice on {notice().name} + + + )}
@@ -220,9 +227,7 @@ export function ReleaseTabs(props: Props): JSX.Element {
- {(release) => ( - - )} + {(release) => }
diff --git a/web/src/lib/opportunities.test.ts b/web/src/lib/opportunities.test.ts index 25f5783..b5efe80 100644 --- a/web/src/lib/opportunities.test.ts +++ b/web/src/lib/opportunities.test.ts @@ -2,10 +2,11 @@ import { expect, test } from 'vitest'; import type { OpportunityRelease } from '@/types/Opportunities'; import { + euSupplyTenderUrl, findATenderNoticeUrl, - isFromFindATender, latestRelease, mergeOpportunities, + noticeSite, } from './opportunities'; import { testOpportunity } from './testOpportunity'; @@ -31,9 +32,11 @@ const release = (releaseId: string, releaseDate: string): OpportunityRelease => 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('Find a Tender and EU Supply opportunities have a notice site, others none', () => { + expect(noticeSite(testOpportunity('a'))?.name).toBe('Find a Tender'); + expect(noticeSite(testOpportunity('a', { source: 'EU Supply - UK' }))?.name).toBe('EU Supply'); + expect(noticeSite(testOpportunity('a', { source: 'FTSMock' }))).toBeUndefined(); + expect(noticeSite(testOpportunity('a', { source: '' }))).toBeUndefined(); }); test('the latest release is the newest, wherever it is in the list', () => { @@ -57,3 +60,39 @@ test('a notice’s URL is its release ID on Find a Tender', () => { 'https://www.find-tender.service.gov.uk/notice/095868-2026', ); }); + +// As the backend stores an EU Supply tender, with the & escaped as Go does. +const EU_SUPPLY_URL = + 'https://uk.eu-supply.com/ctm/Supplier/PublicPurchase/107679/0/0?returnUrl=ctm/Supplier/PublicTenders&b=UK'; +const euSupplyData = JSON.stringify({ id: '100112759', pid: '107679', url: EU_SUPPLY_URL }).replace( + '&', + '\\u0026', +); + +test('an EU Supply tender’s page is the url in its release, unchanged', () => { + expect(euSupplyTenderUrl(euSupplyData)).toBe(EU_SUPPLY_URL); + const site = noticeSite(testOpportunity('a', { source: 'EU Supply - UK' })); + expect( + site?.urlFor({ ...release('100112759', '2026-08-04T15:31:00Z'), data: euSupplyData }), + ).toBe(EU_SUPPLY_URL); + // Not normalised, as new URL(...).href would (it adds the trailing slash). + expect(euSupplyTenderUrl('{"url":"https://uk.eu-supply.com"}')).toBe('https://uk.eu-supply.com'); +}); + +test.each<{ name: string; data: string }>([ + { name: 'no url', data: '{"id":"1"}' }, + { name: 'a url that isn’t text', data: '{"url":42}' }, + { name: 'a relative url', data: '{"url":"/ctm/Supplier/PublicPurchase/1"}' }, + { name: 'a javascript: url', data: '{"url":"javascript:alert(1)"}' }, + { name: 'data that isn’t an object', data: '"https://uk.eu-supply.com/"' }, + { name: 'data that isn’t JSON', data: 'not json' }, +])('an EU Supply release with $name has no page', ({ data }) => { + expect(euSupplyTenderUrl(data)).toBeUndefined(); +}); + +test('a Find a Tender release’s page is its notice', () => { + const site = noticeSite(testOpportunity('a')); + expect(site?.urlFor(release('095868-2026', '2026-10-01T09:00:00Z'))).toBe( + 'https://www.find-tender.service.gov.uk/notice/095868-2026', + ); +}); diff --git a/web/src/lib/opportunities.ts b/web/src/lib/opportunities.ts index 25cd0ad..648f1d4 100644 --- a/web/src/lib/opportunities.ts +++ b/web/src/lib/opportunities.ts @@ -13,14 +13,6 @@ export function mergeOpportunities( 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; @@ -36,3 +28,58 @@ export function latestRelease(opportunity: Opportunity): OpportunityRelease | un export function findATenderNoticeUrl(releaseId: string): string { return `https://www.find-tender.service.gov.uk/notice/${encodeURIComponent(releaseId)}`; } + +// EU Supply's releases are the portal's tenders, as the backend stores them +// (internal/eusupply's Tender), with the address of the tender's page. +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const isWebAddress = (address: string): boolean => { + try { + const { protocol } = new URL(address); + return protocol === 'https:' || protocol === 'http:'; + } catch { + return false; + } +}; + +/** + * The page of an EU Supply tender: the url in its release's data, as it is, + * or undefined if there's none. Only http and https addresses are used, so + * a malformed or javascript: one is never opened. + */ +export function euSupplyTenderUrl(data: string): string | undefined { + let tender: unknown; + try { + tender = JSON.parse(data); + } catch { + return undefined; + } + if (!isRecord(tender) || typeof tender.url !== 'string') return undefined; + return isWebAddress(tender.url) ? tender.url : undefined; +} + +/** A site an opportunity's releases can be opened on. */ +export interface NoticeSite { + /** The site's name, for labels: "Open the latest notice on ". */ + name: string; + /** The page of release on the site, or undefined if it has none. */ + urlFor: (release: OpportunityRelease) => string | undefined; +} + +// By the source the backend gives the opportunities fetched from each site. +const NOTICE_SITES: Record = { + FindATender: { + name: 'Find a Tender', + urlFor: (release) => findATenderNoticeUrl(release.release_id), + }, + 'EU Supply - UK': { + name: 'EU Supply', + urlFor: (release) => euSupplyTenderUrl(release.data), + }, +}; + +/** The site the opportunity's releases can be opened on, or undefined for sources without one. */ +export function noticeSite(opportunity: Opportunity): NoticeSite | undefined { + return NOTICE_SITES[opportunity.source]; +}