Files
memby/admin-ui/src/lib/hooks.ts
T
2026-08-24 22:56:46 +12:00

185 lines
7.1 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
import { subscribeToRecovery } from './availability';
/* The two hooks every page is built from.
*
* They exist because the previous console's `Admin.onRefresh` did one thing this cannot:
* it guaranteed that a poll never redrew markup the operator was working inside. React
* makes that guarantee differently — a controlled input holds its own value and a re-render
* cannot take it away — but the other half of the old rule still applies here and is easy
* to lose: a response that arrives for a request the page has already moved on from must
* be dropped, not rendered. Both hooks below do that with a generation counter. */
export interface Loadable<T> {
data: T | undefined;
error: string;
/** loading is the *first* load only. A refresh keeps the last data on screen, because a
* page that blanks itself every thirty seconds is unreadable. */
loading: boolean;
refreshing: boolean;
reload: () => Promise<void>;
/** set replaces the data locally, for an optimistic update after a mutation. */
set: (next: T) => void;
}
export interface QueryOptions {
/** pollMs re-fetches on an interval. It stops entirely while the tab is hidden: a
* console left open on a second monitor is otherwise the gateway's most frequent
* caller by a wide margin, and a background tab nobody is reading has no status worth
* fetching. */
pollMs?: number;
/** enabled false holds the request — a page whose URL parameter has not resolved yet. */
enabled?: boolean;
}
export function useQuery<T>(path: string, options: QueryOptions = {}): Loadable<T> {
const { pollMs, enabled = true } = options;
const [data, setData] = useState<T>();
const [error, setError] = useState('');
const [loading, setLoading] = useState(enabled);
const [refreshing, setRefreshing] = useState(false);
// The generation counter is the whole correctness argument: a filter change fires a new
// request while the old one is still in flight, and without this the slower of the two
// wins whichever order they were asked in.
const generation = useRef(0);
const loaded = useRef(false);
const run = useCallback(async () => {
if (!enabled) return;
const mine = ++generation.current;
if (loaded.current) setRefreshing(true);
try {
const next = await api.get<T>(path);
if (mine !== generation.current) return;
setData(next);
setError('');
loaded.current = true;
} catch (err) {
if (mine !== generation.current) return;
setError(err instanceof Error ? err.message : String(err));
} finally {
if (mine === generation.current) {
setLoading(false);
setRefreshing(false);
}
}
}, [path, enabled]);
useEffect(() => {
// A changed path is a different question, so the previous answer is no longer this
// component's data — but it is kept on screen until the new one lands, which is what
// makes changing a filter feel like a table updating rather than a page reloading.
loaded.current = false;
setLoading(true);
void run();
return () => {
generation.current += 1;
};
}, [run]);
useEffect(() => subscribeToRecovery(() => void run()), [run]);
useEffect(() => {
if (!pollMs || !enabled) return;
let timer: number | undefined;
const start = () => {
window.clearInterval(timer);
timer = document.hidden ? undefined : window.setInterval(() => void run(), pollMs);
};
const onVisibility = () => {
start();
if (!document.hidden) void run();
};
start();
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', onVisibility);
};
}, [pollMs, enabled, run]);
return { data, error, loading, refreshing, reload: run, set: setData };
}
/** useAction runs a mutation and reports whether it is in flight, so a button can disable
* itself. Every mutation in the console goes through it, which is what makes "pressed
* twice" impossible without each page having to remember to guard against it. */
export function useAction(): {
busy: string | null;
run: (key: string, fn: () => Promise<unknown>) => Promise<boolean>;
} {
const [busy, setBusy] = useState<string | null>(null);
const alive = useRef(true);
useEffect(() => () => {
alive.current = false;
}, []);
const run = useCallback(async (key: string, fn: () => Promise<unknown>) => {
setBusy(key);
try {
await fn();
return true;
} finally {
if (alive.current) setBusy(null);
}
}, []);
return { busy, run };
}
/** useDebounced delays a value, for a search field that filters as it is typed. */
export function useDebounced<T>(value: T, delayMs = 250): T {
const [settled, setSettled] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setSettled(value), delayMs);
return () => window.clearTimeout(timer);
}, [value, delayMs]);
return settled;
}
/** useSorted is the shared table sort: stable, and it never re-sorts on a refresh, so a
* row the operator is reading does not move under the pointer when the poll lands. */
export function useSorted<T>(
rows: readonly T[],
key: keyof T | null,
direction: 'asc' | 'desc',
): T[] {
return useMemo(() => {
const copy = [...rows];
if (!key) return copy;
const sign = direction === 'asc' ? 1 : -1;
return copy.sort((a, b) => {
const left = a[key];
const right = b[key];
if (typeof left === 'number' && typeof right === 'number') return (left - right) * sign;
return String(left ?? '').localeCompare(String(right ?? ''), undefined, { numeric: true }) * sign;
});
}, [rows, key, direction]);
}
/* The product name every tab is suffixed with. It is deliberately not "Memby admin", which
* is what the pre-mount title in index.html says: that one is a placeholder for the moment
* before the console knows which page it is on, and these are real page names that already
* say the console is what they belong to. */
const TITLE_SUFFIX = 'Memby';
/** useDocumentTitle names the browser tab after the page.
*
* It is called from PageHead rather than from each page, because PageHead is already the
* one component that states a page's name and every route renders exactly one of them —
* so the tab and the heading cannot come apart, and a page added tomorrow is titled
* without anybody remembering to do it. That also means the dynamic pages are named after
* the thing they are about ("Kitchen TV | Memby") rather than after their route.
*
* Nothing is restored on unmount: the next page's PageHead sets the title in the same
* commit, and clearing it first would blink the product name into the tab between every
* navigation. A blank name falls back to the suffix alone rather than printing a bare
* separator. */
export function useDocumentTitle(title: string): void {
useEffect(() => {
const name = title.trim();
document.title = name ? `${name} | ${TITLE_SUFFIX}` : TITLE_SUFFIX;
}, [title]);
}