Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(PHC-4380): row selection to TableModule #333

Merged
merged 2 commits into from
Mar 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 81 additions & 4 deletions src/components/TableModule/TableModule.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.`,
},
},
Expand Down Expand Up @@ -443,3 +448,75 @@ actions toolbar.`,
},
},
};

export const RowSelection: ComponentStory<typeof TableModule> = (args) => {
const tableRef = useRef<HTMLTableElement | null>(null);
const initialRowSelection: RowSelectionState = { '0': true };
const [rowSelection, setRowSelection] = React.useState(initialRowSelection);
const selectionColumn = {
id: 'select',
header: {
content: () => '',
},
cell: {
content: (rowData: any) => (
<Checkbox
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you think it's not too much for this PR, could you add a class to the checkbox here that makes its click area larger for improved UX? Here it is for your reference:

checkboxClass: {
    padding: theme.spacing(1.5),
    margin: `calc(-1 * ${theme.spacing(1.5)})`,
  },

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to add something to the TableModule.stories.tsx but there's no theme so just need to figure out how to use this snippet

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see some other components that leverage a theme in /src/styles. IMO, every table should have the same spacing here as a default and maybe it should be extensible, but I'm guessing most cases will want the basic spacing (or we may want to just enforce this)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a temp solution before deprecating the table in favor of one that leverages tanStack's we can go without this for now

label=" "
checked={rowData.getIsSelected()}
disabled={!rowData.getCanSelect()}
onChange={rowData.getToggleSelectedHandler()}
/>
),
},
};
return (
<TableModule
{...args}
data={data}
config={[
selectionColumn,
{
header: {
label: 'Description',
},
cell: {
valuePath: 'description',
},
},
{
header: {
label: 'Calories',
},
cell: {
valuePath: 'calories',
},
},
{
header: {
label: 'Fat',
},
cell: {
valuePath: 'fat',
},
},
]}
ref={tableRef}
rowClickLabel="row-click-label"
enableRowSelection={true}
state={{ rowSelection }}
onRowSelectionChange={setRowSelection}
/>
);
};

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.`,
},
},
};
26 changes: 13 additions & 13 deletions src/components/TableModule/TableModule.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { log, error } from 'console';

const testId = 'TableModule';

const configWithCellContent: Array<TableConfiguration> = [
const configWithCellContent: Array<TableConfiguration<any>> = [
{
header: {
label: 'Description',
Expand All @@ -37,7 +37,7 @@ const configWithCellContent: Array<TableConfiguration> = [
},
];

const configWithCellValuePath: Array<TableConfiguration> = [
const configWithCellValuePath: Array<TableConfiguration<any>> = [
{
header: {
label: 'Description',
Expand Down Expand Up @@ -125,7 +125,7 @@ test('it renders the provided "noResultsMessage"', async () => {
});

test('it renders columns using "header.label"', async () => {
const config: Array<TableConfiguration> = [
const config: Array<TableConfiguration<any>> = [
{
header: {
label: 'Description',
Expand Down Expand Up @@ -154,7 +154,7 @@ test('it renders columns using "header.label"', async () => {
});

test('it renders columns using "header.content"', async () => {
const config: Array<TableConfiguration> = [
const config: Array<TableConfiguration<any>> = [
{
header: {
content: (header: TableHeader) => {
Expand Down Expand Up @@ -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<TableConfiguration> = [
const config: Array<TableConfiguration<any>> = [
{
header: {
label: 'foo',
Expand Down Expand Up @@ -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<TableConfiguration> = [
const config: Array<TableConfiguration<any>> = [
{
header: {
align: 'right',
Expand Down Expand Up @@ -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<TableConfiguration> = [
const config: Array<TableConfiguration<any>> = [
{
header: {
label: 'Description',
Expand Down Expand Up @@ -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<TableConfiguration> = [
const config: Array<TableConfiguration<any>> = [
{
header: {
label: 'Description',
Expand Down Expand Up @@ -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<TableConfiguration> = [
const config: Array<TableConfiguration<any>> = [
{
header: {
label: 'Description',
Expand Down Expand Up @@ -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<TableConfiguration> = [
const config: Array<TableConfiguration<any>> = [
{
header: {
label: 'Description',
Expand Down Expand Up @@ -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<TableConfiguration> = [
const config: Array<TableConfiguration<any>> = [
{
header: {
label: 'Description',
Expand Down Expand Up @@ -671,7 +671,7 @@ test('it uses the provided "sortState"', async () => {
});

test('it sets isSticky class on all sticky columns', async () => {
const config: Array<TableConfiguration> = configWithStickyColumns;
const config: Array<TableConfiguration<any>> = configWithStickyColumns;

const { findByTestId } = renderWithTheme(
<TableModule data-testid={testId} config={config} data={data} />
Expand Down Expand Up @@ -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<TableConfiguration> = configWithStickyColumns;
const config: Array<TableConfiguration<any>> = configWithStickyColumns;

const { findByTestId } = renderWithTheme(
<TableModule data-testid={testId} config={config} data={data} />
Expand Down
63 changes: 62 additions & 1 deletion src/components/TableModule/TableModule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -227,6 +230,10 @@ export interface TableModuleProps<Item = any>
maxCellWidth?: 1 | 2;
rowActions?: (row: any) => React.ReactNode;
rowClickLabel?: string;
state?: Partial<TableState>;
enableRowSelection?: boolean;
getRowId?: (row: Item, index: number) => string;
onRowSelectionChange?: (state: RowSelectionState) => void;
}

/**
Expand Down Expand Up @@ -263,6 +270,10 @@ export const TableModule = React.memo(
maxCellWidth,
rowActions,
rowClickLabel,
state,
enableRowSelection = false,
getRowId = (_, index) => index.toString(),
onRowSelectionChange,
...rootProps
},
forwardedRef
Expand All @@ -278,6 +289,10 @@ export const TableModule = React.memo(

const [sort, setSort] = React.useState<TableSortState>(sortState);

const [rowSelection, setRowSelection] = React.useState<RowSelectionState>(
state?.rowSelection || {}
);

const [headings, setHeadings] = React.useState<Array<TableHeader>>(
config?.map((c) => c.header) || []
);
Expand Down Expand Up @@ -412,6 +427,51 @@ export const TableModule = React.memo(
}
};

const isRowSelected = (row: any, index: number): boolean => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, double checking that this any (and the following one) are definitely what we want

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 (
<table
role="table"
Expand Down Expand Up @@ -483,14 +543,15 @@ export const TableModule = React.memo(
</tr>
)}
{data?.map((row, rowIndex) => {
const rowData = createRow(row, rowIndex);
return (
<TableModuleRow
key={`tableRow-${rowIndex}`}
data={row}
onRowClick={onRowClick}
rowRole={rowRole}
maxCellWidth={maxCellWidth}
row={row}
row={Object.assign(row, rowData)}
headingsLength={headings?.length}
cells={cells}
rowActions={rowActions}
Expand Down
18 changes: 16 additions & 2 deletions src/components/TableModule/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,26 @@ export interface TableHeader extends TableAlignOptions {

export interface TableCell<Item = any> extends TableAlignOptions {
valuePath?: string;
content?(cell: Item): any;
content?(row: Item): any;
className?: string;
}

export interface TableConfiguration<Item = any> {
export interface TableConfiguration<Item extends RowSelectionRow> {
header: TableHeader;
cell: TableCell<Item>;
isSticky?: boolean;
}

// stanStack table core APIs
export type RowSelectionState = Record<string, boolean>;

export interface TableState {
rowSelection: RowSelectionState;
}

export interface RowSelectionRow {
getIsSelected?: () => boolean;
getCanSelect?: () => boolean;
toggleSelected?: (value?: boolean) => void;
getToggleSelectedHandler?: () => (event: unknown) => void;
}