From e6d71f6f0b620e4a3e13ebfe1aa8229bc9541df8 Mon Sep 17 00:00:00 2001 From: Athira P Babu Date: Thu, 15 May 2025 11:48:47 +0530 Subject: [PATCH 1/2] feat: Add onRowClick feature --- src/App.tsx | 1 + src/components/MultiLevelTable.tsx | 4 ++ src/components/TableRow.tsx | 23 ++++++++++-- src/styles/TableRow.css | 2 +- tests/components/TableRow.test.tsx | 60 +++++++++++++++++++++++++++++- 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2f1855e..a9a1638 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -455,6 +455,7 @@ const App: React.FC = () => { sortable={true} selectable={true} onSelectionChange={handleSelectionChange} + onRowClick={(data) => console.log(data)} /> {selectedRows.size > 0 && ( diff --git a/src/components/MultiLevelTable.tsx b/src/components/MultiLevelTable.tsx index 19eeddf..d23338e 100644 --- a/src/components/MultiLevelTable.tsx +++ b/src/components/MultiLevelTable.tsx @@ -33,6 +33,7 @@ import "../styles/MultiLevelTable.css"; * @property {Column[]} columns - Array of column configurations * @property {number} [pageSize=10] - Number of items per page * @property {ThemeProps} theme - Theme properties + * @property {(row: DataItem) => void} [onRowClick] - Optional callback function when a parent row is clicked */ export interface MultiLevelTableProps { data: DataItem[]; @@ -46,6 +47,7 @@ export interface MultiLevelTableProps { expandIcon?: React.ReactNode; selectable?: boolean; onSelectionChange?: (selectedRows: Set) => void; + onRowClick?: (row: DataItem) => void; } /** @@ -66,6 +68,7 @@ export const MultiLevelTable: React.FC = ({ expandIcon, selectable = false, onSelectionChange, + onRowClick, }) => { const mergedTheme = mergeThemeProps(defaultTheme, theme); const [filterInput, setFilterInput] = useState(""); @@ -300,6 +303,7 @@ export const MultiLevelTable: React.FC = ({ selectable={true} isRowSelected={selectionState.selectedRows.has(row.original.id)} onRowSelect={handleRowSelect} + onRowClick={onRowClick} /> {renderNestedRows(parentId)} diff --git a/src/components/TableRow.tsx b/src/components/TableRow.tsx index d8fe2c7..3967721 100644 --- a/src/components/TableRow.tsx +++ b/src/components/TableRow.tsx @@ -23,6 +23,7 @@ import "../styles/TableRow.css"; * @property {boolean} [selectable=false] - Whether the row is selectable * @property {boolean} [isRowSelected=false] - Whether the row is selected * @property {(rowId: number) => void} [onRowSelect] - Function to select a row + * @property {(row: DataItem) => void} [onRowClick] - Optional callback function when a parent row is clicked */ interface TableRowProps { row: Row | DataItem; @@ -36,6 +37,7 @@ interface TableRowProps { selectable?: boolean; isRowSelected?: boolean; onRowSelect?: (rowId: number) => void; + onRowClick?: (row: DataItem) => void; } /** @@ -56,16 +58,18 @@ export const TableRow: React.FC = ({ selectable = false, isRowSelected = false, onRowSelect, + onRowClick, }) => { const getRowClassName = useMemo(() => { - const classes = ["table-row"]; + const classes = []; if (isExpanded) classes.push("table-row-expanded"); if (level === 0) classes.push("table-row-main"); + if(onRowClick) classes.push("table-row-clickable"); else classes.push("table-row-nested"); return classes.join(" "); - }, [isExpanded, level]); + }, [isExpanded, level, onRowClick]); const getRowStyle = useMemo(() => { const rowShades = theme.table?.row?.levelColors || []; @@ -80,12 +84,24 @@ export const TableRow: React.FC = ({ onToggle(); }; + const handleRowClick = () => { + if (onRowClick) { + const dataItem = "original" in row ? row.original : row as DataItem; + + onRowClick(dataItem); + } + }; + // For nested rows that don't have getRowProps if (!("getRowProps" in row)) { const dataItem = row as DataItem; return ( - + {columns.map((column: Column, index: number) => { const value = dataItem[column.key as keyof DataItem]; const displayValue = @@ -139,6 +155,7 @@ export const TableRow: React.FC = ({ {...rowProps} className={getRowClassName} style={getRowStyle} + onClick={handleRowClick} > {tableRow.cells.map((cell: Cell, index: number) => ( { ); const row = screen.getByText('Test Item').closest('tr'); - expect(row).toHaveClass('table-row'); expect(row).toHaveClass('table-row-main'); }); + it('applies clickable class when onRowClick is provided', () => { + render( + {}} + level={0} + theme={mockTheme} + onRowClick={() => {}} + /> + ); + + const row = screen.getByText('Test Item').closest('tr'); + expect(row).toHaveClass('table-row-clickable'); + }); + + it('handles row click correctly', () => { + const onRowClick = vi.fn(); + render( + {}} + level={0} + theme={mockTheme} + onRowClick={onRowClick} + /> + ); + + const row = screen.getByText('Test Item').closest('tr'); + fireEvent.click(row!); + expect(onRowClick).toHaveBeenCalledWith(mockData); + }); + it('handles row expansion correctly', () => { const onToggle = vi.fn(); render( @@ -198,10 +235,29 @@ describe('TableRow', () => { ); const row = screen.getByText('Test Item').closest('tr'); - expect(row).toHaveClass('table-row'); expect(row).toHaveClass('table-row-nested'); }); + it('does not trigger click handler for nested rows', () => { + const onRowClick = vi.fn(); + render( + {}} + level={1} + theme={mockTheme} + onRowClick={onRowClick} + /> + ); + + const row = screen.getByText('Test Item').closest('tr'); + fireEvent.click(row!); + expect(onRowClick).not.toHaveBeenCalled(); + }); + it('applies correct background color based on nesting level', () => { render( Date: Thu, 15 May 2025 14:16:09 +0530 Subject: [PATCH 2/2] feat: Accept react node as column header --- src/components/MultiLevelTable.tsx | 5 +- src/components/TableHeader.tsx | 6 +- src/components/TableRow.tsx | 2 +- src/types/types.ts | 2 +- tests/components/MultiLevelTable.test.tsx | 13 ++- tests/components/TableHeader.test.tsx | 55 +++++++++--- tests/components/TableRow.test.tsx | 101 ++++++++++++++++++++-- 7 files changed, 154 insertions(+), 30 deletions(-) diff --git a/src/components/MultiLevelTable.tsx b/src/components/MultiLevelTable.tsx index d23338e..b5bec4a 100644 --- a/src/components/MultiLevelTable.tsx +++ b/src/components/MultiLevelTable.tsx @@ -116,7 +116,8 @@ export const MultiLevelTable: React.FC = ({ */ const tableColumns = useMemo[]>(() => { return columns.map((col) => ({ - Header: col.title, + id: col.key, + Header: () => col.title, accessor: (row: DataItem) => row[col.key as keyof DataItem], disableSortBy: sortable ? col.sortable === false : true, sortType: col.customSortFn ? SortType.Custom : SortType.Basic, @@ -142,7 +143,7 @@ export const MultiLevelTable: React.FC = ({ setFilterInput(e.target.value); column.setFilter(e.target.value); }} - placeholder={`Filter ${col.title}...`} + placeholder={`Filter ${typeof col.title === 'string' ? col.title : col.key}...`} /> ) : undefined, diff --git a/src/components/TableHeader.tsx b/src/components/TableHeader.tsx index 64bcb90..22d2f71 100644 --- a/src/components/TableHeader.tsx +++ b/src/components/TableHeader.tsx @@ -46,7 +46,7 @@ type ColumnWithSorting = { Filter?: React.ComponentType<{ column: ColumnWithSorting }>; id: string; disableSortBy?: boolean; - title?: string; + title?: string | React.ReactNode; filterValue?: string; setFilter?: (value: string) => void; }; @@ -106,7 +106,7 @@ export const TableHeader: React.FC = ({ style={{ display: 'inline-flex', alignItems: 'center', cursor: isColumnSortable ? 'pointer' : 'default', userSelect: 'none' }} onClick={isColumnSortable ? (e => { e.stopPropagation(); (sortProps.onClick as any)?.(e); }) : undefined} > - {column.title || column.id} + {column.render('Header')} {column.isSorted ? column.isSortedDesc @@ -121,7 +121,7 @@ export const TableHeader: React.FC = ({ className="filter-input" value={column.filterValue || ""} onChange={(e) => column.setFilter?.(e.target.value)} - placeholder={`Filter ${column.title || column.id}...`} + placeholder={`Filter ${typeof column.title === 'string' ? column.title : column.id}...`} style={{ color: theme.table?.filter?.textColor, borderColor: theme.table?.filter?.borderColor, diff --git a/src/components/TableRow.tsx b/src/components/TableRow.tsx index 3967721..5495177 100644 --- a/src/components/TableRow.tsx +++ b/src/components/TableRow.tsx @@ -85,7 +85,7 @@ export const TableRow: React.FC = ({ }; const handleRowClick = () => { - if (onRowClick) { + if (onRowClick && level === 0) { const dataItem = "original" in row ? row.original : row as DataItem; onRowClick(dataItem); diff --git a/src/types/types.ts b/src/types/types.ts index 1ab7aaa..d2afdda 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -4,7 +4,7 @@ import type { Row, TableInstance, TableState } from 'react-table'; export interface Column { key: string; - title: string; + title: string | React.ReactNode; filterable?: boolean; render?: (value: string | number, item: DataItem) => React.ReactNode; sortable?: boolean; diff --git a/tests/components/MultiLevelTable.test.tsx b/tests/components/MultiLevelTable.test.tsx index fe02ff7..1cd16cf 100644 --- a/tests/components/MultiLevelTable.test.tsx +++ b/tests/components/MultiLevelTable.test.tsx @@ -1,6 +1,8 @@ import React from 'react'; + import { fireEvent, render, screen, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; + import { MultiLevelTable } from '../../src/components/MultiLevelTable'; import type { Column, DataItem } from '../../src/types/types'; // Mock data for testing @@ -71,6 +73,7 @@ const mockColumns: Column[] = [ ), }, ]; + describe('MultiLevelTable', () => { it('renders table with basic data', () => { render(); @@ -96,6 +99,7 @@ describe('MultiLevelTable', () => { // Click expand button for first parent const expandButton = screen.getAllByRole('button')[0]; + fireEvent.click(expandButton); // Children should now be visible @@ -113,6 +117,7 @@ describe('MultiLevelTable', () => { // Click name header to sort const nameHeader = screen.getByText('Name'); + fireEvent.click(nameHeader); // Get all rows and check order @@ -129,6 +134,7 @@ describe('MultiLevelTable', () => { // Check if order is reversed const updatedRows = screen.getAllByRole('row').slice(1); + expect(within(updatedRows[0]).getByText('Parent 2')).toBeInTheDocument(); expect(within(updatedRows[1]).getByText('Parent 1')).toBeInTheDocument(); }); @@ -145,6 +151,7 @@ describe('MultiLevelTable', () => { // Check if pagination controls are present const nextButton = screen.getByRole('button', { name: '>' }); const prevButton = screen.getByRole('button', { name: '<' }); + expect(nextButton).toBeInTheDocument(); expect(prevButton).toBeInTheDocument(); @@ -190,6 +197,7 @@ describe('MultiLevelTable', () => { const table = screen.getByRole('table'); const tableWrapper = table.closest('.table-wrapper'); + expect(tableWrapper?.parentElement).toHaveStyle({ backgroundColor: '#f0f0f0' }); expect(table).toHaveStyle({ borderColor: '#ff0000' }); }); @@ -207,6 +215,7 @@ describe('MultiLevelTable', () => { // Check if custom render is applied const customElements = screen.getAllByTestId('custom-name'); + expect(customElements).toHaveLength(2); // Two parent rows expect(customElements[0]).toHaveTextContent('Parent 1'); }); @@ -214,7 +223,7 @@ describe('MultiLevelTable', () => { render(); // Find filter input - const filterInput = screen.getByPlaceholderText('Filter Name...'); + const filterInput = screen.getByPlaceholderText('Filter name...'); // Type in filter fireEvent.change(filterInput, { target: { value: 'Parent 1' } }); @@ -241,10 +250,12 @@ describe('MultiLevelTable', () => { render(); const statusCells = screen.getAllByTestId('status-cell'); + expect(statusCells).toHaveLength(2); // Two parent rows // Check if status cells have correct styles const activeCell = statusCells.find(cell => cell.textContent === 'Active'); + expect(activeCell).toHaveStyle({ backgroundColor: '#e6ffe6', color: '#006600', diff --git a/tests/components/TableHeader.test.tsx b/tests/components/TableHeader.test.tsx index c7ba261..ba33df0 100644 --- a/tests/components/TableHeader.test.tsx +++ b/tests/components/TableHeader.test.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; + +import { fireEvent, render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { describe, it, expect, vi } from 'vitest'; -import { TableHeader } from '../../src/components/TableHeader'; import type { HeaderGroup } from 'react-table'; -import type { DataItem } from '../../src/types/types'; +import { describe, expect, it, vi } from 'vitest'; + +import { TableHeader } from '../../src/components/TableHeader'; import type { ThemeProps } from '../../src/types/theme'; +import type { DataItem } from '../../src/types/types'; // Mock theme const mockTheme: ThemeProps = { @@ -28,7 +30,7 @@ const mockTheme: ThemeProps = { // Mock header groups const createMockHeaderGroup = ( id: string, - title: string, + title: string | React.ReactNode, isSorted = false, isSortedDesc = false, hasFilter = false @@ -42,21 +44,22 @@ const createMockHeaderGroup = ( getSortByToggleProps: () => ({ onClick: vi.fn(), }), - render: (type: string) => (type === 'Header' ? title : null), + render: (type: string) => (type === 'Header' ? (title || id) : null), isSorted, isSortedDesc, Filter: hasFilter ? ({ column }: { column: any }) => ( - column.setFilter?.(e.target.value)} - /> - ) + column.setFilter?.(e.target.value)} + placeholder={`Filter ${column.title || column.id}...`} + /> + ) : undefined, setFilter: hasFilter ? vi.fn() : undefined, disableSortBy: false, - title, + title: title || id, filterValue: '', }; @@ -119,9 +122,11 @@ describe('TableHeader', () => { it('applies theme styles correctly', () => { const headerGroups = [createMockHeaderGroup('name', 'Name')]; + renderTableHeader({ headerGroups }); const headerCell = screen.getByRole('columnheader', { name: 'Name' }); + expect(headerCell).toHaveStyle({ backgroundColor: mockTheme.table?.header?.background, color: mockTheme.table?.header?.textColor, @@ -160,22 +165,27 @@ describe('TableHeader', () => { it('uses column id when title is not provided', () => { const headerGroups = [createMockHeaderGroup('name', '')]; + renderTableHeader({ headerGroups }); expect(screen.getByText('name')).toBeInTheDocument(); }); it('renders filter input when column has Filter component', () => { const headerGroups = [createMockHeaderGroup('name', 'Name', false, false, true)]; + renderTableHeader({ headerGroups }); const filterInput = screen.getByPlaceholderText('Filter Name...'); + expect(filterInput).toBeInTheDocument(); expect(filterInput).toHaveClass('filter-input'); }); it('applies filter theme styles correctly', () => { const headerGroups = [createMockHeaderGroup('name', 'Name', false, false, true)]; + renderTableHeader({ headerGroups }); const filterInput = screen.getByPlaceholderText('Filter Name...'); + expect(filterInput).toHaveStyle({ color: mockTheme.table?.filter?.textColor, borderColor: mockTheme.table?.filter?.borderColor, @@ -185,24 +195,43 @@ describe('TableHeader', () => { it('handles filter input change correctly', () => { const headerGroups = [createMockHeaderGroup('name', 'Name', false, false, true)]; + renderTableHeader({ headerGroups }); const filterInput = screen.getByPlaceholderText('Filter Name...'); + fireEvent.change(filterInput, { target: { value: 'test' } }); const mockColumn = (headerGroups[0].headers[0] as any).column; + expect(mockColumn.setFilter).toHaveBeenCalledWith('test'); }); it('renders filter input with column id when title is not provided', () => { const headerGroups = [createMockHeaderGroup('name', '', false, false, true)]; + renderTableHeader({ headerGroups }); const filterInput = screen.getByPlaceholderText('Filter name...'); + expect(filterInput).toBeInTheDocument(); }); it('does not render filter input when column has no Filter component', () => { const headerGroups = [createMockHeaderGroup('name', 'Name', false, false, false)]; + renderTableHeader({ headerGroups }); const filterInput = screen.queryByPlaceholderText('Filter Name...'); + expect(filterInput).not.toBeInTheDocument(); }); + + it('renders a React node as column header', () => { + const customHeader = Custom; + const headerGroups = [ + createMockHeaderGroup('custom', customHeader as any), + ]; + + // Patch the render function to return the React node for 'Header' + (headerGroups[0].headers[0] as any).render = (type: string) => type === 'Header' ? customHeader : null; + renderTableHeader({ headerGroups }); + expect(screen.getByTestId('custom-header')).toBeInTheDocument(); + }); }); \ No newline at end of file diff --git a/tests/components/TableRow.test.tsx b/tests/components/TableRow.test.tsx index a2bcef0..c4e5600 100644 --- a/tests/components/TableRow.test.tsx +++ b/tests/components/TableRow.test.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; + +import { fireEvent, render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; +import type { Row } from 'react-table'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + import { TableRow } from '../../src/components/TableRow'; -import type { Column, DataItem } from '../../src/types/types'; import type { ThemeProps } from '../../src/types/theme'; -import type { Row } from 'react-table'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Column, DataItem } from '../../src/types/types'; // Mock theme for testing const mockTheme: ThemeProps = { @@ -142,6 +144,7 @@ describe('TableRow', () => { ); const row = screen.getByText('Test Item').closest('tr'); + expect(row).toHaveClass('table-row-main'); }); @@ -160,11 +163,13 @@ describe('TableRow', () => { ); const row = screen.getByText('Test Item').closest('tr'); + expect(row).toHaveClass('table-row-clickable'); }); it('handles row click correctly', () => { const onRowClick = vi.fn(); + render( { ); const row = screen.getByText('Test Item').closest('tr'); + fireEvent.click(row!); expect(onRowClick).toHaveBeenCalledWith(mockData); }); it('handles row expansion correctly', () => { const onToggle = vi.fn(); + render( { ); const expandButton = screen.getByRole('button'); + fireEvent.click(expandButton); expect(onToggle).toHaveBeenCalledTimes(1); }); }); describe('Nested Table Row', () => { + let mockNestedRow: Row; + + beforeEach(() => { + mockNestedRow = { + getRowProps: () => ({ + key: 'nested-row-1', + className: 'test-nested-row' + }), + cells: [ + { + column: { + id: 'name', + isVisible: true, + render: () => null, + totalLeft: 0, + totalWidth: 100, + width: 100, + minWidth: 0, + maxWidth: 100, + depth: 0, + parent: undefined, + placeholderOf: undefined, + Header: 'Name', + getHeaderProps: () => ({ key: 'header-name' }), + getFooterProps: () => ({ key: 'footer-name' }), + toggleHidden: () => {}, + getToggleHiddenProps: () => ({}) + }, + value: 'Test Item', + row: {} as Row, + getCellProps: () => ({ key: 'cell-name' }), + render: () => 'Test Item' + }, + { + column: { + id: 'value', + isVisible: true, + render: () => null, + totalLeft: 100, + totalWidth: 100, + width: 100, + minWidth: 0, + maxWidth: 100, + depth: 0, + parent: undefined, + placeholderOf: undefined, + Header: 'Value', + getHeaderProps: () => ({ key: 'header-value' }), + getFooterProps: () => ({ key: 'footer-value' }), + toggleHidden: () => {}, + getToggleHiddenProps: () => ({}) + }, + value: 100, + row: {} as Row, + getCellProps: () => ({ key: 'cell-value' }), + render: () => '100' + } + ], + allCells: [], + values: {}, + index: 0, + original: mockData, + id: 'nested-row-1', + subRows: [] + }; + + // Update the row reference in cells after mockNestedRow is created + mockNestedRow.cells[0].row = mockNestedRow; + mockNestedRow.cells[1].row = mockNestedRow; + }); + it('renders nested row correctly', () => { render( { it('applies correct classes for nested row', () => { render( { ); const row = screen.getByText('Test Item').closest('tr'); + expect(row).toHaveClass('table-row-nested'); }); it('does not trigger click handler for nested rows', () => { const onRowClick = vi.fn(); + render( { ); const row = screen.getByText('Test Item').closest('tr'); + fireEvent.click(row!); expect(onRowClick).not.toHaveBeenCalled(); }); @@ -261,7 +343,7 @@ describe('TableRow', () => { it('applies correct background color based on nesting level', () => { render( { ); const row = screen.getByText('Test Item').closest('tr'); + expect(row).toHaveStyle({ backgroundColor: '#f5f5f5' }); }); }); @@ -298,7 +381,7 @@ describe('TableRow', () => { }); describe('Column Rendering', () => { - const customRenderColumn = (value: string | number, item: DataItem) => ( + const customRenderColumn = (value: string | number) => ( {`Custom ${value}`} );