From 0d001fb833c6839a9932b8755bcc56518dd11868 Mon Sep 17 00:00:00 2001 From: Vinit Khandal Date: Mon, 20 Apr 2026 23:39:00 +0530 Subject: [PATCH 1/3] feat: enhance DataTable with date filtering and join date display --- apps/site/src/demos/dataTableDemo.tsx | 92 ++++++++++------- .../components/DataTable/TableCell/index.tsx | 44 ++++++--- .../TableHeader/FilterComponents.tsx | 99 ++++++++++++++++++- .../TableHeader/MobileFilterDrawer.tsx | 72 ++++++++++++++ .../DataTable/TableHeader/handlers.ts | 2 +- .../blend/lib/components/DataTable/utils.ts | 67 ++++++++++++- 6 files changed, 321 insertions(+), 55 deletions(-) diff --git a/apps/site/src/demos/dataTableDemo.tsx b/apps/site/src/demos/dataTableDemo.tsx index 3763da01e..e3dd5c827 100644 --- a/apps/site/src/demos/dataTableDemo.tsx +++ b/apps/site/src/demos/dataTableDemo.tsx @@ -1662,50 +1662,43 @@ const DataTableDemo = () => { const statuses = ['Active', 'Inactive', 'Pending', 'Suspended'] + const joinDates = [ + '2014-08-01', + '2015-09-01', + '2016-03-01', + '2017-11-01', + '2018-07-01', + '2019-01-01', + '2020-04-01', + '2021-06-01', + '2022-10-01', + '2023-02-01', + '2020-05-01', + '2021-12-01', + '2022-03-01', + '2023-08-01', + '2019-11-01', + ] + + const formatJoinMonth = (dateString: string) => + new Date(dateString).toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + }) + return Array.from({ length: count }, (_, index) => { const userName = names[index % names.length] const userStatus = statuses[index % statuses.length] + const joinDate = joinDates[index % joinDates.length] return { id: index + 1, name: { label: userName, - sublabel: [ - 'August 2014', - 'September 2015', - 'March 2016', - 'November 2017', - 'July 2018', - 'January 2019', - 'April 2020', - 'June 2021', - 'October 2022', - 'February 2023', - 'May 2020', - 'December 2021', - 'March 2022', - 'August 2023', - 'November 2019', - ][index % 15], + sublabel: formatJoinMonth(joinDate), imageUrl: `https://randomuser.me/api/portraits/${index % 2 ? 'men' : 'women'}/${index % 70}.jpg`, } as AvatarColumnProps, - joinDate: [ - 'August 2014', - 'September 2015', - 'March 2016', - 'November 2017', - 'July 2018', - 'January 2019', - 'April 2020', - 'June 2021', - 'October 2022', - 'February 2023', - 'May 2020', - 'December 2021', - 'March 2022', - 'August 2023', - 'November 2019', - ][index % 15], + joinDate, number: `${300 + index}`, gateway: [ 'Gateway A', @@ -1912,6 +1905,25 @@ const DataTableDemo = () => { minWidth: '150px', maxWidth: '250px', }, + { + field: 'joinDate', + header: 'Join Date', + headerSubtext: 'Date user joined', + type: ColumnType.DATE, + isSortable: true, + isEditable: false, + renderCell: (value: unknown): React.ReactNode => { + const parsedDate = new Date(String(value)) + if (isNaN(parsedDate.getTime())) return '-' + return parsedDate.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: '2-digit', + }) + }, + minWidth: '130px', + maxWidth: '170px', + }, { field: 'role', header: 'System Access Level and Authorization Status', @@ -2517,7 +2529,7 @@ const DataTableDemo = () => { `Last login: ${statusText === 'Active' ? '2 hours ago' : '1 week ago'}`, `Profile updated: ${user.role === 'Admin' ? '1 day ago' : '3 days ago'}`, `Password changed: ${user.gateway === 'Gateway A' ? '1 week ago' : '2 weeks ago'}`, - `Role assigned: ${user.joinDate}`, + `Role assigned: ${new Date(user.joinDate).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}`, ] return activities } @@ -2672,7 +2684,13 @@ const DataTableDemo = () => {
Member Since:{' '} - {userRow.joinDate} + {new Date(userRow.joinDate).toLocaleDateString( + 'en-US', + { + month: 'short', + year: 'numeric', + } + )}
@@ -2926,7 +2944,7 @@ const DataTableDemo = () => { } // Priority 3: Recently joined users - New members (2023+) - const joinYear = parseInt(userData.joinDate.split(' ')[1] || '2020') + const joinYear = new Date(userData.joinDate).getFullYear() || Number.NaN if (joinYear >= 2023) { return { backgroundColor: '#f0fdf4', // Light green background diff --git a/packages/blend/lib/components/DataTable/TableCell/index.tsx b/packages/blend/lib/components/DataTable/TableCell/index.tsx index 526821efa..d24b59fe5 100644 --- a/packages/blend/lib/components/DataTable/TableCell/index.tsx +++ b/packages/blend/lib/components/DataTable/TableCell/index.tsx @@ -59,13 +59,14 @@ const isEmptyValue = (value: unknown, columnType?: ColumnType): boolean => { } if (columnType === ColumnType.DATE) { - const dateData = value as DateColumnProps + const dateValue = + typeof value === 'object' && value !== null && 'date' in value + ? (value as DateColumnProps).date + : value if ( - !dateData || - !dateData.date || - (typeof dateData.date === 'string' && - dateData.date.trim() === '') || - isNaN(new Date(dateData.date).getTime()) + !dateValue || + (typeof dateValue === 'string' && dateValue.trim() === '') || + isNaN(new Date(String(dateValue)).getTime()) ) { return true } @@ -226,16 +227,27 @@ const TableCell = forwardRef< attrs['data-numeric'] = String(valueToCheck || 0) } else if (column.type === ColumnType.DATE) { attrs['data-type'] = 'date' - const dateData = valueToCheck as DateColumnProps - if (dateData && dateData.date) { - const date = new Date(dateData.date) + const dateValue = + typeof valueToCheck === 'object' && + valueToCheck !== null && + 'date' in valueToCheck + ? (valueToCheck as DateColumnProps).date + : valueToCheck + const showTime = + typeof valueToCheck === 'object' && + valueToCheck !== null && + 'showTime' in valueToCheck + ? Boolean((valueToCheck as DateColumnProps).showTime) + : false + if (dateValue) { + const date = new Date(String(dateValue)) if (!isNaN(date.getTime())) { attrs['data-date'] = date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit', - hour: dateData.showTime ? '2-digit' : undefined, - minute: dateData.showTime ? '2-digit' : undefined, + hour: showTime ? '2-digit' : undefined, + minute: showTime ? '2-digit' : undefined, }) } } @@ -412,7 +424,15 @@ const TableCell = forwardRef< } if (column.type === ColumnType.DATE && !isEditing) { - const dateData = displayValue as DateColumnProps + const dateData = + typeof displayValue === 'object' && + displayValue !== null && + 'date' in displayValue + ? (displayValue as DateColumnProps) + : ({ + date: displayValue as Date | string, + showTime: false, + } as DateColumnProps) if (isEmptyValue(dateData, ColumnType.DATE)) { return ( diff --git a/packages/blend/lib/components/DataTable/TableHeader/FilterComponents.tsx b/packages/blend/lib/components/DataTable/TableHeader/FilterComponents.tsx index 4c0bca0c5..1154c2bf4 100644 --- a/packages/blend/lib/components/DataTable/TableHeader/FilterComponents.tsx +++ b/packages/blend/lib/components/DataTable/TableHeader/FilterComponents.tsx @@ -44,6 +44,8 @@ import MobileFilterDrawer from './MobileFilterDrawer' import { Checkbox } from '../../Checkbox' import { CheckboxSize } from '../../Checkbox/types' import { VirtualListItem } from '../../VirtualList' +import DateRangePicker from '../../DateRangePicker/DateRangePicker' +import { DateRange } from '../../DateRangePicker/types' const FILTER_VIRTUAL_ITEM_ESTIMATE_HEIGHT = 40 const FILTER_VIRTUAL_LIST_MAX_HEIGHT = 220 @@ -1220,6 +1222,89 @@ export const SliderFilter: React.FC<{ ) } +export const DateFilter: React.FC<{ + column: ColumnDefinition> + fieldKey: string + tableToken: TableTokenType + filterState: FilterState + onColumnFilter?: ColumnFilterHandler +}> = ({ column, fieldKey, tableToken, filterState, onColumnFilter }) => { + const selectedValue = filterState.columnSelectedValues[fieldKey] + const selectedRange = Array.isArray(selectedValue) + ? selectedValue + : typeof selectedValue === 'string' && selectedValue + ? [selectedValue, selectedValue] + : [] + + const pickerValue: DateRange | undefined = + selectedRange.length > 0 && selectedRange[0] + ? { + startDate: new Date(selectedRange[0]), + endDate: selectedRange[1] + ? new Date(selectedRange[1]) + : new Date(selectedRange[0]), + } + : undefined + + return ( + + { + const start = range.startDate.toISOString() + const end = (range.endDate || range.startDate).toISOString() + onColumnFilter?.( + String(column.field), + FilterType.DATE, + [start, end], + 'range' + ) + }} + showDateTimePicker={false} + showDateInput={false} + showPresets={false} + allowSingleDateSelection={false} + useDrawerOnMobile={false} + triggerConfig={{ + renderTrigger: ({ onClick }) => ( + + } + label="Filter" + onClick={(e) => { + e.stopPropagation() + onClick() + }} + trailingIcon={ + + } + tableToken={tableToken} + /> + ), + }} + /> + + ) +} + export const ColumnFilter: React.FC = ({ column, data, @@ -1360,8 +1445,18 @@ export const ColumnFilter: React.FC = ({ /> )} - {/* Filter — nested popover */} - {hasFiltering && ( + {/* Filter — direct for date range, nested for others */} + {hasFiltering && columnConfig.filterComponent === 'dateRange' && ( + + )} + + {hasFiltering && columnConfig.filterComponent !== 'dateRange' && ( > @@ -598,6 +600,76 @@ export const MobileFilterDrawer: React.FC = ({ )} + {columnConfig.filterComponent === + 'dateRange' && ( + + { + const selectedValue = + filterState + .columnSelectedValues[ + fieldKey + ] + const selectedRange = + Array.isArray(selectedValue) + ? selectedValue + : typeof selectedValue === + 'string' && + selectedValue + ? [ + selectedValue, + selectedValue, + ] + : [] + if ( + selectedRange.length === + 0 || + !selectedRange[0] + ) { + return undefined + } + + return { + startDate: new Date( + selectedRange[0] + ), + endDate: selectedRange[1] + ? new Date( + selectedRange[1] + ) + : new Date( + selectedRange[0] + ), + } + })()} + onChange={(range) => { + const start = + range.startDate.toISOString() + const end = ( + range.endDate || + range.startDate + ).toISOString() + onColumnFilter?.( + fieldKey, + FilterType.DATE, + [start, end], + 'range' + ) + }} + showDateTimePicker={false} + showPresets={false} + allowSingleDateSelection={false} + useDrawerOnMobile={false} + /> + + )} + {!columnConfig.filterComponent && ( columnSelectedValues: Record< string, - string[] | { min: number; max: number } + string | string[] | { min: number; max: number } > } diff --git a/packages/blend/lib/components/DataTable/utils.ts b/packages/blend/lib/components/DataTable/utils.ts index 2cd4cd9fc..17db7ba27 100644 --- a/packages/blend/lib/components/DataTable/utils.ts +++ b/packages/blend/lib/components/DataTable/utils.ts @@ -300,6 +300,15 @@ export const applyColumnFilters = >( } case FilterType.DATE: + if (operator === 'range' && Array.isArray(filterValue)) { + const [startDate, endDate] = filterValue + if (!startDate || !endDate) return true + return applyDateRangeFilter( + cellValue, + startDate, + endDate + ) + } return applyDateFilter( cellValue, new Date(String(filterValue)), @@ -380,11 +389,27 @@ const applyDateFilter = ( filterValue: Date, operator: string ): boolean => { - const cellDate = new Date(String(cellValue)) + if (isNaN(filterValue.getTime())) return false + + let cellDate: Date + if ( + typeof cellValue === 'object' && + cellValue !== null && + 'date' in cellValue + ) { + const dateCellValue = cellValue as { date: Date | string } + cellDate = new Date(dateCellValue.date) + } else { + cellDate = new Date(String(cellValue)) + } + if (isNaN(cellDate.getTime())) return false - const cellTime = cellDate.getTime() - const filterTime = filterValue.getTime() + const normalizeToDateOnly = (date: Date): number => + new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() + + const cellTime = normalizeToDateOnly(cellDate) + const filterTime = normalizeToDateOnly(filterValue) switch (operator) { case 'equals': @@ -402,6 +427,42 @@ const applyDateFilter = ( } } +const applyDateRangeFilter = ( + cellValue: unknown, + startValue: string, + endValue: string +): boolean => { + const startDate = new Date(startValue) + const endDate = new Date(endValue) + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return false + + let cellDate: Date + if ( + typeof cellValue === 'object' && + cellValue !== null && + 'date' in cellValue + ) { + const dateCellValue = cellValue as { date: Date | string } + cellDate = new Date(dateCellValue.date) + } else { + cellDate = new Date(String(cellValue)) + } + + if (isNaN(cellDate.getTime())) return false + + const normalizeToDateOnly = (date: Date): number => + new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() + + const cellTime = normalizeToDateOnly(cellDate) + const startTime = normalizeToDateOnly(startDate) + const endTime = normalizeToDateOnly(endDate) + + return ( + cellTime >= Math.min(startTime, endTime) && + cellTime <= Math.max(startTime, endTime) + ) +} + export const getUniqueColumnValues = >( data: T[], field: keyof T From 00ca4d550398a35d9017530d9e774a8176c5c54b Mon Sep 17 00:00:00 2001 From: Vinit Khandal Date: Tue, 21 Apr 2026 14:04:06 +0530 Subject: [PATCH 2/3] feat: add visibleRows prop to DataTable --- .../lib/components/DataTable/DataTable.tsx | 1 + .../DataTable/DataTablePagination.tsx | 24 +++++++++ .../DataTable/TableFooter/index.tsx | 2 + .../components/DataTable/TableFooter/types.ts | 1 + .../TableHeader/FilterComponents.tsx | 51 ++++++++++++++++--- .../TableHeader/MobileFilterDrawer.tsx | 17 +++++-- .../DataTable/TableHeader/index.tsx | 9 ++-- .../DateRangePicker/DateRangePicker.tsx | 7 +-- .../lib/components/DateRangePicker/types.ts | 7 +++ 9 files changed, 101 insertions(+), 18 deletions(-) diff --git a/packages/blend/lib/components/DataTable/DataTable.tsx b/packages/blend/lib/components/DataTable/DataTable.tsx index cd74b0213..831b6830b 100644 --- a/packages/blend/lib/components/DataTable/DataTable.tsx +++ b/packages/blend/lib/components/DataTable/DataTable.tsx @@ -1893,6 +1893,7 @@ const DataTable = forwardRef( currentPage={currentPage} pageSize={pageSize} totalRows={totalRows} + visibleRows={currentData.length} isLoading={isLoading} showSkeleton={showSkeleton} hasData={currentData.length > 0} diff --git a/packages/blend/lib/components/DataTable/DataTablePagination.tsx b/packages/blend/lib/components/DataTable/DataTablePagination.tsx index 4b6c67fa7..224c72577 100644 --- a/packages/blend/lib/components/DataTable/DataTablePagination.tsx +++ b/packages/blend/lib/components/DataTable/DataTablePagination.tsx @@ -14,6 +14,7 @@ type DataTablePaginationProps = { currentPage: number pageSize: number totalRows: number + visibleRows?: number pageSizeOptions: number[] isLoading?: boolean hasData?: boolean @@ -27,6 +28,7 @@ export function DataTablePagination({ pageSize, totalRows, pageSizeOptions, + visibleRows = 0, isLoading = false, hasData = true, isNarrowContainer = false, @@ -39,6 +41,9 @@ export function DataTablePagination({ const PAGINATION_ITEM_HEIGHT = 33 const totalPages = Math.ceil(totalRows / pageSize) + const hasVisibleRows = hasData && visibleRows > 0 + const rangeStart = hasVisibleRows ? (currentPage - 1) * pageSize + 1 : 0 + const rangeEnd = hasVisibleRows ? rangeStart + visibleRows - 1 : 0 const getPageNumbers = () => { const pages = [] @@ -254,6 +259,25 @@ export function DataTablePagination({ disabled={!hasData || isLoading} /> + + {hasVisibleRows + ? `${rangeStart}-${rangeEnd} of ${totalRows}` + : `0 of ${totalRows}`} + ( currentPage, pageSize, totalRows, + visibleRows = 0, isLoading, showSkeleton, onPageChange, @@ -42,6 +43,7 @@ const TableFooter = forwardRef( currentPage={currentPage} pageSize={pageSize} totalRows={totalRows} + visibleRows={visibleRows} pageSizeOptions={ pagination.pageSizeOptions || [10, 20, 50, 100] } diff --git a/packages/blend/lib/components/DataTable/TableFooter/types.ts b/packages/blend/lib/components/DataTable/TableFooter/types.ts index 0ccf3aaae..7f244d212 100644 --- a/packages/blend/lib/components/DataTable/TableFooter/types.ts +++ b/packages/blend/lib/components/DataTable/TableFooter/types.ts @@ -5,6 +5,7 @@ export type TableFooterProps = { currentPage: number pageSize: number totalRows: number + visibleRows?: number isLoading?: boolean showSkeleton?: boolean hasData?: boolean diff --git a/packages/blend/lib/components/DataTable/TableHeader/FilterComponents.tsx b/packages/blend/lib/components/DataTable/TableHeader/FilterComponents.tsx index 1154c2bf4..a38b7bbcf 100644 --- a/packages/blend/lib/components/DataTable/TableHeader/FilterComponents.tsx +++ b/packages/blend/lib/components/DataTable/TableHeader/FilterComponents.tsx @@ -1229,6 +1229,13 @@ export const DateFilter: React.FC<{ filterState: FilterState onColumnFilter?: ColumnFilterHandler }> = ({ column, fieldKey, tableToken, filterState, onColumnFilter }) => { + const toLocalDateString = (date: Date): string => { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` + } + const selectedValue = filterState.columnSelectedValues[fieldKey] const selectedRange = Array.isArray(selectedValue) ? selectedValue @@ -1245,18 +1252,17 @@ export const DateFilter: React.FC<{ : new Date(selectedRange[0]), } : undefined + const hasActiveDateRange = selectedRange.length > 0 return ( - + { - const start = range.startDate.toISOString() - const end = (range.endDate || range.startDate).toISOString() + const start = toLocalDateString(range.startDate) + const end = toLocalDateString( + range.endDate || range.startDate + ) onColumnFilter?.( String(column.field), FilterType.DATE, @@ -1269,7 +1275,13 @@ export const DateFilter: React.FC<{ showPresets={false} allowSingleDateSelection={false} useDrawerOnMobile={false} + popoverConfig={{ + side: 'right', + align: 'start', + sideOffset: 10, + }} triggerConfig={{ + style: { width: '100%' }, renderTrigger: ({ onClick }) => ( } tableToken={tableToken} + style={{ width: '100%' }} /> ), }} /> + {hasActiveDateRange && ( + + } + label="Clear Filter" + onClick={(e) => { + e.stopPropagation() + onColumnFilter?.( + String(column.field), + FilterType.DATE, + [], + 'range' + ) + }} + isDestructive + tableToken={tableToken} + style={{ width: '100%' }} + /> + )} ) } diff --git a/packages/blend/lib/components/DataTable/TableHeader/MobileFilterDrawer.tsx b/packages/blend/lib/components/DataTable/TableHeader/MobileFilterDrawer.tsx index 02a4e3c11..694cf23c8 100644 --- a/packages/blend/lib/components/DataTable/TableHeader/MobileFilterDrawer.tsx +++ b/packages/blend/lib/components/DataTable/TableHeader/MobileFilterDrawer.tsx @@ -67,6 +67,12 @@ export const MobileFilterDrawer: React.FC = ({ onPopoverClose, }) => { const [filterDrawerOpen, setFilterDrawerOpen] = useState(false) + const toLocalDateString = (date: Date): string => { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` + } const columnConfig = getColumnTypeConfig(column.type || ColumnType.TEXT) const fieldKey = String(column.field) @@ -649,12 +655,13 @@ export const MobileFilterDrawer: React.FC = ({ } })()} onChange={(range) => { - const start = - range.startDate.toISOString() - const end = ( - range.endDate || + const start = toLocalDateString( range.startDate - ).toISOString() + ) + const end = toLocalDateString( + range.endDate || + range.startDate + ) onColumnFilter?.( fieldKey, FilterType.DATE, diff --git a/packages/blend/lib/components/DataTable/TableHeader/index.tsx b/packages/blend/lib/components/DataTable/TableHeader/index.tsx index e9ded8db7..bfea42987 100644 --- a/packages/blend/lib/components/DataTable/TableHeader/index.tsx +++ b/packages/blend/lib/components/DataTable/TableHeader/index.tsx @@ -296,10 +296,10 @@ const TableHeader = forwardRef< const extractFilterValues = ( filters: typeof columnFilters - ): Record => { + ): Record => { const values: Record< string, - string[] | { min: number; max: number } + string | string[] | { min: number; max: number } > = {} filters.forEach((filter) => { @@ -320,7 +320,7 @@ const TableHeader = forwardRef< ) { values[filter.field] = filter.value } else if (typeof filter.value === 'string') { - values[filter.field] = [filter.value] + values[filter.field] = filter.value } }) @@ -775,6 +775,9 @@ const TableHeader = forwardRef< if (Array.isArray(selectedValues)) { return selectedValues.length > 0 } + if (typeof selectedValues === 'string') { + return selectedValues.trim() !== '' + } if ( typeof selectedValues === 'object' && selectedValues !== null && diff --git a/packages/blend/lib/components/DateRangePicker/DateRangePicker.tsx b/packages/blend/lib/components/DateRangePicker/DateRangePicker.tsx index 8415471b2..39ce909fe 100644 --- a/packages/blend/lib/components/DateRangePicker/DateRangePicker.tsx +++ b/packages/blend/lib/components/DateRangePicker/DateRangePicker.tsx @@ -389,6 +389,7 @@ const DateRangePicker = forwardRef( size = DateRangePickerSize.MEDIUM, formatConfig, triggerConfig, + popoverConfig, maxMenuHeight = 250, showPreset = false, timezone, @@ -1200,9 +1201,9 @@ const DateRangePicker = forwardRef( setIsOpen(open) }} trigger={renderTrigger()} - side="bottom" - align="start" - sideOffset={4} + side={popoverConfig?.side || 'bottom'} + align={popoverConfig?.align || 'start'} + sideOffset={popoverConfig?.sideOffset ?? 4} shadow="sm" > ReactNode } +export type DateRangePickerPopoverConfig = { + side?: 'top' | 'right' | 'bottom' | 'left' + align?: 'start' | 'center' | 'end' + sideOffset?: number +} + /** * Date validation result */ @@ -326,6 +332,7 @@ export type DateRangePickerProps = { size?: DateRangePickerSize formatConfig?: DateFormatConfig triggerConfig?: TriggerConfig + popoverConfig?: DateRangePickerPopoverConfig maxMenuHeight?: number showPreset?: boolean /** From 74062749c04574bea8ea34043503548f80ee8ac2 Mon Sep 17 00:00:00 2001 From: Vinit Khandal Date: Wed, 6 May 2026 18:32:28 +0530 Subject: [PATCH 3/3] feat: implement date parsing utility functions --- apps/site/src/demos/dataTableDemo.tsx | 58 +++++++--- .../components/DataTable/TableCell/index.tsx | 28 +++-- .../TableHeader/FilterComponents.tsx | 24 ++-- .../TableHeader/MobileFilterDrawer.tsx | 105 ++++++++++++++---- .../blend/lib/components/DataTable/utils.ts | 94 +++++++++------- 5 files changed, 207 insertions(+), 102 deletions(-) diff --git a/apps/site/src/demos/dataTableDemo.tsx b/apps/site/src/demos/dataTableDemo.tsx index e3dd5c827..a90e4b2a4 100644 --- a/apps/site/src/demos/dataTableDemo.tsx +++ b/apps/site/src/demos/dataTableDemo.tsx @@ -47,6 +47,25 @@ import { TooltipSide, } from '../../../../packages/blend/lib/components/Tooltip/types' +const isDateOnlyString = (value: string): boolean => + /^\d{4}-\d{2}-\d{2}$/.test(value) + +const parseDateOnlyLocal = (dateOnly: string): Date => { + const [y, m, d] = dateOnly.split('-').map((p) => Number(p)) + return new Date(y, (m || 1) - 1, d || 1) +} + +const parseDateLike = (value: unknown): Date | null => { + if (value instanceof Date) return isNaN(value.getTime()) ? null : value + if (typeof value !== 'string') return null + const trimmed = value.trim() + if (!trimmed) return null + const parsed = isDateOnlyString(trimmed) + ? parseDateOnlyLocal(trimmed) + : new Date(trimmed) + return isNaN(parsed.getTime()) ? null : parsed +} + const SimpleDataTableExample = () => { // Modal state for table demo const [isTableModalOpen, setIsTableModalOpen] = useState(false) @@ -454,7 +473,8 @@ const SimpleDataTableExample = () => { isEditable: false, renderCell: (value: unknown): React.ReactNode => { const dateValue = value as DateColumnProps - const date = new Date(dateValue.date) + const date = parseDateLike(dateValue.date) + if (!date) return '-' return ( {date.toLocaleDateString('en-US', { @@ -517,7 +537,7 @@ const SimpleDataTableExample = () => { {selectedOption.label} ) : ( - // @ts-expect-error + // @ts-expect-error selectedValue can be non-renderable type in demo data {dropdownValue.selectedValue} ) }, @@ -1680,11 +1700,14 @@ const DataTableDemo = () => { '2019-11-01', ] - const formatJoinMonth = (dateString: string) => - new Date(dateString).toLocaleDateString('en-US', { + const formatJoinMonth = (dateString: string) => { + const parsed = parseDateLike(dateString) + if (!parsed) return '-' + return parsed.toLocaleDateString('en-US', { month: 'long', year: 'numeric', }) + } return Array.from({ length: count }, (_, index) => { const userName = names[index % names.length] @@ -1913,8 +1936,8 @@ const DataTableDemo = () => { isSortable: true, isEditable: false, renderCell: (value: unknown): React.ReactNode => { - const parsedDate = new Date(String(value)) - if (isNaN(parsedDate.getTime())) return '-' + const parsedDate = parseDateLike(String(value)) + if (!parsedDate) return '-' return parsedDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short', @@ -2529,7 +2552,12 @@ const DataTableDemo = () => { `Last login: ${statusText === 'Active' ? '2 hours ago' : '1 week ago'}`, `Profile updated: ${user.role === 'Admin' ? '1 day ago' : '3 days ago'}`, `Password changed: ${user.gateway === 'Gateway A' ? '1 week ago' : '2 weeks ago'}`, - `Role assigned: ${new Date(user.joinDate).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}`, + `Role assigned: ${ + parseDateLike(user.joinDate)?.toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + }) || '-' + }`, ] return activities } @@ -2684,13 +2712,12 @@ const DataTableDemo = () => {
Member Since:{' '} - {new Date(userRow.joinDate).toLocaleDateString( - 'en-US', - { - month: 'short', - year: 'numeric', - } - )} + {parseDateLike( + userRow.joinDate + )?.toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + }) || '-'}
@@ -2944,7 +2971,8 @@ const DataTableDemo = () => { } // Priority 3: Recently joined users - New members (2023+) - const joinYear = new Date(userData.joinDate).getFullYear() || Number.NaN + const joinYear = + parseDateLike(userData.joinDate)?.getFullYear() ?? Number.NaN if (joinYear >= 2023) { return { backgroundColor: '#f0fdf4', // Light green background diff --git a/packages/blend/lib/components/DataTable/TableCell/index.tsx b/packages/blend/lib/components/DataTable/TableCell/index.tsx index d24b59fe5..440bf33ca 100644 --- a/packages/blend/lib/components/DataTable/TableCell/index.tsx +++ b/packages/blend/lib/components/DataTable/TableCell/index.tsx @@ -19,6 +19,7 @@ import { useResponsiveTokens } from '../../../hooks/useResponsiveTokens' import { useResizeObserver } from '../../../hooks/useResizeObserver' import Tooltip from '../../Tooltip/Tooltip' import { TooltipSize } from '../../Tooltip/types' +import { parseDateLike } from '../utils' const StyledTableCell = styled.td<{ width?: React.CSSProperties @@ -66,7 +67,7 @@ const isEmptyValue = (value: unknown, columnType?: ColumnType): boolean => { if ( !dateValue || (typeof dateValue === 'string' && dateValue.trim() === '') || - isNaN(new Date(String(dateValue)).getTime()) + !parseDateLike(dateValue) ) { return true } @@ -240,15 +241,18 @@ const TableCell = forwardRef< ? Boolean((valueToCheck as DateColumnProps).showTime) : false if (dateValue) { - const date = new Date(String(dateValue)) - if (!isNaN(date.getTime())) { - attrs['data-date'] = date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: '2-digit', - hour: showTime ? '2-digit' : undefined, - minute: showTime ? '2-digit' : undefined, - }) + const parsedDate = parseDateLike(dateValue) + if (parsedDate) { + attrs['data-date'] = parsedDate.toLocaleDateString( + 'en-US', + { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: showTime ? '2-digit' : undefined, + minute: showTime ? '2-digit' : undefined, + } + ) } } } else if ( @@ -448,7 +452,7 @@ const TableCell = forwardRef< ) } - const date = new Date(dateData.date) + const date = parseDateLike(dateData.date) const showTime = dateData.showTime || false const formatDate = (date: Date): string => { @@ -479,7 +483,7 @@ const TableCell = forwardRef< }} > = ({ column, fieldKey, tableToken, filterState, onColumnFilter }) => { - const toLocalDateString = (date: Date): string => { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` - } - const selectedValue = filterState.columnSelectedValues[fieldKey] const selectedRange = Array.isArray(selectedValue) ? selectedValue @@ -1243,15 +1237,13 @@ export const DateFilter: React.FC<{ ? [selectedValue, selectedValue] : [] - const pickerValue: DateRange | undefined = - selectedRange.length > 0 && selectedRange[0] - ? { - startDate: new Date(selectedRange[0]), - endDate: selectedRange[1] - ? new Date(selectedRange[1]) - : new Date(selectedRange[0]), - } - : undefined + const pickerValue: DateRange | undefined = (() => { + if (selectedRange.length === 0 || !selectedRange[0]) return undefined + const start = parseDateLike(selectedRange[0]) + if (!start) return undefined + const end = parseDateLike(selectedRange[1] || selectedRange[0]) || start + return { startDate: start, endDate: end } + })() const hasActiveDateRange = selectedRange.length > 0 return ( diff --git a/packages/blend/lib/components/DataTable/TableHeader/MobileFilterDrawer.tsx b/packages/blend/lib/components/DataTable/TableHeader/MobileFilterDrawer.tsx index 694cf23c8..b499c90cb 100644 --- a/packages/blend/lib/components/DataTable/TableHeader/MobileFilterDrawer.tsx +++ b/packages/blend/lib/components/DataTable/TableHeader/MobileFilterDrawer.tsx @@ -43,6 +43,7 @@ import { } from '../../Drawer' import DateRangePicker from '../../DateRangePicker/DateRangePicker' import { DateRange } from '../../DateRangePicker/types' +import { parseDateLike, toLocalDateString } from '../utils' type MobileFilterDrawerProps = { column: ColumnDefinition> @@ -67,12 +68,6 @@ export const MobileFilterDrawer: React.FC = ({ onPopoverClose, }) => { const [filterDrawerOpen, setFilterDrawerOpen] = useState(false) - const toLocalDateString = (date: Date): string => { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` - } const columnConfig = getColumnTypeConfig(column.type || ColumnType.TEXT) const fieldKey = String(column.field) @@ -522,12 +517,18 @@ export const MobileFilterDrawer: React.FC = ({ .columnSelectedValues[ fieldKey ] - const isSelected = + const currentSelected = Array.isArray( selectedValues - ) && - selectedValues[0] === - item.value + ) + ? selectedValues[0] + : typeof selectedValues === + 'string' + ? selectedValues + : '' + const isSelected = + currentSelected === + item.value return (
= ({ flexDirection="column" padding="14px 20px" > + {(() => { + const selectedValue = + filterState + .columnSelectedValues[ + fieldKey + ] + const selectedRange = Array.isArray( + selectedValue + ) + ? selectedValue + : typeof selectedValue === + 'string' && + selectedValue + ? [ + selectedValue, + selectedValue, + ] + : [] + const hasActiveDateRange = + selectedRange.length > 0 && + Boolean(selectedRange[0]) + + return hasActiveDateRange ? ( + + + + ) : null + })()} = ({ return undefined } + const start = parseDateLike( + selectedRange[0] + ) + if (!start) return undefined + const end = + parseDateLike( + selectedRange[1] || + selectedRange[0] + ) || start + return { - startDate: new Date( - selectedRange[0] - ), - endDate: selectedRange[1] - ? new Date( - selectedRange[1] - ) - : new Date( - selectedRange[0] - ), + startDate: start, + endDate: end, } })()} onChange={(range) => { diff --git a/packages/blend/lib/components/DataTable/utils.ts b/packages/blend/lib/components/DataTable/utils.ts index 17db7ba27..62bab64de 100644 --- a/packages/blend/lib/components/DataTable/utils.ts +++ b/packages/blend/lib/components/DataTable/utils.ts @@ -17,6 +17,47 @@ import { DateRangeData, } from './columnTypes' +export const isDateOnlyString = (value: string): boolean => + /^\d{4}-\d{2}-\d{2}$/.test(value) + +/** + * Parse a date-only string (`YYYY-MM-DD`) as a local date at midnight. + * Avoids JS `new Date("YYYY-MM-DD")` UTC parsing and off-by-one issues. + */ +export const parseDateOnlyLocal = (dateOnly: string): Date => { + const [y, m, d] = dateOnly.split('-').map((p) => Number(p)) + return new Date(y, (m || 1) - 1, d || 1) +} + +export const parseDateLike = (value: unknown): Date | null => { + if (value instanceof Date) { + return isNaN(value.getTime()) ? null : value + } + + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return null + const parsed = isDateOnlyString(trimmed) + ? parseDateOnlyLocal(trimmed) + : new Date(trimmed) + return isNaN(parsed.getTime()) ? null : parsed + } + + if (typeof value === 'object' && value !== null && 'date' in value) { + const dateValue = (value as { date?: unknown }).date + return parseDateLike(dateValue) + } + + return null +} + +export const toLocalDateString = (date: Date): string => { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + export const filterData = >( data: T[], filters: Record @@ -309,11 +350,7 @@ export const applyColumnFilters = >( endDate ) } - return applyDateFilter( - cellValue, - new Date(String(filterValue)), - operator - ) + return applyDateFilter(cellValue, filterValue, operator) default: return true @@ -386,30 +423,20 @@ const applyNumberFilter = ( const applyDateFilter = ( cellValue: unknown, - filterValue: Date, + filterValue: unknown, operator: string ): boolean => { - if (isNaN(filterValue.getTime())) return false - - let cellDate: Date - if ( - typeof cellValue === 'object' && - cellValue !== null && - 'date' in cellValue - ) { - const dateCellValue = cellValue as { date: Date | string } - cellDate = new Date(dateCellValue.date) - } else { - cellDate = new Date(String(cellValue)) - } + const parsedFilter = parseDateLike(filterValue) + if (!parsedFilter) return false - if (isNaN(cellDate.getTime())) return false + const parsedCell = parseDateLike(cellValue) + if (!parsedCell) return false const normalizeToDateOnly = (date: Date): number => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() - const cellTime = normalizeToDateOnly(cellDate) - const filterTime = normalizeToDateOnly(filterValue) + const cellTime = normalizeToDateOnly(parsedCell) + const filterTime = normalizeToDateOnly(parsedFilter) switch (operator) { case 'equals': @@ -432,28 +459,17 @@ const applyDateRangeFilter = ( startValue: string, endValue: string ): boolean => { - const startDate = new Date(startValue) - const endDate = new Date(endValue) - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return false - - let cellDate: Date - if ( - typeof cellValue === 'object' && - cellValue !== null && - 'date' in cellValue - ) { - const dateCellValue = cellValue as { date: Date | string } - cellDate = new Date(dateCellValue.date) - } else { - cellDate = new Date(String(cellValue)) - } + const startDate = parseDateLike(startValue) + const endDate = parseDateLike(endValue) + if (!startDate || !endDate) return false - if (isNaN(cellDate.getTime())) return false + const parsedCell = parseDateLike(cellValue) + if (!parsedCell) return false const normalizeToDateOnly = (date: Date): number => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() - const cellTime = normalizeToDateOnly(cellDate) + const cellTime = normalizeToDateOnly(parsedCell) const startTime = normalizeToDateOnly(startDate) const endTime = normalizeToDateOnly(endDate)