It hides the opportunities the filters show that aren't hidden already, after asking for confirmation with their count. Those the filters leave out keep their marks, so clearing the filters shows them as before. The backend has no bulk route, so each opportunity is hidden with its own request. The rows go at once; every mark change is now saved at most six at a time (lib/concurrency.ts), so hundreds of requests aren't sent together. Any that fail are shown again, with one toast for them all.
127 lines
5.1 KiB
TypeScript
127 lines
5.1 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 { formatCount } from '@/lib/format';
|
|
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());
|
|
};
|
|
|
|
// Only the rows shown are hidden: those a filter leaves out keep their
|
|
// marks, and come back as they were when it's cleared.
|
|
const hideable = createMemo(() =>
|
|
rows()
|
|
.map((opportunity) => opportunity.uuid)
|
|
.filter((uuid) => !annotations.get(uuid).hidden),
|
|
);
|
|
const hideAllVisible = (): void => {
|
|
const uuids = hideable();
|
|
const count =
|
|
uuids.length === 1 ? 'this opportunity' : `these ${formatCount(uuids.length)} opportunities`;
|
|
if (window.confirm(`Hide ${count}?`)) annotations.hideAll(uuids);
|
|
};
|
|
|
|
// 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}
|
|
hideable={hideable().length}
|
|
onHideAll={hideAllVisible}
|
|
/>
|
|
|
|
<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>
|
|
);
|
|
}
|