diff --git a/app/companies/CompanyTable.tsx b/app/companies/CompanyTable.tsx index e93aa534..c3d0423c 100644 --- a/app/companies/CompanyTable.tsx +++ b/app/companies/CompanyTable.tsx @@ -68,13 +68,13 @@ const CompanyTable = ({ sortingFn: "text", }, size: { - cell: info => info.getValue() || "N/A", + cell: info => info.getValue(), header: "Size", id: "size", sortingFn: "alphanumeric", }, hq: { - cell: info => info.getValue() || "N/A", + cell: info => info.getValue(), header: "HQ", id: "hq", sortingFn: "text", @@ -97,7 +97,7 @@ const CompanyTable = ({ columnName, columnDefsMap[columnName] ?? { // default column definition, so that all company values are also accessible - cell: info => info.getValue() || "N/A", + cell: info => info.getValue(), header: columnName, sortingFn: "alphanumeric", id: columnName, diff --git a/app/companies/[slug]/page.tsx b/app/companies/[slug]/page.tsx index 2cdf2671..9d880fb9 100644 --- a/app/companies/[slug]/page.tsx +++ b/app/companies/[slug]/page.tsx @@ -197,7 +197,7 @@ const CompanyPage = async ({ params }: { params: { slug: string } }) => { info.getValue(), @@ -112,7 +112,7 @@ const EventTable = ({ columnName, columnDefsMap[columnName] ?? { // default column definition, so that all company values are also accessible - cell: info => info.getValue() || "N/A", + cell: info => info.getValue(), header: columnName, sortingFn: "alphanumeric", id: columnName, diff --git a/app/opportunities/OpportunityTable.tsx b/app/opportunities/OpportunityTable.tsx index 2ee5a821..29408b1b 100644 --- a/app/opportunities/OpportunityTable.tsx +++ b/app/opportunities/OpportunityTable.tsx @@ -2,7 +2,7 @@ import { DeleteOpportunity } from "@/components/DeleteOpportunity" import Link from "@/components/Link" -import TanstackTable from "@/components/TanstackTable" +import TanstackTable, { dateFilterFn } from "@/components/TanstackTable" import { EditOpportunity } from "@/components/UpsertOpportunity" import styles from "./opportunityTable.module.scss" @@ -11,7 +11,7 @@ import { getCompanyLink } from "../companies/getCompanyLink" import type { CompanyProfile, Opportunity } from "@prisma/client" import { Flex } from "@radix-ui/themes" import { ColumnDef, DisplayColumnDef, createColumnHelper } from "@tanstack/react-table" -import { formatDistanceToNowStrict } from "date-fns" +import { format, formatDistanceToNowStrict } from "date-fns" import Image from "next/image" import { useMemo } from "react" @@ -87,7 +87,14 @@ const OpportunityTable = ({ header: "Posted", sortingFn: "datetime", id: "posted", - enableColumnFilter: false, + filterFn: dateFilterFn, + }, + deadline: { + cell: info => , + header: "Application Deadline", + sortingFn: "datetime", + id: "deadline", + filterFn: dateFilterFn, }, } diff --git a/app/opportunities/page.tsx b/app/opportunities/page.tsx index 58696560..c1d899ae 100644 --- a/app/opportunities/page.tsx +++ b/app/opportunities/page.tsx @@ -19,7 +19,7 @@ const OpportunitiesPage = async () => { Opportunities diff --git a/app/students/StudentTable.tsx b/app/students/StudentTable.tsx index cd5c1bc1..9681a3cd 100644 --- a/app/students/StudentTable.tsx +++ b/app/students/StudentTable.tsx @@ -1,7 +1,7 @@ "use client" import Link from "@/components/Link" -import TanstackTable from "@/components/TanstackTable" +import TanstackTable, { dateFilterFn } from "@/components/TanstackTable" import UserAvatar from "@/components/UserAvatar" import { StudentProfile, User } from "@prisma/client" @@ -45,7 +45,7 @@ const StudentTable = ({ header: "Graduating", sortingFn: "datetime", id: "graduationDate", - enableColumnFilter: true, + filterFn: dateFilterFn, }, course: { cell: info => info.getValue(), diff --git a/components/DateTimePicker.tsx b/components/DateTimePicker.tsx index 1c71b4bc..c3e3de11 100644 --- a/components/DateTimePicker.tsx +++ b/components/DateTimePicker.tsx @@ -13,20 +13,30 @@ const DateTimePicker = ({ placeholder, required = false, defaultDate, + onChange, + showTime = true, + value, }: { name: string placeholder: string required?: boolean defaultDate?: Date | null + onChange?: (e: React.ChangeEvent) => void + showTime?: boolean + value?: string }) => { return ( ) } diff --git a/components/TanstackTable.tsx b/components/TanstackTable.tsx index 7dd84f31..afb74260 100644 --- a/components/TanstackTable.tsx +++ b/components/TanstackTable.tsx @@ -1,6 +1,7 @@ "use client" import Chip from "./Chip" +import DateTimePicker from "./DateTimePicker" import { Dropdown } from "./Dropdown" import styles from "./tanstack-table.module.scss" @@ -19,6 +20,7 @@ import { Box, Button, Flex, Grid, IconButton, Table, Text } from "@radix-ui/them import { ColumnDef, ColumnFiltersState, + Row, SortDirection, SortingState, VisibilityState, @@ -29,7 +31,8 @@ import { getSortedRowModel, useReactTable, } from "@tanstack/react-table" -import React, { useEffect, useMemo, useState } from "react" +import { format, isAfter } from "date-fns" +import React, { useCallback, useEffect, useMemo, useState } from "react" import { Pagination } from "react-headless-pagination" import { useMediaQuery } from "react-responsive" @@ -43,6 +46,32 @@ interface TanstackTableProps { invisibleColumns?: VisibilityState } +const formatDateRange = (dates: [string, string]): string => { + const [start, end] = dates + if (start && end) { + return `between "${format(start, "dd-MM-yyyy")}" and "${format(end, "dd-MM-yyyy")}"` + } + if (start) { + return `on or after "${format(start, "dd-MM-yyyy")}"` + } + if (end) { + return `on or before "${format(end, "dd-MM-yyyy")}"` + } + return "" +} + +/** + * Filter function for columns with type date. Keeps rows which are after the start date (if set) and before the end date (if set). + * @template T The type of the row + * @param row The row to filter + * @param id The column id to filter + * @param filterValue The filter value to be applied of the form [start, end] + * @returns Whether the row should be displayed + */ +export const dateFilterFn = (row: Row, id: string, filterValue: [string, string]): boolean => + (!filterValue[0] || isAfter(row.getValue(id), filterValue[0] + "T00:00")) && + (!filterValue[1] || isAfter(filterValue[1] + "T23:59", row.getValue(id))) + const getSortingIcon = (isSorted: false | SortDirection): React.ReactNode => { switch (isSorted) { case false: @@ -95,6 +124,10 @@ export default function TanstackTable({ setIsClient(true) }, []) + const dateFilterColumns = useMemo(() => { + return columns.filter(column => column.sortingFn === "datetime").map(column => column.id) + }, [columns]) + const filterableColumns = useMemo( () => table @@ -107,8 +140,76 @@ export default function TanstackTable({ const [searchQuery, setSearchQuery] = useState("") const [currentFilteredColumn, setCurrentFilteredColumn] = useState(filterableColumns[0]?.id ?? "") - const [prevFilters, setPrevFilters] = useState([]) + const [dateStart, setDateStart] = useState("") + const [dateEnd, setDateEnd] = useState("") + + const FooterWrapper = isLowWidth ? Flex : Grid + + /** Creates a chip to display a filter that the user has created as they were typing into the search box & they have since pressed Add Filter */ + const addChip = useCallback(() => { + if (dateFilterColumns.includes(currentFilteredColumn)) { + setPrevFilters([ + ...prevFilters.filter(f => f.id !== currentFilteredColumn), // Remove the previous filter for the same column + { id: currentFilteredColumn, value: [dateStart, dateEnd] }, + ]) + setDateStart("") + setDateEnd("") + } else { + setPrevFilters([ + ...prevFilters.filter(f => f.id !== currentFilteredColumn), // Remove the previous filter for the same column + { id: currentFilteredColumn, value: searchQuery }, + ]) + setSearchQuery("") + } + }, [currentFilteredColumn, dateEnd, dateFilterColumns, dateStart, prevFilters, searchQuery]) + + /** Unset a filter and delete its chip that is displayed to the user */ + const deleteChip = useCallback( + (index: number) => { + setPrevFilters(prevFilters.filter((_, i) => i !== index)) + setColumnFilters([...columnFilters.filter((_, i) => i !== index)]) + }, + [columnFilters, prevFilters], + ) + + /** + * Update the chips, and then set the filters on the table. + * + * If a filter already exists & is shown as a chip, update the chip instead of setting a new filter. + */ + const updateFilters = useCallback( + (value: any) => { + const newFilters = [ + ...columnFilters.filter(f => f.id !== currentFilteredColumn), // Remove the previous filter for the same column + { id: currentFilteredColumn, value }, // Add the new filter for this column + ] + + // Live-update filter chips which are currently displayed (i.e. in prevFilters) + if (prevFilters.find(f => f.id === currentFilteredColumn)) { + if (!value || (Array.isArray(value) && value.every(v => !v))) { + // Delete the chip if the search query is now empty + setPrevFilters(prevFilters.filter(f => f.id !== currentFilteredColumn)) + } else { + // Update the chip with the new search query + setPrevFilters(newFilters) + } + } + + // Update the actual filters that are applied to the table + setColumnFilters(newFilters) + }, + [columnFilters, currentFilteredColumn, prevFilters], + ) + + /** Clears the current filter if it is not set as a chip */ + const clearCurrentFilter = useCallback(() => { + if (!prevFilters.find(f => f.id === currentFilteredColumn)) { + setColumnFilters(columnFilters.filter(f => f.id !== currentFilteredColumn)) + } + + setSearchQuery("") + }, [columnFilters, currentFilteredColumn, prevFilters]) if (!isClient) { return ( @@ -119,84 +220,106 @@ export default function TanstackTable({ ) } - const FooterWrapper = isLowWidth ? Flex : Grid - - const addFilter = () => { - setPrevFilters([ - ...prevFilters.filter(f => f.id !== currentFilteredColumn), // Remove the previous filter for the same column - { id: currentFilteredColumn, value: searchQuery }, - ]) - setSearchQuery("") - } - - const deleteFilter = (index: number) => { - setPrevFilters(prevFilters.filter((_, i) => i !== index)) - setColumnFilters([...columnFilters.filter((_, i) => i !== index)]) - } - return ( {enableSearch && ( - - { - if (e.key === "Enter") { - addFilter() - } - }} - onChange={e => { - setSearchQuery(e.target.value) - - const newFilters = [ - ...columnFilters.filter(f => f.id !== currentFilteredColumn), // Remove the previous filter for the same column - { id: currentFilteredColumn, value: e.target.value }, // Add the new filter for this column - ] - - // Live-update filter chips which are currently displayed (i.e. in prevFilters) - if (prevFilters.find(f => f.id === currentFilteredColumn)) { - if (e.target.value === "") { - // Delete the chip if the search query is now empty - setPrevFilters(prevFilters.filter(f => f.id !== currentFilteredColumn)) - } else { - // Update the chip with the new search query - setPrevFilters(newFilters) + + {!dateFilterColumns.includes(currentFilteredColumn) ? ( + { + if (e.key === "Enter") { + addChip() } - } - - // Update the actual filters that are applied to the table - setColumnFilters(newFilters) - }} - value={searchQuery} - > - - - - - - - + }} + onChange={e => { + setSearchQuery(e.target.value) + updateFilters(e.target.value) + }} + value={searchQuery} + > + + + + + + + + ) : ( + + + + + + + + + )} ({ item: col.columnDef.header!.toString(), value: col.columnDef.id!, }))} defaultValue={filterableColumns[0].id ?? ""} - onValueChange={setCurrentFilteredColumn} + onValueChange={newFilterCol => { + clearCurrentFilter() + + setCurrentFilteredColumn(newFilterCol) + setDateStart("") + setDateEnd("") + }} triggerProps={{ "aria-label": "Filter by column", title: "Filter by column", }} /> - + {prevFilters.map(({ id, value }, index) => ( - deleteFilter(index)} /> + def.id === id)?.header} ${formatDateRange(value as [string, string])}` + : `${columns.find(def => def.id === id)?.header} includes "${value}"` + } + deletable + onDelete={() => deleteChip(index)} + /> ))}