diff --git a/web/package-lock.json b/web/package-lock.json index 6dfca55..2123870 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 9653e18..6f6960a 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "sync": "node scripts/syncDist.ts" }, "dependencies": { + "lucide-solid": "^1.52.0", "solid-js": "^1.9.3" }, "devDependencies": { diff --git a/web/src/App.tsx b/web/src/App.tsx index 93df902..91970f0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -67,7 +67,15 @@ export function App(): JSX.Element { />
- }> + + } + >

Loading opportunities…

diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 053f64d..6ef0a03 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -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' }); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e5cb83b..80c0b2b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + return request(opportunityPath(uuid), { signal }); + }, + /** Every classification some opportunity has, ordered by code. */ listClassifications(signal?: AbortSignal): Promise { return request('/classifications', { signal }); diff --git a/web/src/components/OpportunityTable.tsx b/web/src/components/OpportunityTable.tsx index 41efbbd..b735b92 100644 --- a/web/src/components/OpportunityTable.tsx +++ b/web/src/components/OpportunityTable.tsx @@ -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 (
@@ -56,7 +67,11 @@ export function OpportunityTable(props: Props): JSX.Element { {(opportunity) => ( - + )} @@ -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 => { + // 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 ( {(m) => } - - {opportunity().title || 'Untitled opportunity'} - - {opportunity().buyer_name} + {/* The link's place is kept when it's hidden, so every title starts at + the same point. */} +
+ + + + + +
+ + {opportunityTitle()} + + {opportunity().buyer_name} +
+
{ @@ -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', + ); +}); diff --git a/web/src/lib/opportunities.ts b/web/src/lib/opportunities.ts index 0f09af9..25cd0ad 100644 --- a/web/src/lib/opportunities.ts +++ b/web/src/lib/opportunities.ts @@ -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)}`; +}