Added links to EU Supply tenders, as there are to Find a Tender notices

This commit is contained in:
bwilliams committed 2026-10-21 14:14:47 +01:00
1 parent 3a06132071
commit 64886a2f1e
5 files changed
+128 -35

No files matched your search

+2 -5
View File
@@ -15,7 +15,7 @@ import { api } from '@/api/client';
import { errorText } from '@/lib/errors'; import { errorText } from '@/lib/errors';
import { statusLabel } from '@/lib/filterOpportunities'; import { statusLabel } from '@/lib/filterOpportunities';
import { formatDate, formatMoney } from '@/lib/format'; import { formatDate, formatMoney } from '@/lib/format';
import { isFromFindATender } from '@/lib/opportunities'; import { noticeSite } from '@/lib/opportunities';
import type { Opportunity } from '@/types/Opportunities'; import type { Opportunity } from '@/types/Opportunities';
import { ReleaseTabs } from './ReleaseTabs'; import { ReleaseTabs } from './ReleaseTabs';
import { buttonPrimary, statusBadge } from './styles'; import { buttonPrimary, statusBadge } from './styles';
@@ -179,10 +179,7 @@ export function OpportunityDialog(props: Props): JSX.Element {
)} )}
</Match> </Match>
<Match when={loaded.state === 'ready'}> <Match when={loaded.state === 'ready'}>
<ReleaseTabs <ReleaseTabs releases={releases()} site={noticeSite(opportunity())} />
releases={releases()}
isFromFindATender={isFromFindATender(opportunity())}
/>
</Match> </Match>
</Switch> </Switch>
</section> </section>
+13 -8
View File
@@ -10,7 +10,8 @@ import { api } from '@/api/client';
import { errorText } from '@/lib/errors'; import { errorText } from '@/lib/errors';
import { statusLabel } from '@/lib/filterOpportunities'; import { statusLabel } from '@/lib/filterOpportunities';
import { formatDate, formatMoney } from '@/lib/format'; 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 type { OpportunitySort, SortColumn } from '@/lib/sortOpportunities';
import { nextSort } from '@/lib/sortOpportunities'; import { nextSort } from '@/lib/sortOpportunities';
import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations'; 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 * 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. * latest notice there.
*/ */
export function OpportunityTable(props: Props): JSX.Element { export function OpportunityTable(props: Props): JSX.Element {
@@ -187,11 +188,12 @@ function OpportunityRow(props: RowProps): JSX.Element {
const marks = () => annotation().marks; const marks = () => annotation().marks;
const hidden = () => annotation().hidden; const hidden = () => annotation().hidden;
const opportunityTitle = () => opportunity().title || 'Untitled opportunity'; const opportunityTitle = () => opportunity().title || 'Untitled opportunity';
const site = (): NoticeSite | undefined => noticeSite(opportunity());
const [isOpening, setIsOpening] = createSignal(false); const [isOpening, setIsOpening] = createSignal(false);
// The list doesn't include releases, so the opportunity is fetched with // The list doesn't include releases, so the opportunity is fetched with
// them when the link is clicked. // them when the link is clicked.
const openNotice = async (): Promise<void> => { const openNotice = async (site: NoticeSite): Promise<void> => {
// The tab is opened now, while the click allows it: one opened after the // The tab is opened now, while the click allows it: one opened after the
// request would be blocked as a popup. // request would be blocked as a popup.
const tab = window.open('', '_blank'); const tab = window.open('', '_blank');
@@ -199,7 +201,8 @@ function OpportunityRow(props: RowProps): JSX.Element {
try { try {
const release = latestRelease(await api.getOpportunity(opportunity().uuid)); const release = latestRelease(await api.getOpportunity(opportunity().uuid));
if (!release) throw new Error('it has no releases'); 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) { if (tab) {
tab.opener = null; tab.opener = null;
tab.location.href = url; tab.location.href = url;
@@ -244,18 +247,20 @@ function OpportunityRow(props: RowProps): JSX.Element {
> >
<Info size={14} strokeWidth={2} /> <Info size={14} strokeWidth={2} />
</button> </button>
<Show when={isFromFindATender(opportunity())}> <Show when={site()}>
{(site) => (
<button <button
type="button" type="button"
class={iconButton} class={iconButton}
title="Open the latest notice on Find a Tender" title={`Open the latest notice on ${site().name}`}
aria-label={`Open the latest Find a Tender notice for ${opportunityTitle()}`} aria-label={`Open the latest ${site().name} notice for ${opportunityTitle()}`}
aria-busy={isOpening() || undefined} aria-busy={isOpening() || undefined}
disabled={isOpening()} disabled={isOpening()}
onClick={() => void openNotice()} onClick={() => void openNotice(site())}
> >
<ExternalLink size={14} strokeWidth={2} /> <ExternalLink size={14} strokeWidth={2} />
</button> </button>
)}
</Show> </Show>
</span> </span>
<div> <div>
+15 -10
View File
@@ -6,14 +6,14 @@ import { statusLabel } from '@/lib/filterOpportunities';
import { formatDate, formatDateTime, formatValue } from '@/lib/format'; import { formatDate, formatDateTime, formatValue } from '@/lib/format';
import type { ReleaseValue } from '@/lib/ocdsRelease'; import type { ReleaseValue } from '@/lib/ocdsRelease';
import { parseReleaseData, summariseRelease } 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 type { OpportunityRelease } from '@/types/Opportunities';
import { JsonView } from './JsonView'; import { JsonView } from './JsonView';
interface Props { interface Props {
releases: OpportunityRelease[]; releases: OpportunityRelease[];
/** Whether the releases are Find a Tender notices, so each can link to its notice. */ /** The site the releases are notices on, so each can link to its notice; undefined if none. */
isFromFindATender: boolean; site: NoticeSite | undefined;
} }
const tabClass = const tabClass =
@@ -44,7 +44,7 @@ function Field(props: FieldProps): JSX.Element {
interface ReleasePanelProps { interface ReleasePanelProps {
release: OpportunityRelease; release: OpportunityRelease;
isFromFindATender: boolean; site: NoticeSite | undefined;
} }
/** One release: its notice, a summary of its tender, awards and contracts, and its JSON. */ /** 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())); const summary = createMemo(() => summariseRelease(parsed()));
// The JSON is only laid out once it's opened: a release can be 100KB or more. // The JSON is only laid out once it's opened: a release can be 100KB or more.
const [isJsonOpen, setIsJsonOpen] = createSignal(false); 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 json = () => {
const data = parsed(); const data = parsed();
return data === undefined ? props.release.data : JSON.stringify(data, null, 2); return data === undefined ? props.release.data : JSON.stringify(data, null, 2);
@@ -66,16 +71,18 @@ function ReleasePanel(props: ReleasePanelProps): JSX.Element {
<Field term="Published">{formatDateTime(props.release.release_date)}</Field> <Field term="Published">{formatDateTime(props.release.release_date)}</Field>
<Field term="Tags">{tagsOf(props.release) || '—'}</Field> <Field term="Tags">{tagsOf(props.release) || '—'}</Field>
</dl> </dl>
<Show when={props.isFromFindATender}> <Show when={notice()}>
{(notice) => (
<a <a
class="inline-flex items-center gap-1.5 text-sm text-accent underline" class="inline-flex items-center gap-1.5 text-sm text-accent underline"
href={findATenderNoticeUrl(props.release.release_id)} href={notice().url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
Open notice on Find a Tender Open notice on {notice().name}
<ExternalLink size={14} strokeWidth={2} /> <ExternalLink size={14} strokeWidth={2} />
</a> </a>
)}
</Show> </Show>
</div> </div>
@@ -220,9 +227,7 @@ export function ReleaseTabs(props: Props): JSX.Element {
</div> </div>
<div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-tab-${selected()}`}> <div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-tab-${selected()}`}>
<Show when={releases()[selected()]} keyed> <Show when={releases()[selected()]} keyed>
{(release) => ( {(release) => <ReleasePanel release={release} site={props.site} />}
<ReleasePanel release={release} isFromFindATender={props.isFromFindATender} />
)}
</Show> </Show>
</div> </div>
</Show> </Show>
+43 -4
View File
@@ -2,10 +2,11 @@ import { expect, test } from 'vitest';
import type { OpportunityRelease } from '@/types/Opportunities'; import type { OpportunityRelease } from '@/types/Opportunities';
import { import {
euSupplyTenderUrl,
findATenderNoticeUrl, findATenderNoticeUrl,
isFromFindATender,
latestRelease, latestRelease,
mergeOpportunities, mergeOpportunities,
noticeSite,
} from './opportunities'; } from './opportunities';
import { testOpportunity } from './testOpportunity'; import { testOpportunity } from './testOpportunity';
@@ -31,9 +32,11 @@ const release = (releaseId: string, releaseDate: string): OpportunityRelease =>
data: '{}', data: '{}',
}); });
test('only Find a Tender opportunities are from Find a Tender', () => { test('Find a Tender and EU Supply opportunities have a notice site, others none', () => {
expect(isFromFindATender(testOpportunity('a'))).toBe(true); expect(noticeSite(testOpportunity('a'))?.name).toBe('Find a Tender');
expect(isFromFindATender(testOpportunity('a', { source: 'FTSMock' }))).toBe(false); 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', () => { 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', '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',
);
});
+55 -8
View File
@@ -13,14 +13,6 @@ export function mergeOpportunities(
return [...byUuid.values()]; 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. */ /** The opportunity's newest release, or undefined if it has none. */
export function latestRelease(opportunity: Opportunity): OpportunityRelease | undefined { export function latestRelease(opportunity: Opportunity): OpportunityRelease | undefined {
let latest: OpportunityRelease | undefined; let latest: OpportunityRelease | undefined;
@@ -36,3 +28,58 @@ export function latestRelease(opportunity: Opportunity): OpportunityRelease | un
export function findATenderNoticeUrl(releaseId: string): string { export function findATenderNoticeUrl(releaseId: string): string {
return `https://www.find-tender.service.gov.uk/notice/${encodeURIComponent(releaseId)}`; 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<string, unknown> =>
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>". */
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<string, NoticeSite> = {
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];
}