added tags filter

This commit is contained in:
Nick A 2025-04-27 13:04:39 -04:00
parent 3750fcf66f
commit c21d12b408
7 changed files with 139 additions and 36 deletions

View File

@ -7,6 +7,15 @@ import {
FunnelIcon, FunnelIcon,
XMarkIcon, XMarkIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { Details } from "../Drawer/Drawer";
import FilterDropdown, { FilterFn } from "./FilterDropdown";
import DataPoint from "@/utils/models/DataPoint";
interface ColumnHeaderProps<T extends DataPoint> {
header: Header<T, any>;
details: Details | undefined;
setFilterFn?: (field: string, filterFn: FilterFn) => void;
}
function DropdownCheckIcon({ className }: { className?: string }) { function DropdownCheckIcon({ className }: { className?: string }) {
return ( return (
@ -18,7 +27,11 @@ function DropdownCheckIcon({ className }: { className?: string }) {
* Component for rendering the header of a table column, * Component for rendering the header of a table column,
* as well as the dropdown menu for sorting and filtering. * as well as the dropdown menu for sorting and filtering.
*/ */
export function ColumnHeader<T>({ header }: { header: Header<T, unknown> }) { export function ColumnHeader<T extends DataPoint>({
header,
details,
setFilterFn,
}: ColumnHeaderProps<T>) {
const { column } = header; const { column } = header;
const [dropdownType, setDropdownType] = useState<"menu" | "filter" | null>( const [dropdownType, setDropdownType] = useState<"menu" | "filter" | null>(
@ -68,23 +81,33 @@ export function ColumnHeader<T>({ header }: { header: Header<T, unknown> }) {
} }
}, [sortDirection, column]); }, [sortDirection, column]);
if (!details) {
return <div className="border-gray-200 border-y" />;
}
return ( return (
<th <th
scope="col" scope="col"
className="border-gray-200 border-y font-medium" className={`border-gray-200 border-y font-medium ${
isFiltered ? "bg-purple-50" : ""
}`}
ref={headerRef} ref={headerRef}
> >
<div> <div>
{header.isPlaceholder ? null : ( {header.isPlaceholder ? null : (
<div <div
className="flex p-2 h-auto items-center justify-between px-2 relative cursor-pointer hover:bg-gray-200/50" className={`flex p-2 h-auto items-center justify-between px-2 relative cursor-pointer hover:bg-gray-200/50`}
onClick={() => onClick={() =>
setDropdownType((prev) => setDropdownType((prev) =>
prev === null ? "menu" : null prev === null ? "menu" : null
) )
} }
> >
<div className="flex items-center"> <div
className={`flex items-center ${
isFiltered ? "" : ""
}`}
>
{flexRender( {flexRender(
column.columnDef.header, column.columnDef.header,
header.getContext() header.getContext()
@ -166,20 +189,11 @@ export function ColumnHeader<T>({ header }: { header: Header<T, unknown> }) {
ref={filterRef} ref={filterRef}
className="absolute -top-2 left-0 mt-2 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-10" 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"> <FilterDropdown
<span>Contains</span> column={column}
<input details={details}
type="text" setFilterFn={setFilterFn}
value={ />
(column.getFilterValue() ?? "") as string
}
onChange={(e) => {
column.setFilterValue(e.target.value);
}}
placeholder="Type a value…"
className="border border-gray-300 rounded p-1"
/>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -1,11 +1,10 @@
import { import {
Bars2Icon,
CheckCircleIcon, CheckCircleIcon,
DocumentTextIcon, DocumentTextIcon,
ListBulletIcon, ListBulletIcon,
UserIcon, UserIcon,
} from "@heroicons/react/24/solid"; } from "@heroicons/react/24/solid";
import { Dispatch, SetStateAction, useState } from "react"; import { Dispatch, SetStateAction, useMemo, useState } from "react";
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"; import { ColumnDef, createColumnHelper } from "@tanstack/react-table";
import Table from "@/components/Table/Table"; import Table from "@/components/Table/Table";
import { RowOpenAction } from "@/components/Table/RowOpenAction"; import { RowOpenAction } from "@/components/Table/RowOpenAction";
@ -13,6 +12,7 @@ import Service from "@/utils/models/Service";
import { Details } from "../Drawer/Drawer"; import { Details } from "../Drawer/Drawer";
import { Tag } from "../TagsInput/Tag"; import { Tag } from "../TagsInput/Tag";
import User from "@/utils/models/User"; import User from "@/utils/models/User";
import { FilterFn } from "./FilterDropdown";
type ServiceTableProps = { type ServiceTableProps = {
data: Service[]; data: Service[];
@ -31,6 +31,8 @@ export default function ServiceTable({
user, user,
}: ServiceTableProps) { }: ServiceTableProps) {
const columnHelper = createColumnHelper<Service>(); const columnHelper = createColumnHelper<Service>();
const [requirementsFilterFn, setRequirementsFilterFn] =
useState<FilterFn>("arrIncludesSome");
const [programPresets, setProgramPresets] = useState([ const [programPresets, setProgramPresets] = useState([
"DOMESTIC", "DOMESTIC",
@ -151,6 +153,13 @@ export default function ServiceTable({
</Tag> </Tag>
</div> </div>
), ),
filterFn: (row, columnId, filterValue) => {
const rowValue = row.getValue(columnId);
if (Array.isArray(filterValue)) {
return filterValue.includes(rowValue);
}
return true;
},
}), }),
columnHelper.accessor("requirements", { columnHelper.accessor("requirements", {
header: () => ( header: () => (
@ -170,6 +179,7 @@ export default function ServiceTable({
)} )}
</div> </div>
), ),
filterFn: requirementsFilterFn,
}), }),
columnHelper.accessor("summary", { columnHelper.accessor("summary", {
header: () => ( header: () => (
@ -188,11 +198,18 @@ export default function ServiceTable({
}), }),
]; ];
const setFilterFn = (field: string, filterFn: FilterFn) => {
if (field === "requirements") {
setRequirementsFilterFn(filterFn);
}
};
return ( return (
<Table <Table
data={data} data={data}
setData={setData} setData={setData}
columns={columns} columns={columns}
setFilterFn={setFilterFn}
details={serviceDetails} details={serviceDetails}
createEndpoint={`/api/service/create?uuid=${user?.uuid}`} createEndpoint={`/api/service/create?uuid=${user?.uuid}`}
isAdmin={user?.role === "ADMIN"} isAdmin={user?.role === "ADMIN"}

View File

@ -10,7 +10,12 @@ import {
getSortedRowModel, getSortedRowModel,
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 { TableSearch } from "@/components/Table/TableSearch"; import { TableSearch } from "@/components/Table/TableSearch";
import { RowOptionMenu } from "@/components/Table/RowOptionMenu"; import { RowOptionMenu } from "@/components/Table/RowOptionMenu";
import { ColumnHeader } from "@/components/Table/ColumnHeader"; import { ColumnHeader } from "@/components/Table/ColumnHeader";
@ -18,11 +23,13 @@ import CreateDrawer from "@/components/Drawer/CreateDrawer";
import { Details } from "@/components/Drawer/Drawer"; import { Details } from "@/components/Drawer/Drawer";
import DataPoint from "@/utils/models/DataPoint"; import DataPoint from "@/utils/models/DataPoint";
import { rankItem } from "@tanstack/match-sorter-utils"; import { rankItem } from "@tanstack/match-sorter-utils";
import { FilterFn } from "./FilterDropdown";
type TableProps<T extends DataPoint> = { type TableProps<T extends DataPoint> = {
data: T[]; data: T[];
setData: Dispatch<SetStateAction<T[]>>; setData: Dispatch<SetStateAction<T[]>>;
columns: ColumnDef<T, any>[]; columns: ColumnDef<T, any>[];
setFilterFn?: (field: string, filterFn: FilterFn) => void;
details: Details[]; details: Details[];
createEndpoint: string; createEndpoint: string;
isAdmin?: boolean; isAdmin?: boolean;
@ -74,6 +81,7 @@ export default function Table<T extends DataPoint>({
data, data,
setData, setData,
columns, columns,
setFilterFn,
details, details,
createEndpoint, createEndpoint,
isAdmin = false, isAdmin = false,
@ -182,7 +190,14 @@ export default function Table<T extends DataPoint>({
{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) => (
<ColumnHeader header={header} key={header.id} /> <ColumnHeader
header={header}
details={details.find(
(d) => d.key === header.column.id
)}
setFilterFn={setFilterFn}
key={header.id}
/>
))} ))}
</tr> </tr>
))} ))}
@ -199,9 +214,11 @@ export default function Table<T extends DataPoint>({
{row.getVisibleCells().map((cell, i) => ( {row.getVisibleCells().map((cell, i) => (
<td <td
key={cell.id} key={cell.id}
className={ className={`[&:nth-child(n+3)]:border-x pl-2 relative first:text-left first:px-0 last:border-none ${
"[&:nth-child(n+3)]:border-x relative first:text-left first:px-0 last:border-none" cell.column.getIsFiltered()
} ? "bg-purple-50"
: ""
}`}
> >
{flexRender( {flexRender(
cell.column.columnDef.cell, cell.column.columnDef.cell,

View File

@ -10,6 +10,7 @@ import { RowOpenAction } from "@/components/Table/RowOpenAction";
import User from "@/utils/models/User"; import User from "@/utils/models/User";
import { Details } from "../Drawer/Drawer"; import { Details } from "../Drawer/Drawer";
import { Tag } from "../TagsInput/Tag"; import { Tag } from "../TagsInput/Tag";
import { FilterFn } from "./FilterDropdown";
type UserTableProps = { type UserTableProps = {
data: User[]; data: User[];
@ -24,6 +25,8 @@ type UserTableProps = {
*/ */
export default function UserTable({ data, setData, user }: UserTableProps) { export default function UserTable({ data, setData, user }: UserTableProps) {
const columnHelper = createColumnHelper<User>(); const columnHelper = createColumnHelper<User>();
const [programFilterFn, setProgramFilterFn] =
useState<FilterFn>("arrIncludesSome");
const [rolePresets, setRolePresets] = useState([ const [rolePresets, setRolePresets] = useState([
"ADMIN", "ADMIN",
@ -103,6 +106,7 @@ export default function UserTable({ data, setData, user }: UserTableProps) {
</Tag> </Tag>
</div> </div>
), ),
filterFn: "arrIncludesSome",
}), }),
columnHelper.accessor("email", { columnHelper.accessor("email", {
header: () => ( header: () => (
@ -133,14 +137,22 @@ export default function UserTable({ data, setData, user }: UserTableProps) {
)} )}
</div> </div>
), ),
filterFn: programFilterFn,
}), }),
]; ];
const setFilterFn = (field: string, filterFn: FilterFn) => {
if (field === "program") {
setProgramFilterFn(filterFn);
}
};
return ( return (
<Table<User> <Table
data={data} data={data}
setData={setData} setData={setData}
columns={columns} columns={columns}
setFilterFn={setFilterFn}
details={userDetails} details={userDetails}
createEndpoint={`/api/user/create?uuid=${user?.uuid}`} createEndpoint={`/api/user/create?uuid=${user?.uuid}`}
isAdmin={user?.role === "ADMIN"} isAdmin={user?.role === "ADMIN"}

View File

@ -3,6 +3,7 @@ import "tailwindcss/tailwind.css";
import { TagsArray } from "./TagsArray"; import { TagsArray } from "./TagsArray";
import { TagDropdown } from "./TagDropdown"; import { TagDropdown } from "./TagDropdown";
import { CreateNewTagAction } from "./CreateNewTagAction"; import { CreateNewTagAction } from "./CreateNewTagAction";
import { FilterFn } from "../Table/FilterDropdown";
interface TagsInputProps { interface TagsInputProps {
presetOptions: string[]; presetOptions: string[];
@ -10,6 +11,8 @@ interface TagsInputProps {
setPresetOptions: Dispatch<SetStateAction<string[]>>; setPresetOptions: Dispatch<SetStateAction<string[]>>;
onTagsChange?: (tags: Set<string>) => void; onTagsChange?: (tags: Set<string>) => void;
singleValue?: boolean; singleValue?: boolean;
cellSelectedPreset?: boolean;
filterState?: [FilterFn | null, Dispatch<SetStateAction<FilterFn | null>>];
} }
const TagsInput: React.FC<TagsInputProps> = ({ const TagsInput: React.FC<TagsInputProps> = ({
@ -18,9 +21,11 @@ const TagsInput: React.FC<TagsInputProps> = ({
setPresetOptions, setPresetOptions,
onTagsChange, onTagsChange,
singleValue = false, singleValue = false,
cellSelectedPreset = false,
filterState,
}) => { }) => {
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [cellSelected, setCellSelected] = useState(false); const [cellSelected, setCellSelected] = useState(cellSelectedPreset);
// TODO: Add tags to the database and remove the presetValue and lowercasing // TODO: Add tags to the database and remove the presetValue and lowercasing
const [tags, setTags] = useState<Set<string>>(new Set(presetValue)); const [tags, setTags] = useState<Set<string>>(new Set(presetValue));
@ -31,7 +36,7 @@ const TagsInput: React.FC<TagsInputProps> = ({
const dropdown = useRef<HTMLDivElement>(null); const dropdown = useRef<HTMLDivElement>(null);
const handleClick = () => { const handleClick = () => {
if (!cellSelected) { if (!cellSelectedPreset) {
setCellSelected(true); setCellSelected(true);
setTimeout(() => { setTimeout(() => {
window.addEventListener("click", handleOutsideClick); window.addEventListener("click", handleOutsideClick);
@ -138,19 +143,42 @@ const TagsInput: React.FC<TagsInputProps> = ({
} }
}; };
const FilterSelect = () => {
const [filter, setFilter] = filterState ?? [null, null];
return (
filter != null &&
setFilter != null && (
<select
value={filter}
onChange={(e) => {
setFilter(e.target.value as FilterFn);
}}
className="cursor-pointer bg-inherit rounded py-0 px-1"
>
<option value="arrIncludesSome">Includes</option>
<option value="arrIncludesAll">Includes All</option>
</select>
)
);
};
return ( return (
<div className="cursor-pointer" onClick={handleClick}> <div className="cursor-pointer" onClick={handleClick}>
{!cellSelected ? ( {!cellSelectedPreset ? (
<TagsArray <>
active={true} <FilterSelect />
handleDelete={handleDeleteTag} <TagsArray
tags={tags} active={true}
/> handleDelete={handleDeleteTag}
tags={tags}
/>
</>
) : ( ) : (
<div ref={dropdown}> <div ref={dropdown}>
<div className="absolute w-64 z-50 ml-1 mt-5"> <div className="absolute w-64 z-50 ml-1 mt-5">
<div className="rounded-md border border-gray-200 shadow"> <div className="rounded-md border border-gray-200 shadow">
<div className="flex flex-wrap rounded-t-md items-center gap-2 bg-gray-50 p-2"> <div className="flex flex-col flex-wrap rounded-t-md items-start justify-center gap-2 bg-gray-50 p-2">
<FilterSelect />
<TagsArray <TagsArray
handleDelete={handleDeleteTag} handleDelete={handleDeleteTag}
active active

View File

@ -13,6 +13,7 @@
"@supabase/supabase-js": "^2.42.3", "@supabase/supabase-js": "^2.42.3",
"@tanstack/match-sorter-utils": "^8.15.1", "@tanstack/match-sorter-utils": "^8.15.1",
"@tanstack/react-table": "^8.15.0", "@tanstack/react-table": "^8.15.0",
"@tanstack/table-core": "^8.21.3",
"bufferutil": "^4.0.8", "bufferutil": "^4.0.8",
"next": "^13.5.8", "next": "^13.5.8",
"react": "^18", "react": "^18",
@ -655,7 +656,7 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/@tanstack/table-core": { "node_modules/@tanstack/react-table/node_modules/@tanstack/table-core": {
"version": "8.21.2", "version": "8.21.2",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz",
"integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==", "integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==",
@ -668,6 +669,19 @@
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/cookie": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",

View File

@ -15,6 +15,7 @@
"@supabase/supabase-js": "^2.42.3", "@supabase/supabase-js": "^2.42.3",
"@tanstack/match-sorter-utils": "^8.15.1", "@tanstack/match-sorter-utils": "^8.15.1",
"@tanstack/react-table": "^8.15.0", "@tanstack/react-table": "^8.15.0",
"@tanstack/table-core": "^8.21.3",
"bufferutil": "^4.0.8", "bufferutil": "^4.0.8",
"next": "^13.5.8", "next": "^13.5.8",
"react": "^18", "react": "^18",