diff --git a/compass/components/Table/ColumnHeader.tsx b/compass/components/Table/ColumnHeader.tsx new file mode 100644 index 0000000..f955c18 --- /dev/null +++ b/compass/components/Table/ColumnHeader.tsx @@ -0,0 +1,206 @@ +import { flexRender, Header } from "@tanstack/react-table"; +import { useState, useEffect, useRef } from "react"; +import { + CheckIcon, + ArrowUpIcon, + ArrowDownIcon, + FunnelIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; +import { Details } from "../Drawer/Drawer"; +import FilterDropdown, { FilterFn } from "./FilterDropdown"; +import DataPoint from "@/utils/models/DataPoint"; + +interface ColumnHeaderProps { + header: Header; + details: Details | undefined; + setFilterFn?: (field: string, filterFn: FilterFn) => void; + className?: string; +} + +function DropdownCheckIcon({ className }: { className?: string }) { + return ( + + ); +} + +/** + * Component for rendering the header of a table column, + * as well as the dropdown menu for sorting and filtering. + * @param props.header The header object from TanStack Table. + * @param props.details The details object containing metadata about the column. + * @param props.setFilterFn Include this state setter if the column has multiple filter options. + * @param props.className Optional additional class names for styling. + */ +export function ColumnHeader({ + header, + details, + setFilterFn, + className +}: ColumnHeaderProps) { + const { column } = header; + + const [dropdownType, setDropdownType] = useState<"menu" | "filter" | null>( + null + ); + const [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>( + null + ); + + const isFiltered = + column.getFilterValue() != null && column.getFilterValue() !== ""; + + const menuRef = useRef(null); + const filterRef = useRef(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]); + + if (!details) { + return
; + } + + return ( + +
+ {header.isPlaceholder ? null : ( +
+ setDropdownType((prev) => + prev === null ? "menu" : null + ) + } + > +
+ {flexRender( + column.columnDef.header, + header.getContext() + )} + {/* Choose the icon based on sort direction */} + { + { + asc: ( + + ), + desc: ( + + ), + }[column.getIsSorted() as "asc" | "desc"] + } +
+
+ )} +
+
+ {/* Dropdown menu to add sorting or filter */} + {column.getCanFilter() && dropdownType === "menu" && ( +
+ + +
+ +
+ )} + {/* Dropdown menu to add a filter value */} + {column.getCanFilter() && dropdownType === "filter" && ( +
+ +
+ )} +
+ + ); +} diff --git a/compass/components/Table/FilterDropdown.tsx b/compass/components/Table/FilterDropdown.tsx new file mode 100644 index 0000000..ce9d917 --- /dev/null +++ b/compass/components/Table/FilterDropdown.tsx @@ -0,0 +1,91 @@ +import DataPoint from "@/utils/models/DataPoint"; +import { Column } from "@tanstack/react-table"; +import { Details } from "../Drawer/Drawer"; +import { useEffect, useState } from "react"; +import TagsInput from "../TagsInput/Index"; + +export type FilterFn = "arrIncludesSome" | "arrIncludesAll"; + +interface FilterDropdownProps { + details: Details; + column: Column; + setFilterFn?: (field: string, filterFn: FilterFn) => void; +} + +/** + * Component for rendering a dropdown menu when adding a filter to a column. + * @param props.details The details object containing metadata about the column. + * @param props.column The column object from TanStack Table. + * @param props.setFilterFn Include this state setter if the column has multiple filter options. + */ +export default function FilterDropdown({ + details, + column, + setFilterFn, +}: FilterDropdownProps) { + const filterState = useState( + details.inputType === "select-multiple" || + details.inputType === "select-one" + ? "arrIncludesSome" + : null + ); + const [filter] = filterState; + const { inputType, presetOptionsValues, presetOptionsSetter } = details; + + // Update the column filter function when the state changes + useEffect(() => { + if (filter && setFilterFn) { + setFilterFn(details.key, filter); + column.setFilterValue((prev: any) => prev); // Trigger a re-render based on new filter value + } + }, [details.key, filter, setFilterFn, column]); + + switch (inputType) { + case "select-one": + return ( +
+ {})} + presetValue={[]} + onTagsChange={(tags) => { + const tagsArray = Array.from(tags); + column.setFilterValue(tagsArray); + }} + cellSelectedPreset={true} + /> +
+ ); + case "select-multiple": + return ( +
+ {})} + presetValue={[]} + onTagsChange={(tags) => { + const tagsArray = Array.from(tags); + column.setFilterValue(tagsArray); + }} + cellSelectedPreset={true} + filterState={filterState} + /> +
+ ); + default: + return ( +
+ Contains + { + column.setFilterValue(e.target.value); + }} + placeholder="Type a value…" + className="border border-gray-300 rounded p-1" + /> +
+ ); + } +} diff --git a/compass/components/Table/ServiceTable.tsx b/compass/components/Table/ServiceTable.tsx index 8169f7c..1a60575 100644 --- a/compass/components/Table/ServiceTable.tsx +++ b/compass/components/Table/ServiceTable.tsx @@ -1,5 +1,4 @@ import { - Bars2Icon, CheckCircleIcon, DocumentTextIcon, ListBulletIcon, @@ -13,6 +12,7 @@ import Service from "@/utils/models/Service"; import { Details } from "../Drawer/Drawer"; import { Tag } from "../TagsInput/Tag"; import User from "@/utils/models/User"; +import { FilterFn } from "./FilterDropdown"; type ServiceTableProps = { data: Service[]; @@ -31,6 +31,8 @@ export default function ServiceTable({ user, }: ServiceTableProps) { const columnHelper = createColumnHelper(); + const [requirementsFilterFn, setRequirementsFilterFn] = + useState("arrIncludesSome"); const [programPresets, setProgramPresets] = useState([ "DOMESTIC", @@ -151,6 +153,14 @@ export default function ServiceTable({
), + // Filter by if the value is in the tags array + filterFn: (row, columnId, filterValue) => { + const rowValue = row.getValue(columnId); + if (Array.isArray(filterValue)) { + return filterValue.includes(rowValue); + } + return true; + }, }), columnHelper.accessor("requirements", { header: () => ( @@ -170,6 +180,7 @@ export default function ServiceTable({ )} ), + filterFn: requirementsFilterFn, }), columnHelper.accessor("summary", { header: () => ( @@ -188,11 +199,18 @@ export default function ServiceTable({ }), ]; + const setFilterFn = (field: string, filterFn: FilterFn) => { + if (field === "requirements") { + setRequirementsFilterFn(filterFn); + } + }; + return ( = { data: T[]; setData: Dispatch>; columns: ColumnDef[]; + setFilterFn?: (field: string, filterFn: FilterFn) => void; details: Details[]; createEndpoint: string; deleteEndpoint: string; @@ -73,16 +72,21 @@ const fuzzyFilter = ( * @param props.data Stateful list of data to be held in the table * @param props.setData State setter for the list of data * @param props.columns Column definitions made with Tanstack columnHelper + * @param props.setFilterFn This optional state setter should change the filter funciton of the provided column if possible. + * It should be included if the column has multiple filter options. */ export default function Table({ data, setData, columns, + setFilterFn, details, createEndpoint, deleteEndpoint, isAdmin = false, }: TableProps) { + const [filters, setFilters] = useState([]); + const [sorting, setSorting] = useState([]); const offset = isAdmin ? 1 : 0; const columnHelper = createColumnHelper(); @@ -114,20 +118,6 @@ export default function Table({ const visibilitySort = (a: T, b: T) => a.visible === b.visible ? 0 : a.visible ? -1 : 1; - // // Sort data on load - // useEffect(() => { - // setData((prevData) => prevData.sort(visibilitySort)); - // }, [setData]); - - // // Data manipulation methods - // // TODO: Connect data manipulation methods to the database (deleteData, hideData, addData) - // const deleteData = (dataId: number) => { - // console.log(data); - // setData((currentData) => - // currentData.filter((data) => data.id !== dataId) - // ); - // }; - const hideData = (dataId: number) => { console.log(`Toggling visibility for data with ID: ${dataId}`); setData((currentData) => { @@ -189,10 +179,6 @@ export default function Table({ setQuery(String(target.value)); }; - // TODO: Filtering - - // TODO: Sorting - // Define Tanstack table const table = useReactTable({ columns, @@ -202,39 +188,41 @@ export default function Table({ }, state: { globalFilter: query, + columnFilters: filters, + sorting, }, onGlobalFilterChange: setQuery, globalFilterFn: fuzzyFilter, getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setFilters, + onSortingChange: setSorting, }); return (
- +
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header, i) => ( - + /> ))} ))} @@ -243,16 +231,21 @@ export default function Table({ {table.getRowModel().rows.map((row) => { // Individual row const isDataVisible = row.original.visible; - const rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${!isDataVisible ? "bg-gray-200 text-gray-500" : "" - }`; + const rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${ + !isDataVisible ? "bg-gray-200 text-gray-500" : "" + }`; return ( {row.getVisibleCells().map((cell, i) => (
d.key === header.column.id + )} + setFilterFn={setFilterFn} className={ - "p-2 border-gray-200 border-y font-medium " + - (0 + offset < i && i < columns.length - 1 + offset < i && i < columns.length - 1 ? "border-x" - : "") + : "" } key={header.id} - > - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} -
{flexRender( cell.column.columnDef.cell, diff --git a/compass/components/Table/TableAction.tsx b/compass/components/Table/TableSearch.tsx similarity index 64% rename from compass/components/Table/TableAction.tsx rename to compass/components/Table/TableSearch.tsx index c8cecc9..3364b66 100644 --- a/compass/components/Table/TableAction.tsx +++ b/compass/components/Table/TableSearch.tsx @@ -1,20 +1,17 @@ -// TableAction.tsx import { MagnifyingGlassIcon } from "@heroicons/react/24/solid"; import { ChangeEventHandler, FunctionComponent, useRef, useState } from "react"; -import { FilterBox } from "../FilterBox"; -type TableActionProps = { +type TableSearchProps = { query: string; handleChange: ChangeEventHandler; }; -export const TableAction: FunctionComponent = ({ +export const TableSearch: FunctionComponent = ({ query, handleChange, }) => { const searchInput = useRef(null); const [searchActive, setSearchActive] = useState(false); - const [showFilterBox, setShowFilterBox] = useState(false); const activateSearch = () => { setSearchActive(true); @@ -25,29 +22,15 @@ export const TableAction: FunctionComponent = ({ searchInput.current.addEventListener("focusout", () => { if (searchInput.current?.value.trim() === "") { searchInput.current.value = ""; - deactivateSearch(); + setSearchActive(false); } }); }; - const deactivateSearch = () => setSearchActive(false); - - const toggleFilterBox = () => setShowFilterBox((prev) => !prev); - return (
- Filter - - {showFilterBox && } - - Sort - - diff --git a/compass/components/Table/UserTable.tsx b/compass/components/Table/UserTable.tsx index 9665b41..fd8c0c1 100644 --- a/compass/components/Table/UserTable.tsx +++ b/compass/components/Table/UserTable.tsx @@ -10,6 +10,7 @@ import { RowOpenAction } from "@/components/Table/RowOpenAction"; import User from "@/utils/models/User"; import { Details } from "../Drawer/Drawer"; import { Tag } from "../TagsInput/Tag"; +import { FilterFn } from "./FilterDropdown"; type UserTableProps = { data: User[]; @@ -24,6 +25,8 @@ type UserTableProps = { */ export default function UserTable({ data, setData, user }: UserTableProps) { const columnHelper = createColumnHelper(); + const [programFilterFn, setProgramFilterFn] = + useState("arrIncludesSome"); const [rolePresets, setRolePresets] = useState([ "ADMIN", @@ -103,6 +106,7 @@ export default function UserTable({ data, setData, user }: UserTableProps) {
), + filterFn: "arrIncludesSome", }), columnHelper.accessor("email", { header: () => ( @@ -133,14 +137,22 @@ export default function UserTable({ data, setData, user }: UserTableProps) { )} ), + filterFn: programFilterFn, }), ]; + const setFilterFn = (field: string, filterFn: FilterFn) => { + if (field === "program") { + setProgramFilterFn(filterFn); + } + }; + return ( - + >; onTagsChange?: (tags: Set) => void; singleValue?: boolean; + cellSelectedPreset?: boolean; + filterState?: [FilterFn | null, Dispatch>]; } const TagsInput: React.FC = ({ @@ -18,9 +21,11 @@ const TagsInput: React.FC = ({ setPresetOptions, onTagsChange, singleValue = false, + cellSelectedPreset = false, + filterState, }) => { 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 const [tags, setTags] = useState>(new Set(presetValue)); @@ -138,19 +143,42 @@ const TagsInput: React.FC = ({ } }; + const FilterSelect = () => { + const [filter, setFilter] = filterState ?? [null, null]; + return ( + filter != null && + setFilter != null && ( + + ) + ); + }; + return (
{!cellSelected ? ( - + <> + + + ) : (
-
+
+ =16.8" } }, - "node_modules/@tanstack/table-core": { + "node_modules/@tanstack/react-table/node_modules/@tanstack/table-core": { "version": "8.21.2", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz", "integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==", @@ -668,6 +669,19 @@ "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": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", diff --git a/compass/package.json b/compass/package.json index 9ac0308..cf9d20c 100644 --- a/compass/package.json +++ b/compass/package.json @@ -15,6 +15,7 @@ "@supabase/supabase-js": "^2.42.3", "@tanstack/match-sorter-utils": "^8.15.1", "@tanstack/react-table": "^8.15.0", + "@tanstack/table-core": "^8.21.3", "bufferutil": "^4.0.8", "next": "^13.5.8", "react": "^18",