Skip to content
206 changes: 206 additions & 0 deletions compass/components/Table/ColumnHeader.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends DataPoint> {
header: Header<T, any>;
details: Details | undefined;
setFilterFn?: (field: string, filterFn: FilterFn) => void;
className?: string;
}

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.
* @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<T extends DataPoint>({
header,
details,
setFilterFn,
className
}: ColumnHeaderProps<T>) {
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<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]);

if (!details) {
return <div className="border-gray-200 border-y" />;
}

return (
<th
scope="col"
className={`border-gray-200 border-y font-medium ${
isFiltered ? "bg-purple-50" : ""
} ${className ?? ""}`}
>
<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 ${
isFiltered ? "" : ""
}`}
>
{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 "asc" | "desc"]
}
</div>
</div>
)}
</div>
<div className="relative">
{/* Dropdown menu to add sorting or filter */}
{column.getCanFilter() && dropdownType === "menu" && (
<div
ref={menuRef}
className="absolute flex flex-col justify-center items-center -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>
<hr className="w-40" />
<button
className={`flex items-center w-full text-left px-4 py-2 text-xs hover:bg-gray-100 ${
isFiltered ? "text-red-400" : ""
}`}
onClick={() => {
column.getCanFilter() &&
column.setFilterValue("");
setDropdownType(isFiltered ? null : "filter");
}}
>
<FunnelIcon className="w-4 h-4 mr-2" />
{isFiltered ? (
<>
<span>Clear Filter</span>
<XMarkIcon className="w-4 h-4 ml-auto" />
</>
) : (
<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"
>
<FilterDropdown
column={column}
details={details}
setFilterFn={setFilterFn}
/>
</div>
)}
</div>
</th>
);
}
91 changes: 91 additions & 0 deletions compass/components/Table/FilterDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends DataPoint> {
details: Details;
column: Column<T, any>;
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<T extends DataPoint>({
details,
column,
setFilterFn,
}: FilterDropdownProps<T>) {
const filterState = useState<FilterFn | null>(
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 (
<div className="absolute -top-5 -left-1">
<TagsInput
presetOptions={presetOptionsValues ?? []}
setPresetOptions={presetOptionsSetter ?? (() => {})}
presetValue={[]}
onTagsChange={(tags) => {
const tagsArray = Array.from(tags);
column.setFilterValue(tagsArray);
}}
cellSelectedPreset={true}
/>
</div>
);
case "select-multiple":
return (
<div className="absolute -top-5 -left-1">
<TagsInput
presetOptions={presetOptionsValues ?? []}
setPresetOptions={presetOptionsSetter ?? (() => {})}
presetValue={[]}
onTagsChange={(tags) => {
const tagsArray = Array.from(tags);
column.setFilterValue(tagsArray);
}}
cellSelectedPreset={true}
filterState={filterState}
/>
</div>
);
default:
return (
<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="Type a value…"
className="border border-gray-300 rounded p-1"
/>
</div>
);
}
}
20 changes: 19 additions & 1 deletion compass/components/Table/ServiceTable.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
Bars2Icon,
CheckCircleIcon,
DocumentTextIcon,
ListBulletIcon,
Expand All @@ -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[];
Expand All @@ -31,6 +31,8 @@ export default function ServiceTable({
user,
}: ServiceTableProps) {
const columnHelper = createColumnHelper<Service>();
const [requirementsFilterFn, setRequirementsFilterFn] =
useState<FilterFn>("arrIncludesSome");

const [programPresets, setProgramPresets] = useState([
"DOMESTIC",
Expand Down Expand Up @@ -151,6 +153,14 @@ export default function ServiceTable({
</Tag>
</div>
),
// 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: () => (
Expand All @@ -170,6 +180,7 @@ export default function ServiceTable({
)}
</div>
),
filterFn: requirementsFilterFn,
}),
columnHelper.accessor("summary", {
header: () => (
Expand All @@ -188,11 +199,18 @@ export default function ServiceTable({
}),
];

const setFilterFn = (field: string, filterFn: FilterFn) => {
if (field === "requirements") {
setRequirementsFilterFn(filterFn);
}
};

return (
<Table
data={data}
setData={setData}
columns={columns}
setFilterFn={setFilterFn}
details={serviceDetails}
createEndpoint={`/api/service/create?uuid=${user?.uuid}`}
deleteEndpoint={`/api/service/delete?uuid=${user?.uuid}`}
Expand Down
Loading