Added filter and sort functionality and dropdowns on each column

This commit is contained in:
Nick A 2025-04-13 14:01:14 -04:00
parent 2c5fd0ea76
commit f84231e64c
3 changed files with 200 additions and 84 deletions

View File

@ -0,0 +1,188 @@
import { flexRender, Header } from "@tanstack/react-table";
import { useState, useEffect, useRef } from "react";
import {
CheckIcon,
ArrowUpIcon,
ArrowDownIcon,
FunnelIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
function DropdownCheckIcon({ className }: { className?: string }) {
return (
<CheckIcon className={`w-4 h-4 ml-auto ${className}`} strokeWidth={2} />
);
}
/**
* Component for rendering the header of a table column,
* as well as the dropdown menu for sorting and filtering.
*/
export function ColumnHeader<T>({ header }: { header: Header<T, unknown> }) {
const { column } = header;
const [dropdownType, setDropdownType] = useState<"menu" | "filter" | null>(
null
);
const [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>(
null
);
const isFiltered =
column.getFilterValue() !== undefined &&
column.getFilterValue() !== null &&
column.getFilterValue() !== "";
const headerRef = useRef<HTMLTableCellElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const filterRef = useRef<HTMLDivElement>(null);
// Close the dropdown menu/filter input when clicking outside of it
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
const target = e.target as Node;
const clickOutsideMenu =
menuRef.current && !menuRef.current.contains(target);
const clickOutsideFilter =
filterRef.current && !filterRef.current.contains(target);
if (clickOutsideMenu || clickOutsideFilter) {
setDropdownType(null);
}
};
document.addEventListener("click", handleOutsideClick);
return () => {
document.removeEventListener("click", handleOutsideClick);
};
}, [dropdownType]);
// Set the sort direction based on the current state
useEffect(() => {
switch (sortDirection) {
case "asc":
column.toggleSorting(false);
break;
case "desc":
column.toggleSorting(true);
break;
default:
column.clearSorting();
}
}, [sortDirection, column]);
return (
<th
scope="col"
className="border-gray-200 border-y font-medium"
ref={headerRef}
>
<div>
{header.isPlaceholder ? null : (
<div
className="flex p-2 h-auto items-center justify-between px-2 relative cursor-pointer hover:bg-gray-200/50"
onClick={() =>
setDropdownType((prev) =>
prev === null ? "menu" : null
)
}
>
<div className="flex items-center">
{flexRender(
column.columnDef.header,
header.getContext()
)}
{/* Choose the icon based on sort direction */}
{{
asc: <ArrowUpIcon className="w-3 h-3 ml-1" />,
desc: (
<ArrowDownIcon className="w-3 h-3 ml-1" />
),
}[column.getIsSorted() as string] ?? null}
</div>
</div>
)}
</div>
<div className="relative">
{/* Dropdown menu to add sorting or filter */}
{column.getCanFilter() && dropdownType === "menu" && (
<div
ref={menuRef}
className="absolute -top-2 left-0 mt-2 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-10"
>
<button
className="flex items-center w-full text-left px-4 py-2 text-xs hover:bg-gray-100"
onClick={() => {
setSortDirection((prev) =>
prev === "asc" ? null : "asc"
);
setDropdownType(null);
}}
>
<ArrowUpIcon className="w-4 h-4 mr-2" />
<span>Sort Ascending</span>
{sortDirection === "asc" && <DropdownCheckIcon />}
</button>
<button
className="flex items-center w-full text-left px-4 py-2 text-xs hover:bg-gray-100"
onClick={() => {
setSortDirection((prev) =>
prev === "desc" ? null : "desc"
);
setDropdownType(null);
}}
>
<ArrowDownIcon className="w-4 h-4 mr-2" />
<span>Sort Descending</span>
{sortDirection === "desc" && <DropdownCheckIcon />}
</button>
<button
className="flex items-center w-full text-left px-4 py-2 text-xs hover:bg-gray-100"
onClick={() => {
column.getCanFilter() &&
column.setFilterValue("");
setDropdownType(isFiltered ? null : "filter");
}}
>
{isFiltered ? (
<>
<FunnelIcon className="`w-4 h-4 mr-2 text-red-400" />
<span className="text-red-400">
Clear Filter
</span>
<XMarkIcon className="w-4 h-4 ml-auto text-red-400" />
</>
) : (
<>
<FunnelIcon className="w-4 h-4 mr-2" />
<span>Add Filter</span>
</>
)}
</button>
</div>
)}
{/* Dropdown menu to add a filter value */}
{column.getCanFilter() && dropdownType === "filter" && (
<div
ref={filterRef}
className="absolute -top-2 left-0 mt-2 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-10"
>
<div className="flex flex-col px-4 py-2">
<span>Contains</span>
<input
type="text"
value={
(column.getFilterValue() ?? "") as string
}
onChange={(e) => {
column.setFilterValue(e.target.value);
}}
placeholder="Filter..."
className="border border-gray-300 rounded p-1"
/>
</div>
</div>
)}
</div>
</th>
);
}

View File

