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:
@@ -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,
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user