feat: Stripe-style filter bar for freight backoffice (pilot: contracts)

Replace the ad-hoc filter controls with a URL-linkable pill filter bar:
each filter is a pill that opens a type-aware popover (text/enum/date/
number/boolean, each with the right operator set), overflow filters live
behind a searchable "More filters" menu, sorting is a separate control,
and filter state round-trips through the URL query string (shareable,
back/forward-safe, backward compatible with existing ?statuses=A,B links).

Frontend (apps/edr-freight-web/backoffice/src/components/filters/):
- FilterDef schema + a pure url.ts codec (parse/serialize/toApiParams),
  with a 24-case round-trip + malformed-input test suite
- useFilters hook driving react-query params straight from useSearchParams,
  debounced search, saved views in localStorage (@mantine/hooks
  useLocalStorage), page-reset-on-filter-change baked into one
  setSearchParams call instead of a separate effect
- FilterBar/FilterPill/OperatorSelect/MoreFiltersMenu/SortControl +
  per-type popover bodies (Mantine)
- ContractRequestsPage migrated end to end as the pilot

Backend (apps/edr-freight-api):
- pagination.util: applySort() — whitelisted sortBy resolved against a
  per-module column map (never interpolated), with a mandatory `id ASC`
  tiebreaker so paginating a non-unique sort can't drop/duplicate rows
- facets.util: computeFacets() — one GROUP BY per enum column, each
  omitting its own predicate, so picking a value doesn't hide its siblings
- contracts/bookings: list-summary now returns real filter-scoped facet
  counts (contracts' getStatusCounts was unfiltered/global; superseded)
- deleted drivers/vehicles findAllWithFilters — dead code that
  interpolated an unwhitelisted sortBy straight into orderBy()
- migration: missing bookings(status)/wagons(status) indexes +
  (created_at DESC, id ASC) partials on the hot list tables

UI polish pass: inactive pill uses the opaque "default" variant instead
of a faint tinted outline, active pill uses "light" not "filled", larger
X hit target, applied filters sort first, sort control separated behind
a divider on the right and wraps independently from the filter row,
popover option rows are fully clickable (count moved inside the native
label) with bigger hit area and font, fixed a real date-filter bug where
the calendar's own portal falsely registered as an "outside click" and
closed the popover, and fixed a timezone bug where bare YYYY-MM-DD
strings were parsed as UTC instead of local time (shifts a day for EAT).

Not in this commit: rollout to the other ~59 list pages, the Ethiopian-
calendar DateBody branch, and the Family-B (client-side) bridge mode —
tracked in the filter-bar plan.
This commit is contained in:
Nathnael
2026-08-14 13:18:46 +00:00
parent ab5a4117df
commit 4a4d3077d7
32 changed files with 1879 additions and 376 deletions

View File

@@ -0,0 +1,121 @@
import { useState, type ReactNode } from "react";
import { Anchor, Divider, Group, TextInput } from "@mantine/core";
import { Search, Trash2 } from "lucide-react";
import type { FilterDef, SortOption } from "./types";
import type { UseFilters } from "./useFilters";
import { FilterPill } from "./FilterPill";
import { MoreFiltersMenu } from "./MoreFiltersMenu";
import { SavedViews } from "./SavedViews";
import { SortControl } from "./SortControl";
export interface FilterBarProps {
defs: FilterDef[];
controls: UseFilters;
searchPlaceholder?: string;
showSearch?: boolean;
/** value already "field:DIR" — the page's existing SORT_OPTIONS, moved not rewritten. */
sortOptions?: SortOption[];
/** localStorage namespace for saved views. Omit to hide the control. */
viewId?: string;
/** Escape hatch: tabs, row count, a "New" button — rendered at the far right. */
children?: ReactNode;
}
export function FilterBar({
defs,
controls,
searchPlaceholder = "Search…",
showSearch = true,
sortOptions,
viewId,
children,
}: FilterBarProps) {
// Filters just picked from "More filters" render as an already-open pill
// until the popover closes, then fall back to the ordinary pinned/active split.
const [justPicked, setJustPicked] = useState<string[]>([]);
const pinned = defs.filter((d) => !d.secondary || controls.values[d.key] || justPicked.includes(d.key));
const secondary = defs.filter((d) => !pinned.includes(d));
// Applied filters read first, left to right — a stable partition keeps
// each group in its original def order rather than resorting on every apply.
const orderedPinned = [
...pinned.filter((d) => controls.values[d.key]),
...pinned.filter((d) => !controls.values[d.key]),
];
return (
// Two independent flex zones, not one big wrapping Group: the left side
// (search + pills + more filters + clear) wraps to as many lines as it
// needs; the right side (sort) stays put on the first line — `nowrap` +
// `flexShrink: 0` on the right zone stop it from ever getting pushed
// down when the left side overflows.
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, flexWrap: "nowrap" }}>
<Group gap="xs" wrap="wrap" align="center" style={{ flex: 1, minWidth: 0 }}>
{viewId && (
<SavedViews
viewId={viewId}
currentQueryString={controls.currentQueryString}
applyQueryString={controls.applyQueryString}
/>
)}
{showSearch && (
<TextInput
placeholder={searchPlaceholder}
leftSection={<Search size={14} />}
value={controls.searchText}
onChange={(e) => controls.setSearchText(e.currentTarget.value)}
size="xs"
radius="lg"
style={{ minWidth: 220 }}
/>
)}
{orderedPinned.map((def) => (
<FilterPill
key={def.key}
def={def}
value={controls.values[def.key]}
onChange={(v) => controls.setFilter(def.key, v)}
autoOpen={justPicked.includes(def.key)}
/>
))}
<MoreFiltersMenu
defs={secondary}
onPick={(key) => setJustPicked((prev) => [...prev, key])}
/>
{controls.activeCount > 0 && (
<Anchor
size="xs"
c="red.6"
underline="never"
onClick={() => {
controls.clearFilters();
setJustPicked([]);
}}
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
>
<Trash2 size={13} />
Clear
</Anchor>
)}
</Group>
{/* Sorting is a different kind of control (view order, not scope) —
cut off from the filter pills by a vertical divider and pinned to
the right, independent of how the left side wraps. */}
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{children}
{sortOptions && sortOptions.length > 0 && (
<>
<Divider orientation="vertical" />
<SortControl options={sortOptions} value={controls.sort} onChange={controls.setSort} />
</>
)}
</Group>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { useState } from "react";
import { ActionIcon, Button, Popover } from "@mantine/core";
import { Plus, X } from "lucide-react";
import type { FilterDef, FilterValue } from "./types";
import { BooleanBody } from "./bodies/BooleanBody";
import { DateBody } from "./bodies/DateBody";
import { EnumBody } from "./bodies/EnumBody";
import { NumberBody } from "./bodies/NumberBody";
import { TextBody } from "./bodies/TextBody";
const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
text: TextBody,
enum: EnumBody,
date: DateBody,
number: NumberBody,
boolean: BooleanBody,
};
function formatValue(def: FilterDef, value: FilterValue): string {
if (def.format) return def.format(value, def);
if (def.type === "enum") {
const labels = value.v.map((v) => def.options.find((o) => o.value === v)?.label ?? v);
return labels.join(", ");
}
if (def.type === "date" && value.v.length === 2) {
return `${value.v[0].slice(0, 10)}${value.v[1].slice(0, 10)}`;
}
return value.v.join(", ");
}
export interface FilterPillProps {
def: FilterDef;
value: FilterValue | undefined;
onChange: (v: FilterValue | undefined) => void;
/** Opened immediately (used when picked from "More filters"). */
autoOpen?: boolean;
}
export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps) {
const [opened, setOpened] = useState(Boolean(autoOpen));
const Body = BODIES[def.type];
const active = Boolean(value);
return (
<Popover position="bottom-start" withinPortal shadow="md" opened={opened} onChange={setOpened}>
<Popover.Target>
<Button
size="xs"
radius="xl"
// Inactive: "default" variant (solid border, opaque text) reads far
// less faint than a color-tinted outline — dashed border is the only
// thing marking it as "not set yet". Active: "light" (soft tinted
// fill), not "filled" — a whole row of solid green buttons was the
// "too loud" complaint; light keeps the active/inactive contrast
// without shouting.
variant={active ? "light" : "default"}
color={active ? "edr-green" : undefined}
styles={active ? undefined : { root: { borderStyle: "dashed" } }}
leftSection={!active && <Plus size={12} />}
rightSection={
active && (
<ActionIcon
component="span"
size={16}
radius="xl"
variant="subtle"
color="edr-green"
onClick={(e) => {
e.stopPropagation();
onChange(undefined);
}}
>
<X size={12} />
</ActionIcon>
)
}
onClick={() => setOpened((o) => !o)}
>
{active ? `${def.label} | ${formatValue(def, value!)}` : def.label}
</Button>
</Popover.Target>
<Popover.Dropdown miw={260} p="xs">
<Body def={def} value={value} onChange={onChange} onClose={() => setOpened(false)} />
</Popover.Dropdown>
</Popover>
);
}