@ -11,12 +11,13 @@ import {
SortingState, SortingState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { ChangeEvent, useState, Dispatch, SetStateAction } from "react"; import { ChangeEvent, useState, Dispatch, SetStateAction } from "react";
import { TableAction } from "./TableAction"; import { TableSearch } from "@/components/Table/TableSearch";
import { rankItem } from "@tanstack/match-sorter-utils"; import { RowOptionMenu } from "@/components/Table/RowOptionMenu";
import { RowOptionMenu } from "./RowOptionMenu"; import { ColumnHeader } from "@/components/Table/ColumnHeader";
import CreateDrawer from "@/components/Drawer/CreateDrawer";
import { Details } from "@/components/Drawer/Drawer";
import DataPoint from "@/utils/models/DataPoint"; import DataPoint from "@/utils/models/DataPoint";
import CreateDrawer from "../Drawer/CreateDrawer"; import { rankItem } from "@tanstack/match-sorter-utils";
import { Details } from "../Drawer/Drawer";
type TableProps<T extends DataPoint> = { type TableProps<T extends DataPoint> = {
data: T[]; data: T[];
@ -150,10 +151,6 @@ export default function Table<T extends DataPoint>({
setQuery(String(target.value)); setQuery(String(target.value));
}; };
// TODO: Filtering
// TODO: Sorting
// Define Tanstack table // Define Tanstack table
const table = useReactTable({ const table = useReactTable({
columns, columns,
@ -178,50 +175,14 @@ export default function Table<T extends DataPoint>({
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">
<TableAction query={query} handleChange={handleSearchChange} /> <TableSearch query={query} handleChange={handleSearchChange} />
</div> </div>
<table className="w-full text-xs text-left rtl:text-right"> <table className="w-full text-xs text-left rtl:text-right">
<thead className="text-xs text-gray-500 capitalize"> <thead className="text-xs text-gray-500 capitalize">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header, i) => ( {headerGroup.headers.map((header, i) => (
<th <ColumnHeader header={header} key={header.id} />
scope="col"
className={
"p-2 border-gray-200 border-y font-medium cursor-pointer select-none" +
(1 < i && i < columns.length - 1
? "border-x"
: "")
}
key={header.id}
onClick={header.column.getToggleSortingHandler()}
title={
header.column.getCanSort()
? header.column.getNextSortingOrder() ===
"asc"
? "Sort ascending"
: header.column.getNextSortingOrder() ===
"desc"
? "Sort descending"
: "Clear sort"
: undefined
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: "🔼",
desc: "🔽",
}[header.column.getIsSorted() as string] ??
null}
{header.column.getCanFilter() && (
<Filter column={header.column} />
)}
</th>
))} ))}
</tr> </tr>
))} ))}
@ -289,19 +250,3 @@ export default function Table<T extends DataPoint>({
</div> </div>
); );
} }
function Filter({ column }: { column: any }) {
return (
<div>
<input
type="text"
value={(column.getFilterValue() ?? "") as string}
onChange={(e) => {
column.setFilterValue(e.target.value);
}}
placeholder="Search..."
className="border border-gray-300 rounded p-1"
/>
</div>
);
}

View File

@ -1,20 +1,17 @@
// TableAction.tsx
import { MagnifyingGlassIcon } from "@heroicons/react/24/solid"; import { MagnifyingGlassIcon } from "@heroicons/react/24/solid";
import { ChangeEventHandler, FunctionComponent, useRef, useState } from "react"; import { ChangeEventHandler, FunctionComponent, useRef, useState } from "react";
import { FilterBox } from "../FilterBox/FilterBox";
type TableActionProps = { type TableSearchProps = {
query: string; query: string;
handleChange: ChangeEventHandler<HTMLInputElement>; handleChange: ChangeEventHandler<HTMLInputElement>;
}; };
export const TableAction: FunctionComponent<TableActionProps> = ({ export const TableSearch: FunctionComponent<TableSearchProps> = ({
query, query,
handleChange, handleChange,
}) => { }) => {
const searchInput = useRef<HTMLInputElement>(null); const searchInput = useRef<HTMLInputElement>(null);
const [searchActive, setSearchActive] = useState(false); const [searchActive, setSearchActive] = useState(false);
const [showFilterBox, setShowFilterBox] = useState(false);
const activateSearch = () => { const activateSearch = () => {
setSearchActive(true); setSearchActive(true);
@ -25,29 +22,15 @@ export const TableAction: FunctionComponent<TableActionProps> = ({
searchInput.current.addEventListener("focusout", () => { searchInput.current.addEventListener("focusout", () => {
if (searchInput.current?.value.trim() === "") { if (searchInput.current?.value.trim() === "") {
searchInput.current.value = ""; searchInput.current.value = "";
deactivateSearch(); setSearchActive(false);
} }
}); });
}; };
const deactivateSearch = () => setSearchActive(false);
const toggleFilterBox = () => setShowFilterBox((prev) => !prev);
return ( return (
<div className="w-auto flex flex-row gap-x-0.5 items-center justify-between text-xs font-medium text-gray-500 p-2"> <div className="w-auto flex flex-row gap-x-0.5 items-center justify-between text-xs font-medium text-gray-500 p-2">
<span <span
className="p-1 rounded hover:text-purple-700 focus:bg-purple-50 hover:bg-purple-50" className="p-1 rounded hover:text-purple-700 focus:bg-purple-50 hover:bg-purple-50 cursor-pointer"
onClick={toggleFilterBox}
>
Filter
</span>
{showFilterBox && <FilterBox />}
<span className="p-1 rounded hover:text-purple-700 focus:bg-purple-50 hover:bg-purple-50 hover:bg-gray-100">
Sort
</span>
<span
className="p-1 rounded hover:text-purple-700 focus:bg-purple-50 hover:bg-purple-50 hover:bg-gray-100"
onClick={activateSearch} onClick={activateSearch}
> >
<MagnifyingGlassIcon className="w-4 h-4 inline" /> <MagnifyingGlassIcon className="w-4 h-4 inline" />