All files / src/lib admin-list-search.ts

100% Statements 30/30
100% Branches 25/25
100% Functions 11/11
100% Lines 25/25

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116                                                                27x                 9x                   12x   12x 12x   12x 14x                 43x 42x 14x         12x                 16x 15x 15x 3x   12x                 1057x 1057x 1057x   1057x 44x 44x   2x       1x         1057x    
import { useState } from "react";
import { redirect, useNavigate, useSearch } from "@tanstack/react-router";
 
/**
 * Filters for the admin Pros and Projects lists (#1153).
 *
 * Each filter is local state seeded from the URL and written back to it with
 * `replace`, so browser Back and a reload land on the same filtered list.
 *
 * Back arrows, breadcrumbs, post-delete redirects and the sidebar all go to
 * the bare list path. So the list route remembers the last search it showed
 * and puts it back on any bare URL the filters did not write themselves. The
 * filters tag their own writes in history state; that is what lets an admin
 * clear every filter on purpose. It also covers a sidebar click while already
 * on the list: the page stays mounted with its filters, and the restore keeps
 * the URL in step with them.
 *
 * The memory lasts only while the admin stays inside that list's pages (the
 * list, a detail, a sub-page).
 * Going anywhere else clears it, so a dashboard tile or the sidebar opened
 * from elsewhere still shows an unfiltered list.
 */
export type AdminListPath = "/admin/pros" | "/admin/projects";
 
export type AdminListSearch = {
	q?: string;
	status?: string;
	incomplete?: boolean;
	/** Column key; a leading "-" means descending. */
	sort?: string;
};
 
const text = (v: unknown) =>
	(typeof v === "string" || typeof v === "number") && v !== ""
		? String(v)
		: undefined;
 
/** `validateSearch` for both list routes. Unknown or malformed values are dropped. */
export function parseAdminListSearch(
	raw: Record<string, unknown>,
): AdminListSearch {
	return {
		q: text(raw.q),
		status: text(raw.status),
		incomplete: raw.incomplete === true ? true : undefined,
		sort: text(raw.sort),
	};
}
 
// ponytail: in-memory, so a full reload of a detail page forgets it — the
// list then opens unfiltered, which is the pre-#1153 behaviour.
const lastSearch = new Map<AdminListPath, AdminListSearch>();
 
const isEmpty = (s: AdminListSearch) =>
	Object.values(s).every((v) => v === undefined);
 
const isUnder = (pathname: string, path: AdminListPath) =>
	pathname === path || pathname.startsWith(`${path}/`);
 
type BeforeLoadCause = "enter" | "stay" | "preload";
 
/** Admin layout `beforeLoad`: forget a list's filters once the admin leaves its pages. */
export function forgetListSearchOutside(
	pathname: string,
	cause: BeforeLoadCause,
) {
	if (cause === "preload") return;
	for (const path of lastSearch.keys()) {
		if (!isUnder(pathname, path)) lastSearch.delete(path);
	}
}
 
/** History-state tag on the URL writes made by `useListFilter`. */
export const LIST_FILTER_WRITE = "adminListFilter";
 
/** List route `beforeLoad`: a bare URL the filters did not write gets the remembered filters back; anything else is remembered. */
export function restoreListSearch(
	path: AdminListPath,
	search: AdminListSearch,
	cause: BeforeLoadCause,
	writtenByFilter: boolean,
) {
	if (cause === "preload") return;
	const saved = lastSearch.get(path);
	if (!writtenByFilter && isEmpty(search) && saved && !isEmpty(saved)) {
		throw redirect({ to: path, search: saved, replace: true });
	}
	lastSearch.set(path, search);
}
 
/** Like `useState`, seeded from the list's URL param and mirrored back to it. */
export function useListFilter<K extends keyof AdminListSearch>(
	path: AdminListPath,
	key: K,
	fallback: NonNullable<AdminListSearch[K]>,
) {
	const navigate = useNavigate();
	const fromUrl = (useSearch({ strict: false }) as AdminListSearch)[key];
	const [value, setValue] = useState(fromUrl ?? fallback);
 
	const set = (next: NonNullable<AdminListSearch[K]>) => {
		setValue(next);
		navigate({
			to: path,
			search: (prev: AdminListSearch) => ({
				...prev,
				[key]: next === fallback ? undefined : next,
			}),
			state: (prev) => ({ ...prev, [LIST_FILTER_WRITE]: true }),
			replace: true,
		});
	};
 
	return [value, set] as const;
}