From 0c257c2e7167455e071d61a4a0ad651545d9fdf0 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sun, 14 Dec 2025 22:15:45 -0600 Subject: [PATCH 01/14] fixed filtering issue where if you were on a later page it wouldn't work --- src/core/components/Table.tsx | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/core/components/Table.tsx b/src/core/components/Table.tsx index cbb5eabc..831d0df4 100644 --- a/src/core/components/Table.tsx +++ b/src/core/components/Table.tsx @@ -1,5 +1,6 @@ import { ColumnDef, + FilterFn, flexRender, getCoreRowModel, getSortedRowModel, @@ -21,6 +22,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 +36,25 @@ 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 = JSON.stringify(row.original ?? {}).toLowerCase() + return rowValue.includes(searchValue) + } catch (error) { + return false } } @@ -42,9 +64,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 +84,7 @@ const Table = ({ getFacetedMinMaxValues: getFacetedMinMaxValues(), state: { sorting: sorting, + globalFilter: globalFilter, }, initialState: { pagination: { @@ -66,14 +92,41 @@ 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 + + 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" + 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) => ( From 492d3760a144bc106085ce99a852f217e6e87e59 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sun, 14 Dec 2025 22:26:32 -0600 Subject: [PATCH 02/14] turn off global filter on dashboard --- src/core/components/GetWidgetDisplay.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/components/GetWidgetDisplay.tsx b/src/core/components/GetWidgetDisplay.tsx index 11098104..d0bc9a2b 100644 --- a/src/core/components/GetWidgetDisplay.tsx +++ b/src/core/components/GetWidgetDisplay.tsx @@ -14,6 +14,7 @@ export function GetTableDisplay({ data, columns, type }) { return (
Date: Sun, 14 Dec 2025 22:26:45 -0600 Subject: [PATCH 03/14] fix date filtering --- src/core/components/Table.tsx | 3 +- src/core/utils/tableFilters.ts | 171 ++++++++++++++++++ .../tables/columns/MilestoneTasksColumns.tsx | 3 + src/tasks/tables/columns/AllTasksColumns.tsx | 4 + .../tables/columns/ProjectTasksColumns.tsx | 3 + 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/core/utils/tableFilters.ts diff --git a/src/core/components/Table.tsx b/src/core/components/Table.tsx index 831d0df4..23ae7ba4 100644 --- a/src/core/components/Table.tsx +++ b/src/core/components/Table.tsx @@ -15,6 +15,7 @@ import React from "react" import { ChevronUpIcon, ChevronDownIcon, ChevronUpDownIcon } from "@heroicons/react/24/outline" import Filter from "src/core/components/Filter" +import { buildSearchableString } from "src/core/utils/tableFilters" type TableProps = { columns: ColumnDef[] @@ -51,7 +52,7 @@ const defaultGlobalFilterFn: FilterFn = (row, _columnId, filterValue) => { } try { - const rowValue = JSON.stringify(row.original ?? {}).toLowerCase() + const rowValue = buildSearchableString(row.original ?? {}) return rowValue.includes(searchValue) } catch (error) { return false diff --git a/src/core/utils/tableFilters.ts b/src/core/utils/tableFilters.ts new file mode 100644 index 00000000..cc5ee73f --- /dev/null +++ b/src/core/utils/tableFilters.ts @@ -0,0 +1,171 @@ +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): 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") { + return value ? "true" : "false" + } + + 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): string => { + if (depth > MAX_RECURSION_DEPTH) { + return "" + } + + if (input === null || input === undefined) { + return "" + } + + if (input instanceof Date || typeof input !== "object") { + return describePrimitive(input, locale) + } + + 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)).join(" ") + } else if (isPlainObject(input)) { + result = Object.values(input) + .map((item) => helper(item, depth + 1)) + .join(" ") + } else { + // Non-plain objects (e.g., Maps) fallback to string conversion + result = describePrimitive(String(input), locale) + } + + visited.delete(input as object) + return result.trim() + } + + return helper(value, 0).trim().toLowerCase() +} diff --git a/src/milestones/tables/columns/MilestoneTasksColumns.tsx b/src/milestones/tables/columns/MilestoneTasksColumns.tsx index 4f5c807e..1a0f47bb 100644 --- a/src/milestones/tables/columns/MilestoneTasksColumns.tsx +++ b/src/milestones/tables/columns/MilestoneTasksColumns.tsx @@ -4,8 +4,10 @@ import { createColumnHelper } from "@tanstack/react-table" import Link from "next/link" import DateFormat from "src/core/components/DateFormat" import { MilestoneTasksData } from "../processing/processMilestoneTasks" +import { createDateTextFilter } from "src/core/utils/tableFilters" const columnHelperMilestone = createColumnHelper() +const milestoneDueDateFilter = createDateTextFilter({ emptyLabel: "no due date" }) export const MilestoneTasksColumns = [ columnHelperMilestone.accessor("name", { @@ -22,6 +24,7 @@ export const MilestoneTasksColumns = [ header: "Due Date", enableColumnFilter: true, enableSorting: true, + filterFn: milestoneDueDateFilter, meta: { filterVariant: "text", }, 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..d6761298 100644 --- a/src/tasks/tables/columns/ProjectTasksColumns.tsx +++ b/src/tasks/tables/columns/ProjectTasksColumns.tsx @@ -7,9 +7,11 @@ 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" }) // ColumnDefs export const ProjectTasksColumns = [ @@ -67,6 +69,7 @@ export const ProjectTasksColumns = [ header: "Due Date", enableColumnFilter: true, enableSorting: true, + filterFn: projectDueDateFilter, meta: { filterVariant: "text", }, From cd8efa089ee6c11c0c920a3d6580ac23f681ae55 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sun, 14 Dec 2025 22:38:39 -0600 Subject: [PATCH 04/14] fix date filtering and global search for read/unread --- src/core/components/Table.tsx | 11 +++++++ src/core/utils/tableFilters.ts | 20 +++++++----- src/invites/tables/columns/InviteColumns.tsx | 8 +++++ .../tables/columns/NotificationColumns.tsx | 32 ++++++++++++++++--- 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/src/core/components/Table.tsx b/src/core/components/Table.tsx index 23ae7ba4..0359b348 100644 --- a/src/core/components/Table.tsx +++ b/src/core/components/Table.tsx @@ -17,6 +17,14 @@ import { ChevronUpIcon, ChevronDownIcon, ChevronUpDownIcon } from "@heroicons/re import Filter from "src/core/components/Filter" import { buildSearchableString } from "src/core/utils/tableFilters" +const readSearchTokens = new Set(["read", "unread"]) +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +const containsWholeWord = (text: string, word: string) => { + const escapedWord = escapeRegExp(word) + const regex = new RegExp(`\\b${escapedWord}\\b`) + return regex.test(text) +} + type TableProps = { columns: ColumnDef[] data: TData[] @@ -53,6 +61,9 @@ const defaultGlobalFilterFn: FilterFn = (row, _columnId, filterValue) => { try { const rowValue = buildSearchableString(row.original ?? {}) + if (readSearchTokens.has(searchValue)) { + return containsWholeWord(rowValue, searchValue) + } return rowValue.includes(searchValue) } catch (error) { return false diff --git a/src/core/utils/tableFilters.ts b/src/core/utils/tableFilters.ts index cc5ee73f..c65b9ab0 100644 --- a/src/core/utils/tableFilters.ts +++ b/src/core/utils/tableFilters.ts @@ -88,7 +88,7 @@ const isPlainObject = (value: unknown): value is Record => { return Object.getPrototypeOf(value) === Object.prototype } -const describePrimitive = (value: unknown, locale: string): string => { +const describePrimitive = (value: unknown, locale: string, keyHint?: string): string => { if (value === null || value === undefined) { return "" } @@ -117,7 +117,11 @@ const describePrimitive = (value: unknown, locale: string): string => { } if (typeof value === "boolean") { - return value ? "true" : "false" + const normalizedKey = keyHint?.toLowerCase() ?? "" + if (normalizedKey.includes("read")) { + return value ? "read true yes" : "unread false no" + } + return value ? "true yes" : "false no" } return "" @@ -132,7 +136,7 @@ export const buildSearchableString = ( const locale = options?.locale ?? DEFAULT_LOCALE const visited = new WeakSet() - const helper = (input: unknown, depth: number): string => { + const helper = (input: unknown, depth: number, keyHint?: string): string => { if (depth > MAX_RECURSION_DEPTH) { return "" } @@ -142,7 +146,7 @@ export const buildSearchableString = ( } if (input instanceof Date || typeof input !== "object") { - return describePrimitive(input, locale) + return describePrimitive(input, locale, keyHint) } if (visited.has(input as object)) { @@ -153,14 +157,14 @@ export const buildSearchableString = ( let result = "" if (Array.isArray(input)) { - result = input.map((item) => helper(item, depth + 1)).join(" ") + result = input.map((item) => helper(item, depth + 1, keyHint)).join(" ") } else if (isPlainObject(input)) { - result = Object.values(input) - .map((item) => helper(item, depth + 1)) + result = Object.entries(input) + .map(([key, item]) => helper(item, depth + 1, key)) .join(" ") } else { // Non-plain objects (e.g., Maps) fallback to string conversion - result = describePrimitive(String(input), locale) + result = describePrimitive(String(input), locale, keyHint) } visited.delete(input as object) 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/notifications/tables/columns/NotificationColumns.tsx b/src/notifications/tables/columns/NotificationColumns.tsx index f16ebc6f..345a847e 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,18 @@ 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 { 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 +28,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,11 +67,18 @@ 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", From 1610350150978845a3d483e936bcb7d0b990b80d Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sun, 14 Dec 2025 22:41:08 -0600 Subject: [PATCH 05/14] fix date filtering --- src/forms/tables/columns/FormsColumns.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) 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", From 5863d07fb002ff9da0b83a27850bf7385b432fa0 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sun, 14 Dec 2025 22:41:19 -0600 Subject: [PATCH 06/14] turn off global search on dashboard --- src/core/components/GetWidgetDisplay.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/components/GetWidgetDisplay.tsx b/src/core/components/GetWidgetDisplay.tsx index d0bc9a2b..f977e7d1 100644 --- a/src/core/components/GetWidgetDisplay.tsx +++ b/src/core/components/GetWidgetDisplay.tsx @@ -86,6 +86,7 @@ export function GetProjectSummaryDisplay({ project, projectManagers }) {
Date: Sun, 14 Dec 2025 22:44:22 -0600 Subject: [PATCH 07/14] add completed versus uncompleted --- src/core/components/Table.tsx | 4 ++-- src/core/utils/tableFilters.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/components/Table.tsx b/src/core/components/Table.tsx index 0359b348..50b1c65c 100644 --- a/src/core/components/Table.tsx +++ b/src/core/components/Table.tsx @@ -17,7 +17,7 @@ import { ChevronUpIcon, ChevronDownIcon, ChevronUpDownIcon } from "@heroicons/re import Filter from "src/core/components/Filter" import { buildSearchableString } from "src/core/utils/tableFilters" -const readSearchTokens = new Set(["read", "unread"]) +const specialSearchTokens = new Set(["read", "unread", "completed", "complete", "incomplete"]) const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") const containsWholeWord = (text: string, word: string) => { const escapedWord = escapeRegExp(word) @@ -61,7 +61,7 @@ const defaultGlobalFilterFn: FilterFn = (row, _columnId, filterValue) => { try { const rowValue = buildSearchableString(row.original ?? {}) - if (readSearchTokens.has(searchValue)) { + if (specialSearchTokens.has(searchValue)) { return containsWholeWord(rowValue, searchValue) } return rowValue.includes(searchValue) diff --git a/src/core/utils/tableFilters.ts b/src/core/utils/tableFilters.ts index c65b9ab0..8ba5b985 100644 --- a/src/core/utils/tableFilters.ts +++ b/src/core/utils/tableFilters.ts @@ -121,6 +121,9 @@ const describePrimitive = (value: unknown, locale: string, keyHint?: string): st if (normalizedKey.includes("read")) { return value ? "read true yes" : "unread false no" } + if (normalizedKey.includes("status") || normalizedKey.includes("complete")) { + return value ? "completed true yes" : "not completed incomplete false no" + } return value ? "true yes" : "false no" } From eea5717482d5ecddb56a964ffbf486ae9c026aea Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sun, 14 Dec 2025 23:02:35 -0600 Subject: [PATCH 08/14] adding date and text search filter updates --- src/core/components/Filter.tsx | 29 ++++--- src/core/components/Table.tsx | 81 ++++++++++++++++++- src/core/utils/tableFilters.ts | 28 ++++--- .../tables/columns/InvitePMColumns.tsx | 8 ++ 4 files changed, 125 insertions(+), 21 deletions(-) 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/Table.tsx b/src/core/components/Table.tsx index 50b1c65c..deefdf88 100644 --- a/src/core/components/Table.tsx +++ b/src/core/components/Table.tsx @@ -17,7 +17,8 @@ import { ChevronUpIcon, ChevronDownIcon, ChevronUpDownIcon } from "@heroicons/re import Filter from "src/core/components/Filter" import { buildSearchableString } from "src/core/utils/tableFilters" -const specialSearchTokens = new Set(["read", "unread", "completed", "complete", "incomplete"]) +const specialSearchTokens = new Set(["read", "unread", "completed", "complete", "not completed"]) + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") const containsWholeWord = (text: string, word: string) => { const escapedWord = escapeRegExp(word) @@ -25,6 +26,79 @@ const containsWholeWord = (text: string, word: string) => { 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") + + 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 + } + + return false +} + +const matchesSpecialToken = (data: unknown, token: string, keyPath = ""): boolean => { + if (data === null || data === undefined) { + return false + } + + 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[] data: TData[] @@ -62,7 +136,10 @@ const defaultGlobalFilterFn: FilterFn = (row, _columnId, filterValue) => { try { const rowValue = buildSearchableString(row.original ?? {}) if (specialSearchTokens.has(searchValue)) { - return containsWholeWord(rowValue, searchValue) + if (matchesSpecialToken(row.original, searchValue)) { + return true + } + return matchesSpecialTokenInText(rowValue, searchValue) } return rowValue.includes(searchValue) } catch (error) { diff --git a/src/core/utils/tableFilters.ts b/src/core/utils/tableFilters.ts index 8ba5b985..5ceddf34 100644 --- a/src/core/utils/tableFilters.ts +++ b/src/core/utils/tableFilters.ts @@ -88,7 +88,12 @@ const isPlainObject = (value: unknown): value is Record => { return Object.getPrototypeOf(value) === Object.prototype } -const describePrimitive = (value: unknown, locale: string, keyHint?: string): string => { +const describePrimitive = ( + value: unknown, + locale: string, + keyHint?: string, + parentKey?: string +): string => { if (value === null || value === undefined) { return "" } @@ -118,12 +123,17 @@ const describePrimitive = (value: unknown, locale: string, keyHint?: string): st if (typeof value === "boolean") { const normalizedKey = keyHint?.toLowerCase() ?? "" - if (normalizedKey.includes("read")) { + const normalizedParent = parentKey?.toLowerCase() ?? "" + const combined = `${normalizedParent} ${normalizedKey}` + + if (combined.includes("read")) { return value ? "read true yes" : "unread false no" } - if (normalizedKey.includes("status") || normalizedKey.includes("complete")) { - return value ? "completed true yes" : "not completed incomplete false no" + + if (combined.includes("status") || combined.includes("complete")) { + return value ? "completed true yes" : "incomplete false no" } + return value ? "true yes" : "false no" } @@ -139,7 +149,7 @@ export const buildSearchableString = ( const locale = options?.locale ?? DEFAULT_LOCALE const visited = new WeakSet() - const helper = (input: unknown, depth: number, keyHint?: string): string => { + const helper = (input: unknown, depth: number, keyHint?: string, parentKey?: string): string => { if (depth > MAX_RECURSION_DEPTH) { return "" } @@ -149,7 +159,7 @@ export const buildSearchableString = ( } if (input instanceof Date || typeof input !== "object") { - return describePrimitive(input, locale, keyHint) + return describePrimitive(input, locale, keyHint, parentKey) } if (visited.has(input as object)) { @@ -160,14 +170,14 @@ export const buildSearchableString = ( let result = "" if (Array.isArray(input)) { - result = input.map((item) => helper(item, depth + 1, keyHint)).join(" ") + 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)) + .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) + result = describePrimitive(String(input), locale, keyHint, parentKey) } visited.delete(input as object) 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()}, From 70d11911629eab33a4ec929bde126edc1065adc0 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sun, 14 Dec 2025 23:17:17 -0600 Subject: [PATCH 09/14] delete unused table --- .../tables/columns/MilestoneTasksColumns.tsx | 58 ------------------- 1 file changed, 58 deletions(-) delete mode 100644 src/milestones/tables/columns/MilestoneTasksColumns.tsx diff --git a/src/milestones/tables/columns/MilestoneTasksColumns.tsx b/src/milestones/tables/columns/MilestoneTasksColumns.tsx deleted file mode 100644 index 1a0f47bb..00000000 --- a/src/milestones/tables/columns/MilestoneTasksColumns.tsx +++ /dev/null @@ -1,58 +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" -import { createDateTextFilter } from "src/core/utils/tableFilters" - -const columnHelperMilestone = createColumnHelper() -const milestoneDueDateFilter = createDateTextFilter({ emptyLabel: "no due date" }) - -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, - filterFn: milestoneDueDateFilter, - 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) => ( - - - - ), - }), -] From 54f1d3cbc0c9666b4b0fc029c36c4900d8043f21 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sun, 14 Dec 2025 23:17:28 -0600 Subject: [PATCH 10/14] remove unused tables --- src/milestones/components/MilestoneInformation.tsx | 2 -- 1 file changed, 2 deletions(-) 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" From 82d7f6066162021882487c401c9bd70ebf568d50 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sun, 14 Dec 2025 23:17:39 -0600 Subject: [PATCH 11/14] add read/unread filter and fix dates --- .../ProjectNotificationTableColumns.tsx | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/notifications/tables/columns/ProjectNotificationTableColumns.tsx b/src/notifications/tables/columns/ProjectNotificationTableColumns.tsx index ebe1a3e4..7d32cd12 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,26 @@ 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" // 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 +39,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,11 +72,22 @@ 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", + selectOptions: [ + { label: "Unread", value: "unread" }, + { label: "Read", value: "read" }, + ], + }, }), columnHelper.accessor("id", { id: "multiple", From 3a27cf571f0462004a46f62995dbb77056b37c35 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Mon, 15 Dec 2025 10:03:40 -0600 Subject: [PATCH 12/14] update date and read/unread/completed filters --- .../tables/columns/NotificationColumns.tsx | 15 +++++++++- .../ProjectNotificationTableColumns.tsx | 21 +++++++++---- .../columns/RoleContributorTableColumns.tsx | 15 ++++++++-- .../tables/columns/RoleTaskTableColumns.tsx | 15 ++++++++-- .../tables/columns/TagMilestoneColumns.tsx | 15 ++++++++++ src/tags/tables/columns/TagPeopleColumns.tsx | 30 ++++++++++++++++++- .../tables/processing/processTagPeople.ts | 1 + .../tables/columns/ProjectTasksColumns.tsx | 2 +- 8 files changed, 101 insertions(+), 13 deletions(-) diff --git a/src/notifications/tables/columns/NotificationColumns.tsx b/src/notifications/tables/columns/NotificationColumns.tsx index 345a847e..1ddd7a66 100644 --- a/src/notifications/tables/columns/NotificationColumns.tsx +++ b/src/notifications/tables/columns/NotificationColumns.tsx @@ -6,6 +6,7 @@ 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 @@ -85,7 +86,19 @@ export const useNotificationTableColumns = (refetch: () => void, data: Notificat 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 7d32cd12..0796b1e9 100644 --- a/src/notifications/tables/columns/ProjectNotificationTableColumns.tsx +++ b/src/notifications/tables/columns/ProjectNotificationTableColumns.tsx @@ -7,6 +7,7 @@ import { MultiSelectCheckbox } from "src/core/components/fields/MultiSelectCheck 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() @@ -72,7 +73,7 @@ export const useProjectNotificationTableColumns = ( isHtml: true, }, }), - columnHelper.accessor((row) => (row.notification.read ? "read" : "unread"), { + columnHelper.accessor((row) => (row.notification.read ? "Read" : "Unread"), { id: "readStatus", enableColumnFilter: true, enableSorting: false, @@ -83,10 +84,6 @@ export const useProjectNotificationTableColumns = ( filterFn: readStatusFilter, meta: { filterVariant: "select", - selectOptions: [ - { label: "Unread", value: "unread" }, - { label: "Read", value: "read" }, - ], }, }), columnHelper.accessor("id", { @@ -94,7 +91,19 @@ export const useProjectNotificationTableColumns = ( 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..3bae9482 100644 --- a/src/tags/tables/columns/TagPeopleColumns.tsx +++ b/src/tags/tables/columns/TagPeopleColumns.tsx @@ -1,7 +1,8 @@ -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 @@ -14,9 +15,20 @@ export type TagPeopleData = { type: string userId: number projectId: number + completionStatus: "Completed" | "Not completed" } const columnHelper = createColumnHelper() +const createdDateFilter = createDateTextFilter({ emptyLabel: "no date" }) +const completionStatusFilter: FilterFn = (row, columnId, filterValue) => { + const selected = String(filterValue ?? "").trim() + + if (!selected) { + return true + } + + return String(row.getValue(columnId) ?? "") === selected +} export const TagPeopleColumns = [ columnHelper.accessor("name", { @@ -37,6 +49,12 @@ 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", @@ -47,6 +65,16 @@ export const TagPeopleColumns = [ filterVariant: "range", }, }), + columnHelper.accessor("completionStatus", { + header: "Status", + cell: (info) => info.getValue(), + enableColumnFilter: true, + enableSorting: true, + filterFn: completionStatusFilter, + meta: { + filterVariant: "select", + }, + }), columnHelper.accessor("percentApproved", { header: "Tasks Approved", cell: (info) => (info.getValue() === null ? "N/A" : `${info.getValue()}%`), diff --git a/src/tags/tables/processing/processTagPeople.ts b/src/tags/tables/processing/processTagPeople.ts index 6148925d..99f494f6 100644 --- a/src/tags/tables/processing/processTagPeople.ts +++ b/src/tags/tables/processing/processTagPeople.ts @@ -76,6 +76,7 @@ export function processTagPeople( type: person.name ? "Team" : "Individual", userId: person.id, projectId: projectId, + completionStatus: total > 0 && complete === total ? "Completed" : "Not completed", } }) } diff --git a/src/tasks/tables/columns/ProjectTasksColumns.tsx b/src/tasks/tables/columns/ProjectTasksColumns.tsx index d6761298..cf09a8d0 100644 --- a/src/tasks/tables/columns/ProjectTasksColumns.tsx +++ b/src/tasks/tables/columns/ProjectTasksColumns.tsx @@ -45,7 +45,7 @@ export const ProjectTasksColumns = [ ), From 5e0b1328a5f57048ae0f9518c74cc7cd0ef296cf Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Mon, 15 Dec 2025 10:49:33 -0600 Subject: [PATCH 13/14] completed / not completed filtering --- src/tasks/tables/columns/ProjectTasksColumns.tsx | 12 +++++++++++- types.ts | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/tasks/tables/columns/ProjectTasksColumns.tsx b/src/tasks/tables/columns/ProjectTasksColumns.tsx index cf09a8d0..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" @@ -12,6 +12,15 @@ 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 = [ @@ -92,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 }[] } } From b5fd07a44de896f0db1fbb083e49ace471d4e74d Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Mon, 15 Dec 2025 13:06:16 -0600 Subject: [PATCH 14/14] updates to fix filtering --- src/core/components/Table.tsx | 42 ++++++++-- src/tags/tables/columns/TagPeopleColumns.tsx | 49 +++++++----- .../tables/processing/processTagPeople.ts | 1 - .../components/TaskLogHistoryModal.tsx | 8 +- .../tables/columns/TaskLogCompleteColumns.tsx | 78 +++++++++++++++++- .../tables/columns/TaskLogFormColumns.tsx | 80 +++++++++++++++++-- .../columns/TaskLogHistoryCompleteColumns.tsx | 67 +++++++++++++++- .../columns/TaskLogHistoryFormColumns.tsx | 67 +++++++++++++++- .../columns/TaskLogProjectMemberColumns.tsx | 72 +++++++++++++++-- 9 files changed, 416 insertions(+), 48 deletions(-) diff --git a/src/core/components/Table.tsx b/src/core/components/Table.tsx index deefdf88..51359e3e 100644 --- a/src/core/components/Table.tsx +++ b/src/core/components/Table.tsx @@ -16,8 +16,18 @@ import { ChevronUpIcon, ChevronDownIcon, ChevronUpDownIcon } from "@heroicons/re import Filter from "src/core/components/Filter" import { buildSearchableString } from "src/core/utils/tableFilters" - -const specialSearchTokens = new Set(["read", "unread", "completed", "complete", "not completed"]) +import TooltipWrapper from "./TooltipWrapper" + +const specialSearchTokens = new Set([ + "read", + "unread", + "completed", + "complete", + "not completed", + "approved", + "not approved", + "pending", +]) const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") const containsWholeWord = (text: string, word: string) => { @@ -48,10 +58,11 @@ const matchesSpecialTokenInText = (text: string, token: string) => { return containsWholeWord(normalized, token) } -const matchesBooleanToken = (token: string, value: boolean, keyPath: string): boolean => { +const matchesBooleanToken = (token: string, value: boolean | null, keyPath: string): boolean => { 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 @@ -69,12 +80,24 @@ const matchesBooleanToken = (token: string, value: boolean, keyPath: string): bo 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 false + return matchesBooleanToken(token, data as null, keyPath) } if (typeof data === "boolean") { @@ -190,6 +213,8 @@ const Table = ({ const pageCount = table.getPageCount() const pageIndex = table.getState().pagination.pageIndex + const globalSearchTooltipId = React.useId() + React.useEffect(() => { if (!addPagination) { return @@ -203,17 +228,24 @@ const 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 || "" }`} /> +
)}
diff --git a/src/tags/tables/columns/TagPeopleColumns.tsx b/src/tags/tables/columns/TagPeopleColumns.tsx index 3bae9482..be5a4fee 100644 --- a/src/tags/tables/columns/TagPeopleColumns.tsx +++ b/src/tags/tables/columns/TagPeopleColumns.tsx @@ -6,7 +6,7 @@ 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 @@ -15,21 +15,37 @@ export type TagPeopleData = { type: string userId: number projectId: number - completionStatus: "Completed" | "Not completed" } const columnHelper = createColumnHelper() const createdDateFilter = createDateTextFilter({ emptyLabel: "no date" }) -const completionStatusFilter: FilterFn = (row, columnId, filterValue) => { - const selected = String(filterValue ?? "").trim() - if (!selected) { +const nullableRangeFilter: FilterFn = (row, columnId, filterValue) => { + const value = row.getValue(columnId) + + // Always include rows without numeric data + if (value === null || value === undefined) { return true } - return String(row.getValue(columnId) ?? "") === selected -} + 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", @@ -58,28 +74,20 @@ export const TagPeopleColumns = [ }), 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("completionStatus", { - header: "Status", - cell: (info) => info.getValue(), - enableColumnFilter: true, - enableSorting: true, - filterFn: completionStatusFilter, - meta: { - filterVariant: "select", - }, - }), 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", }, @@ -88,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/tags/tables/processing/processTagPeople.ts b/src/tags/tables/processing/processTagPeople.ts index 99f494f6..6148925d 100644 --- a/src/tags/tables/processing/processTagPeople.ts +++ b/src/tags/tables/processing/processTagPeople.ts @@ -76,7 +76,6 @@ export function processTagPeople( type: person.name ? "Team" : "Individual", userId: person.id, projectId: projectId, - completionStatus: total > 0 && complete === total ? "Completed" : "Not completed", } }) } 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", {