persist a advance filter obj

This commit is contained in:
pramod.mahajan 2025-12-24 21:00:17 +05:30
parent c51c6978e9
commit a5bfad7214
5 changed files with 290 additions and 259 deletions

View File

@ -0,0 +1,119 @@
import { useState, useEffect } from "react";
const OPERATORS = {
number: [
{ key: "eq", label: "Equals" },
{ key: "neq", label: "Not Equal" },
{ key: "gt", label: "Greater Than" },
{ key: "gte", label: "Greater or Equal" },
{ key: "lt", label: "Less Than" },
{ key: "lte", label: "Less or Equal" },
{ key: "between", label: "Between" },
],
text: [
{ key: "contains", label: "Contains" },
{ key: "starts", label: "Starts With" },
{ key: "ends", label: "Ends With" },
{ key: "eq", label: "Equals" },
],
date: [
{ key: "before", label: "Before" },
{ key: "after", label: "After" },
{ key: "between", label: "Between" },
],
};
export function AdvanceFilter({
type = "number",
value,
onApply,
onClear,
}) {
const [operation, setOperation] = useState("");
const [v1, setV1] = useState("");
const [v2, setV2] = useState("");
useEffect(() => {
if (!value) {
setOperation("");
setV1("");
setV2("");
return;
}
setOperation(value.operation);
setV1(value.value ?? value.from ?? "");
setV2(value.to ?? "");
}, [value]);
const apply = () => {
if (!operation) return;
onApply(
operation === "between"
? { operation, from: v1, to: v2 }
: { operation, value: v1 }
);
};
return (
<div style={{ width: 240 }} onClick={(e) => e.stopPropagation()}>
<div className="mb-2">
<label className="form-label">Condition</label>
<select
className="form-select form-select-sm"
value={operation}
onChange={(e) => setOperation(e.target.value)}
>
<option value="">Select</option>
{OPERATORS[type].map((o) => (
<option key={o.key} value={o.key}>
{o.label}
</option>
))}
</select>
</div>
{operation && (
<div className="mb-2">
<label className="form-label">Value</label>
{operation !== "between" ? (
<input
type={type === "date" ? "date" : "number"}
className="form-control form-control-sm"
value={v1}
onChange={(e) => setV1(e.target.value)}
/>
) : (
<div className="d-flex gap-2">
<input
type={type === "date" ? "date" : "number"}
className="form-control form-control-sm"
placeholder="From"
value={v1}
onChange={(e) => setV1(e.target.value)}
/>
<input
type={type === "date" ? "date" : "number"}
className="form-control form-control-sm"
placeholder="To"
value={v2}
onChange={(e) => setV2(e.target.value)}
/>
</div>
)}
</div>
)}
<div className="d-flex justify-content-between mt-3">
<button className="btn btn-label-secondary btn-xs" onClick={onClear}>
Clear
</button>
<button className="btn btn-primary btn-xs" onClick={apply}>
Apply
</button>
</div>
</div>
);
}

View File

