diff --git a/src/components/TableModule/TableModule.stories.tsx b/src/components/TableModule/TableModule.stories.tsx index 4922b585..5e4eb18d 100644 --- a/src/components/TableModule/TableModule.stories.tsx +++ b/src/components/TableModule/TableModule.stories.tsx @@ -2,8 +2,13 @@ import React, { useRef } from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; import { TableModule } from './TableModule'; -import { TableConfiguration, TableSortClickProps } from './types'; +import { + RowSelectionState, + TableConfiguration, + TableSortClickProps, +} from './types'; import { TableModuleActions } from './TableModuleActions'; +import { Checkbox } from '../Checkbox'; import { IconButton } from '../IconButton'; import { Share, Trash } from '@lifeomic/chromicons'; import { Button } from '../Button'; @@ -259,10 +264,10 @@ Sticky.parameters = { docs: { description: { story: `Columns can be made "sticky" or so they don't travel off-screen when scrolling the - table horizontally. This helps keep track of what row one is looking at in tables with more + table horizontally. This helps keep track of what row one is looking at in tables with more columns than can be visible at one time in the document. \n \n Any number of columns can be made - sticky. They don't have to be consecutive, and can start and end at any column. However, - most common use-cases will likely just involve the first one or two consecutive columns + sticky. They don't have to be consecutive, and can start and end at any column. However, + most common use-cases will likely just involve the first one or two consecutive columns being sticky.`, }, }, @@ -443,3 +448,75 @@ actions toolbar.`, }, }, }; + +export const RowSelection: ComponentStory = (args) => { + const tableRef = useRef(null); + const initialRowSelection: RowSelectionState = { '0': true }; + const [rowSelection, setRowSelection] = React.useState(initialRowSelection); + const selectionColumn = { + id: 'select', + header: { + content: () => '', + }, + cell: { + content: (rowData: any) => ( + + ), + }, + }; + return ( + + ); +}; + +RowSelection.parameters = { + docs: { + description: { + story: `It is a common design pattern to include multi-selection for a + table. The TableModule exposes properties like \`enableRowSelection\` to + enable this capability and use \`state.rowSelection\` to initialize row + selection state. In order to access row selection state, use the \`onRowSelectionChange\` + handler.`, + }, + }, +}; diff --git a/src/components/TableModule/TableModule.test.tsx b/src/components/TableModule/TableModule.test.tsx index 8136af34..d7978b7b 100644 --- a/src/components/TableModule/TableModule.test.tsx +++ b/src/components/TableModule/TableModule.test.tsx @@ -14,7 +14,7 @@ import { log, error } from 'console'; const testId = 'TableModule'; -const configWithCellContent: Array = [ +const configWithCellContent: Array> = [ { header: { label: 'Description', @@ -37,7 +37,7 @@ const configWithCellContent: Array = [ }, ]; -const configWithCellValuePath: Array = [ +const configWithCellValuePath: Array> = [ { header: { label: 'Description', @@ -125,7 +125,7 @@ test('it renders the provided "noResultsMessage"', async () => { }); test('it renders columns using "header.label"', async () => { - const config: Array = [ + const config: Array> = [ { header: { label: 'Description', @@ -154,7 +154,7 @@ test('it renders columns using "header.label"', async () => { }); test('it renders columns using "header.content"', async () => { - const config: Array = [ + const config: Array> = [ { header: { content: (header: TableHeader) => { @@ -228,7 +228,7 @@ test('it renders a table with data using "cell.valuePath"', async () => { }); test('it applies the provided class to a cell with data using "cell.className"', async () => { - const config: Array = [ + const config: Array> = [ { header: { label: 'foo', @@ -261,7 +261,7 @@ test('it applies the provided class to a cell with data using "cell.className"', }); test('it respects the "align" properties on "config"', async () => { - const config: Array = [ + const config: Array> = [ { header: { align: 'right', @@ -310,7 +310,7 @@ test('it respects the "align" properties on "config"', async () => { }); test('it renders a single column table appropriately', async () => { - const config: Array = [ + const config: Array> = [ { header: { label: 'Description', @@ -414,7 +414,7 @@ test('it applies "maxCellWidth=2"', async () => { test('it sorts on column click from no sort => sort asc', async () => { const sortFn = jest.fn(); - const config: Array = [ + const config: Array> = [ { header: { label: 'Description', @@ -468,7 +468,7 @@ test('it sorts on column click from no sort => sort asc', async () => { test('it sorts on column click from no sort => sort asc => sort desc', async () => { const sortFn = jest.fn(); - const config: Array = [ + const config: Array> = [ { header: { label: 'Description', @@ -527,7 +527,7 @@ test('it sorts on column click from no sort => sort asc => sort desc', async () test('it sorts on column click from no sort => sort asc => sort desc => no sort', async () => { const sortFn = jest.fn(); - const config: Array = [ + const config: Array> = [ { header: { label: 'Description', @@ -590,7 +590,7 @@ test('it sorts by first column click, then resets sort when a secondary column i const col1SortFn = jest.fn(); const col2SortFn = jest.fn(); - const config: Array = [ + const config: Array> = [ { header: { label: 'Description', @@ -671,7 +671,7 @@ test('it uses the provided "sortState"', async () => { }); test('it sets isSticky class on all sticky columns', async () => { - const config: Array = configWithStickyColumns; + const config: Array> = configWithStickyColumns; const { findByTestId } = renderWithTheme( @@ -705,7 +705,7 @@ test('it sets isSticky class on all sticky columns', async () => { }); test('it sets "isStickyLast" class on last sticky column', async () => { - const config: Array = configWithStickyColumns; + const config: Array> = configWithStickyColumns; const { findByTestId } = renderWithTheme( diff --git a/src/components/TableModule/TableModule.tsx b/src/components/TableModule/TableModule.tsx index 1e7e4b3c..7e0b98bc 100644 --- a/src/components/TableModule/TableModule.tsx +++ b/src/components/TableModule/TableModule.tsx @@ -8,11 +8,14 @@ import { TableHeaderCell } from './TableHeaderCell'; import { TableModuleRow } from './TableModuleRow'; import { warning } from '../../utils'; import { + RowSelectionState, + RowSelectionRow, TableSortState, TableHeader, TableCell, TableConfiguration, TableSortClickProps, + TableState, } from './types'; import * as React from 'react'; import clsx from 'clsx'; @@ -227,6 +230,10 @@ export interface TableModuleProps maxCellWidth?: 1 | 2; rowActions?: (row: any) => React.ReactNode; rowClickLabel?: string; + state?: Partial; + enableRowSelection?: boolean; + getRowId?: (row: Item, index: number) => string; + onRowSelectionChange?: (state: RowSelectionState) => void; } /** @@ -263,6 +270,10 @@ export const TableModule = React.memo( maxCellWidth, rowActions, rowClickLabel, + state, + enableRowSelection = false, + getRowId = (_, index) => index.toString(), + onRowSelectionChange, ...rootProps }, forwardedRef @@ -278,6 +289,10 @@ export const TableModule = React.memo( const [sort, setSort] = React.useState(sortState); + const [rowSelection, setRowSelection] = React.useState( + state?.rowSelection || {} + ); + const [headings, setHeadings] = React.useState>( config?.map((c) => c.header) || [] ); @@ -412,6 +427,51 @@ export const TableModule = React.memo( } }; + const isRowSelected = (row: any, index: number): boolean => { + return rowSelection[getRowId(row, index)] ?? false; + }; + + const createRow = React.useCallback( + (row: any, index: number): RowSelectionRow => ({ + getIsSelected: () => { + return isRowSelected(row, index); + }, + getCanSelect: () => enableRowSelection, + toggleSelected: (value?: boolean) => { + const isSelected = isRowSelected(row, index); + value = typeof value !== 'undefined' ? value : !isSelected; + + if (isSelected === value) { + return; + } + + const newRowSelection = { ...rowSelection }; + + if (isSelected) { + delete newRowSelection[getRowId(row, index)]; + } else { + newRowSelection[getRowId(row, index)] = true; + } + + setRowSelection(newRowSelection); + + if (onRowSelectionChange) { + onRowSelectionChange(newRowSelection); + } + }, + getToggleSelectedHandler: () => { + return (e: unknown) => { + if (enableRowSelection) { + row.toggleSelected( + ((e as MouseEvent).target as HTMLInputElement).checked + ); + } + }; + }, + }), + [setRowSelection, isRowSelected, onRowSelectionChange] + ); + return ( )} {data?.map((row, rowIndex) => { + const rowData = createRow(row, rowIndex); return ( extends TableAlignOptions { valuePath?: string; - content?(cell: Item): any; + content?(row: Item): any; className?: string; } -export interface TableConfiguration { +export interface TableConfiguration { header: TableHeader; cell: TableCell; isSticky?: boolean; } + +// stanStack table core APIs +export type RowSelectionState = Record; + +export interface TableState { + rowSelection: RowSelectionState; +} + +export interface RowSelectionRow { + getIsSelected?: () => boolean; + getCanSelect?: () => boolean; + toggleSelected?: (value?: boolean) => void; + getToggleSelectedHandler?: () => (event: unknown) => void; +}