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
+143 -50

No files matched your search

+2 -5
View File
@@ -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 {
)}
</Match>
<Match when={loaded.state === 'ready'}>
<ReleaseTabs
releases={releases()}
isFromFindATender={isFromFindATender(opportunity())}
/>
<ReleaseTabs releases={releases()} site={noticeSite(opportunity())} />
</Match>
</Switch>
</section>
+21 -16
View File
@@ -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<void> => {
const openNotice = async (site: NoticeSite): 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');
@@ -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 {
>
<Info size={14} strokeWidth={2} />
</button>
<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 when={site()}>
{(site) => (
<button
type="button"
class={iconButton}
title={`Open the latest notice on ${site().name}`}
aria-label={`Open the latest ${site().name} notice for ${opportunityTitle()}`}
aria-busy={isOpening() || undefined}
disabled={isOpening()}
onClick={() => void openNotice(site())}
>
<ExternalLink size={14} strokeWidth={2} />
</button>
)}
</Show>
</span>
<div>
+22 -17
View File
@@ -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 {
<Field term="Published">{formatDateTime(props.release.release_date)}</Field>
<Field term="Tags">{tagsOf(props.release) || '—'}</Field>
</dl>
<Show when={props.isFromFindATender}>
<a
class="inline-flex items-center gap-1.5 text-sm text-accent underline"
href={findATenderNoticeUrl(props.release.release_id)}
target="_blank"
rel="noopener noreferrer"
>
Open notice on Find a Tender
<ExternalLink size={14} strokeWidth={2} />
</a>
<Show when={notice()}>
{(notice) => (
<a
class="inline-flex items-center gap-1.5 text-sm text-accent underline"
href={notice().url}
target="_blank"
rel="noopener noreferrer"
>
Open notice on {notice().name}
<ExternalLink size={14} strokeWidth={2} />
</a>
)}
</Show>
</div>
@@ -220,9 +227,7 @@ export function ReleaseTabs(props: Props): JSX.Element {
</div>
<div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-tab-${selected()}`}>
<Show when={releases()[selected()]} keyed>
{(release) => (
<ReleasePanel release={release} isFromFindATender={props.isFromFindATender} />
)}
{(release) => <ReleasePanel release={release} site={props.site} />}
</Show>
</div>
</Show>