Added a "Hide all visible" button
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.
This commit is contained in:
1 parent
b1cc9745ba
commit
b8244da2e5
7 files changed
+205
-10
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { limitConcurrency } from './concurrency';
|
||||
|
||||
/** A task that runs until it's finished by hand, recording when it starts. */
|
||||
function manualTask(started: string[], name: string) {
|
||||
let finish!: (value: string) => void;
|
||||
let fail!: (err: Error) => void;
|
||||
const task = () => {
|
||||
started.push(name);
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
finish = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
};
|
||||
return { task, finish: () => finish(name), fail: () => fail(new Error(name)) };
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('runs at most max tasks at once, in order', async () => {
|
||||
const limited = limitConcurrency(2);
|
||||
const started: string[] = [];
|
||||
const tasks = ['a', 'b', 'c', 'd'].map((name) => manualTask(started, name));
|
||||
const results = tasks.map((t) => limited(t.task));
|
||||
|
||||
await settle();
|
||||
expect(started).toEqual(['a', 'b']);
|
||||
|
||||
tasks[1]?.finish();
|
||||
await settle();
|
||||
expect(started).toEqual(['a', 'b', 'c']);
|
||||
|
||||
tasks[0]?.finish();
|
||||
tasks[2]?.finish();
|
||||
await settle();
|
||||
tasks[3]?.finish();
|
||||
await expect(Promise.all(results)).resolves.toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
test('a failed task makes room for the next', async () => {
|
||||
const limited = limitConcurrency(1);
|
||||
const started: string[] = [];
|
||||
const first = manualTask(started, 'a');
|
||||
const second = manualTask(started, 'b');
|
||||
const firstResult = limited(first.task);
|
||||
const secondResult = limited(second.task);
|
||||
|
||||
first.fail();
|
||||
await expect(firstResult).rejects.toThrow('a');
|
||||
await settle();
|
||||
expect(started).toEqual(['a', 'b']);
|
||||
|
||||
second.finish();
|
||||
await expect(secondResult).resolves.toBe('b');
|
||||
});
|
||||
|
||||
test('a task given a finished task’s place still counts towards max', async () => {
|
||||
const limited = limitConcurrency(1);
|
||||
const started: string[] = [];
|
||||
const first = manualTask(started, 'a');
|
||||
const second = manualTask(started, 'b');
|
||||
const third = manualTask(started, 'c');
|
||||
void limited(first.task);
|
||||
void limited(second.task);
|
||||
|
||||
first.finish();
|
||||
await settle();
|
||||
void limited(third.task);
|
||||
await settle();
|
||||
expect(started).toEqual(['a', 'b']);
|
||||
|
||||
second.finish();
|
||||
await settle();
|
||||
expect(started).toEqual(['a', 'b', 'c']);
|
||||
third.finish();
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Runs a task when there's room for it, and returns its result. */
|
||||
export type Limited = <T>(task: () => Promise<T>) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Runs tasks at most max at a time, in the order they're given; the rest
|
||||
* wait until one finishes, whether it succeeds or fails.
|
||||
*/
|
||||
export function limitConcurrency(max: number): Limited {
|
||||
let running = 0;
|
||||
const waiting: (() => void)[] = [];
|
||||
|
||||
// A finished task hands its place to the first waiting one, if there is
|
||||
// one, so running only falls when nothing is waiting.
|
||||
const next = (): void => {
|
||||
const resume = waiting.shift();
|
||||
if (resume) resume();
|
||||
else running -= 1;
|
||||
};
|
||||
|
||||
return async <T>(task: () => Promise<T>): Promise<T> => {
|
||||
if (running >= max) await new Promise<void>((resolve) => waiting.push(resolve));
|
||||
else running += 1;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
next();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { formatDate, formatMoney, parseDate } from './format';
|
||||
import { formatCount, formatDate, formatMoney, parseDate } from './format';
|
||||
|
||||
test.each([
|
||||
{ name: 'null', value: null },
|
||||
@@ -26,3 +26,8 @@ test('money is shown in whole pounds, and nothing as a dash', () => {
|
||||
expect(formatMoney(0)).toBe('—');
|
||||
expect(formatMoney(-5)).toBe('—');
|
||||
});
|
||||
|
||||
test('formatCount groups thousands', () => {
|
||||
expect(formatCount(4854)).toBe('4,854');
|
||||
expect(formatCount(7)).toBe('7');
|
||||
});
|
||||
@@ -14,6 +14,8 @@ const moneyFormat = new Intl.NumberFormat('en-GB', {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const countFormat = new Intl.NumberFormat('en-GB');
|
||||
|
||||
/** Treats null and Go's zero time.Time as "no date". */
|
||||
export function parseDate(value: DateTimeString | null | undefined): Date | null {
|
||||
if (!value || value.startsWith(ZERO_TIME_PREFIX)) return null;
|
||||
@@ -31,3 +33,8 @@ export function formatDate(value: DateTimeString | null | undefined): string {
|
||||
export function formatMoney(value: number): string {
|
||||
return value > 0 ? moneyFormat.format(value) : '—';
|
||||
}
|
||||
|
||||
/** A count for display, e.g. "4,854". */
|
||||
export function formatCount(value: number): string {
|
||||
return countFormat.format(value);
|
||||
}
|
||||
Reference in new issue
Block a user