diff --git a/apps/site/src/demos/dataTableDemo.tsx b/apps/site/src/demos/dataTableDemo.tsx index 3763da01e..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} ) }, @@ -1662,50 +1682,46 @@ 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) => { + 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] 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 +1928,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 = parseDateLike(String(value)) + if (!parsedDate) 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 +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: ${user.joinDate}`, + `Role assigned: ${ + parseDateLike(user.joinDate)?.toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + }) || '-' + }`, ] return activities } @@ -2672,7 +2712,12 @@ const DataTableDemo = () => {
Member Since:{' '} - {userRow.joinDate} + {parseDateLike( + userRow.joinDate + )?.toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + }) || '-'}
@@ -2926,7 +2971,8 @@ const DataTableDemo = () => { } // Priority 3: Recently joined users - New members (2023+) - const joinYear = parseInt(userData.joinDate.split(' ')[1] || '2020') + 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/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}`} + { } 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() === '') || + !parseDateLike(dateValue) ) { return true } @@ -226,17 +228,31 @@ 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) - 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, - }) + 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 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 ( @@ -412,7 +428,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 ( @@ -428,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 => { @@ -459,7 +483,7 @@ const TableCell = forwardRef< }} > ( 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 4c0bca0c5..f72a56231 100644 --- a/packages/blend/lib/components/DataTable/TableHeader/FilterComponents.tsx +++ b/packages/blend/lib/components/DataTable/TableHeader/FilterComponents.tsx @@ -44,6 +44,9 @@ 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' +import { parseDateLike, toLocalDateString } from '../utils' const FILTER_VIRTUAL_ITEM_ESTIMATE_HEIGHT = 40 const FILTER_VIRTUAL_LIST_MAX_HEIGHT = 220 @@ -1220,6 +1223,117 @@ 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 = (() => { + 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 ( + + { + const start = toLocalDateString(range.startDate) + const end = toLocalDateString( + range.endDate || range.startDate + ) + onColumnFilter?.( + String(column.field), + FilterType.DATE, + [start, end], + 'range' + ) + }} + showDateTimePicker={false} + showDateInput={false} + showPresets={false} + allowSingleDateSelection={false} + useDrawerOnMobile={false} + popoverConfig={{ + side: 'right', + align: 'start', + sideOffset: 10, + }} + triggerConfig={{ + style: { width: '100%' }, + renderTrigger: ({ onClick }) => ( + + } + label="Filter" + onClick={(e) => { + e.stopPropagation() + onClick() + }} + trailingIcon={ + + } + 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%' }} + /> + )} + + ) +} + export const ColumnFilter: React.FC = ({ column, data, @@ -1360,8 +1474,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' && ( > @@ -514,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 (
= ({ )} + {columnConfig.filterComponent === + 'dateRange' && ( + + {(() => { + 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 + })()} + { + 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 + } + + const start = parseDateLike( + selectedRange[0] + ) + if (!start) return undefined + const end = + parseDateLike( + selectedRange[1] || + selectedRange[0] + ) || start + + return { + startDate: start, + endDate: end, + } + })()} + onChange={(range) => { + const start = toLocalDateString( + range.startDate + ) + const end = toLocalDateString( + range.endDate || + range.startDate + ) + 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/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/DataTable/utils.ts b/packages/blend/lib/components/DataTable/utils.ts index 2cd4cd9fc..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 @@ -300,11 +341,16 @@ export const applyColumnFilters = >( } case FilterType.DATE: - return applyDateFilter( - cellValue, - new Date(String(filterValue)), - operator - ) + if (operator === 'range' && Array.isArray(filterValue)) { + const [startDate, endDate] = filterValue + if (!startDate || !endDate) return true + return applyDateRangeFilter( + cellValue, + startDate, + endDate + ) + } + return applyDateFilter(cellValue, filterValue, operator) default: return true @@ -377,14 +423,20 @@ const applyNumberFilter = ( const applyDateFilter = ( cellValue: unknown, - filterValue: Date, + filterValue: unknown, operator: string ): boolean => { - const cellDate = new Date(String(cellValue)) - if (isNaN(cellDate.getTime())) return false + const parsedFilter = parseDateLike(filterValue) + if (!parsedFilter) return false + + const parsedCell = parseDateLike(cellValue) + if (!parsedCell) 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(parsedCell) + const filterTime = normalizeToDateOnly(parsedFilter) switch (operator) { case 'equals': @@ -402,6 +454,31 @@ const applyDateFilter = ( } } +const applyDateRangeFilter = ( + cellValue: unknown, + startValue: string, + endValue: string +): boolean => { + const startDate = parseDateLike(startValue) + const endDate = parseDateLike(endValue) + if (!startDate || !endDate) 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(parsedCell) + 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 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 /**