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:
1 parent
a6630f112f
commit
e98f18b790
8 files changed
+186
-10
No files matched your search
Generated
+10
@@ -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",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"sync": "node scripts/syncDist.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-solid": "^1.52.0",
|
||||
"solid-js": "^1.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+9
-1
@@ -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>
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in new issue
Block a user