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",
|
"name": "job-search-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-solid": "^1.52.0",
|
||||||
"solid-js": "^1.9.3"
|
"solid-js": "^1.9.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -4033,6 +4034,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"sync": "node scripts/syncDist.ts"
|
"sync": "node scripts/syncDist.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-solid": "^1.52.0",
|
||||||
"solid-js": "^1.9.3"
|
"solid-js": "^1.9.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+9
-1
@@ -67,7 +67,15 @@ export function App(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<main>
|
<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}>
|
<Match when={opportunities.isLoading() && opportunities.items().length === 0}>
|
||||||
<p class={`${statePanel} text-muted`}>Loading opportunities…</p>
|
<p class={`${statePanel} text-muted`}>Loading opportunities…</p>
|
||||||
</Match>
|
</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', () => {
|
describe('setMark', () => {
|
||||||
const marks = { ...testOpportunity('ocds-1').marks, won: true };
|
const marks = { ...testOpportunity('ocds-1').marks, won: true };
|
||||||
const csrf = (token: string): Response => json({ token, header: 'X-Csrf-Token' });
|
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?offset=&limit= -> OpportunityPage
|
||||||
* GET {base}/opportunities?changed_after={batch} -> OpportunityPage (changed since)
|
* GET {base}/opportunities?changed_after={batch} -> OpportunityPage (changed since)
|
||||||
|
* GET {base}/opportunities/{uuid} -> Opportunity (with its releases)
|
||||||
* GET {base}/classifications -> ClassificationCode[]
|
* GET {base}/classifications -> ClassificationCode[]
|
||||||
* PUT {base}/opportunities/{uuid}/marks/{mark} -> OpportunityMarks (sets it)
|
* PUT {base}/opportunities/{uuid}/marks/{mark} -> OpportunityMarks (sets it)
|
||||||
* DELETE {base}/opportunities/{uuid}/marks/{mark} -> OpportunityMarks (clears it)
|
* DELETE {base}/opportunities/{uuid}/marks/{mark} -> OpportunityMarks (clears it)
|
||||||
@@ -137,6 +138,11 @@ export const api = {
|
|||||||
return { opportunities: page.opportunities, batch: page.batch };
|
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. */
|
/** Every classification some opportunity has, ordered by code. */
|
||||||
listClassifications(signal?: AbortSignal): Promise<ClassificationCode[]> {
|
listClassifications(signal?: AbortSignal): Promise<ClassificationCode[]> {
|
||||||
return request<ClassificationCode[]>('/classifications', { signal });
|
return request<ClassificationCode[]>('/classifications', { signal });
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import ExternalLink from 'lucide-solid/icons/external-link';
|
||||||
import type { JSX } from 'solid-js';
|
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 { 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 { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
|
import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
|
||||||
import type { Opportunity } from '@/types/Opportunities';
|
import type { Opportunity } from '@/types/Opportunities';
|
||||||
import { MarkMenu } from './MarkMenu';
|
import { MarkMenu } from './MarkMenu';
|
||||||
@@ -11,13 +15,20 @@ import { buttonQuiet, MARK_BG, statusBadge } from './styles';
|
|||||||
interface Props {
|
interface Props {
|
||||||
opportunities: Opportunity[];
|
opportunities: Opportunity[];
|
||||||
annotations: OpportunityAnnotations;
|
annotations: OpportunityAnnotations;
|
||||||
|
/** Called with a message when an opportunity's notice couldn't be opened. */
|
||||||
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const th =
|
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';
|
'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 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 {
|
export function OpportunityTable(props: Props): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div class="overflow-x-auto rounded-[10px] border border-line bg-surface">
|
<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">
|
<tbody class="[&>tr:last-child>td]:border-b-0">
|
||||||
<For each={props.opportunities}>
|
<For each={props.opportunities}>
|
||||||
{(opportunity) => (
|
{(opportunity) => (
|
||||||
<OpportunityRow opportunity={opportunity} annotations={props.annotations} />
|
<OpportunityRow
|
||||||
|
opportunity={opportunity}
|
||||||
|
annotations={props.annotations}
|
||||||
|
onError={props.onError}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -68,6 +83,7 @@ export function OpportunityTable(props: Props): JSX.Element {
|
|||||||
interface RowProps {
|
interface RowProps {
|
||||||
opportunity: Opportunity;
|
opportunity: Opportunity;
|
||||||
annotations: OpportunityAnnotations;
|
annotations: OpportunityAnnotations;
|
||||||
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OpportunityRow(props: RowProps): JSX.Element {
|
function OpportunityRow(props: RowProps): JSX.Element {
|
||||||
@@ -75,6 +91,33 @@ function OpportunityRow(props: RowProps): JSX.Element {
|
|||||||
const annotation = () => props.annotations.get(opportunity().uuid);
|
const annotation = () => props.annotations.get(opportunity().uuid);
|
||||||
const marks = () => annotation().marks;
|
const marks = () => annotation().marks;
|
||||||
const hidden = () => annotation().hidden;
|
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 (
|
return (
|
||||||
<tr
|
<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">
|
<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>
|
<For each={marks()}>{(m) => <span class={`flex-1 ${MARK_BG[m]}`} />}</For>
|
||||||
</span>
|
</span>
|
||||||
<span class="block font-semibold" title={opportunity().description || undefined}>
|
{/* The link's place is kept when it's hidden, so every title starts at
|
||||||
{opportunity().title || 'Untitled opportunity'}
|
the same point. */}
|
||||||
</span>
|
<div class="flex items-start gap-2">
|
||||||
<span class="mt-0.5 block text-sm text-muted">{opportunity().buyer_name}</span>
|
<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>
|
||||||
<td class={td}>
|
<td class={td}>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { expect, test } from 'vitest';
|
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';
|
import { testOpportunity } from './testOpportunity';
|
||||||
|
|
||||||
test('replaces opportunities in place and adds new ones at the end', () => {
|
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');
|
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
|
* 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);
|
for (const opportunity of changed) byUuid.set(opportunity.uuid, opportunity);
|
||||||
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. */
|
||||||
|
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