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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user