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.
110 lines
4.4 KiB
TypeScript
110 lines
4.4 KiB
TypeScript
import type { JSX } from 'solid-js';
|
|
import { createEffect, createMemo, createSignal, Match, Switch } from 'solid-js';
|
|
|
|
import { FilterPanel } from '@/components/FilterPanel';
|
|
import { OpportunityTable } from '@/components/OpportunityTable';
|
|
import { buttonPrimary } from '@/components/styles';
|
|
import { ThemeToggle } from '@/components/ThemeToggle';
|
|
import { ToastList } from '@/components/ToastList';
|
|
import type { OpportunityFilters } from '@/lib/filterOpportunities';
|
|
import { emptyFilters, filterOpportunities, statusOptions } from '@/lib/filterOpportunities';
|
|
import { createLiveUpdates } from '@/primitives/createLiveUpdates';
|
|
import { createClassifications, createOpportunities } from '@/primitives/createOpportunities';
|
|
import { createOpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
|
|
import { createTheme } from '@/primitives/createTheme';
|
|
import { createToasts } from '@/primitives/createToasts';
|
|
|
|
const statePanel = 'rounded-[10px] border border-line bg-surface px-6 py-12 text-center';
|
|
|
|
/** The page: the filters, the table of opportunities, and toasts for errors. */
|
|
export function App(): JSX.Element {
|
|
const toasts = createToasts();
|
|
const opportunities = createOpportunities();
|
|
const classifications = createClassifications();
|
|
const annotations = createOpportunityAnnotations(opportunities.items, toasts.show);
|
|
createLiveUpdates({ opportunities, classifications, annotations });
|
|
const theme = createTheme();
|
|
const [filters, setFilters] = createSignal<OpportunityFilters>(emptyFilters());
|
|
|
|
const statuses = createMemo(() => statusOptions(opportunities.items()));
|
|
// annotations.get reads from a store, so hiding an opportunity re-runs this.
|
|
const rows = createMemo(() =>
|
|
filterOpportunities(opportunities.items(), filters(), annotations.get),
|
|
);
|
|
const clear = (): void => {
|
|
setFilters(emptyFilters());
|
|
};
|
|
|
|
// A failed load is shown where the list would be, with a retry, and as a
|
|
// toast, so it's seen wherever the user is looking.
|
|
createEffect(() => {
|
|
const err = opportunities.error();
|
|
if (err) toasts.show(`Opportunities didn't load: ${err.message}`);
|
|
});
|
|
createEffect(() => {
|
|
const err = classifications.error();
|
|
if (err) toasts.show(`Classifications didn't load: ${err.message}`);
|
|
});
|
|
|
|
return (
|
|
<div class="mx-auto max-w-[1440px] px-3 pt-4 pb-12 sm:px-6 sm:pt-7 sm:pb-16">
|
|
<header class="mb-5 flex items-center justify-between gap-4">
|
|
<h1 class="text-[1.75rem] font-bold tracking-tight">Opportunities</h1>
|
|
<ThemeToggle theme={theme.theme()} onChange={theme.setTheme} />
|
|
</header>
|
|
|
|
<FilterPanel
|
|
filters={filters()}
|
|
onChange={setFilters}
|
|
onClear={clear}
|
|
statuses={statuses()}
|
|
classifications={classifications.items()}
|
|
isLoadingClassifications={classifications.isLoading()}
|
|
classificationsError={classifications.error()}
|
|
onRetryClassifications={classifications.reload}
|
|
shown={rows().length}
|
|
total={opportunities.items().length}
|
|
/>
|
|
|
|
<main>
|
|
<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>
|
|
<Match when={opportunities.error()}>
|
|
{(err) => (
|
|
<div class={`${statePanel} text-danger`} role="alert">
|
|
<p class="mb-3">Opportunities didn't load: {err().message}</p>
|
|
<button type="button" class={buttonPrimary} onClick={opportunities.reload}>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
)}
|
|
</Match>
|
|
<Match when={opportunities.items().length === 0}>
|
|
<p class={`${statePanel} text-muted`}>There are no opportunities yet.</p>
|
|
</Match>
|
|
<Match when={rows().length === 0}>
|
|
<div class={`${statePanel} text-muted`}>
|
|
<p class="mb-3">No opportunities match these filters.</p>
|
|
<button type="button" class={buttonPrimary} onClick={clear}>
|
|
Clear filters
|
|
</button>
|
|
</div>
|
|
</Match>
|
|
</Switch>
|
|
</main>
|
|
|
|
<ToastList toasts={toasts.items()} onDismiss={toasts.dismiss} />
|
|
</div>
|
|
);
|
|
}
|