mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 08:25:43 +00:00
feat(filter-bar): fix pageSize bug, add client bridge, migrate 4 more pages
fix(useFilters): pageSize was a hardcoded constant, never read from the URL, with no setter — the DataTable/RuleEngineListFooter page-size dropdown silently did nothing on every page using the filter bar, including the already-shipped ContractRequestsPage pilot. Added a `size` URL param (mirrors `page`), `setPageSize`, and wired `tableProps().onPaginationChange` to route page-size vs page-index changes to the right setter instead of only ever calling setPage(). feat(clientFilter): applyClientFilters — the Family-B bridge the plan called for. Generalizes ListControls'/useListControls' one hardcoded search box + one date range to every FilterDef, matched against `row[def.key]`. Reuses matchesDayRange/toDayString from hooks/useListControls.ts (imported, not duplicated) so the inclusive- range/timezone-safe semantics stay defined in exactly one place. Lets a page ship the full pill-bar UI immediately and flip to server-side filtering later by deleting one function call — no endpoint changes required up front. Migrated to the filter bar (mechanical, pattern established by ContractRequestsPage): - WarehouseInvoicesPage, LoadedInventoryPage, TrucksOnSitePage — Family B (ListControls/useListControls → FilterBar + applyClientFilters) - CustomersPage — Family A (useState bag → useFilters), no filter pills needed here (search + sort only), the SegmentedControl "view" stays page-level tab state (like ContractStatusTabs), not a filter pill — it now resets the page via controls.setPage(1) on change, the same hazard useFilters already guards its own filters against
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { matchesDayRange, toDayString } from "@/hooks/useListControls";
|
||||
import type { FilterDef, FilterValue } from "./types";
|
||||
|
||||
export { matchesDayRange, toDayString };
|
||||
|
||||
const readField = (row: unknown, key: string): unknown =>
|
||||
row && typeof row === "object" ? (row as Record<string, unknown>)[key] : undefined;
|
||||
|
||||
export interface ClientFilterOptions<T> {
|
||||
/** Row fields matched against the free-text search box. */
|
||||
searchKeys?: (keyof T)[];
|
||||
/** Custom search extractor when the value isn't a top-level field. */
|
||||
searchValue?: (row: T) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side bridge for pages whose endpoint doesn't (yet) accept
|
||||
* filter/sort/pagination params — the Family-B pages this app inherited from
|
||||
* `ListControls`/`useListControls`. Same idea, generalized: instead of one
|
||||
* hardcoded search box + one date range, every `FilterDef` is matched
|
||||
* against `row[def.key]` (override the def's `key` to line up with the row
|
||||
* shape, or filter/map the rows before calling this).
|
||||
*
|
||||
* Flip a page to server mode later by deleting the `applyClientFilters` call
|
||||
* and passing `controls.params` straight to the API — `useFilters`'s output
|
||||
* shape doesn't change either way.
|
||||
*
|
||||
* ponytail: linear scan per keystroke, no debounce — matches
|
||||
* `useListControls`'s existing behavior at this data size (~1k rows,
|
||||
* `useListControls.ts:4-18`). Move to server-side filtering if a list
|
||||
* outgrows that.
|
||||
*/
|
||||
export function applyClientFilters<T>(
|
||||
rows: T[],
|
||||
defs: FilterDef[],
|
||||
values: Record<string, FilterValue>,
|
||||
searchText: string,
|
||||
options: ClientFilterOptions<T> = {},
|
||||
): T[] {
|
||||
const term = searchText.trim().toLowerCase();
|
||||
const { searchKeys = [], searchValue } = options;
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (term) {
|
||||
const haystack = searchValue
|
||||
? searchValue(row)
|
||||
: searchKeys.map((k) => String(readField(row, String(k)) ?? "")).join(" ");
|
||||
if (!haystack.toLowerCase().includes(term)) return false;
|
||||
}
|
||||
for (const def of defs) {
|
||||
const value = values[def.key];
|
||||
if (!value) continue;
|
||||
if (!matchesFilter(def, value, readField(row, def.key))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function matchesFilter(def: FilterDef, value: FilterValue, raw: unknown): boolean {
|
||||
switch (def.type) {
|
||||
case "enum": {
|
||||
const inSet = value.v.includes(String(raw ?? ""));
|
||||
return value.op === "isNot" ? !inSet : inSet;
|
||||
}
|
||||
case "date": {
|
||||
if (value.op === "between") {
|
||||
return matchesDayRange(raw, value.v[0]?.slice(0, 10) ?? null, value.v[1]?.slice(0, 10) ?? null);
|
||||
}
|
||||
const day = toDayString(raw);
|
||||
const target = value.v[0]?.slice(0, 10);
|
||||
if (!day || !target) return false;
|
||||
return value.op === "before" ? day <= target : day >= target;
|
||||
}
|
||||
case "number": {
|
||||
const num = Number(raw);
|
||||
if (Number.isNaN(num)) return false;
|
||||
if (value.op === "between") {
|
||||
const [min, max] = value.v.map(Number);
|
||||
return num >= min && num <= max;
|
||||
}
|
||||
return value.op === "isNot" ? num !== Number(value.v[0]) : num === Number(value.v[0]);
|
||||
}
|
||||
case "boolean":
|
||||
return Boolean(raw) === (value.v[0] === "true");
|
||||
case "text": {
|
||||
const rawStr = String(raw ?? "").toLowerCase();
|
||||
const target = (value.v[0] ?? "").toLowerCase();
|
||||
return value.op === "isNot" ? !rawStr.includes(target) : rawStr.includes(target);
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from "./types";
|
||||
export * from "./url";
|
||||
export * from "./dates";
|
||||
export * from "./format";
|
||||
export * from "./clientFilter";
|
||||
export * from "./useFilters";
|
||||
export * from "./useSavedViews";
|
||||
export { FilterBar } from "./FilterBar";
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface UseFilters {
|
||||
clearFilters: () => void;
|
||||
setSort: (s: string) => void;
|
||||
setPage: (p: number) => void;
|
||||
setPageSize: (size: number) => void;
|
||||
activeCount: number;
|
||||
/** Spread onto <DataTable/>. Same shape useListControls.tableProps returns today. */
|
||||
tableProps: (total: number) => Pick<DataTableProps<any, any>, "pagination" | "tableOptions">;
|
||||
@@ -55,11 +56,12 @@ export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}):
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const searchKey = ns ? `${ns}.q` : "q";
|
||||
const pageKey = ns ? `${ns}.page` : "page";
|
||||
const sizeKey = ns ? `${ns}.size` : "size";
|
||||
|
||||
const values = useMemo(() => parseFilters(defs, sp, ns), [defs, sp, ns]);
|
||||
const sort = sp.get(ns ? `${ns}.sort` : "sort") ?? defaultSort;
|
||||
const page = Math.max(1, Number(sp.get(pageKey)) || 1);
|
||||
const pageSize = defaultPageSize;
|
||||
const pageSize = Math.max(1, Number(sp.get(sizeKey)) || defaultPageSize);
|
||||
|
||||
// Free text: local draft debounced into the URL with `replace`, so typing
|
||||
// leaves exactly one history entry instead of one per keystroke.
|
||||
@@ -144,6 +146,19 @@ export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}):
|
||||
[pageKey, setSp],
|
||||
);
|
||||
|
||||
const setPageSize = useCallback(
|
||||
(size: number) => {
|
||||
setSp((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (size === defaultPageSize) next.delete(sizeKey);
|
||||
else next.set(sizeKey, String(size));
|
||||
next.delete(pageKey); // a different page size invalidates the current page index
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[defaultPageSize, sizeKey, pageKey, setSp],
|
||||
);
|
||||
|
||||
const params = useMemo(() => {
|
||||
const filterParams = toApiParams(defs, values);
|
||||
const base: Record<string, string | number | undefined> =
|
||||
@@ -177,12 +192,13 @@ export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}):
|
||||
onPaginationChange: (updater) => {
|
||||
const current = { pageIndex: page - 1, pageSize };
|
||||
const next = typeof updater === "function" ? updater(current) : updater;
|
||||
setPage(next.pageIndex + 1);
|
||||
if (next.pageSize !== pageSize) setPageSize(next.pageSize);
|
||||
else if (next.pageIndex !== current.pageIndex) setPage(next.pageIndex + 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
[page, pageSize, setPage],
|
||||
[page, pageSize, setPage, setPageSize],
|
||||
);
|
||||
|
||||
const applyQueryString = useCallback(
|
||||
@@ -209,6 +225,7 @@ export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}):
|
||||
clearFilters,
|
||||
setSort,
|
||||
setPage,
|
||||
setPageSize,
|
||||
activeCount,
|
||||
tableProps,
|
||||
applyQueryString,
|
||||
|
||||
Reference in New Issue
Block a user