feat(web): persist list-page filters in URL search params

Resources, projects, and allocations filter state now syncs to/from
URL so filters survive refresh and can be shared via link.
Text inputs are debounced (300ms) to avoid URL churn.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 17:00:30 +02:00
parent 7264f0728a
commit 6bf60c8e07
4 changed files with 172 additions and 46 deletions
@@ -1,6 +1,8 @@
"use client"; "use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { formatDate, formatMoney } from "~/lib/format.js"; import { formatDate, formatMoney } from "~/lib/format.js";
import type { Project, ColumnDef } from "@capakraken/shared"; import type { Project, ColumnDef } from "@capakraken/shared";
@@ -177,9 +179,28 @@ interface ProjectRow {
// ─── Main component ─────────────────────────────────────────────────────────── // ─── Main component ───────────────────────────────────────────────────────────
export function ProjectsClient() { export function ProjectsClient() {
const [search, setSearch] = useState(""); const [filters, setFilters] = useUrlFilters({ search: "", status: "", orderType: "" });
const [statusFilter, setStatusFilter] = useState<string>(""); const { status: statusFilter, orderType: orderTypeFilter } = filters;
const [orderTypeFilter, setOrderTypeFilter] = useState<string>("");
// Debounced local state for the search text input
const [searchInput, setSearchInput] = useState(filters.search);
const debouncedSearch = useDebounce(searchInput, 300);
const search = filters.search;
// Flush debounced input to URL
useEffect(() => {
setFilters({ search: debouncedSearch });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch]);
// Keep local input in sync when URL changes externally (e.g. back/forward)
useEffect(() => {
setSearchInput(filters.search);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.search]);
const setStatusFilter = useCallback((v: string) => setFilters({ status: v }), [setFilters]);
const setOrderTypeFilter = useCallback((v: string) => setFilters({ orderType: v }), [setFilters]);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null); const [editingProject, setEditingProject] = useState<Project | null>(null);
@@ -346,7 +367,7 @@ export function ProjectsClient() {
function openNewModal() { setEditingProject(null); setModalOpen(true); } function openNewModal() { setEditingProject(null); setModalOpen(true); }
function openEditModal(project: Project) { setEditingProject(project); setModalOpen(true); } function openEditModal(project: Project) { setEditingProject(project); setModalOpen(true); }
function closeModal() { setModalOpen(false); setEditingProject(null); } function closeModal() { setModalOpen(false); setEditingProject(null); }
function clearAll() { setSearch(""); setStatusFilter(""); setOrderTypeFilter(""); } function clearAll() { setSearchInput(""); setFilters({ search: "", status: "", orderType: "" }); }
const exportSelectedCsv = useCallback(() => { const exportSelectedCsv = useCallback(() => {
const selected = projects.filter((p) => selection.selectedIds.has(p.id)); const selected = projects.filter((p) => selection.selectedIds.has(p.id));
@@ -368,7 +389,7 @@ export function ProjectsClient() {
}, [projects, selection.selectedIds]); }, [projects, selection.selectedIds]);
const chips = [ const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []), ...(search ? [{ label: `Search: "${search}"`, onRemove: () => { setSearchInput(""); setFilters({ search: "" }); } }] : []),
...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []), ...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []),
...(orderTypeFilter ? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }] : []), ...(orderTypeFilter ? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }] : []),
]; ];
@@ -531,8 +552,8 @@ export function ProjectsClient() {
<input <input
type="search" type="search"
placeholder="Search projects..." placeholder="Search projects..."
value={search} value={searchInput}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
className="app-input max-w-xs" className="app-input max-w-xs"
/> />
<select <select
@@ -2,6 +2,8 @@
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useState, useEffect, useCallback, useMemo, useRef, type ReactNode } from "react"; import { useState, useEffect, useCallback, useMemo, useRef, type ReactNode } from "react";
import { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import Link from "next/link"; import Link from "next/link";
import type { Resource, SkillEntry } from "@capakraken/shared"; import type { Resource, SkillEntry } from "@capakraken/shared";
import { RESOURCE_COLUMNS } from "@capakraken/shared"; import { RESOURCE_COLUMNS } from "@capakraken/shared";
@@ -147,9 +149,48 @@ function BooleanBadge({ value }: { value: boolean }) {
} }
export function ResourcesClient() { export function ResourcesClient() {
const [search, setSearch] = useState(""); const [resourceUrlFilters, setResourceUrlFilters] = useUrlFilters({
const [chapterFilter, setChapterFilter] = useState<string[]>([]); search: "",
const [isActiveFilter, setIsActiveFilter] = useState<ActiveFilter>("active"); activeFilter: "active" as string,
rolledOff: DEFAULT_BOOLEAN_FILTER as string,
departed: DEFAULT_BOOLEAN_FILTER as string,
chapters: "",
});
// Debounced local state for search text input
const [searchInput, setSearchInput] = useState(resourceUrlFilters.search);
const debouncedSearch = useDebounce(searchInput, 300);
const search = resourceUrlFilters.search;
const isActiveFilter = resourceUrlFilters.activeFilter as ActiveFilter;
const rolledOffFilter = resourceUrlFilters.rolledOff as BooleanFilter;
const departedFilter = resourceUrlFilters.departed as BooleanFilter;
// chapters stored as comma-separated string; empty string means "all chapters visible"
const chaptersParam = resourceUrlFilters.chapters;
// eslint-disable-next-line react-hooks/exhaustive-deps
const chapterFilter: string[] = useMemo(
() => (chaptersParam ? chaptersParam.split(",").filter(Boolean) : []),
[chaptersParam],
);
// Flush debounced search input to URL
useEffect(() => {
setResourceUrlFilters({ search: debouncedSearch });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch]);
// Keep local search input in sync when URL changes externally
useEffect(() => {
setSearchInput(resourceUrlFilters.search);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resourceUrlFilters.search]);
const setIsActiveFilter = useCallback((v: ActiveFilter) => setResourceUrlFilters({ activeFilter: v }), [setResourceUrlFilters]);
const setRolledOffFilter = useCallback((v: BooleanFilter) => setResourceUrlFilters({ rolledOff: v }), [setResourceUrlFilters]);
const setDepartedFilter = useCallback((v: BooleanFilter) => setResourceUrlFilters({ departed: v }), [setResourceUrlFilters]);
const setChapterFilter = useCallback((v: string[]) => {
setResourceUrlFilters({ chapters: v.join(",") });
}, [setResourceUrlFilters]);
const [includeProposedChargeability, setIncludeProposedChargeability] = useState(false); const [includeProposedChargeability, setIncludeProposedChargeability] = useState(false);
const [hiddenCountryIds, setHiddenCountryIds] = useState<string[]>([]); const [hiddenCountryIds, setHiddenCountryIds] = useState<string[]>([]);
const [includeWithoutCountry, setIncludeWithoutCountry] = useState(true); const [includeWithoutCountry, setIncludeWithoutCountry] = useState(true);
@@ -157,8 +198,6 @@ export function ResourcesClient() {
...DEFAULT_HIDDEN_RESOURCE_TYPES, ...DEFAULT_HIDDEN_RESOURCE_TYPES,
]); ]);
const [includeWithoutResourceType, setIncludeWithoutResourceType] = useState(true); const [includeWithoutResourceType, setIncludeWithoutResourceType] = useState(true);
const [rolledOffFilter, setRolledOffFilter] = useState<BooleanFilter>(DEFAULT_BOOLEAN_FILTER);
const [departedFilter, setDepartedFilter] = useState<BooleanFilter>(DEFAULT_BOOLEAN_FILTER);
const [modal, setModal] = useState<ModalState>({ type: "closed" }); const [modal, setModal] = useState<ModalState>({ type: "closed" });
const [confirm, setConfirm] = useState<ConfirmState>({ type: "closed" }); const [confirm, setConfirm] = useState<ConfirmState>({ type: "closed" });
const [successToast, setSuccessToast] = useState<string | null>(null); const [successToast, setSuccessToast] = useState<string | null>(null);
@@ -372,15 +411,12 @@ export function ResourcesClient() {
} }
function clearAll() { function clearAll() {
setSearch(""); setSearchInput("");
setChapterFilter([]); setResourceUrlFilters({ search: "", activeFilter: "active", rolledOff: DEFAULT_BOOLEAN_FILTER, departed: DEFAULT_BOOLEAN_FILTER, chapters: "" });
setIsActiveFilter("active");
setHiddenCountryIds([]); setHiddenCountryIds([]);
setIncludeWithoutCountry(true); setIncludeWithoutCountry(true);
setHiddenResourceTypes([...DEFAULT_HIDDEN_RESOURCE_TYPES]); setHiddenResourceTypes([...DEFAULT_HIDDEN_RESOURCE_TYPES]);
setIncludeWithoutResourceType(true); setIncludeWithoutResourceType(true);
setRolledOffFilter(DEFAULT_BOOLEAN_FILTER);
setDepartedFilter(DEFAULT_BOOLEAN_FILTER);
clearCustomFilters(); clearCustomFilters();
} }
@@ -425,18 +461,17 @@ export function ResourcesClient() {
const toggleChapter = useCallback( const toggleChapter = useCallback(
(chapter: string) => { (chapter: string) => {
setChapterFilter((current) => { const currentSelection = chapterFilter.length === 0 ? [...chapters] : chapterFilter;
const currentSelection = current.length === 0 ? [...chapters] : current; const next = currentSelection.includes(chapter)
const next = currentSelection.includes(chapter) ? currentSelection.filter((value) => value !== chapter)
? currentSelection.filter((value) => value !== chapter) : [...currentSelection, chapter];
: [...currentSelection, chapter]; if (next.length === chapters.length) {
if (next.length === chapters.length) { setChapterFilter([]);
return []; } else {
} setChapterFilter(next.sort((left, right) => chapters.indexOf(left) - chapters.indexOf(right)));
return next.sort((left, right) => chapters.indexOf(left) - chapters.indexOf(right)); }
});
}, },
[chapters], [chapters, chapterFilter, setChapterFilter],
); );
const visibleCountryCount = countries.length - hiddenCountryIds.length; const visibleCountryCount = countries.length - hiddenCountryIds.length;
@@ -504,7 +539,7 @@ export function ResourcesClient() {
}, [displayedResources, selection.selectedIds]); }, [displayedResources, selection.selectedIds]);
const chips = [ const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []), ...(search ? [{ label: `Search: "${search}"`, onRemove: () => { setSearchInput(""); setResourceUrlFilters({ search: "" }); } }] : []),
...(chapterFilter.length > 0 ...(chapterFilter.length > 0
? [ ? [
{ {
@@ -625,8 +660,8 @@ export function ResourcesClient() {
<input <input
type="search" type="search"
placeholder="Search by name, EID, email..." placeholder="Search by name, EID, email..."
value={search} value={searchInput}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
className="app-input max-w-xs" className="app-input max-w-xs"
/> />
<InfoTooltip <InfoTooltip
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useLocalStorage } from "~/hooks/useLocalStorage.js"; import { useLocalStorage } from "~/hooks/useLocalStorage.js";
import { formatDate } from "~/lib/format.js"; import { formatDate } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
@@ -106,12 +107,29 @@ function getAllocationQueryFailure(error: AllocationQueryError) {
export function AllocationsClient() { export function AllocationsClient() {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editingAllocation, setEditingAllocation] = useState<AllocationWithDetails | null>(null); const [editingAllocation, setEditingAllocation] = useState<AllocationWithDetails | null>(null);
const [filterProjectId, setFilterProjectId] = useState<string>("");
const [filterResourceId, setFilterResourceId] = useState<string>(""); const [allocFilters, setAllocFilters] = useUrlFilters({
const [filterStatus, setFilterStatus] = useState<string>(""); projectId: "",
const [hidePastProjects, setHidePastProjects] = useState(true); resourceId: "",
const [hideCompletedProjects, setHideCompletedProjects] = useState(true); status: "",
const [hideDraftProjects, setHideDraftProjects] = useState(false); hidePast: "true",
hideCompleted: "true",
hideDraft: "false",
});
const filterProjectId = allocFilters.projectId;
const filterResourceId = allocFilters.resourceId;
const filterStatus = allocFilters.status;
const hidePastProjects = allocFilters.hidePast === "true";
const hideCompletedProjects = allocFilters.hideCompleted === "true";
const hideDraftProjects = allocFilters.hideDraft === "true";
const setFilterProjectId = useCallback((v: string) => setAllocFilters({ projectId: v }), [setAllocFilters]);
const setFilterResourceId = useCallback((v: string) => setAllocFilters({ resourceId: v }), [setAllocFilters]);
const setFilterStatus = useCallback((v: string) => setAllocFilters({ status: v }), [setAllocFilters]);
const setHidePastProjects = useCallback((v: boolean) => setAllocFilters({ hidePast: v ? "true" : "false" }), [setAllocFilters]);
const setHideCompletedProjects = useCallback((v: boolean) => setAllocFilters({ hideCompleted: v ? "true" : "false" }), [setAllocFilters]);
const setHideDraftProjects = useCallback((v: boolean) => setAllocFilters({ hideDraft: v ? "true" : "false" }), [setAllocFilters]);
const [confirmDelete, setConfirmDelete] = useState<{ single?: AllocationWithDetails; ids?: string[] } | null>(null); const [confirmDelete, setConfirmDelete] = useState<{ single?: AllocationWithDetails; ids?: string[] } | null>(null);
const [batchStatusPicker, setBatchStatusPicker] = useState(false); const [batchStatusPicker, setBatchStatusPicker] = useState(false);
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null); const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
@@ -405,12 +423,7 @@ export function AllocationsClient() {
} }
function clearAll() { function clearAll() {
setFilterProjectId(""); setAllocFilters({ projectId: "", resourceId: "", status: "", hidePast: "false", hideCompleted: "false", hideDraft: "false" });
setFilterResourceId("");
setFilterStatus("");
setHidePastProjects(false);
setHideCompletedProjects(false);
setHideDraftProjects(false);
} }
const chips = [ const chips = [
@@ -450,9 +463,7 @@ export function AllocationsClient() {
hideDraftProjects, hideDraftProjects,
}) })
) { ) {
setHidePastProjects(false); setAllocFilters({ hidePast: "false", hideCompleted: "false", hideDraft: "false" });
setHideCompletedProjects(false);
setHideDraftProjects(false);
} }
}, [ }, [
assignmentList.length, assignmentList.length,
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useCallback, useRef, useTransition } from "react";
// Typed-routes are enabled in next.config.ts; bypass for dynamic URLs.
type PlainRouter = { replace: (url: string, opts?: { scroll?: boolean }) => void };
/**
* Syncs a filter object with URL search params.
* T must be a flat record of string | undefined values.
*
* - Reads current filter values from URL search params, falling back to `defaults`.
* - Returns a stable `setFilters` updater that writes to the URL via `router.replace`
* (no back-button history entry per keystroke).
* - Default values are NOT written to the URL, keeping URLs clean and shareable.
*/
export function useUrlFilters<T extends Record<string, string | undefined>>(
defaults: T,
): [T, (updates: Partial<T>) => void] {
const searchParams = useSearchParams();
const router = useRouter() as unknown as PlainRouter;
const pathname = usePathname();
const [, startTransition] = useTransition();
// Stable ref so setFilters doesn't need defaults in its deps array.
const defaultsRef = useRef(defaults);
defaultsRef.current = defaults;
// Read current values from URL, falling back to defaults
const filters = Object.fromEntries(
Object.keys(defaults).map((key) => {
const val = searchParams.get(key);
return [key, val ?? defaults[key]];
}),
) as T;
const setFilters = useCallback(
(updates: Partial<T>) => {
const params = new URLSearchParams(searchParams.toString());
const defs = defaultsRef.current;
for (const [key, value] of Object.entries(updates)) {
if (value === undefined || value === defs[key as keyof T]) {
params.delete(key); // Clean URL — don't show default values
} else {
params.set(key, String(value));
}
}
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
});
},
[searchParams, router, pathname],
);
return [filters, setFilters];
}