diff --git a/packages/raystack/components/data-table/__tests__/data-table.test.tsx b/packages/raystack/components/data-table/__tests__/data-table.test.tsx index 522213254..c359dca2a 100644 --- a/packages/raystack/components/data-table/__tests__/data-table.test.tsx +++ b/packages/raystack/components/data-table/__tests__/data-table.test.tsx @@ -1,10 +1,18 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { DataTable } from '../data-table'; import styles from '../data-table.module.css'; import { DataTableColumnDef } from '../data-table.types'; +beforeAll(() => { + global.IntersectionObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn() + })); +}); + interface TestData { id: number; name: string; @@ -43,7 +51,11 @@ describe('DataTable', () => { describe('Basic Rendering', () => { it('renders data table with content', () => { render( - + ); @@ -58,7 +70,11 @@ describe('DataTable', () => { }; render( - + ); @@ -68,7 +84,11 @@ describe('DataTable', () => { it('renders with empty data', () => { render( - + ); @@ -80,7 +100,11 @@ describe('DataTable', () => { describe('Data Display', () => { it('displays table data in content', () => { render( - + ); @@ -94,7 +118,11 @@ describe('DataTable', () => { it('displays column headers', () => { render( - + ); @@ -114,6 +142,7 @@ describe('DataTable', () => { @@ -129,7 +158,11 @@ describe('DataTable', () => { describe('Component Composition', () => { it('renders with toolbar', () => { const { container } = render( - + @@ -142,7 +175,11 @@ describe('DataTable', () => { it('renders with search', () => { render( - + @@ -409,4 +446,133 @@ describe('DataTable', () => { expect(screen.getByText('John Doe')).toBeInTheDocument(); }); }); + + describe('Display Settings Reset', () => { + const columnsWithSortAndGroup: DataTableColumnDef[] = [ + { + id: 'name', + accessorKey: 'name', + header: 'Name', + cell: ({ getValue }) => getValue(), + enableSorting: true, + enableGrouping: true + }, + { + id: 'email', + accessorKey: 'email', + header: 'Email', + cell: ({ getValue }) => getValue(), + enableSorting: true + }, + { + id: 'status', + accessorKey: 'status', + header: 'Status', + cell: ({ getValue }) => getValue(), + enableSorting: true, + enableGrouping: true + } + ]; + + it('resets sort and group to defaults on reset click', async () => { + const onTableQueryChange = vi.fn(); + const user = userEvent.setup(); + + render( + + + + + ); + + // Open Display popover and click reset + await user.click(screen.getByText('Display')); + await user.click(screen.getByText('Reset to default')); + + // Verify onTableQueryChange was called with default sort and no group + expect(onTableQueryChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + sort: [{ name: 'name', order: 'asc' }], + group_by: [] + }) + ); + }); + + it('does not show zero state when sort or group changes in client mode', () => { + render( + + No data} + emptyState={
No results
} + /> +
+ ); + + // Data should still be visible, not zero/empty state + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByTestId('zero-state')).not.toBeInTheDocument(); + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument(); + }); + + it('shows empty state when sort is changed and no data', () => { + render( + + No data} + emptyState={
No results
} + /> +
+ ); + + expect(screen.getByTestId('empty-state')).toBeInTheDocument(); + expect(screen.queryByTestId('zero-state')).not.toBeInTheDocument(); + }); + + it('shows empty state when group is changed and no data', () => { + render( + + No data} + emptyState={
No results
} + /> +
+ ); + + expect(screen.getByTestId('empty-state')).toBeInTheDocument(); + expect(screen.queryByTestId('zero-state')).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/raystack/components/data-table/components/content.tsx b/packages/raystack/components/data-table/components/content.tsx index ddbae614f..4cc3d3e66 100644 --- a/packages/raystack/components/data-table/components/content.tsx +++ b/packages/raystack/components/data-table/components/content.tsx @@ -18,6 +18,7 @@ import { GroupedData } from '../data-table.types'; import { useDataTable } from '../hooks/useDataTable'; +import { hasActiveQuery } from '../utils'; function Headers({ headerGroups = [], @@ -172,7 +173,8 @@ export function Content({ isLoading, loadMoreData, loadingRowCount = 3, - tableQuery + tableQuery, + defaultSort } = useDataTable(); const headerGroups = table?.getHeaderGroups(); @@ -219,12 +221,10 @@ export function Content({ const hasData = rows?.length > 0 || isLoading; - const hasFiltersOrSearch = - (tableQuery?.filters && tableQuery.filters.length > 0) || - Boolean(tableQuery?.search && tableQuery.search.trim() !== ''); + const hasChanges = hasActiveQuery(tableQuery || {}, defaultSort); - const isZeroState = !hasData && !hasFiltersOrSearch; - const isEmptyState = !hasData && hasFiltersOrSearch; + const isZeroState = !hasData && !hasChanges; + const isEmptyState = !hasData && hasChanges; const stateToShow: React.ReactNode = isZeroState ? (zeroState ?? emptyState ?? ) diff --git a/packages/raystack/components/data-table/components/virtualized-content.tsx b/packages/raystack/components/data-table/components/virtualized-content.tsx index 9aba7bf5c..3eb137eb3 100644 --- a/packages/raystack/components/data-table/components/virtualized-content.tsx +++ b/packages/raystack/components/data-table/components/virtualized-content.tsx @@ -18,6 +18,7 @@ import { VirtualizedContentProps } from '../data-table.types'; import { useDataTable } from '../hooks/useDataTable'; +import { hasActiveQuery } from '../utils'; function VirtualHeaders({ headerGroups = [], @@ -220,6 +221,7 @@ export function VirtualizedContent({ isLoading, loadMoreData, tableQuery, + defaultSort, loadingRowCount = 3 } = useDataTable(); @@ -255,12 +257,10 @@ export function VirtualizedContent({ const hasData = rows?.length > 0 || isLoading; - const hasFiltersOrSearch = - (tableQuery?.filters && tableQuery.filters.length > 0) || - Boolean(tableQuery?.search && tableQuery.search.trim() !== ''); + const hasChanges = hasActiveQuery(tableQuery || {}, defaultSort); - const isZeroState = !hasData && !hasFiltersOrSearch; - const isEmptyState = !hasData && hasFiltersOrSearch; + const isZeroState = !hasData && !hasChanges; + const isEmptyState = !hasData && hasChanges; const stateToShow: React.ReactNode = isZeroState ? (zeroState ?? emptyState ?? ) diff --git a/packages/raystack/components/data-table/data-table.tsx b/packages/raystack/components/data-table/data-table.tsx index d948d2ab7..1ac060131 100644 --- a/packages/raystack/components/data-table/data-table.tsx +++ b/packages/raystack/components/data-table/data-table.tsx @@ -1,30 +1,30 @@ -"use client"; +'use client'; import { - Updater, getCoreRowModel, getExpandedRowModel, getFilteredRowModel, getSortedRowModel, + Updater, useReactTable, - VisibilityState, -} from "@tanstack/react-table"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Content } from "./components/content"; -import { DisplaySettings } from "./components/display-settings"; -import { Filters } from "./components/filters"; -import { TableSearch } from "./components/search"; -import { Toolbar } from "./components/toolbar"; -import { VirtualizedContent } from "./components/virtualized-content"; -import { TableContext } from "./context"; + VisibilityState +} from '@tanstack/react-table'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Content } from './components/content'; +import { DisplaySettings } from './components/display-settings'; +import { Filters } from './components/filters'; +import { TableSearch } from './components/search'; +import { Toolbar } from './components/toolbar'; +import { VirtualizedContent } from './components/virtualized-content'; +import { TableContext } from './context'; import { DataTableProps, defaultGroupOption, GroupedData, InternalQuery, TableContextType, - TableQueryUpdateFn, -} from "./data-table.types"; + TableQueryUpdateFn +} from './data-table.types'; import { getColumnsWithFilterFn, getDefaultTableQuery, @@ -32,14 +32,14 @@ import { groupData, hasQueryChanged, queryToTableState, - transformToDataTableQuery, -} from "./utils"; + transformToDataTableQuery +} from './utils'; function DataTableRoot({ data = [], columns, query, - mode = "client", + mode = 'client', isLoading = false, loadingRowCount = 3, defaultSort, @@ -47,9 +47,12 @@ function DataTableRoot({ onTableQueryChange, onLoadMore, onRowClick, - onColumnVisibilityChange, + onColumnVisibilityChange }: React.PropsWithChildren>) { - const defaultTableQuery = getDefaultTableQuery(defaultSort, query); + const defaultTableQuery = useMemo( + () => getDefaultTableQuery(defaultSort, query), + [defaultSort, query] + ); const initialColumnVisibility = getInitialColumnVisibility(columns); const [columnVisibility, setColumnVisibility] = useState( @@ -57,8 +60,8 @@ function DataTableRoot({ ); const handleColumnVisibilityChange = useCallback( (value: Updater) => { - setColumnVisibility((prev) => { - const newValue = typeof value === "function" ? value(prev) : value; + setColumnVisibility(prev => { + const newValue = typeof value === 'function' ? value(prev) : value; onColumnVisibilityChange?.(newValue); return newValue; }); @@ -77,12 +80,18 @@ function DataTableRoot({ ); const onDisplaySettingsReset = useCallback(() => { - setTableQuery((prev) => ({ ...prev, ...defaultTableQuery })); + setTableQuery(prev => ({ + ...prev, + ...defaultTableQuery, + sort: [defaultSort], + group_by: [defaultGroupOption.id] + })); handleColumnVisibilityChange(initialColumnVisibility); }, [ + defaultSort, defaultTableQuery, initialColumnVisibility, - handleColumnVisibilityChange, + handleColumnVisibilityChange ]); const group_by = tableQuery.group_by?.[0]; @@ -102,7 +111,7 @@ function DataTableRoot({ tableQuery && onTableQueryChange && hasQueryChanged(oldQueryRef.current, tableQuery) && - mode === "server" + mode === 'server' ) { onTableQueryChange(transformToDataTableQuery(tableQuery)); oldQueryRef.current = tableQuery; @@ -114,31 +123,31 @@ function DataTableRoot({ columns: columnsWithFilters, getCoreRowModel: getCoreRowModel(), getExpandedRowModel: getExpandedRowModel(), - getSubRows: (row) => (row as unknown as GroupedData)?.subRows || [], - getSortedRowModel: mode === "server" ? undefined : getSortedRowModel(), - getFilteredRowModel: mode === "server" ? undefined : getFilteredRowModel(), - manualSorting: mode === "server", - manualFiltering: mode === "server", + getSubRows: row => (row as unknown as GroupedData)?.subRows || [], + getSortedRowModel: mode === 'server' ? undefined : getSortedRowModel(), + getFilteredRowModel: mode === 'server' ? undefined : getFilteredRowModel(), + manualSorting: mode === 'server', + manualFiltering: mode === 'server', onColumnVisibilityChange: handleColumnVisibilityChange, - globalFilterFn: mode === "server" ? undefined : "auto", + globalFilterFn: mode === 'server' ? undefined : 'auto', initialState: { - columnVisibility: initialColumnVisibility, + columnVisibility: initialColumnVisibility }, filterFromLeafRows: true, state: { ...reactTableState, columnVisibility: columnVisibility, expanded: - group_by && group_by !== defaultGroupOption.id ? true : undefined, - }, + group_by && group_by !== defaultGroupOption.id ? true : undefined + } }); function updateTableQuery(fn: TableQueryUpdateFn) { - setTableQuery((prev) => fn(prev)); + setTableQuery(prev => fn(prev)); } const loadMoreData = useCallback(() => { - if (mode === "server" && onLoadMore) { + if (mode === 'server' && onLoadMore) { onLoadMore(); } }, [mode, onLoadMore]); @@ -146,9 +155,9 @@ function DataTableRoot({ const searchQuery = query?.search; useEffect(() => { if (searchQuery) { - updateTableQuery((prev) => ({ + updateTableQuery(prev => ({ ...prev, - search: searchQuery, + search: searchQuery })); } }, [searchQuery]); @@ -184,7 +193,7 @@ function DataTableRoot({ defaultSort, loadingRowCount, onRowClick, - shouldShowFilters, + shouldShowFilters }; }, [ table, @@ -198,7 +207,7 @@ function DataTableRoot({ defaultSort, loadingRowCount, onRowClick, - shouldShowFilters, + shouldShowFilters ]); return ( @@ -214,5 +223,5 @@ export const DataTable = Object.assign(DataTableRoot, { Toolbar: Toolbar, Search: TableSearch, Filters: Filters, - DisplayControls: DisplaySettings, + DisplayControls: DisplaySettings }); diff --git a/packages/raystack/components/data-table/utils/index.tsx b/packages/raystack/components/data-table/utils/index.tsx index fdf8ff86e..1ae74255a 100644 --- a/packages/raystack/components/data-table/utils/index.tsx +++ b/packages/raystack/components/data-table/utils/index.tsx @@ -159,6 +159,27 @@ const isSearchChanged = (oldSearch?: string, newSearch?: string): boolean => { return oldSearch !== newSearch; }; +/** + * Checks if there is an active filter, search, or updated sort/grouping + * compared to the defaults. Used to distinguish zero state from empty state. + */ +export const hasActiveQuery = ( + tableQuery: InternalQuery, + defaultSort: DataTableSort +): boolean => { + const hasFilters = + (tableQuery?.filters && tableQuery.filters.length > 0) || false; + const hasSearch = Boolean( + tableQuery?.search && tableQuery.search.trim() !== '' + ); + const sortChanged = isSortChanged([defaultSort], tableQuery.sort || []); + const groupChanged = isGroupChanged( + [defaultGroupOption.id], + tableQuery.group_by || [] + ); + return hasFilters || hasSearch || sortChanged || groupChanged; +}; + export const hasQueryChanged = ( oldQuery: InternalQuery | null, newQuery: InternalQuery