@ -23,7 +23,7 @@ export default function PmsGrid({
features = {},
renderExpanded,
}) {
const [isFullScreen,setFullScreen] = useState(false)
const [isFullScreen, setFullScreen] = useState(false);
const grid = useGridCore({
data,
serverMode,
@ -59,8 +59,11 @@ export default function PmsGrid({
expanded,
toggleExpand,
setColState,
onAdvanceFilters,
setAdanceFilter,
// onAdvanceFilters,
// setAdanceFilter,
advanceFilters,
setColumnAdvanceFilter,
} = grid;
// --- Pin / Unpin helpers ---
@ -149,7 +152,11 @@ export default function PmsGrid({
const currentRows = rows;
return (
<div className={`pms-grid ${isFullScreen ? "card card-action card-fullscreen p-4":""}`}>
<div
className={`pms-grid ${
isFullScreen ? "card card-action card-fullscreen p-4" : ""
}`}
>
<div className="row px-4">
<div className="col-8">
<div className="d-flex flex-row gap-2 gap-2 ">
@ -218,14 +225,22 @@ export default function PmsGrid({
}
/>
)}
<i className={`bx bx-${isFullScreen ? "exit-fullscreen":"fullscreen"} cursor-pointer`} onClick={()=>setFullScreen(!isFullScreen)}></i>
<i
className={`bx bx-${
isFullScreen ? "exit-fullscreen" : "fullscreen"
} cursor-pointer`}
onClick={() => setFullScreen(!isFullScreen)}
></i>
</div>
</div>
</div>
<div className="d-flex">
<FilterApplied groupBy={groupBy} removeGroupby={()=>setGroupBy(null)} advance={onAdvanceFilters} />
<FilterApplied
groupBy={groupBy}
removeGroupby={() => setGroupBy(null)}
advance={advanceFilters}
/>
</div>
{/* Table-Start*/}
<div
@ -322,7 +337,8 @@ export default function PmsGrid({
onPinLeft={() => pinColumn(col.key, "left")}
onPinRight={() => pinColumn(col.key, "right")}
onUnpin={() => unpinColumn(col.key)}
onAdvancedFilter={setAdanceFilter}
advanceFilters={advanceFilters}
setColumnAdvanceFilter={setColumnAdvanceFilter}
/>
)}
{features.resizing && (
@ -411,7 +427,8 @@ export default function PmsGrid({
</option>
))}
</select>
)} <small>{totalRows} rows</small>
)}{" "}
<small>{totalRows} rows</small>
</div>
<div>
<button

View File

@ -1,155 +1,42 @@
import { useState, useRef, useEffect } from "react";
import { useDropdownPosition } from "./useGridCore";
import { AdvanceFilter } from "./AdvanceFilter";
const OPERATORS = {
number: [
{ key: "eq", label: "Equals" },
{ key: "neq", label: "Not Equal" },
{ key: "gt", label: "Greater Than" },
{ key: "gte", label: "Greater or Equal" },
{ key: "lt", label: "Less Than" },
{ key: "lte", label: "Less or Equal" },
{ key: "between", label: "Between" },
],
text: [
{ key: "contains", label: "Contains" },
{ key: "starts", label: "Starts With" },
{ key: "ends", label: "Ends With" },
{ key: "eq", label: "Equals" },
],
date: [
{ key: "before", label: "Before" },
{ key: "after", label: "After" },
{ key: "between", label: "Between" },
],
};
// ----------- FILTER UI COMPONENT ----------
function AdvanceFilter({ type = "number", onApply, onClear }) {
const [operation, setOperator] = useState("");
const [value1, setValue1] = useState("");
const [value2, setValue2] = useState("");
const ops = OPERATORS[type];
const apply = () => {
if (!operation) return;
if (operation === "between") {
onApply({
operation,
from: value1,
to: value2,
});
} else {
onApply({
operation,
value: value1,
});
}
};
return (
<div
className=""
onClick={(e) => e.stopPropagation()}
style={{ width: 240 }}
>
<div className="mb-2">
<label className="form-label text-decoration-none">Condition</label>
<select
className="form-select form-select-sm"
value={operation}
onChange={(e) => setOperator(e.target.value)}
>
<option value="">Select</option>
{ops.map((o) => (
<option key={o.key} value={o.key}>
{o.label}
</option>
))}
</select>
</div>
{/* Values */}
{operation && (
<div className="text-decoration-none">
<label className="form-label">Value</label>
{operation !== "between" ? (
<input
type={type === "date" ? "date" : "number"}
className="form-control form-control-sm"
value={value1}
onChange={(e) => setValue1(e.target.value)}
/>
) : (
<div className="d-flex gap-2">
<input
type={type === "date" ? "date" : "number"}
className="form-control form-control-sm"
placeholder="From"
value={value1}
onChange={(e) => setValue1(e.target.value)}
/>
<input
type={type === "date" ? "date" : "number"}
className="form-control form-control-sm"
placeholder="To"
value={value2}
onChange={(e) => setValue2(e.target.value)}
/>
</div>
)}
</div>
)}
{/* Buttons */}
<div className="d-flex flex-row mt-3">
<button className="btn btn-primary btn-xs" onClick={apply}>
Apply
</button>
</div>
</div>
);
}
// -------- HEADER OPTION ---------------
const PmsHeaderOption = ({
column,
pinned,
onPinLeft,
onPinRight,
onUnpin,
onAdvancedFilter,
}) => {
const { key: columnKey, enableAdvancedFilter } = column;
const rootRef = useRef(null);
// from useGridCore
advanceFilters,
setColumnAdvanceFilter,
}) => {
const {
key: columnKey,
enableAdvancedFilter,
filterType = "number",
} = column;
const rootRef = useRef(null);
const btnRef = useRef(null);
const menuRef = useRef(null);
const filterBtnRef = useRef(null);
const filterMenuRef = useRef(null);
const pinBtnRef = useRef(null);
const pinMenuRef = useRef(null);
const [open, setOpen] = useState(false);
const [openMenu, setOpenMenu] = useState(null);
// Main dropdown position
const mainStyle = useDropdownPosition(btnRef, menuRef, open, 0);
// Submenus
const filterStyle = useDropdownPosition(
filterBtnRef,
filterMenuRef,
openMenu === "filter",
1
);
const pinStyle = useDropdownPosition(
pinBtnRef,
pinMenuRef,
@ -159,54 +46,47 @@ const PmsHeaderOption = ({
const toggleMenu = (name, e) => {
e.stopPropagation();
setOpenMenu((prev) => (prev === name ? null : name));
setOpenMenu((p) => (p === name ? null : name));
};
// ----------------------------
// CLOSE WHEN CLICKING OUTSIDE
// ----------------------------
useEffect(() => {
const handleOutside = (e) => {
if (!rootRef.current) return;
if (!rootRef.current.contains(e.target)) {
const close = (e) => {
if (!rootRef.current?.contains(e.target)) {
setOpen(false);
setOpenMenu(null);
}
};
if (open) document.addEventListener("mousedown", handleOutside);
return () => document.removeEventListener("mousedown", handleOutside);
if (open) document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
// {}
console.log(advanceFilters);
console.log(columnKey);
return (
<div
ref={rootRef}
className="dropdown "
style={{ zIndex: 9999, position: "relative" }}
>
{/* BUTTON */}
<div ref={rootRef} style={{ position: "relative", zIndex: 9999 }}>
<button
ref={btnRef}
className="btn btn-icon btn-text-secondary rounded-pill p-0"
class="btn btn-icon btn-text-secondary rounded-pill d-inline-block"
onClick={() => setOpen((p) => !p)}
>
<i className="bx bx-dots-vertical-rounded bx-sm text-muted"></i>
<span className="icon-base bx bx-dots-vertical-rounded bx-sm"></span>
{advanceFilters[columnKey] && (
<span className="badge rounded-pill bg-info badge-dot badge-notifications"></span>
)}
</button>
{/* MAIN MENU */}
{open && (
<ul
ref={menuRef}
className="dropdown-menu dropdown-menu-end shadow border rounded-3 py-2 show"
className="dropdown-menu dropdown-menu-end shadow rounded-3 py-2 show"
style={mainStyle}
>
{/* ADVANCED FILTER */}
{enableAdvancedFilter && (
<li className="dropdown-submenu dropstart position-relative">
<li className="dropdown-submenu dropstart">
<button
ref={filterBtnRef}
className="dropdown-item d-flex justify-content-between py-1 px-2 "
className="dropdown-item d-flex justify-content-between py-1 px-2"
onClick={(e) => toggleMenu("filter", e)}
>
<span>
@ -218,26 +98,25 @@ const PmsHeaderOption = ({
{openMenu === "filter" && (
<ul
ref={filterMenuRef}
className="dropdown-menu shadow rounded-3 py-2 p-0 show"
className="dropdown-menu shadow rounded-3 p-2 show"
style={filterStyle}
>
<li className="p-2">
<AdvanceFilter
type="number"
onApply={(f) => onAdvancedFilter({ ...f, columnKey })}
onClear={() => onAdvancedFilter(null)}
type={filterType}
value={advanceFilters[columnKey]}
onApply={(f) => setColumnAdvanceFilter(columnKey, f)}
onClear={() => setColumnAdvanceFilter(columnKey, null)}
/>
</li>
</ul>
)}
</li>
)}
{/* PIN COLUMN */}
<li className="dropdown-submenu dropstart position-relative">
{/* PIN */}
<li className="dropdown-submenu dropstart">
<button
ref={pinBtnRef}
className="dropdown-item d-flex justify-content-between py-1 px-2 "
className="dropdown-item d-flex justify-content-between py-1 px-2"
onClick={(e) => toggleMenu("pin", e)}
>
<span>
@ -249,39 +128,18 @@ const PmsHeaderOption = ({
{openMenu === "pin" && (
<ul
ref={pinMenuRef}
className=" dropdown-menu dropdown-menu-end shadow border rounded-3 py-2 show"
className="dropdown-menu shadow rounded-3 py-2 show"
style={pinStyle}
>
<li>
<button className="dropdown-item py-1 px-2" onClick={onUnpin}>
{!pinned && <i className="bx bx-check me-2"></i>}
<button className="dropdown-item" onClick={onUnpin}>
No Pin
</button>
</li>
<li>
<button
className={`dropdown-item py-1 px-2 ${
pinned === "left" ? "active" : ""
}`}
onClick={onPinLeft}
>
{pinned === "left" && <i className="bx bx-check me-2"></i>}
<button className="dropdown-item" onClick={onPinLeft}>
Pin Left
</button>
</li>
<li>
<button
className={`dropdown-item py-1 px-2 ${
pinned === "right" ? "active" : ""
}`}
onClick={onPinRight}
>
{pinned === "right" && <i className="bx bx-check me-2"></i>}
<button className="dropdown-item" onClick={onPinRight}>
Pin Right
</button>
</li>
</ul>
)}
</li>

View File

@ -6,6 +6,8 @@ import { useState, useMemo, useCallback, useEffect,useLayoutEffect } from "react
- rowKey
- initialPageSize
*/
export function useGridCore({
data,
serverMode = false,
@ -19,47 +21,57 @@ export function useGridCore({
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [groupBy, setGroupBy] = useState(null);
const [serverGroupRows, setServerGroupRows] = useState(null);
const [onAdvanceFilter, setAdanceFilter] = useState(null);
// FIX: store ADVANCED FILTERS PER COLUMN
// { amount: { columnKey, operation, value } }
const [advanceFilters, setAdvanceFilters] = useState({});
const [sortBy, setSortBy] = useState({ key: null, dir: "asc" });
const [selected, setSelected] = useState(new Set());
const [expanded, setExpanded] = useState(new Set());
const [colState, setColState] = useState(() =>
columns.map((c, i) => ({ ...c, visible: c.visible !== false, order: i }))
columns.map((c, i) => ({
...c,
visible: c.visible !== false,
order: i,
}))
);
const [totalRows, setTotalRows] = useState(data ? data.length : 0);
const [loading, setLoading] = useState(false);
const [serverRows, setServerRows] = useState([]);
/* ---------------- SEARCH (DEBOUNCE) ---------------- */
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(search);
setPage(1); // Important — when search changes, go back to page 1
setPage(1);
}, 500);
return () => clearTimeout(handler);
}, [search]);
// client-side derived rows
/* ---------------- CLIENT MODE ---------------- */
const clientFiltered = useMemo(() => {
if (!data) return [];
const q = (search || "").toLowerCase();
const filtered = q
? data.filter((r) =>
let filtered = data;
if (search) {
const q = search.toLowerCase();
filtered = filtered.filter((r) =>
Object.values(r).some((v) =>
String(v ?? "")
.toLowerCase()
.includes(q)
String(v ?? "").toLowerCase().includes(q)
)
)
: data;
// sort if needed
);
}
if (sortBy.key) {
const dir = sortBy.dir === "asc" ? 1 : -1;
filtered.sort((a, b) => {
const A = a[sortBy.key],
B = b[sortBy.key];
filtered = [...filtered].sort((a, b) => {
const A = a[sortBy.key];
const B = b[sortBy.key];
if (A == null && B == null) return 0;
if (A == null) return -1 * dir;
if (B == null) return 1 * dir;
@ -68,14 +80,17 @@ export function useGridCore({
return String(A).localeCompare(String(B)) * dir;
});
}
setTotalRows(filtered.length);
const start = (page - 1) * pageSize;
return filtered.slice(start, start + pageSize);
}, [data, search, sortBy, page, pageSize]);
// server-side fetch
/* ---------------- SERVER MODE ---------------- */
const fetchServer = useCallback(async () => {
// sorting column wise
if (!serverMode || typeof fetcher !== "function") return;
const sortFilters = sortBy.key
? [
{
@ -85,22 +100,15 @@ export function useGridCore({
]
: [];
// -----------------------------
// 3. ADVANCED FILTERS (NUMERIC FILTER POPOVER)
// The grid will pass "filter" already shaped like:
// filter = { totalAmount: { type: "gt", value: 200 } }
// -----------------------------
let advanceFilters = [];
if (onAdvanceFilter) {
advanceFilters.push(onAdvanceFilter);
}
// convert map → array
const advanceFilterArray = Object.values(advanceFilters);
const filterPayload = JSON.stringify({
sortFilters,
groupByColumn: groupBy || null,
advanceFilters,
advanceFilters: advanceFilterArray,
});
if (!serverMode || typeof fetcher !== "function") return;
setLoading(true);
try {
const resp = await fetcher({
@ -110,9 +118,9 @@ export function useGridCore({
search: debouncedSearch,
filter: filterPayload,
});
// expected: { rows: [], total }
setServerRows(resp.rows || []);
setTotalRows(resp.total || (resp.rows ? resp.rows.length : 0));
setServerRows(resp?.rows || []);
setTotalRows(resp?.total ?? resp?.rows?.length ?? 0);
} finally {
setLoading(false);
}
@ -124,14 +132,35 @@ export function useGridCore({
sortBy,
debouncedSearch,
groupBy,
onAdvanceFilter,
advanceFilters,
]);
useEffect(() => {
if (serverMode) fetchServer();
}, [serverMode, fetchServer]);
// selection
/* ---------------- ADVANCED FILTER API ---------------- */
const setColumnAdvanceFilter = useCallback((columnKey, filter) => {
setAdvanceFilters((prev) => {
if (!filter) {
const copy = { ...prev };
delete copy[columnKey];
return copy;
}
return {
...prev,
[columnKey]: {
columnKey,
...filter,
},
};
});
setPage(1);
}, []);
/* ---------------- SELECTION ---------------- */
const toggleSelect = useCallback((id) => {
setSelected((prev) => {
const s = new Set(prev);
@ -162,6 +191,7 @@ export function useGridCore({
[rowKey]
);
/* ---------------- EXPAND ---------------- */
const toggleExpand = useCallback((id) => {
setExpanded((prev) => {
const s = new Set(prev);
@ -170,72 +200,81 @@ export function useGridCore({
});
}, []);
/* ---------------- SORT ---------------- */
const changeSort = useCallback((key) => {
setSortBy((prev) => {
// first click = asc
if (prev.key !== key) {
return { key, dir: "asc" };
}
// second click = desc
if (prev.dir === "asc") {
return { key, dir: "desc" };
}
// third click = remove sort
if (prev.key !== key) return { key, dir: "asc" };
if (prev.dir === "asc") return { key, dir: "desc" };
return { key: null, dir: "asc" };
});
setPage(1);
}, []);
/* ---------------- COLUMNS ---------------- */
const visibleColumns = useMemo(
() => colState.filter((c) => c.visible).sort((a, b) => a.order - b.order),
[colState]
);
const rows = serverMode ? serverRows : clientFiltered;
const totalPages = Math.max(1, Math.ceil((totalRows || 0) / pageSize));
// update columns externally (reorder/pin/resize)
const updateColumn = useCallback((key, patch) => {
setColState((prev) =>
prev.map((c) => (c.key === key ? { ...c, ...patch } : c))
);
}, []);
/* ---------------- FINAL ---------------- */
const rows = serverMode ? serverRows : clientFiltered;
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
return {
// state
// paging
page,
setPage,
pageSize,
setPageSize,
totalRows,
totalPages,
// loading
loading,
// search
search,
setSearch,
// sorting
sortBy,
changeSort,
// selection
selected,
setSelected,
toggleSelect,
selectAllOnPage,
deselectAllOnPage,
setSelected,
// expand
expanded,
toggleExpand,
// columns
colState,
visibleColumns,
updateColumn,
setColState,
// grouping
groupBy,
setGroupBy,
onAdvanceFilter,
setAdanceFilter,
// advanced filter
advanceFilters,
setColumnAdvanceFilter,
// data
rows,
// mode helpers
// mode
serverMode,
};
}
@ -247,7 +286,6 @@ export function useGridCore({
export function useDropdownPosition(btnRef, menuRef, isOpen, level = 0) {
const [style, setStyle] = useState({});

View File

@ -40,4 +40,3 @@ function autoPositionInsideContainer(triggerEl, dropdownEl, containerEl) {
dropdownEl.classList.remove("open-left");
}
}