diff --git a/src/core/components/Filter.tsx b/src/core/components/Filter.tsx index d8f271f9..8463e25c 100644 --- a/src/core/components/Filter.tsx +++ b/src/core/components/Filter.tsx @@ -4,6 +4,9 @@ import { Column } from "@tanstack/react-table" function Filter({ column }: { column: Column }) { const { filterVariant } = column.columnDef.meta ?? {} const isHtml = column.columnDef.meta?.isHtml || false + const selectOptions = column.columnDef.meta?.selectOptions as + | { label: string; value: string }[] + | undefined const columnFilterValue = column.getFilterValue() const facetedUniqueValues = column.getFacetedUniqueValues() @@ -64,16 +67,22 @@ function Filter({ column }: { column: Column }) { className={sharedInputStyles} > - {sortedUniqueValues.map((value, index) => ( - // dynamically generated select options from faceted values feature - - ))} + {selectOptions + ? selectOptions.map((option, index) => ( + + )) + : sortedUniqueValues.map((value, index) => ( + // dynamically generated select options from faceted values feature + + ))} ) : filterVariant === "multiselect" ? (
diff --git a/src/core/components/GetWidgetDisplay.tsx b/src/core/components/GetWidgetDisplay.tsx index 11098104..f977e7d1 100644 --- a/src/core/components/GetWidgetDisplay.tsx +++ b/src/core/components/GetWidgetDisplay.tsx @@ -14,6 +14,7 @@ export function GetTableDisplay({ data, columns, type }) { return ( value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +const containsWholeWord = (text: string, word: string) => { + const escapedWord = escapeRegExp(word) + const regex = new RegExp(`\\b${escapedWord}\\b`) + return regex.test(text) +} + +const matchesSpecialTokenInText = (text: string, token: string) => { + if (!text) { + return false + } + + const normalized = text.toLowerCase() + + if (token === "completed") { + return /(? { + const normalizedKey = keyPath.toLowerCase() + const isReadKey = normalizedKey.includes("read") + const isCompletionKey = normalizedKey.includes("status") || normalizedKey.includes("complete") + const isApprovalKey = normalizedKey.includes("approve") + + if (token === "read") { + return isReadKey && value === true + } + + if (token === "unread") { + return isReadKey && value === false + } + + if (token === "completed" || token === "complete") { + return isCompletionKey && value === true + } + + if (token === "not completed") { + return isCompletionKey && value === false + } + + if (token === "approved") { + return isApprovalKey && value === true + } + + if (token === "not approved") { + return isApprovalKey && value === false + } + + if (token === "pending") { + return isApprovalKey && (value === null || value === undefined) + } + + return false +} + +const matchesSpecialToken = (data: unknown, token: string, keyPath = ""): boolean => { + if (data === null || data === undefined) { + return matchesBooleanToken(token, data as null, keyPath) + } + + if (typeof data === "boolean") { + return matchesBooleanToken(token, data, keyPath) + } + + if (Array.isArray(data)) { + return data.some((item) => matchesSpecialToken(item, token, keyPath)) + } + + if (data instanceof Date) { + return false + } + + if (typeof data === "object") { + return Object.entries(data as Record).some(([key, value]) => { + const nextPath = keyPath ? `${keyPath}.${key}` : key + return matchesSpecialToken(value, token, nextPath) + }) + } + + return false +} type TableProps = { columns: ColumnDef[] @@ -21,6 +128,8 @@ type TableProps = { filters?: {} //pass object with the type of filter for a given colunm based on colunm id enableSorting?: boolean enableFilters?: boolean + enableGlobalSearch?: boolean + globalSearchPlaceholder?: string addPagination?: boolean classNames?: { table?: string @@ -33,6 +142,31 @@ type TableProps = { pageInfo?: string goToPageInput?: string pageSizeSelect?: string + searchContainer?: string + searchInput?: string + } +} + +const defaultGlobalFilterFn: FilterFn = (row, _columnId, filterValue) => { + const searchValue = String(filterValue ?? "") + .toLowerCase() + .trim() + + if (!searchValue) { + return true + } + + try { + const rowValue = buildSearchableString(row.original ?? {}) + if (specialSearchTokens.has(searchValue)) { + if (matchesSpecialToken(row.original, searchValue)) { + return true + } + return matchesSpecialTokenInText(rowValue, searchValue) + } + return rowValue.includes(searchValue) + } catch (error) { + return false } } @@ -42,9 +176,12 @@ const Table = ({ classNames, enableSorting = true, enableFilters = true, + enableGlobalSearch = true, + globalSearchPlaceholder = "Search...", addPagination = false, }: TableProps) => { const [sorting, setSorting] = React.useState([]) + const [globalFilter, setGlobalFilter] = React.useState("") const table = useReactTable({ data, @@ -59,6 +196,7 @@ const Table = ({ getFacetedMinMaxValues: getFacetedMinMaxValues(), state: { sorting: sorting, + globalFilter: globalFilter, }, initialState: { pagination: { @@ -66,14 +204,50 @@ const Table = ({ }, }, onSortingChange: setSorting, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: defaultGlobalFilterFn, autoResetPageIndex: false, }) const currentPage = table.getState().pagination.pageIndex + 1 const pageCount = table.getPageCount() + const pageIndex = table.getState().pagination.pageIndex + + const globalSearchTooltipId = React.useId() + + React.useEffect(() => { + if (!addPagination) { + return + } + + if (pageCount > 0 && pageIndex >= pageCount) { + table.setPageIndex(0) + } + }, [addPagination, pageCount, pageIndex, table]) return ( <> + {enableGlobalSearch && ( +
+ setGlobalFilter(event.target.value)} + placeholder={globalSearchPlaceholder} + aria-label="Search table data" + data-tooltip-id={globalSearchTooltipId} + data-tooltip-content="Searches all data in table (including comments, log dates, and more)." + className={`input input-primary input-bordered border-2 bg-base-300 rounded input-sm w-full max-w-xs focus:outline-secondary ${ + classNames?.searchInput || "" + }`} + /> + +
+ )}
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/src/core/utils/tableFilters.ts b/src/core/utils/tableFilters.ts new file mode 100644 index 00000000..5ceddf34 --- /dev/null +++ b/src/core/utils/tableFilters.ts @@ -0,0 +1,188 @@ +import { FilterFn } from "@tanstack/react-table" + +type DateLike = Date | string | number | null | undefined + +type DateFilterOptions = { + emptyLabel?: string + locale?: string +} + +const DEFAULT_LOCALE = "en-US" +const MAX_RECURSION_DEPTH = 5 + +const parseDateLike = (value: DateLike): Date | null => { + if (value instanceof Date) { + return value + } + + if (typeof value === "number") { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date + } + + if (typeof value === "string") { + const parsed = Date.parse(value) + if (Number.isNaN(parsed)) { + return null + } + const date = new Date(parsed) + return Number.isNaN(date.getTime()) ? null : date + } + + return null +} + +export const buildDateSearchValue = (value: DateLike, options?: DateFilterOptions): string => { + const emptyLabel = options?.emptyLabel ?? "no date" + const locale = options?.locale ?? DEFAULT_LOCALE + + if (value === null || value === undefined || value === "") { + return emptyLabel.toLowerCase() + } + + const date = parseDateLike(value) + if (!date) { + return "" + } + + const iso = date.toISOString().split("T")[0] + const longDate = new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "long", + day: "numeric", + }).format(date) + const shortDate = new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "short", + day: "numeric", + }).format(date) + + return `${iso} ${longDate.toLowerCase()} ${shortDate.toLowerCase()}`.trim() +} + +export const createDateTextFilter = (options?: DateFilterOptions): FilterFn => { + return (row, columnId, filterValue) => { + const searchValue = String(filterValue ?? "") + .trim() + .toLowerCase() + + if (!searchValue) { + return true + } + + const rawValue = row.getValue(columnId) as DateLike + const searchableDate = buildDateSearchValue(rawValue, options) + + if (!searchableDate) { + return false + } + + return searchableDate.includes(searchValue) + } +} + +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== "object") { + return false + } + return Object.getPrototypeOf(value) === Object.prototype +} + +const describePrimitive = ( + value: unknown, + locale: string, + keyHint?: string, + parentKey?: string +): string => { + if (value === null || value === undefined) { + return "" + } + + if (value instanceof Date) { + return buildDateSearchValue(value, { emptyLabel: "", locale }) + } + + if (typeof value === "string") { + const trimmed = value.trim() + if (!trimmed) { + return "" + } + + const parsedDate = parseDateLike(value) + if (parsedDate) { + const formatted = buildDateSearchValue(parsedDate, { emptyLabel: "", locale }) + return `${trimmed.toLowerCase()} ${formatted}`.trim() + } + + return trimmed.toLowerCase() + } + + if (typeof value === "number" || typeof value === "bigint") { + return String(value) + } + + if (typeof value === "boolean") { + const normalizedKey = keyHint?.toLowerCase() ?? "" + const normalizedParent = parentKey?.toLowerCase() ?? "" + const combined = `${normalizedParent} ${normalizedKey}` + + if (combined.includes("read")) { + return value ? "read true yes" : "unread false no" + } + + if (combined.includes("status") || combined.includes("complete")) { + return value ? "completed true yes" : "incomplete false no" + } + + return value ? "true yes" : "false no" + } + + return "" +} + +export const buildSearchableString = ( + value: unknown, + options?: { + locale?: string + } +): string => { + const locale = options?.locale ?? DEFAULT_LOCALE + const visited = new WeakSet() + + const helper = (input: unknown, depth: number, keyHint?: string, parentKey?: string): string => { + if (depth > MAX_RECURSION_DEPTH) { + return "" + } + + if (input === null || input === undefined) { + return "" + } + + if (input instanceof Date || typeof input !== "object") { + return describePrimitive(input, locale, keyHint, parentKey) + } + + if (visited.has(input as object)) { + return "" + } + + visited.add(input as object) + let result = "" + + if (Array.isArray(input)) { + result = input.map((item) => helper(item, depth + 1, keyHint, parentKey)).join(" ") + } else if (isPlainObject(input)) { + result = Object.entries(input) + .map(([key, item]) => helper(item, depth + 1, key, keyHint ?? parentKey)) + .join(" ") + } else { + // Non-plain objects (e.g., Maps) fallback to string conversion + result = describePrimitive(String(input), locale, keyHint, parentKey) + } + + visited.delete(input as object) + return result.trim() + } + + return helper(value, 0).trim().toLowerCase() +} diff --git a/src/forms/tables/columns/FormsColumns.tsx b/src/forms/tables/columns/FormsColumns.tsx index 06b282e1..604bcfc4 100644 --- a/src/forms/tables/columns/FormsColumns.tsx +++ b/src/forms/tables/columns/FormsColumns.tsx @@ -7,9 +7,11 @@ import DateFormat from "src/core/components/DateFormat" import ArchiveFormButton from "../../components/ArchiveFormButton" import { MagnifyingGlassIcon, PencilSquareIcon } from "@heroicons/react/24/outline" import { FormTableData } from "../processing/processForms" +import { createDateTextFilter } from "src/core/utils/tableFilters" // Column helper const columnHelper = createColumnHelper() +const lastUpdateFilter = createDateTextFilter({ emptyLabel: "no date" }) // ColumnDefs export const FormsColumns = [ @@ -20,6 +22,12 @@ export const FormsColumns = [ columnHelper.accessor("updatedAt", { cell: (info) => , header: "Last Update", + enableColumnFilter: true, + enableSorting: true, + filterFn: lastUpdateFilter, + meta: { + filterVariant: "text", + }, }), columnHelper.accessor((row) => "view", { id: "view", diff --git a/src/invites/tables/columns/InviteColumns.tsx b/src/invites/tables/columns/InviteColumns.tsx index 0bd182cd..fb6613f9 100644 --- a/src/invites/tables/columns/InviteColumns.tsx +++ b/src/invites/tables/columns/InviteColumns.tsx @@ -3,6 +3,7 @@ import { createColumnHelper } from "@tanstack/react-table" import DateFormat from "src/core/components/DateFormat" import { AcceptInvite } from "src/invites/components/AcceptInvite" import { DeleteInvite } from "src/invites/components/DeleteInvite" +import { createDateTextFilter } from "src/core/utils/tableFilters" // Define return type for the columns export type InviteTableData = { @@ -14,12 +15,19 @@ export type InviteTableData = { // use column helper const columnHelper = createColumnHelper() +const inviteDateFilter = createDateTextFilter({ emptyLabel: "no date" }) // ColumnDefs export const InviteColumns = [ columnHelper.accessor("createdAt", { cell: (info) => , header: "Date", + enableColumnFilter: true, + enableSorting: true, + filterFn: inviteDateFilter, + meta: { + filterVariant: "text", + }, }), columnHelper.accessor("project.name", { cell: (info) => {info.getValue()}, diff --git a/src/invites/tables/columns/InvitePMColumns.tsx b/src/invites/tables/columns/InvitePMColumns.tsx index 44c0dec3..4986cabc 100644 --- a/src/invites/tables/columns/InvitePMColumns.tsx +++ b/src/invites/tables/columns/InvitePMColumns.tsx @@ -2,6 +2,7 @@ import React from "react" import { createColumnHelper } from "@tanstack/react-table" import DateFormat from "src/core/components/DateFormat" import { DeleteInvite } from "src/invites/components/DeleteInvite" +import { createDateTextFilter } from "src/core/utils/tableFilters" // Define return type for the columns export type InviteTablePMData = { @@ -13,12 +14,19 @@ export type InviteTablePMData = { // use column helper const columnHelper = createColumnHelper() +const invitePMDateFilter = createDateTextFilter({ emptyLabel: "no date" }) // ColumnDefs export const InvitePMColumns = [ columnHelper.accessor("createdAt", { cell: (info) => , header: "Date", + enableColumnFilter: true, + enableSorting: true, + filterFn: invitePMDateFilter, + meta: { + filterVariant: "text", + }, }), columnHelper.accessor("email", { cell: (info) => {info.getValue()}, diff --git a/src/milestones/components/MilestoneInformation.tsx b/src/milestones/components/MilestoneInformation.tsx index 89d917f5..6c32ee6b 100644 --- a/src/milestones/components/MilestoneInformation.tsx +++ b/src/milestones/components/MilestoneInformation.tsx @@ -1,10 +1,8 @@ import Table from "src/core/components/Table" import { Milestone } from "@prisma/client" import DateFormat from "src/core/components/DateFormat" -import { MilestoneTasksColumns } from "../tables/columns/MilestoneTasksColumns" import TooltipWrapper from "src/core/components/TooltipWrapper" import CollapseCard from "src/core/components/CollapseCard" -import { MilestoneTasksData } from "../tables/processing/processMilestoneTasks" import { MilestoneSummary } from "./MilestoneSummary" import { ProjectTasksColumns } from "src/tasks/tables/columns/ProjectTasksColumns" import { ProjectTasksData } from "src/tasks/tables/processing/processProjectTasks" diff --git a/src/milestones/tables/columns/MilestoneTasksColumns.tsx b/src/milestones/tables/columns/MilestoneTasksColumns.tsx deleted file mode 100644 index 4f5c807e..00000000 --- a/src/milestones/tables/columns/MilestoneTasksColumns.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Routes } from "@blitzjs/next" -import { MagnifyingGlassIcon } from "@heroicons/react/24/outline" -import { createColumnHelper } from "@tanstack/react-table" -import Link from "next/link" -import DateFormat from "src/core/components/DateFormat" -import { MilestoneTasksData } from "../processing/processMilestoneTasks" - -const columnHelperMilestone = createColumnHelper() - -export const MilestoneTasksColumns = [ - columnHelperMilestone.accessor("name", { - cell: (info) => {info.getValue()}, - header: "Name", - enableColumnFilter: true, - enableSorting: true, - meta: { - filterVariant: "text", - }, - }), - columnHelperMilestone.accessor("deadline", { - cell: (info) => , - header: "Due Date", - enableColumnFilter: true, - enableSorting: true, - meta: { - filterVariant: "text", - }, - }), - columnHelperMilestone.accessor("status", { - header: "Completed", - cell: (info) => {info.getValue()}, - enableColumnFilter: true, - enableSorting: true, - meta: { - filterVariant: "select", - }, - }), - columnHelperMilestone.accessor("view", { - id: "view", - header: "View", - enableColumnFilter: false, - enableSorting: false, - cell: (info) => ( - - - - ), - }), -] diff --git a/src/notifications/tables/columns/NotificationColumns.tsx b/src/notifications/tables/columns/NotificationColumns.tsx index f16ebc6f..1ddd7a66 100644 --- a/src/notifications/tables/columns/NotificationColumns.tsx +++ b/src/notifications/tables/columns/NotificationColumns.tsx @@ -1,4 +1,4 @@ -import { createColumnHelper } from "@tanstack/react-table" +import { createColumnHelper, FilterFn } from "@tanstack/react-table" import { useMemo } from "react" import DateFormat from "src/core/components/DateFormat" import ReadToggle from "src/notifications/components/ReadToggle" @@ -6,9 +6,19 @@ import { NotificationTableData } from "../processing/processNotification" import NotificationMessage from "src/notifications/components/NotificationMessage" import { MultiSelectCheckbox } from "src/core/components/fields/MultiSelectCheckbox" import { SelectAllCheckbox } from "src/core/components/fields/SelectAllCheckbox" +import { Tooltip } from "react-tooltip" +import { createDateTextFilter } from "src/core/utils/tableFilters" // Column helper const columnHelper = createColumnHelper() +const notificationDateFilter = createDateTextFilter({ emptyLabel: "no date" }) +const readStatusFilter: FilterFn = (row, columnId, filterValue) => { + const selected = String(filterValue ?? "").trim() + if (!selected) { + return true + } + return String(row.getValue(columnId)) === selected +} // ColumnDefs export const useNotificationTableColumns = (refetch: () => void, data: NotificationTableData[]) => { @@ -19,6 +29,12 @@ export const useNotificationTableColumns = (refetch: () => void, data: Notificat columnHelper.accessor("createdAt", { cell: (info) => , header: "Date", + enableColumnFilter: true, + enableSorting: true, + filterFn: notificationDateFilter, + meta: { + filterVariant: "text", + }, }), columnHelper.accessor("projectName", { header: "Project", @@ -52,18 +68,37 @@ export const useNotificationTableColumns = (refetch: () => void, data: Notificat isHtml: true, }, }), - columnHelper.accessor("notification", { - enableColumnFilter: false, - //enableSorting: false, - cell: (info) => , + columnHelper.accessor((row) => (row.notification.read ? "Read" : "Unread"), { + id: "readStatus", header: "Read", + enableColumnFilter: true, + enableSorting: false, + cell: (info) => ( + + ), + filterFn: readStatusFilter, + meta: { + filterVariant: "select", + }, }), columnHelper.accessor("id", { id: "multiple", enableColumnFilter: false, enableSorting: false, cell: (info) => , - header: () => , + header: () => ( +
+ + +
+ ), }), ], [refetch, allIds] diff --git a/src/notifications/tables/columns/ProjectNotificationTableColumns.tsx b/src/notifications/tables/columns/ProjectNotificationTableColumns.tsx index ebe1a3e4..0796b1e9 100644 --- a/src/notifications/tables/columns/ProjectNotificationTableColumns.tsx +++ b/src/notifications/tables/columns/ProjectNotificationTableColumns.tsx @@ -1,4 +1,4 @@ -import { createColumnHelper } from "@tanstack/react-table" +import { createColumnHelper, FilterFn } from "@tanstack/react-table" import { useMemo } from "react" import DateFormat from "src/core/components/DateFormat" import ReadToggle from "src/notifications/components/ReadToggle" @@ -6,9 +6,27 @@ import { ProjectNotificationData } from "../processing/processProjectNotificatio import { MultiSelectCheckbox } from "src/core/components/fields/MultiSelectCheckbox" import NotificationMessage from "src/notifications/components/NotificationMessage" import { SelectAllCheckbox } from "src/core/components/fields/SelectAllCheckbox" +import { createDateTextFilter } from "src/core/utils/tableFilters" +import { Tooltip } from "react-tooltip" // Column helper const columnHelper = createColumnHelper() +const notificationDateFilter = createDateTextFilter({ emptyLabel: "no date" }) +const readStatusFilter: FilterFn = (row, columnId, filterValue) => { + const selected = String(filterValue ?? "") + .trim() + .toLowerCase() + + if (!selected) { + return true + } + + const status = String(row.getValue(columnId) ?? "") + .trim() + .toLowerCase() + + return status === selected +} // ColumnDefs export const useProjectNotificationTableColumns = ( @@ -22,6 +40,12 @@ export const useProjectNotificationTableColumns = ( columnHelper.accessor("createdAt", { cell: (info) => , header: "Date", + enableColumnFilter: true, + enableSorting: true, + filterFn: notificationDateFilter, + meta: { + filterVariant: "text", + }, }), columnHelper.accessor("type", { header: "Type", @@ -49,18 +73,37 @@ export const useProjectNotificationTableColumns = ( isHtml: true, }, }), - columnHelper.accessor("notification", { - enableColumnFilter: false, + columnHelper.accessor((row) => (row.notification.read ? "Read" : "Unread"), { + id: "readStatus", + enableColumnFilter: true, enableSorting: false, - cell: (info) => , + cell: (info) => ( + + ), header: "Read", + filterFn: readStatusFilter, + meta: { + filterVariant: "select", + }, }), columnHelper.accessor("id", { id: "multiple", enableColumnFilter: false, enableSorting: false, cell: (info) => , - header: () => , + header: () => ( +
+ + +
+ ), }), ], [refetch, allIds] diff --git a/src/roles/tables/columns/RoleContributorTableColumns.tsx b/src/roles/tables/columns/RoleContributorTableColumns.tsx index ad3d143b..80108c22 100644 --- a/src/roles/tables/columns/RoleContributorTableColumns.tsx +++ b/src/roles/tables/columns/RoleContributorTableColumns.tsx @@ -2,7 +2,6 @@ import React, { useMemo } from "react" import { createColumnHelper } from "@tanstack/react-table" import { MultiSelectCheckbox } from "src/core/components/fields/MultiSelectCheckbox" import { SelectAllCheckbox } from "src/core/components/fields/SelectAllCheckbox" -import { InformationCircleIcon } from "@heroicons/react/24/outline" import { Tooltip } from "react-tooltip" export type ContributorRoleData = { @@ -48,7 +47,19 @@ export const useRoleContributorTableColumns = (data: ContributorRoleData[]) => { enableColumnFilter: false, enableSorting: false, cell: (info) => , - header: () => , + header: () => ( +
+ + +
+ ), }), ], [allIds] diff --git a/src/roles/tables/columns/RoleTaskTableColumns.tsx b/src/roles/tables/columns/RoleTaskTableColumns.tsx index 8844d230..700aaec7 100644 --- a/src/roles/tables/columns/RoleTaskTableColumns.tsx +++ b/src/roles/tables/columns/RoleTaskTableColumns.tsx @@ -5,7 +5,6 @@ import remarkBreaks from "remark-breaks" import { createColumnHelper } from "@tanstack/react-table" import { MultiSelectCheckbox } from "../../../core/components/fields/MultiSelectCheckbox" import { SelectAllCheckbox } from "../../../core/components/fields/SelectAllCheckbox" -import { InformationCircleIcon } from "@heroicons/react/24/outline" import { Tooltip } from "react-tooltip" export type RoleTaskTableData = { @@ -52,7 +51,19 @@ export const useRoleTaskTableColumns = (data: RoleTaskTableData[]) => { enableColumnFilter: false, enableSorting: false, cell: (info) => , - header: () => , + header: () => ( +
+ + +
+ ), }), ], [allIds] diff --git a/src/tags/tables/columns/TagMilestoneColumns.tsx b/src/tags/tables/columns/TagMilestoneColumns.tsx index 7e6a8167..cd3b4ec5 100644 --- a/src/tags/tables/columns/TagMilestoneColumns.tsx +++ b/src/tags/tables/columns/TagMilestoneColumns.tsx @@ -4,6 +4,7 @@ import { InformationCircleIcon } from "@heroicons/react/24/outline" import { Tooltip } from "react-tooltip" import Link from "next/link" import { Routes } from "@blitzjs/next" +import { createDateTextFilter } from "src/core/utils/tableFilters" export type TagMilestoneData = { id: number @@ -19,6 +20,8 @@ export type TagMilestoneData = { } const columnHelper = createColumnHelper() +const startDateFilter = createDateTextFilter({ emptyLabel: "no date" }) +const endDateFilter = createDateTextFilter({ emptyLabel: "no date" }) export const TagMilestoneColumns = [ columnHelper.accessor("name", { @@ -38,10 +41,22 @@ export const TagMilestoneColumns = [ columnHelper.accessor("startDate", { header: "Start Date", cell: (info) => , + enableColumnFilter: true, + enableSorting: true, + filterFn: startDateFilter, + meta: { + filterVariant: "text", + }, }), columnHelper.accessor("endDate", { header: "End Date", cell: (info) => , + enableColumnFilter: true, + enableSorting: true, + filterFn: endDateFilter, + meta: { + filterVariant: "text", + }, }), columnHelper.accessor("percentTasksComplete", { header: () => ( diff --git a/src/tags/tables/columns/TagPeopleColumns.tsx b/src/tags/tables/columns/TagPeopleColumns.tsx index 22e1c8cf..be5a4fee 100644 --- a/src/tags/tables/columns/TagPeopleColumns.tsx +++ b/src/tags/tables/columns/TagPeopleColumns.tsx @@ -1,11 +1,12 @@ -import { createColumnHelper } from "@tanstack/react-table" +import { createColumnHelper, FilterFn } from "@tanstack/react-table" import DateFormat from "src/core/components/DateFormat" import Link from "next/link" import { Routes } from "@blitzjs/next" +import { createDateTextFilter } from "src/core/utils/tableFilters" export type TagPeopleData = { name: string - createdAt: Date + createdAt: Date | null percentTasksComplete: number | null percentApproved: number | null percentFormsComplete: number | null @@ -17,7 +18,34 @@ export type TagPeopleData = { } const columnHelper = createColumnHelper() +const createdDateFilter = createDateTextFilter({ emptyLabel: "no date" }) +const nullableRangeFilter: FilterFn = (row, columnId, filterValue) => { + const value = row.getValue(columnId) + + // Always include rows without numeric data + if (value === null || value === undefined) { + return true + } + + if (!Array.isArray(filterValue)) { + return true + } + + const parseBound = (bound: unknown, fallback: number) => { + if (bound === null || bound === undefined || bound === "") { + return fallback + } + + const numeric = typeof bound === "number" ? bound : Number(bound) + return Number.isNaN(numeric) ? fallback : numeric + } + + const min = parseBound(filterValue[0], Number.NEGATIVE_INFINITY) + const max = parseBound(filterValue[1], Number.POSITIVE_INFINITY) + + return value >= min && value <= max +} export const TagPeopleColumns = [ columnHelper.accessor("name", { header: "Name", @@ -37,21 +65,29 @@ export const TagPeopleColumns = [ columnHelper.accessor("createdAt", { header: "Start Date", cell: (info) => , + enableColumnFilter: true, + enableSorting: true, + filterFn: createdDateFilter, + meta: { + filterVariant: "text", + }, }), columnHelper.accessor("percentTasksComplete", { header: "Tasks Complete", - cell: (info) => (info.getValue() === null ? "N/A" : `${info.getValue()}%`), + cell: (info) => (info.getValue() === null ? "No tasks" : `${info.getValue()}%`), enableColumnFilter: true, enableSorting: true, + filterFn: nullableRangeFilter, meta: { filterVariant: "range", }, }), columnHelper.accessor("percentApproved", { header: "Tasks Approved", - cell: (info) => (info.getValue() === null ? "N/A" : `${info.getValue()}%`), + cell: (info) => (info.getValue() === null ? "No tasks" : `${info.getValue()}%`), enableColumnFilter: true, enableSorting: true, + filterFn: nullableRangeFilter, meta: { filterVariant: "range", }, @@ -60,10 +96,11 @@ export const TagPeopleColumns = [ header: "Forms Complete", cell: (info) => { const row = info.row.original - return row.formAssignedCount === 0 ? "N/A" : `${info.getValue()}%` + return row.formAssignedCount === 0 ? "No forms" : `${info.getValue()}%` }, enableColumnFilter: true, enableSorting: true, + filterFn: nullableRangeFilter, meta: { filterVariant: "range", }, diff --git a/src/tasklogs/components/TaskLogHistoryModal.tsx b/src/tasklogs/components/TaskLogHistoryModal.tsx index 1c73b610..7a701e8b 100644 --- a/src/tasklogs/components/TaskLogHistoryModal.tsx +++ b/src/tasklogs/components/TaskLogHistoryModal.tsx @@ -54,6 +54,7 @@ export const TaskLogHistoryModal = ({ Task History @@ -78,15 +79,14 @@ export const TaskLogHistoryModal = ({ }} onClose={handleClose} > -
+
diff --git a/src/tasklogs/tables/columns/TaskLogCompleteColumns.tsx b/src/tasklogs/tables/columns/TaskLogCompleteColumns.tsx index ef32aeb7..3cecabf0 100644 --- a/src/tasklogs/tables/columns/TaskLogCompleteColumns.tsx +++ b/src/tasklogs/tables/columns/TaskLogCompleteColumns.tsx @@ -1,5 +1,5 @@ import React from "react" -import { ColumnDef, createColumnHelper } from "@tanstack/react-table" +import { ColumnDef, FilterFn, createColumnHelper } from "@tanstack/react-table" import { TaskLogToggleModal } from "../../components/TaskLogToggleModal" import { ProcessedIndividualTaskLog, ProcessedTeamTaskLog } from "../processing/processTaskLogs" import ToggleModal from "src/core/components/ToggleModal" @@ -18,10 +18,63 @@ import { } from "@heroicons/react/24/outline" import TaskLogHistoryModal from "src/tasklogs/components/TaskLogHistoryModal" import DateFormat from "src/core/components/DateFormat" +import { createDateTextFilter } from "src/core/utils/tableFilters" // Column helper const columnHelper = createColumnHelper() +const lastUpdateFilter = createDateTextFilter({ emptyLabel: "no date" }) + +const statusFilter: FilterFn = ( + row, + columnId, + filterValue +) => { + const selected = String(filterValue ?? "") + .trim() + .toLowerCase() + + if (!selected) { + return true + } + + const value = String(row.getValue(columnId) ?? "") + .trim() + .toLowerCase() + + return value === selected +} + +const approvalFilter: FilterFn = ( + row, + columnId, + filterValue +) => { + const selected = String(filterValue ?? "") + .trim() + .toLowerCase() + + if (!selected) { + return true + } + + const value = row.getValue(columnId) + + if (selected === "approved") { + return value === true + } + + if (selected === "not approved") { + return value === false + } + + if (selected === "pending") { + return value === null + } + + return true +} + // ColumnDefs // Table for assignment without a form export const TaskLogCompleteColumns: ColumnDef< @@ -82,7 +135,7 @@ export const TaskLogCompleteColumns: ColumnDef< )} - + ) }, @@ -101,13 +154,19 @@ export const TaskLogCompleteColumns: ColumnDef< ), id: "updatedAt", + enableColumnFilter: true, + enableSorting: true, + filterFn: lastUpdateFilter, + meta: { + filterVariant: "text", + }, }), columnHelper.accessor("status", { cell: (info) => { const value = info.getValue() const isCompleted = value === "Completed" return ( -
+
{isCompleted ? ( ) : ( @@ -120,8 +179,13 @@ export const TaskLogCompleteColumns: ColumnDef< id: "status", enableColumnFilter: true, enableSorting: true, + filterFn: statusFilter, meta: { filterVariant: "select", + selectOptions: [ + { label: "Completed", value: "completed" }, + { label: "Not completed", value: "not completed" }, + ], }, }), columnHelper.accessor("approved", { @@ -135,14 +199,20 @@ export const TaskLogCompleteColumns: ColumnDef< } else { icon = } - return
{icon}
+ return
{icon}
}, header: "Approved", id: "approved", enableColumnFilter: true, enableSorting: true, + filterFn: approvalFilter, meta: { filterVariant: "select", + selectOptions: [ + { label: "Approved", value: "approved" }, + { label: "Pending", value: "pending" }, + { label: "Not approved", value: "not approved" }, + ], }, }), columnHelper.accessor("taskHistory", { diff --git a/src/tasklogs/tables/columns/TaskLogFormColumns.tsx b/src/tasklogs/tables/columns/TaskLogFormColumns.tsx index 676c0f4f..7bb99934 100644 --- a/src/tasklogs/tables/columns/TaskLogFormColumns.tsx +++ b/src/tasklogs/tables/columns/TaskLogFormColumns.tsx @@ -1,5 +1,5 @@ import React from "react" -import { ColumnDef, createColumnHelper } from "@tanstack/react-table" +import { ColumnDef, FilterFn, createColumnHelper } from "@tanstack/react-table" import { ProcessedIndividualTaskLog, ProcessedTeamTaskLog } from "../processing/processTaskLogs" import { TaskLogSchemaModal } from "../../components/TaskLogSchemaModal" import ToggleModal from "src/core/components/ToggleModal" @@ -14,14 +14,67 @@ import { HandRaisedIcon, InformationCircleIcon, } from "@heroicons/react/24/outline" -import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid" +import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/outline" import TaskLogHistoryModal from "src/tasklogs/components/TaskLogHistoryModal" import { Tooltip } from "react-tooltip" import DateFormat from "src/core/components/DateFormat" +import { createDateTextFilter } from "src/core/utils/tableFilters" // Column helper const columnHelper = createColumnHelper() +const lastUpdateFilter = createDateTextFilter({ emptyLabel: "no date" }) + +const statusFilter: FilterFn = ( + row, + columnId, + filterValue +) => { + const selected = String(filterValue ?? "") + .trim() + .toLowerCase() + + if (!selected) { + return true + } + + const value = String(row.getValue(columnId) ?? "") + .trim() + .toLowerCase() + + return value === selected +} + +const approvalFilter: FilterFn = ( + row, + columnId, + filterValue +) => { + const selected = String(filterValue ?? "") + .trim() + .toLowerCase() + + if (!selected) { + return true + } + + const value = row.getValue(columnId) + + if (selected === "approved") { + return value === true + } + + if (selected === "not approved") { + return value === false + } + + if (selected === "pending") { + return value === null + } + + return true +} + // ColumnDefs // Table for assignment with a form export const TaskLogFormColumns: ColumnDef[] = [ @@ -79,7 +132,7 @@ export const TaskLogFormColumns: ColumnDef )} - +
) }, @@ -98,13 +151,19 @@ export const TaskLogFormColumns: ColumnDef ), id: "updatedAt", + enableColumnFilter: true, + enableSorting: true, + filterFn: lastUpdateFilter, + meta: { + filterVariant: "text", + }, }), columnHelper.accessor("status", { cell: (info) => { const value = info.getValue() const isCompleted = value === "Completed" return ( -
+
{isCompleted ? ( ) : ( @@ -117,8 +176,13 @@ export const TaskLogFormColumns: ColumnDef } - return
{icon}
+ return
{icon}
}, header: "Approved", id: "approved", enableColumnFilter: true, enableSorting: true, + filterFn: approvalFilter, meta: { filterVariant: "select", + selectOptions: [ + { label: "Approved", value: "approved" }, + { label: "Pending", value: "pending" }, + { label: "Not approved", value: "not approved" }, + ], }, }), columnHelper.accessor("taskHistory", { diff --git a/src/tasklogs/tables/columns/TaskLogHistoryCompleteColumns.tsx b/src/tasklogs/tables/columns/TaskLogHistoryCompleteColumns.tsx index 5849ceec..337a01c5 100644 --- a/src/tasklogs/tables/columns/TaskLogHistoryCompleteColumns.tsx +++ b/src/tasklogs/tables/columns/TaskLogHistoryCompleteColumns.tsx @@ -1,12 +1,54 @@ import DateFormat from "src/core/components/DateFormat" import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/outline" -import { ColumnDef, createColumnHelper } from "@tanstack/react-table" +import { ColumnDef, FilterFn, createColumnHelper } from "@tanstack/react-table" import { ApproveDropdown } from "src/tasklogs/components/ApproveTask" import { ProcessedTaskLogHistoryModal } from "../processing/processTaskLogs" // Column helper const columnHelper = createColumnHelper() +const statusFilter: FilterFn = (row, columnId, filterValue) => { + const selected = String(filterValue ?? "") + .trim() + .toLowerCase() + + if (!selected) { + return true + } + + const value = String(row.getValue(columnId) ?? "") + .trim() + .toLowerCase() + + return value === selected +} + +const approvalFilter: FilterFn = (row, columnId, filterValue) => { + const selected = String(filterValue ?? "") + .trim() + .toLowerCase() + + if (!selected) { + return true + } + + const value = row.getValue(columnId) + + if (selected === "approved") { + return value === true + } + + if (selected === "not approved") { + return value === false + } + + if (selected === "pending") { + return value === null + } + + return true +} + // ColumnDefs export const TaskLogHistoryCompleteColumns: ColumnDef[] = [ columnHelper.accessor("projectMemberName", { @@ -24,7 +66,7 @@ export const TaskLogHistoryCompleteColumns: ColumnDef +
{isCompleted ? ( ) : ( @@ -35,6 +77,16 @@ export const TaskLogHistoryCompleteColumns: ColumnDef { @@ -53,5 +105,16 @@ export const TaskLogHistoryCompleteColumns: ColumnDef() +const statusFilter: FilterFn = (row, columnId, filterValue) => { + const selected = String(filterValue ?? "") + .trim() + .toLowerCase() + + if (!selected) { + return true + } + + const value = String(row.getValue(columnId) ?? "") + .trim() + .toLowerCase() + + return value === selected +} + +const approvalFilter: FilterFn = (row, columnId, filterValue) => { + const selected = String(filterValue ?? "") + .trim() + .toLowerCase() + + if (!selected) { + return true + } + + const value = row.getValue(columnId) + + if (selected === "approved") { + return value === true + } + + if (selected === "not approved") { + return value === false + } + + if (selected === "pending") { + return value === null + } + + return true +} + // ColumnDefs export const TaskLogHistoryFormColumns: ColumnDef[] = [ columnHelper.accessor("projectMemberName", { @@ -24,7 +66,7 @@ export const TaskLogHistoryFormColumns: ColumnDef[ const value = info.getValue() const isCompleted = value === "Completed" return ( -
+
{isCompleted ? ( ) : ( @@ -35,6 +77,16 @@ export const TaskLogHistoryFormColumns: ColumnDef[ }, header: "Status", id: "status", + enableColumnFilter: true, + enableSorting: true, + filterFn: statusFilter, + meta: { + filterVariant: "select", + selectOptions: [ + { label: "Completed", value: "completed" }, + { label: "Not completed", value: "not completed" }, + ], + }, }), columnHelper.accessor("approved", { cell: (info) => { @@ -52,6 +104,17 @@ export const TaskLogHistoryFormColumns: ColumnDef[ }, header: "Approved", id: "approved", + enableColumnFilter: true, + enableSorting: true, + filterFn: approvalFilter, + meta: { + filterVariant: "select", + selectOptions: [ + { label: "Approved", value: "approved" }, + { label: "Pending", value: "pending" }, + { label: "Not approved", value: "not approved" }, + ], + }, }), columnHelper.accessor("formData", { cell: (info) => ( diff --git a/src/tasklogs/tables/columns/TaskLogProjectMemberColumns.tsx b/src/tasklogs/tables/columns/TaskLogProjectMemberColumns.tsx index 37291935..e14e9495 100644 --- a/src/tasklogs/tables/columns/TaskLogProjectMemberColumns.tsx +++ b/src/tasklogs/tables/columns/TaskLogProjectMemberColumns.tsx @@ -1,5 +1,5 @@ import React from "react" -import { ColumnDef, createColumnHelper } from "@tanstack/react-table" +import { ColumnDef, FilterFn, createColumnHelper } from "@tanstack/react-table" import { ProcessedIndividualTaskLog } from "../processing/processTaskLogs" import { ProcessedTeamTaskLog } from "../processing/processTaskLogs" import ToggleModal from "src/core/components/ToggleModal" @@ -19,13 +19,58 @@ import TaskLogHistoryModal from "src/tasklogs/components/TaskLogHistoryModal" import { Tooltip } from "react-tooltip" import { TaskLogSchemaModal } from "src/tasklogs/components/TaskLogSchemaModal" import { TaskLogToggleModal } from "src/tasklogs/components/TaskLogToggleModal" +import { createDateTextFilter } from "src/core/utils/tableFilters" type ProcessedTaskLog = (ProcessedIndividualTaskLog | ProcessedTeamTaskLog) & { refetchTaskData?: () => Promise } + +const lastUpdateFilter = createDateTextFilter({ emptyLabel: "no date" }) // Column helper const columnHelper = createColumnHelper() +const statusFilter: FilterFn = (row, columnId, filterValue) => { + const selected = String(filterValue ?? "") + .trim() + .toLowerCase() + + if (!selected) { + return true + } + + const value = String(row.getValue(columnId) ?? "") + .trim() + .toLowerCase() + + return value === selected +} + +const approvalFilter: FilterFn = (row, columnId, filterValue) => { + const selected = String(filterValue ?? "") + .trim() + .toLowerCase() + + if (!selected) { + return true + } + + const value = row.getValue(columnId) + + if (selected === "approved") { + return value === true + } + + if (selected === "not approved") { + return value === false + } + + if (selected === "pending") { + return value === null + } + + return true +} + // ColumnDefs // Table for assignment with a form export const TaskLogProjectMemberColumns: ColumnDef[] = [ @@ -49,13 +94,13 @@ export const TaskLogProjectMemberColumns: ColumnDef[] = [ cell: (info) => { const isOverdue = info.row.original.overdue return ( -
+
{isOverdue && ( )} - +
) }, @@ -74,13 +119,19 @@ export const TaskLogProjectMemberColumns: ColumnDef[] = [
), id: "updatedAt", + enableColumnFilter: true, + enableSorting: true, + filterFn: lastUpdateFilter, + meta: { + filterVariant: "text", + }, }), columnHelper.accessor("status", { cell: (info) => { const value = info.getValue() const isCompleted = value === "Completed" return ( -
+
{isCompleted ? ( ) : ( @@ -93,15 +144,20 @@ export const TaskLogProjectMemberColumns: ColumnDef[] = [ id: "status", enableColumnFilter: true, enableSorting: true, + filterFn: statusFilter, meta: { filterVariant: "select", + selectOptions: [ + { label: "Completed", value: "completed" }, + { label: "Not completed", value: "not completed" }, + ], }, }), columnHelper.accessor("approved", { cell: (info) => { const value = info.getValue() as boolean | null return ( -
+
{value === true ? ( ) : value === false ? ( @@ -116,8 +172,14 @@ export const TaskLogProjectMemberColumns: ColumnDef[] = [ id: "approved", enableColumnFilter: true, enableSorting: true, + filterFn: approvalFilter, meta: { filterVariant: "select", + selectOptions: [ + { label: "Approved", value: "approved" }, + { label: "Pending", value: "pending" }, + { label: "Not approved", value: "not approved" }, + ], }, }), columnHelper.accessor("taskHistory", { diff --git a/src/tasks/tables/columns/AllTasksColumns.tsx b/src/tasks/tables/columns/AllTasksColumns.tsx index f3c7868e..0cbabf96 100644 --- a/src/tasks/tables/columns/AllTasksColumns.tsx +++ b/src/tasks/tables/columns/AllTasksColumns.tsx @@ -5,10 +5,13 @@ import { Routes } from "@blitzjs/next" import DateFormat from "src/core/components/DateFormat" import { MagnifyingGlassIcon, ChatBubbleOvalLeftEllipsisIcon } from "@heroicons/react/24/outline" import { AllTasksData } from "../processing/processAllTasks" +import { createDateTextFilter } from "src/core/utils/tableFilters" // Column helper const columnHelperAll = createColumnHelper() +const dueDateTextFilter = createDateTextFilter({ emptyLabel: "no due date" }) + // ColumnDefs export const AllTasksColumns = [ columnHelperAll.accessor("name", { @@ -34,6 +37,7 @@ export const AllTasksColumns = [ enableSorting: true, cell: (info) => , header: "Due Date", + filterFn: dueDateTextFilter, meta: { filterVariant: "text", }, diff --git a/src/tasks/tables/columns/ProjectTasksColumns.tsx b/src/tasks/tables/columns/ProjectTasksColumns.tsx index 85a9064f..24714c28 100644 --- a/src/tasks/tables/columns/ProjectTasksColumns.tsx +++ b/src/tasks/tables/columns/ProjectTasksColumns.tsx @@ -1,5 +1,5 @@ import React from "react" -import { createColumnHelper } from "@tanstack/react-table" +import { createColumnHelper, FilterFn } from "@tanstack/react-table" import Link from "next/link" import { Routes } from "@blitzjs/next" import DateFormat from "src/core/components/DateFormat" @@ -7,9 +7,20 @@ import { InformationCircleIcon, ChatBubbleOvalLeftEllipsisIcon } from "@heroicon import { ProjectTasksData } from "../processing/processProjectTasks" import { Tooltip } from "react-tooltip" import PrimaryLink from "src/core/components/PrimaryLink" +import { createDateTextFilter } from "src/core/utils/tableFilters" // Column helper const columnHelperProject = createColumnHelper() +const projectDueDateFilter = createDateTextFilter({ emptyLabel: "no due date" }) +const completionStatusFilter: FilterFn = (row, columnId, filterValue) => { + const selected = String(filterValue ?? "").trim() + + if (!selected) { + return true + } + + return String(row.getValue(columnId) ?? "") === selected +} // ColumnDefs export const ProjectTasksColumns = [ @@ -43,7 +54,7 @@ export const ProjectTasksColumns = [
), @@ -67,6 +78,7 @@ export const ProjectTasksColumns = [ header: "Due Date", enableColumnFilter: true, enableSorting: true, + filterFn: projectDueDateFilter, meta: { filterVariant: "text", }, @@ -89,6 +101,7 @@ export const ProjectTasksColumns = [ cell: (info) => {info.getValue()}, enableColumnFilter: true, enableSorting: true, + filterFn: completionStatusFilter, meta: { filterVariant: "select", }, diff --git a/types.ts b/types.ts index 1030c082..30615345 100644 --- a/types.ts +++ b/types.ts @@ -19,5 +19,6 @@ declare module "@tanstack/react-table" { interface ColumnMeta { filterVariant?: "text" | "range" | "select" | "multiselect" isHtml?: boolean + selectOptions?: { label: string; value: string }[] } }