View File

@@ -0,0 +1,82 @@
import { useMemo, useState } from "react";
import { Button, Popover, ScrollArea, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
import { Plus, Search } from "lucide-react";
import type { FilterDef } from "./types";
export interface MoreFiltersMenuProps {
defs: FilterDef[];
/** Called with the picked def's key — the caller pins it and opens its popover. */
onPick: (key: string) => void;
}
/** Searchable list over the page's secondary/inactive filters. Plain filter + list,
* not cmdk — a handful of static strings doesn't need a Combobox store. */
export function MoreFiltersMenu({ defs, onPick }: MoreFiltersMenuProps) {
const [opened, setOpened] = useState(false);
const [query, setQuery] = useState("");
const visible = useMemo(
() => defs.filter((d) => d.label.toLowerCase().includes(query.toLowerCase())),
[defs, query],
);
if (defs.length === 0) return null;
return (
<Popover position="bottom-start" withinPortal shadow="md" opened={opened} onChange={setOpened}>
<Popover.Target>
<Button
size="xs"
radius="xl"
variant="outline"
color="gray"
leftSection={<Plus size={16} />}
onClick={() => setOpened((o) => !o)}
>
More filters
</Button>
</Popover.Target>
<Popover.Dropdown miw={220} p="xs">
<Stack gap="xs">
<TextInput
placeholder="Search filters…"
leftSection={<Search size={14} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
size="sm"
autoFocus
/>
<ScrollArea.Autosize mah={280}>
<Stack gap={2}>
{visible.map((d) => (
<UnstyledButton
key={d.key}
px="xs"
py={6}
className="hover:bg-gray-100 transition-colors"
style={{ borderRadius: 6, display: "flex", alignItems: "center", gap: 8 }}
onClick={() => {
setOpened(false);
setQuery("");
onPick(d.key);
}}
>
<Plus size={14} className="text-[var(--mantine-color-edr-green-6)]" />
<Text size="sm" c="edr-green.7">
{d.label}
</Text>
</UnstyledButton>
))}
{visible.length === 0 && (
<Text size="xs" c="dimmed" px="xs" py={6}>
No matching filters
</Text>
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Popover.Dropdown>
</Popover>
);
}

View File

@@ -0,0 +1,25 @@
import { SegmentedControl } from "@mantine/core";
import { DEFAULT_OP, OPERATOR_LABELS, type FilterDef, type Operator } from "./types";
export interface OperatorSelectProps {
def: FilterDef;
value: Operator;
onChange: (op: Operator) => void;
}
/** Renders nothing when a def has <= 1 operator — most defs, by design: type-aware
* operators are a capability, not a dropdown forced into every popover. */
export function OperatorSelect({ def, value, onChange }: OperatorSelectProps) {
const operators = def.operators ?? [DEFAULT_OP[def.type]];
if (operators.length <= 1) return null;
return (
<SegmentedControl
size="sm"
fullWidth
value={value}
onChange={(v) => onChange(v as Operator)}
data={operators.map((op) => ({ value: op, label: OPERATOR_LABELS[op] }))}
mb="xs"
/>
);
}

View File

@@ -0,0 +1,107 @@
import { useState } from "react";
import { ActionIcon, Button, Menu, Modal, Stack, Text, TextInput } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { Bookmark, Check, Save, Trash2 } from "lucide-react";
interface SavedView {
id: string;
name: string;
query: string;
}
export interface SavedViewsProps {
/** localStorage namespace — one page, not one user (single staff login per
* browser profile). ponytail: add ":<userId>" if shared-terminal login appears. */
viewId: string;
currentQueryString: () => string;
applyQueryString: (query: string) => void;
}
/** URL always wins: this menu only ever WRITES the URL, on click. Nothing
* reads a saved view at mount, so a shared link always beats a saved view —
* there is no "which one applies" branch to get wrong. */
export function SavedViews({ viewId, currentQueryString, applyQueryString }: SavedViewsProps) {
const [views, setViews] = useLocalStorage<SavedView[]>({
key: `edr:saved-views:${viewId}`,
defaultValue: [],
});
const [saveOpen, setSaveOpen] = useState(false);
const [name, setName] = useState("");
const activeQuery = currentQueryString();
const active = views.find((v) => v.query === activeQuery);
const save = () => {
if (!name.trim()) return;
setViews((prev) => [
...prev,
{ id: crypto.randomUUID(), name: name.trim(), query: currentQueryString() },
]);
setName("");
setSaveOpen(false);
};
const remove = (id: string) => setViews((prev) => prev.filter((v) => v.id !== id));
return (
<>
<Menu position="bottom-start" withinPortal shadow="md">
<Menu.Target>
<Button size="xs" variant="subtle" color="gray" leftSection={<Bookmark size={14} />}>
{active?.name ?? "All"}
</Button>
</Menu.Target>
<Menu.Dropdown miw={220}>
{views.length === 0 && (
<Menu.Item disabled>
<Text size="xs" c="dimmed">
No saved views yet
</Text>
</Menu.Item>
)}
{views.map((v) => (
<Menu.Item
key={v.id}
leftSection={v.id === active?.id ? <Check size={14} /> : <span style={{ width: 14 }} />}
rightSection={
<ActionIcon
size="xs"
color="red"
variant="subtle"
onClick={(e) => {
e.stopPropagation();
remove(v.id);
}}
>
<Trash2 size={12} />
</ActionIcon>
}
onClick={() => applyQueryString(v.query)}
>
{v.name}
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item leftSection={<Save size={14} />} onClick={() => setSaveOpen(true)}>
Save current view
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Modal opened={saveOpen} onClose={() => setSaveOpen(false)} title="Save current view" size="sm">
<Stack gap="sm">
<TextInput
placeholder="View name"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && save()}
autoFocus
/>
<Button onClick={save} disabled={!name.trim()}>
Save
</Button>
</Stack>
</Modal>
</>
);
}

View File

@@ -0,0 +1,40 @@
import { Button, Menu } from "@mantine/core";
import { ArrowUpDown, Check } from "lucide-react";
import type { SortOption } from "./types";
export interface SortControlProps {
options: SortOption[];
value: string;
onChange: (value: string) => void;
}
/** A control, not a form field — Menu (not Select) gives the check-mark +
* trigger-label read Stripe's sort control has. Rendered only when a page
* passes sortOptions; inventing options for an endpoint without sortBy
* support would ship a control that silently does nothing. */
export function SortControl({ options, value, onChange }: SortControlProps) {
if (options.length === 0) return null;
const current = options.find((o) => o.value === value);
return (
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<Button size="xs" variant="outline" color="gray" leftSection={<ArrowUpDown size={14} />}>
{current?.label ?? "Sort"}
</Button>
</Menu.Target>
<Menu.Dropdown>
{options.map((o) => (
<Menu.Item
key={o.value}
leftSection={o.value === value ? <Check size={14} /> : <span style={{ width: 14 }} />}
onClick={() => onChange(o.value)}
>
{o.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

View File

@@ -0,0 +1,32 @@
import { useState } from "react";
import { Button, Radio, Stack } from "@mantine/core";
import { DEFAULT_OP } from "../types";
import type { BooleanFilterDef, Operator } from "../types";
import { OperatorSelect } from "../OperatorSelect";
import type { FilterBodyProps } from "./TextBody";
export function BooleanBody({ def, value, onChange, onClose }: FilterBodyProps<BooleanFilterDef>) {
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.boolean);
const [v, setV] = useState(value?.v[0] ?? "");
const apply = () => {
onChange(v ? { op, v: [v] } : undefined);
onClose();
};
return (
<Stack gap="xs">
<OperatorSelect def={def} value={op} onChange={setOp} />
<Radio.Group value={v} onChange={setV}>
<Stack gap={6}>
<Radio value="true" label={def.trueLabel ?? "Yes"} size="sm" />
<Radio value="false" label={def.falseLabel ?? "No"} size="sm" />
</Stack>
</Radio.Group>
<Button size="sm" onClick={apply}>
Apply
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,87 @@
import { useState } from "react";
import { Button, Stack } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates";
import { DEFAULT_OP } from "../types";
import type { DateFilterDef, Operator } from "../types";
import { OperatorSelect } from "../OperatorSelect";
import type { FilterBodyProps } from "./TextBody";
// ponytail: Gregorian only. Record-management pages need the Ethiopian
// calendar (see shared/common/form/fields/AmharicDatePicker.tsx) — add an
// i18n.language !== "en" branch here when this body is first wired into a
// record-management page (Phase 4 of the filter-bar rollout).
export function DateBody({ def, value, onChange, onClose }: FilterBodyProps<DateFilterDef>) {
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.date);
// Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects.
const [from, setFrom] = useState<string | null>(value?.v[0]?.slice(0, 10) ?? null);
const [to, setTo] = useState<string | null>(value?.v[1]?.slice(0, 10) ?? null);
const apply = () => {
if (op === "between") {
onChange(
from && to
? { op, v: [startOfDayIso(parseDateStr(from)), endOfDayIso(parseDateStr(to))] }
: undefined,
);
} else {
onChange(
from
? {
op,
v: [
op === "before"
? startOfDayIso(parseDateStr(from))
: endOfDayIso(parseDateStr(from)),
],
}
: undefined,
);
}
onClose();
};
// This popover already lives inside FilterPill's own Popover. Mantine's
// DatePickerInput opens ITS calendar in a separate portal by default, so a
// click on a day registers as "outside" the outer Popover and closes the
// whole filter before the range can be picked (or Apply reached) — the
// reported "date picker doesn't work". Keeping the calendar un-portalled
// renders it inside the outer popover's own DOM subtree instead, so
// outside-click detection sees it as inside.
const nestedPopoverProps = { withinPortal: false } as const;
return (
<Stack gap="xs">
<OperatorSelect def={def} value={op} onChange={setOp} />
{op === "between" ? (
<DatePickerInput
type="range"
placeholder="Any"
value={[from, to]}
onChange={([f, t]) => {
setFrom(f);
setTo(t);
}}
presets={getDateRangePresets()}
popoverProps={nestedPopoverProps}
clearable
autoFocus
/>
) : (
<DatePickerInput
placeholder="Any"
value={from}
onChange={setFrom}
popoverProps={nestedPopoverProps}
clearable
autoFocus
/>
)}
<Button size="sm" onClick={apply} disabled={op === "between" ? !(from && to) : !from}>
Apply
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,128 @@
import { useMemo, useState } from "react";
import { Button, Checkbox, Group, Radio, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
import { Search } from "lucide-react";
import { DEFAULT_OP } from "../types";
import type { EnumFilterDef, Operator } from "../types";
import { OperatorSelect } from "../OperatorSelect";
import type { FilterBodyProps } from "./TextBody";
/** How many options before a search box appears above the list. */
const SEARCH_THRESHOLD = 8;
/**
* Stretches the Checkbox/Radio's native <label> across the full popover
* width and pads it, so the clickable/tappable area is the whole row —
* not just the ~14px input square — plus a hover cue. `body`/`labelWrapper`
* are Mantine's part names for this; `cursor: pointer` on the row (not just
* the input) makes the affordance visible before you even click.
*/
const ROW_STYLES = {
root: { padding: "10px 10px", borderRadius: 6 },
body: { alignItems: "center" as const },
labelWrapper: { flex: 1 },
label: { cursor: "pointer", paddingLeft: 8 },
};
function OptionLabel({ label, count }: { label: string; count?: number }) {
return (
<Group justify="space-between" wrap="nowrap" gap="sm">
<Text size="sm">{label}</Text>
{count !== undefined && (
<Text size="sm" c="dimmed">
{count}
</Text>
)}
</Group>
);
}
export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<EnumFilterDef>) {
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.enum);
const [selected, setSelected] = useState<string[]>(value?.v ?? []);
const [query, setQuery] = useState("");
const [showAll, setShowAll] = useState(false);
const multiple = def.multiple ?? true;
const visible = useMemo(() => {
const byQuery = query
? def.options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
: def.options;
if (!def.counts || showAll) return byQuery;
// Hide zero-count options, but never hide one the user already picked —
// otherwise a filter that narrows to zero rows becomes impossible to un-select.
return byQuery.filter((o) => (def.counts![o.value] ?? 0) > 0 || selected.includes(o.value));
}, [def.options, def.counts, query, showAll, selected]);
const hiddenCount = def.options.length - visible.length;
const apply = () => {
onChange(selected.length ? { op, v: selected } : undefined);
onClose();
};
return (
<Stack gap="xs">
<OperatorSelect def={def} value={op} onChange={setOp} />
{def.options.length > SEARCH_THRESHOLD && (
<TextInput
placeholder="Search options…"
leftSection={<Search size={14} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
size="sm"
/>
)}
<Stack gap={0} mah={260} style={{ overflowY: "auto" }}>
{multiple ? (
<Checkbox.Group value={selected} onChange={setSelected} aria-label={`Filter by ${def.label}`}>
<Stack gap={0}>
{visible.map((o) => (
<Checkbox
key={o.value}
value={o.value}
size="sm"
// The count sits INSIDE the label, so it's part of the
// native <label> the input is bound to — clicking it (not
// just the tiny checkbox square) toggles the option too.
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
styles={ROW_STYLES}
classNames={{ root: "hover:bg-gray-100 transition-colors" }}
/>
))}
</Stack>
</Checkbox.Group>
) : (
<Radio.Group
value={selected[0] ?? ""}
onChange={(v) => setSelected(v ? [v] : [])}
aria-label={`Filter by ${def.label}`}
>
<Stack gap={0}>
{visible.map((o) => (
<Radio
key={o.value}
value={o.value}
size="sm"
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
styles={ROW_STYLES}
classNames={{ root: "hover:bg-gray-100 transition-colors" }}
/>
))}
</Stack>
</Radio.Group>
)}
{!showAll && hiddenCount > 0 && (
<UnstyledButton onClick={() => setShowAll(true)}>
<Text size="sm" c="edr-green.6">
Show all {def.options.length} options
</Text>
</UnstyledButton>
)}
</Stack>
<Button size="sm" onClick={apply}>
Apply
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,39 @@
import { useState } from "react";
import { Button, Group, NumberInput, Stack } from "@mantine/core";
import { DEFAULT_OP } from "../types";
import type { NumberFilterDef, Operator } from "../types";
import { OperatorSelect } from "../OperatorSelect";
import type { FilterBodyProps } from "./TextBody";
export function NumberBody({ def, value, onChange, onClose }: FilterBodyProps<NumberFilterDef>) {
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.number);
const [from, setFrom] = useState<number | "">(value?.v[0] ? Number(value.v[0]) : "");
const [to, setTo] = useState<number | "">(op === "between" ? (Number(value?.v[1]) || "") : "");
const apply = () => {
if (op === "between") {
onChange(from !== "" && to !== "" ? { op, v: [String(from), String(to)] } : undefined);
} else {
onChange(from !== "" ? { op, v: [String(from)] } : undefined);
}
onClose();
};
return (
<Stack gap="xs">
<OperatorSelect def={def} value={op} onChange={setOp} />
{op === "between" ? (
<Group gap="xs" wrap="nowrap">
<NumberInput placeholder="Min" value={from} onChange={(v) => setFrom(v as number | "")} rightSection={def.unit} autoFocus />
<NumberInput placeholder="Max" value={to} onChange={(v) => setTo(v as number | "")} rightSection={def.unit} />
</Group>
) : (
<NumberInput placeholder="Value" value={from} onChange={(v) => setFrom(v as number | "")} rightSection={def.unit} autoFocus />
)}
<Button size="sm" onClick={apply}>
Apply
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,39 @@
import { useState } from "react";
import { Button, Stack, TextInput } from "@mantine/core";
import { DEFAULT_OP } from "../types";
import type { TextFilterDef, FilterValue, Operator } from "../types";
import { OperatorSelect } from "../OperatorSelect";
export interface FilterBodyProps<Def> {
def: Def;
value: FilterValue | undefined;
onChange: (v: FilterValue | undefined) => void;
onClose: () => void;
}
export function TextBody({ def, value, onChange, onClose }: FilterBodyProps<TextFilterDef>) {
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.text);
const [text, setText] = useState(value?.v[0] ?? "");
const apply = () => {
onChange(text.trim() ? { op, v: [text.trim()] } : undefined);
onClose();
};
return (
<Stack gap="xs">
<OperatorSelect def={def} value={op} onChange={setOp} />
<TextInput
placeholder={def.placeholder ?? `Filter by ${def.label.toLowerCase()}`}
value={text}
onChange={(e) => setText(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && apply()}
autoFocus
/>
<Button size="sm" onClick={apply}>
Apply
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,31 @@
/** Local start-of-day -> ISO, for inclusive "from" date filters. Lifted out of
* ContractRequestsPage (where it was duplicated into BookingRequestsPage) so
* every date filter shares one definition. */
export function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
/** Local end-of-day -> ISO, for inclusive "to" date filters. */
export function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
/**
* Parse a `YYYY-MM-DD` date-picker string into a LOCAL-midnight Date.
*
* `new Date("2026-01-01")` is a date-ONLY ISO string, which the spec parses
* as UTC midnight, not local midnight. For anyone east of UTC (Ethiopia is
* UTC+3) that instant already falls on the PREVIOUS local day, so
* `startOfDayIso`/`endOfDayIso` built from it silently shift the picked date
* back by one — the picker looks fine, the filtered results are wrong. This
* constructor form (`new Date(y, m, d)`) is local by definition; use it for
* every date-only string instead of `new Date(dateString)`.
*/
export function parseDateStr(dateStr: string): Date {
const [y, m, d] = dateStr.split("-").map(Number);
return new Date(y, (m || 1) - 1, d || 1);
}

View File

@@ -0,0 +1,10 @@
export * from "./types";
export * from "./url";
export * from "./dates";
export * from "./useFilters";
export { FilterBar } from "./FilterBar";
export type { FilterBarProps } from "./FilterBar";
export { FilterPill } from "./FilterPill";
export { SortControl } from "./SortControl";
export { SavedViews } from "./SavedViews";
export { MoreFiltersMenu } from "./MoreFiltersMenu";

View File

@@ -0,0 +1,101 @@
export type FilterType = "text" | "enum" | "date" | "number" | "boolean";
export type Operator = "is" | "isNot" | "contains" | "between" | "before" | "after";
/** Operator implied by a filter's type when the def doesn't say otherwise. */
export const DEFAULT_OP: Record<FilterType, Operator> = {
text: "contains",
enum: "is",
date: "between",
number: "is",
boolean: "is",
};
export const OPERATOR_LABELS: Record<Operator, string> = {
is: "is",
isNot: "is not",
contains: "contains",
between: "is between",
before: "is before",
after: "is after",
};
/**
* A filter's current value. `v` holds:
* - 1 entry for is / isNot / contains / before / after
* - 2 entries for between (range)
* - n entries for a multi-select enum (isAnyOf is expressed as op "is" + n values)
*/
export interface FilterValue {
op: Operator;
v: string[];
}
export interface FilterOption {
value: string;
label: string;
}
export interface FacetBucket {
value: string;
count: number;
}
interface FilterDefBase {
/** URL key and, by default, the API param name. */
key: string;
/** Plain string — the caller applies i18n's t() before passing it in. */
label: string;
type: FilterType;
/** Defaults to `[DEFAULT_OP[type]]`. Widen only where the endpoint implements it. */
operators?: Operator[];
/** Pill text override. Default: "Label | value(s)". */
format?: (v: FilterValue, def: FilterDef) => string;
/** Map to API query params. Default `{ [key]: v.join(",") }`. */
toParams?: (v: FilterValue) => Record<string, string | undefined>;
/** Lives behind "More filters" until it has a value. Default false. */
secondary?: boolean;
}
export interface TextFilterDef extends FilterDefBase {
type: "text";
placeholder?: string;
}
export interface EnumFilterDef extends FilterDefBase {
type: "enum";
options: FilterOption[];
/** Default true — checkbox list. false renders a single-select radio list. */
multiple?: boolean;
/** value -> count in the current (filtered) result set. Absent = no counts, hide nothing. */
counts?: Record<string, number>;
}
export interface DateFilterDef extends FilterDefBase {
type: "date";
calendar?: "gregorian" | "ethiopian";
}
export interface NumberFilterDef extends FilterDefBase {
type: "number";
unit?: string;
}
export interface BooleanFilterDef extends FilterDefBase {
type: "boolean";
trueLabel?: string;
falseLabel?: string;
}
export type FilterDef =
| TextFilterDef
| EnumFilterDef
| DateFilterDef
| NumberFilterDef
| BooleanFilterDef;
/** A page's sort options — value is already `"field:DIR"`, the codebase's existing convention. */
export interface SortOption {
value: string;
label: string;
}

View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import type { FilterDef, FilterValue } from "./types";
import {
decodeFilterValue,
encodeFilterValue,
parseFilters,
parseSort,
toApiParams,
writeFilter,
} from "./url";
const STATUS: FilterDef = {
key: "statuses",
label: "Status",
type: "enum",
options: [
{ value: "ACTIVE", label: "Active" },
{ value: "DRAFT", label: "Draft" },
],
};
const DIRECTION: FilterDef = {
key: "tradeDirection",
label: "Direction",
type: "enum",
multiple: false,
options: [{ value: "IMPORT", label: "Import" }],
};
const SEARCH: FilterDef = { key: "q", label: "Search", type: "text" };
const CREATED: FilterDef = {
key: "created",
label: "Created",
type: "date",
toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }),
};
describe("encodeFilterValue / decodeFilterValue round-trip", () => {
const cases: Array<{ name: string; type: FilterDef["type"]; value: FilterValue }> = [
{ name: "text contains (default op omitted)", type: "text", value: { op: "contains", v: ["maersk"] } },
{ name: "enum is (default op omitted, multi value)", type: "enum", value: { op: "is", v: ["ACTIVE", "DRAFT"] } },
{ name: "enum isNot (non-default op prefixed)", type: "enum", value: { op: "isNot", v: ["GOV"] } },
{ name: "date between (default op omitted)", type: "date", value: { op: "between", v: ["2026-01-01", "2026-03-01"] } },
{ name: "date before (non-default op prefixed)", type: "date", value: { op: "before", v: ["2026-01-01"] } },
{ name: "number is", type: "number", value: { op: "is", v: ["42"] } },
{ name: "boolean is", type: "boolean", value: { op: "is", v: ["true"] } },
];
for (const { name, type, value } of cases) {
it(`round-trips: ${name}`, () => {
const encoded = encodeFilterValue(type, value);
const decoded = decodeFilterValue(type, encoded);
expect(decoded).toEqual(value);
});
}
it("omits the operator prefix only when it is the type default", () => {
expect(encodeFilterValue("enum", { op: "is", v: ["ACTIVE"] })).toBe("ACTIVE");
expect(encodeFilterValue("enum", { op: "isNot", v: ["ACTIVE"] })).toBe("isNot:ACTIVE");
});
it("never comma-splits a text value, so a literal comma survives", () => {
const encoded = encodeFilterValue("text", { op: "contains", v: ["Addis, Ethiopia"] });
expect(decodeFilterValue("text", encoded)).toEqual({ op: "contains", v: ["Addis, Ethiopia"] });
});
});
describe("decodeFilterValue malformed-input tolerance", () => {
it("returns null for an empty string", () => {
expect(decodeFilterValue("text", "")).toBeNull();
});
it("does not treat an unknown prefix as an operator", () => {
// "foo" isn't a known Operator, so "foo:bar" is a literal text value, not op:value.
expect(decodeFilterValue("text", "foo:bar")).toEqual({ op: "contains", v: ["foo:bar"] });
});
it("degrades a one-sided 'between' to null (not applied) instead of guessing a half-open range", () => {
expect(decodeFilterValue("date", "between:2026-01-01")).toBeNull();
});
it("never throws on garbage input", () => {
expect(() => decodeFilterValue("enum", "isNot:")).not.toThrow();
expect(() => decodeFilterValue("date", "between:")).not.toThrow();
expect(() => decodeFilterValue("number", ":::")).not.toThrow();
});
});
describe("parseFilters / writeFilter", () => {
it("parses only the defs present, ignoring unrelated params", () => {
const params = new URLSearchParams("statuses=ACTIVE,DRAFT&unrelated=x&q=addis");
const values = parseFilters([STATUS, SEARCH], params);
expect(values).toEqual({
statuses: { op: "is", v: ["ACTIVE", "DRAFT"] },
q: { op: "contains", v: ["addis"] },
});
});
it("writeFilter deletes the param when value is undefined", () => {
const params = new URLSearchParams("statuses=ACTIVE");
const next = writeFilter(params, STATUS, undefined);
expect(next.has("statuses")).toBe(false);
});
it("writeFilter round-trips through parseFilters", () => {
const value: FilterValue = { op: "is", v: ["IMPORT"] };
const next = writeFilter(new URLSearchParams(), DIRECTION, value);
expect(parseFilters([DIRECTION], next)).toEqual({ tradeDirection: value });
});
it("namespaces keys when ns is given, so two tables on one page don't collide", () => {
const next = writeFilter(new URLSearchParams(), STATUS, { op: "is", v: ["ACTIVE"] }, "a");
expect(next.get("a.statuses")).toBe("ACTIVE");
expect(parseFilters([STATUS], new URLSearchParams(), "b")).toEqual({});
});
});
describe("existing deep-link backward compatibility", () => {
it("parses the BookingRequestsPage-style ?statuses=A,B&tradeDirection=IMPORT link unchanged", () => {
const params = new URLSearchParams("statuses=SUBMITTED,APPROVED&tradeDirection=IMPORT");
expect(parseFilters([STATUS, DIRECTION], params)).toEqual({
statuses: { op: "is", v: ["SUBMITTED", "APPROVED"] },
tradeDirection: { op: "is", v: ["IMPORT"] },
});
});
});
describe("parseSort", () => {
const options = [
{ value: "createdAt:DESC", label: "Newest first" },
{ value: "createdAt:ASC", label: "Oldest first" },
];
it("returns the fallback when sort is absent", () => {
expect(parseSort(new URLSearchParams(), options, "createdAt:DESC")).toBe("createdAt:DESC");
});
it("returns the fallback for an unrecognized sort value", () => {
expect(parseSort(new URLSearchParams("sort=bogus:DESC"), options, "createdAt:DESC")).toBe(
"createdAt:DESC",
);
});
it("returns the URL value when it is a known option", () => {
expect(parseSort(new URLSearchParams("sort=createdAt:ASC"), options, "createdAt:DESC")).toBe(
"createdAt:ASC",
);
});
});
describe("toApiParams", () => {
it("uses the default mapping (key: joined csv) when toParams is absent", () => {
const values = { statuses: { op: "is" as const, v: ["ACTIVE", "DRAFT"] } };
expect(toApiParams([STATUS], values)).toEqual({ statuses: "ACTIVE,DRAFT" });
});
it("uses a custom toParams to reproduce an existing API's exact param names", () => {
const values = { created: { op: "between" as const, v: ["2026-01-01", "2026-03-01"] } };
expect(toApiParams([CREATED], values)).toEqual({
createdFrom: "2026-01-01",
createdTo: "2026-03-01",
});
});
it("omits defs with no value", () => {
expect(toApiParams([STATUS, SEARCH], {})).toEqual({});
});
});

View File

@@ -0,0 +1,117 @@
import { DEFAULT_OP, type FilterDef, type FilterValue, type Operator } from "./types";
const OPERATORS: readonly Operator[] = ["is", "isNot", "contains", "between", "before", "after"];
/**
* Encode one filter value as `[op:]csv`, omitting the operator prefix when it
* matches the type's default — that keeps the common case short and, more
* importantly, keeps the existing `?statuses=A,B` deep links this app already
* generates (e.g. the header document-review alarm) parsing identically.
*
* `,` and `:` are structural inside an encoded value. A `text` filter is
* never comma-split (its `v` always has exactly one entry), which is what
* lets a free-text search contain a literal comma safely.
* ponytail: if a value ever legitimately needs a literal "op:" prefix or a
* comma inside a multi-value filter, switch that filter to a JSON-in-one-param
* encoding rather than trying to escape these two characters.
*/
export function encodeFilterValue(type: FilterDef["type"], value: FilterValue): string {
const csv = value.v.map(encodeURIComponent).join(",");
return value.op === DEFAULT_OP[type] ? csv : `${value.op}:${csv}`;
}
/** Inverse of `encodeFilterValue`. Returns null for anything malformed — a bad
* URL is user input and must degrade to "filter not applied", never throw. */
export function decodeFilterValue(type: FilterDef["type"], raw: string): FilterValue | null {
if (!raw) return null;
const firstColon = raw.indexOf(":");
let op: Operator = DEFAULT_OP[type];
let rest = raw;
if (firstColon > 0) {
const prefix = raw.slice(0, firstColon);
if ((OPERATORS as string[]).includes(prefix)) {
op = prefix as Operator;
rest = raw.slice(firstColon + 1);
}
}
// Text filters are single-value and never comma-split, so a literal comma
// in a search term round-trips unchanged.
const v =
type === "text"
? [decodeURIComponent(rest)]
: rest.split(",").filter(Boolean).map(decodeURIComponent);
if (v.length === 0) return null;
// A malformed range (wrong arity) has no safe single-sided interpretation —
// "between:2026-01-01" doesn't say whether that's the from or the to — so
// it degrades to "filter not applied" rather than guessing a half-open range.
if (op === "between" && v.length !== 2) return null;
return { op, v };
}
/** Every FilterDef's current value, parsed from the URL. Unknown/malformed entries are dropped. */
export function parseFilters(
defs: FilterDef[],
params: URLSearchParams,
ns?: string,
): Record<string, FilterValue> {
const out: Record<string, FilterValue> = {};
for (const def of defs) {
const raw = params.get(nsKey(def.key, ns));
if (!raw) continue;
const value = decodeFilterValue(def.type, raw);
if (value) out[def.key] = value;
}
return out;
}
/** Write (or delete) one filter's value into a URLSearchParams, returning a new instance. */
export function writeFilter(
params: URLSearchParams,
def: FilterDef,
value: FilterValue | undefined,
ns?: string,
): URLSearchParams {
const next = new URLSearchParams(params);
const key = nsKey(def.key, ns);
if (!value || value.v.length === 0) next.delete(key);
else next.set(key, encodeFilterValue(def.type, value));
return next;
}
function nsKey(key: string, ns?: string): string {
return ns ? `${ns}.${key}` : key;
}
/** `?sort=field:DIR` -> `"field:DIR"`, defaulting when absent/unrecognized. */
export function parseSort(params: URLSearchParams, options: { value: string }[], fallback: string): string {
const raw = params.get("sort");
if (raw && options.some((o) => o.value === raw)) return raw;
return fallback;
}
/**
* Flatten every def's parsed value into the flat param object a page's
* react-query filter object / axios params already expect. `toParams`
* defaults to `{ [key]: v.join(",") }`, which reproduces exactly what
* `?statuses=A,B` meant before this bar existed.
*/
export function toApiParams(
defs: FilterDef[],
values: Record<string, FilterValue>,
): Record<string, string | undefined> {
const out: Record<string, string | undefined> = {};
for (const def of defs) {
const value = values[def.key];
if (!value) continue;
const mapped = def.toParams ? def.toParams(value) : { [def.key]: value.v.join(",") };
Object.assign(out, mapped);
}
return out;
}
/** Delete emptystring/undefined/null entries — never send them, never write them to the URL. */
export function cleanParams<T extends Record<string, unknown>>(params: T): Partial<T> {
return Object.fromEntries(
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== ""),
) as Partial<T>;
}

View File

@@ -0,0 +1,219 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useDebouncedValue } from "@mantine/hooks";
import type { DataTablePagination, DataTableProps } from "@edr/ui-common";
import type { FilterDef, FilterValue } from "./types";
import { cleanParams, parseFilters, toApiParams, writeFilter } from "./url";
export interface UseFiltersOptions {
/** value = "field:DIR", matching the codebase's existing sort convention. */
defaultSort?: string;
pageSize?: number;
/** "page" (freight: {page,pageSize}) or "skip" (record-management: {skip,take}). */
paginationStyle?: "page" | "skip";
/** Namespaces URL keys ("<ns>.<key>") for pages with two independent tables. */
ns?: string;
/** Search box debounce, ms. */
searchDebounceMs?: number;
}
export interface UseFilters {
values: Record<string, FilterValue>;
/** Flat params ready for the react-query key + axios `params` — IS the query key input. */
params: Record<string, string | number>;
sort: string;
page: number;
pageSize: number;
searchText: string;
setSearchText: (s: string) => void;
setFilter: (key: string, value: FilterValue | undefined) => void;
removeFilter: (key: string) => void;
clearFilters: () => void;
setSort: (s: string) => void;
setPage: (p: number) => void;
activeCount: number;
/** Spread onto <DataTable/>. Same shape useListControls.tableProps returns today. */
tableProps: (total: number) => Pick<DataTableProps<any, any>, "pagination" | "tableOptions">;
/** Replace the whole URL (saved-view restore). Pushes, so Back undoes it. */
applyQueryString: (query: string) => void;
/** Current filter state as a raw query string, for saving as a view (page stripped). */
currentQueryString: () => string;
}
const DEFAULT_PAGE_SIZE = 10;
export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}): UseFilters {
const {
defaultSort = "",
pageSize: defaultPageSize = DEFAULT_PAGE_SIZE,
paginationStyle = "page",
ns,
searchDebounceMs = 300,
} = options;
const [sp, setSp] = useSearchParams();
const searchKey = ns ? `${ns}.q` : "q";
const pageKey = ns ? `${ns}.page` : "page";
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;
// Free text: local draft debounced into the URL with `replace`, so typing
// leaves exactly one history entry instead of one per keystroke.
const [searchText, setSearchTextState] = useState(() => sp.get(searchKey) ?? "");
const [debouncedSearch] = useDebouncedValue(searchText, searchDebounceMs);
useEffect(() => {
const urlValue = sp.get(searchKey) ?? "";
if (urlValue === debouncedSearch) return;
setSp(
(prev) => {
const next = new URLSearchParams(prev);
if (debouncedSearch) next.set(searchKey, debouncedSearch);
else next.delete(searchKey);
next.delete(pageKey);
return next;
},
{ replace: true },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch]);
useEffect(() => {
// External navigation (back/forward, saved-view restore, deep link) —
// sync the local draft from the URL. Comparing against the debounced
// value (not `searchText`) is what stops this from clobbering an
// in-flight keystroke: mid-type, urlValue !== debouncedSearch is expected.
const urlValue = sp.get(searchKey) ?? "";
if (urlValue !== debouncedSearch) setSearchTextState(urlValue);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sp]);
const setFilter = useCallback(
(key: string, value: FilterValue | undefined) => {
const def = defs.find((d) => d.key === key);
if (!def) return;
setSp((prev) => {
const next = writeFilter(prev, def, value, ns);
next.delete(pageKey);
return next;
});
},
[defs, ns, pageKey, setSp],
);
const removeFilter = useCallback((key: string) => setFilter(key, undefined), [setFilter]);
const clearFilters = useCallback(() => {
setSp((prev) => {
const next = new URLSearchParams(prev);
for (const def of defs) next.delete(ns ? `${ns}.${def.key}` : def.key);
next.delete(searchKey);
next.delete(pageKey);
return next;
});
setSearchTextState("");
}, [defs, ns, pageKey, searchKey, setSp]);
const setSort = useCallback(
(value: string) => {
setSp((prev) => {
const next = new URLSearchParams(prev);
const key = ns ? `${ns}.sort` : "sort";
if (value === defaultSort) next.delete(key);
else next.set(key, value);
next.delete(pageKey);
return next;
});
},
[defaultSort, ns, pageKey, setSp],
);
const setPage = useCallback(
(p: number) => {
setSp((prev) => {
const next = new URLSearchParams(prev);
if (p <= 1) next.delete(pageKey);
else next.set(pageKey, String(p));
return next;
});
},
[pageKey, setSp],
);
const params = useMemo(() => {
const filterParams = toApiParams(defs, values);
const base: Record<string, string | number | undefined> =
paginationStyle === "skip"
? { skip: (page - 1) * pageSize, take: pageSize }
: { page, pageSize };
if (debouncedSearch) base.search = debouncedSearch;
if (sort) {
if (paginationStyle === "skip") base.orderBy = sort;
else {
const [sortBy, sortOrder] = sort.split(":");
base.sortBy = sortBy;
base.sortOrder = sortOrder;
}
}
return cleanParams({ ...filterParams, ...base }) as Record<string, string | number>;
}, [defs, values, paginationStyle, page, pageSize, debouncedSearch, sort]);
const activeCount = Object.keys(values).length + (debouncedSearch ? 1 : 0);
const tableProps = useCallback(
(total: number): Pick<DataTableProps<any, any>, "pagination" | "tableOptions"> => {
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const pagination: DataTablePagination = { pageIndex: page - 1, pageSize, pageCount, totalCount: total };
return {
pagination,
tableOptions: {
manualPagination: true,
pageCount,
state: { pagination: { pageIndex: page - 1, pageSize } },
onPaginationChange: (updater) => {
const current = { pageIndex: page - 1, pageSize };
const next = typeof updater === "function" ? updater(current) : updater;
setPage(next.pageIndex + 1);
},
},
};
},
[page, pageSize, setPage],
);
const applyQueryString = useCallback(
(query: string) => setSp(new URLSearchParams(query)),
[setSp],
);
const currentQueryString = useCallback(() => {
const next = new URLSearchParams(sp);
next.delete(pageKey);
return next.toString();
}, [sp, pageKey]);
return {
values,
params,
sort,
page,
pageSize,
searchText,
setSearchText: setSearchTextState,
setFilter,
removeFilter,
clearFilters,
setSort,
setPage,
activeCount,
tableProps,
applyQueryString,
currentQueryString,
};
}
export { toApiParams } from "./url";