diff --git a/change/@fluentui-react-table-ccf8542c-a19a-4fa9-a941-4b5fa851901f.json b/change/@fluentui-react-table-ccf8542c-a19a-4fa9-a941-4b5fa851901f.json new file mode 100644 index 00000000000000..c02a128fd81ef9 --- /dev/null +++ b/change/@fluentui-react-table-ccf8542c-a19a-4fa9-a941-4b5fa851901f.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "BREAKING: refactor `useTable` to be composable", + "packageName": "@fluentui/react-table", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-table/etc/react-table.api.md b/packages/react-components/react-table/etc/react-table.api.md index 32023ee3e96a1b..2f52bd4e0724e5 100644 --- a/packages/react-components/react-table/etc/react-table.api.md +++ b/packages/react-components/react-table/etc/react-table.api.md @@ -27,6 +27,14 @@ export interface ColumnDefinition { // @public (undocumented) export type ColumnId = string | number; +// @public (undocumented) +export interface HeadlessTableState extends Pick, 'items' | 'getRowId'> { + columns: ColumnDefinition[]; + getRows: = RowState>(rowEnhancer?: RowEnhancer) => TRowState[]; + selection: TableSelectionState; + sort: TableSortState; +} + // @public export const renderTable_unstable: (state: TableState, contextValues: TableContextValues) => JSX.Element; @@ -270,7 +278,7 @@ export interface TableSelectionState { clearRows: () => void; deselectRow: (rowId: RowId) => void; isRowSelected: (rowId: RowId) => boolean; - selectedRows: RowId[]; + selectedRows: Set; selectRow: (rowId: RowId) => void; someRowsSelected: boolean; toggleAllRows: () => void; @@ -283,9 +291,10 @@ export type TableSlots = { }; // @public (undocumented) -export interface TableSortState { +export interface TableSortState { getSortDirection: (columnId: ColumnId) => SortDirection | undefined; setColumnSort: (columnId: ColumnId, sortDirection: SortDirection) => void; + sort: (rows: RowState[]) => RowState[]; sortColumn: ColumnId | undefined; sortDirection: SortDirection; toggleColumnSort: (columnId: ColumnId) => void; @@ -295,7 +304,16 @@ export interface TableSortState { export type TableState = ComponentState & Pick, 'size' | 'noNativeElements'> & TableContextValue; // @public (undocumented) -export function useTable = RowState>(options: UseTableOptions): TableState_2; +export type TableStatePlugin = (tableState: HeadlessTableState) => HeadlessTableState; + +// @public (undocumented) +export function useSelection(options: UseSelectionOptions): (tableState: HeadlessTableState) => HeadlessTableState; + +// @public (undocumented) +export function useSort(options: UseSortOptions): (tableState: HeadlessTableState) => HeadlessTableState; + +// @public (undocumented) +export function useTable(options: UseTableOptions, plugins?: TableStatePlugin[]): HeadlessTableState; // @public export const useTable_unstable: (props: TableProps, ref: React_2.Ref) => TableState; @@ -340,23 +358,13 @@ export const useTableHeaderCellStyles_unstable: (state: TableHeaderCellState) => export const useTableHeaderStyles_unstable: (state: TableHeaderState) => TableHeaderState; // @public (undocumented) -export interface UseTableOptions = RowState> { +export interface UseTableOptions { // (undocumented) columns: ColumnDefinition[]; - defaultSelectedRows?: Set; - defaultSortState?: SortState; // (undocumented) getRowId?: (item: TItem) => RowId; // (undocumented) items: TItem[]; - onSelectionChange?: OnSelectionChangeCallback; - onSortChange?: OnSortChangeCallback; - // (undocumented) - rowEnhancer?: RowEnhancer; - selectedRows?: Set; - // (undocumented) - selectionMode?: SelectionMode_2; - sortState?: SortState; } // @public diff --git a/packages/react-components/react-table/src/common/mockTableState.ts b/packages/react-components/react-table/src/common/mockTableState.ts new file mode 100644 index 00000000000000..e32e5aec191f89 --- /dev/null +++ b/packages/react-components/react-table/src/common/mockTableState.ts @@ -0,0 +1,15 @@ +import type { TableState, TableSortState } from '../hooks'; +import { defaultTableSelectionState, defaultTableSortState } from '../hooks'; + +export const mockTableState = (options: Partial> = {}) => { + const mockState: TableState = { + columns: [], + getRows: () => [], + items: [], + selection: defaultTableSelectionState, + sort: defaultTableSortState as TableSortState, + ...options, + }; + + return mockState; +}; diff --git a/packages/react-components/react-table/src/hooks/index.ts b/packages/react-components/react-table/src/hooks/index.ts index f5090f5922fa7e..648de5222c35d0 100644 --- a/packages/react-components/react-table/src/hooks/index.ts +++ b/packages/react-components/react-table/src/hooks/index.ts @@ -1,2 +1,4 @@ export * from './types'; export * from './useTable'; +export * from './useSort'; +export * from './useSelection'; diff --git a/packages/react-components/react-table/src/hooks/types.ts b/packages/react-components/react-table/src/hooks/types.ts index bd5f581722b31a..06fdb272924f6d 100644 --- a/packages/react-components/react-table/src/hooks/types.ts +++ b/packages/react-components/react-table/src/hooks/types.ts @@ -2,10 +2,7 @@ import { SortDirection } from '../components/Table/Table.types'; export type RowId = string | number; export type ColumnId = string | number; -export type GetRowIdInternal = (rowId: TItem, index: number) => RowId; export type SelectionMode = 'single' | 'multiselect'; -export type OnSelectionChangeCallback = (selectedItems: Set) => void; -export type OnSortChangeCallback = (state: { sortColumn: ColumnId | undefined; sortDirection: SortDirection }) => void; export interface SortState { sortColumn: ColumnId | undefined; @@ -19,66 +16,9 @@ export interface ColumnDefinition { export type RowEnhancer = RowState> = ( row: RowState, - state: { selection: TableSelectionState; sort: TableSortState }, ) => TRowState; -export interface TableSortStateInternal { - sortDirection: SortDirection; - sortColumn: ColumnId | undefined; - setColumnSort: (columnId: ColumnId, sortDirection: SortDirection) => void; - toggleColumnSort: (columnId: ColumnId) => void; - getSortDirection: (columnId: ColumnId) => SortDirection | undefined; - /** - * Returns a sorted **shallow** copy of original items - */ - sort: (items: TItem[]) => TItem[]; -} - -export interface UseTableOptions = RowState> { - columns: ColumnDefinition[]; - items: TItem[]; - selectionMode?: SelectionMode; - /** - * Used in uncontrolled mode to set initial selected rows on mount - */ - defaultSelectedRows?: Set; - /** - * Used to control row selection - */ - selectedRows?: Set; - /** - * Called when selection changes - */ - onSelectionChange?: OnSelectionChangeCallback; - /** - * Used to control sorting - */ - sortState?: SortState; - /** - * Used in uncontrolled mode to set initial sort column and direction on mount - */ - defaultSortState?: SortState; - /** - * Called when sort changes - */ - onSortChange?: OnSortChangeCallback; - getRowId?: (item: TItem) => RowId; - rowEnhancer?: RowEnhancer; -} - -export interface TableSelectionStateInternal { - clearRows: () => void; - deselectRow: (rowId: RowId) => void; - selectRow: (rowId: RowId) => void; - toggleAllRows: () => void; - toggleRow: (rowId: RowId) => void; - isRowSelected: (rowId: RowId) => boolean; - selectedRows: Set; - allRowsSelected: boolean; - someRowsSelected: boolean; -} - -export interface TableSortState { +export interface TableSortState { /** * Current sort direction */ @@ -100,6 +40,11 @@ export interface TableSortState { * returns undefined if the column is not sorted */ getSortDirection: (columnId: ColumnId) => SortDirection | undefined; + + /** + * Sorts rows and returns a **shallow** copy of original items + */ + sort: (rows: RowState[]) => RowState[]; } export interface TableSelectionState { @@ -126,7 +71,7 @@ export interface TableSelectionState { /** * Collection of row ids corresponding to selected rows */ - selectedRows: RowId[]; + selectedRows: Set; /** * Whether all rows are selected */ @@ -153,11 +98,14 @@ export interface RowState { rowId: RowId; } -export interface TableState = RowState> { +export interface TableState extends Pick, 'items' | 'getRowId'> { /** * The row data for rendering + * @param rowEnhancer - Enhances the row with extra user data */ - rows: TRowState[]; + getRows: = RowState>( + rowEnhancer?: RowEnhancer, + ) => TRowState[]; /** * State and actions to manage row selection */ @@ -165,5 +113,51 @@ export interface TableState = RowState< /** * State and actions to manage row sorting */ - sort: TableSortState; + sort: TableSortState; + /** + * Table columns + */ + columns: ColumnDefinition[]; +} + +export interface UseSortOptions { + /** + * Used to control sorting + */ + sortState?: SortState; + /** + * Used in uncontrolled mode to set initial sort column and direction on mount + */ + defaultSortState?: SortState; + /** + * Called when sort changes + */ + onSortChange?: (state: SortState) => void; } + +export interface UseSelectionOptions { + /** + * Can be multi or single select + */ + selectionMode: SelectionMode; + /** + * Used in uncontrolled mode to set initial selected rows on mount + */ + defaultSelectedItems?: Set; + /** + * Used to control row selection + */ + selectedItems?: Set; + /** + * Called when selection changes + */ + onSelectionChange?: (selectedItems: Set) => void; +} + +export interface UseTableOptions { + columns: ColumnDefinition[]; + items: TItem[]; + getRowId?: (item: TItem) => RowId; +} + +export type TableStatePlugin = (tableState: TableState) => TableState; diff --git a/packages/react-components/react-table/src/hooks/useSelection.test.ts b/packages/react-components/react-table/src/hooks/useSelection.test.ts index 379100bbbf5332..5f98889b4770ef 100644 --- a/packages/react-components/react-table/src/hooks/useSelection.test.ts +++ b/packages/react-components/react-table/src/hooks/useSelection.test.ts @@ -1,55 +1,61 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useSelection } from './useSelection'; +import { useSelectionState } from './useSelection'; +import { mockTableState } from '../common/mockTableState'; -describe('useSelection', () => { +describe('useSelectionState', () => { const items = [{ value: 'a' }, { value: 'b' }, { value: 'c' }, { value: 'd' }]; - const getRowId = (item: {}, index: number) => index; - it('should use default selected state', () => { const { result } = renderHook(() => - useSelection({ selectionMode: 'multiselect', items, getRowId, defaultSelectedItems: new Set([1]) }), + useSelectionState(mockTableState({ items }), { + selectionMode: 'multiselect', + defaultSelectedItems: new Set([1]), + }), ); - expect(Array.from(result.current.selectedRows)).toEqual([1]); + expect(Array.from(result.current.selection.selectedRows)).toEqual([1]); }); it('should use user selected state', () => { const { result } = renderHook(() => - useSelection({ selectionMode: 'multiselect', items, getRowId, selectedItems: new Set([1]) }), + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect', selectedItems: new Set([1]) }), ); - expect(Array.from(result.current.selectedRows)).toEqual([1]); + expect(Array.from(result.current.selection.selectedRows)).toEqual([1]); }); describe('multiselect', () => { it('should use custom row id', () => { const { result } = renderHook(() => - useSelection({ - selectionMode: 'multiselect', - items, - getRowId: (item: { value: string }) => item.value, - }), + useSelectionState( + mockTableState({ + getRowId: (item: { value: string }) => item.value, + items, + }), + { + selectionMode: 'multiselect', + }, + ), ); act(() => { - result.current.toggleAllRows(); + result.current.selection.toggleAllRows(); }); - expect(Array.from(result.current.selectedRows)).toEqual(items.map(item => item.value)); + expect(Array.from(result.current.selection.selectedRows)).toEqual(items.map(item => item.value)); }); describe('toggleAllRows', () => { it('should select all rows', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect', onSelectionChange }), ); act(() => { - result.current.toggleAllRows(); + result.current.selection.toggleAllRows(); }); - expect(result.current.selectedRows.size).toBe(items.length); - expect(Array.from(result.current.selectedRows)).toEqual(items.map((_, i) => i)); + expect(result.current.selection.selectedRows.size).toBe(items.length); + expect(Array.from(result.current.selection.selectedRows)).toEqual(items.map((_, i) => i)); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onSelectionChange).toHaveBeenCalledWith(new Set([0, 1, 2, 3])); }); @@ -57,18 +63,18 @@ describe('useSelection', () => { it('should deselect all rows', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect', onSelectionChange }), ); act(() => { - result.current.toggleAllRows(); + result.current.selection.toggleAllRows(); }); act(() => { - result.current.toggleAllRows(); + result.current.selection.toggleAllRows(); }); - expect(result.current.selectedRows.size).toBe(0); + expect(result.current.selection.selectedRows.size).toBe(0); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set()); }); @@ -77,17 +83,17 @@ describe('useSelection', () => { it('should clear selection', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect', onSelectionChange }), ); act(() => { - result.current.toggleAllRows(); + result.current.selection.toggleAllRows(); }); act(() => { - result.current.clearRows(); + result.current.selection.clearRows(); }); - expect(result.current.selectedRows.size).toBe(0); + expect(result.current.selection.selectedRows.size).toBe(0); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set()); }); @@ -97,14 +103,14 @@ describe('useSelection', () => { it('should select row', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect', onSelectionChange }), ); act(() => { - result.current.selectRow(1); + result.current.selection.selectRow(1); }); - expect(result.current.selectedRows.has(1)).toBe(true); + expect(result.current.selection.selectedRows.has(1)).toBe(true); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onSelectionChange).toHaveBeenCalledWith(new Set([1])); }); @@ -112,20 +118,20 @@ describe('useSelection', () => { it('should select multiple rows', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect', onSelectionChange }), ); act(() => { - result.current.selectRow(1); + result.current.selection.selectRow(1); }); act(() => { - result.current.selectRow(2); + result.current.selection.selectRow(2); }); - expect(result.current.selectedRows.size).toBe(2); - expect(result.current.selectedRows.has(1)).toBe(true); - expect(result.current.selectedRows.has(2)).toBe(true); + expect(result.current.selection.selectedRows.size).toBe(2); + expect(result.current.selection.selectedRows.has(1)).toBe(true); + expect(result.current.selection.selectedRows.has(2)).toBe(true); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set([1, 2])); }); @@ -135,18 +141,18 @@ describe('useSelection', () => { it('should make row unselected', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect', onSelectionChange }), ); act(() => { - result.current.selectRow(1); + result.current.selection.selectRow(1); }); act(() => { - result.current.deselectRow(1); + result.current.selection.deselectRow(1); }); - expect(result.current.selectedRows.size).toBe(0); + expect(result.current.selection.selectedRows.size).toBe(0); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set()); }); @@ -156,15 +162,15 @@ describe('useSelection', () => { it('should select unselected row', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect', onSelectionChange }), ); act(() => { - result.current.toggleRow(1); + result.current.selection.toggleRow(1); }); - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.selectedRows.has(1)).toBe(true); + expect(result.current.selection.selectedRows.size).toBe(1); + expect(result.current.selection.selectedRows.has(1)).toBe(true); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onSelectionChange).toHaveBeenCalledWith(new Set([1])); }); @@ -172,19 +178,19 @@ describe('useSelection', () => { it('should deselect selected row', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect', onSelectionChange }), ); act(() => { - result.current.toggleRow(1); + result.current.selection.toggleRow(1); }); act(() => { - result.current.toggleRow(1); + result.current.selection.toggleRow(1); }); - expect(result.current.selectedRows.size).toBe(0); - expect(result.current.selectedRows.has(1)).toBe(false); + expect(result.current.selection.selectedRows.size).toBe(0); + expect(result.current.selection.selectedRows.has(1)).toBe(false); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set()); }); @@ -192,20 +198,20 @@ describe('useSelection', () => { it('should select another unselected row', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'multiselect', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect', onSelectionChange }), ); act(() => { - result.current.toggleRow(1); + result.current.selection.toggleRow(1); }); act(() => { - result.current.toggleRow(2); + result.current.selection.toggleRow(2); }); - expect(result.current.selectedRows.size).toBe(2); - expect(result.current.selectedRows.has(1)).toBe(true); - expect(result.current.selectedRows.has(2)).toBe(true); + expect(result.current.selection.selectedRows.size).toBe(2); + expect(result.current.selection.selectedRows.has(1)).toBe(true); + expect(result.current.selection.selectedRows.has(2)).toBe(true); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set([1, 2])); }); @@ -213,55 +219,65 @@ describe('useSelection', () => { describe('allRowsSelected', () => { it('should return true if all rows are selected', () => { - const { result } = renderHook(() => useSelection({ selectionMode: 'multiselect', items, getRowId })); + const { result } = renderHook(() => + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect' }), + ); act(() => { - result.current.toggleAllRows(); + result.current.selection.toggleAllRows(); }); - expect(result.current.allRowsSelected).toBe(true); + expect(result.current.selection.allRowsSelected).toBe(true); }); it('should return false if there is no selected row', () => { - const { result } = renderHook(() => useSelection({ selectionMode: 'multiselect', items, getRowId })); + const { result } = renderHook(() => + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect' }), + ); - expect(result.current.selectedRows.size).toBe(0); - expect(result.current.allRowsSelected).toBe(false); + expect(result.current.selection.selectedRows.size).toBe(0); + expect(result.current.selection.allRowsSelected).toBe(false); }); it('should return false if not all rows are selected', () => { - const { result } = renderHook(() => useSelection({ selectionMode: 'multiselect', items, getRowId })); + const { result } = renderHook(() => + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect' }), + ); act(() => { - result.current.toggleAllRows(); + result.current.selection.toggleAllRows(); }); act(() => { - result.current.deselectRow(1); + result.current.selection.deselectRow(1); }); - expect(result.current.selectedRows.size).toBe(3); - expect(result.current.allRowsSelected).toBe(false); + expect(result.current.selection.selectedRows.size).toBe(3); + expect(result.current.selection.allRowsSelected).toBe(false); }); }); describe('someRowsSelected', () => { it('should return true if there is a selected row', () => { - const { result } = renderHook(() => useSelection({ selectionMode: 'multiselect', items, getRowId })); + const { result } = renderHook(() => + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect' }), + ); act(() => { - result.current.selectRow(1); + result.current.selection.selectRow(1); }); - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.someRowsSelected).toBe(true); + expect(result.current.selection.selectedRows.size).toBe(1); + expect(result.current.selection.someRowsSelected).toBe(true); }); it('should return false if there is no selected row', () => { - const { result } = renderHook(() => useSelection({ selectionMode: 'multiselect', items, getRowId })); + const { result } = renderHook(() => + useSelectionState(mockTableState({ items }), { selectionMode: 'multiselect' }), + ); - expect(result.current.selectedRows.size).toBe(0); - expect(result.current.someRowsSelected).toBe(false); + expect(result.current.selection.selectedRows.size).toBe(0); + expect(result.current.selection.someRowsSelected).toBe(false); }); }); }); @@ -271,10 +287,10 @@ describe('useSelection', () => { it('should throw when not in production', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'single', onSelectionChange }), ); - expect(result.current.toggleAllRows).toThrowErrorMatchingInlineSnapshot( + expect(result.current.selection.toggleAllRows).toThrowErrorMatchingInlineSnapshot( `"[react-table]: \`toggleAllItems\` should not be used in single selection mode"`, ); expect(onSelectionChange).toHaveBeenCalledTimes(0); @@ -284,11 +300,11 @@ describe('useSelection', () => { const nodeEnv = (process.env.NODE_ENV = 'production'); const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'single', onSelectionChange }), ); - result.current.toggleAllRows; - expect(result.current.selectedRows.size).toBe(0); + result.current.selection.toggleAllRows; + expect(result.current.selection.selectedRows.size).toBe(0); expect(onSelectionChange).toHaveBeenCalledTimes(0); process.env.NODE_ENV = nodeEnv; @@ -298,17 +314,17 @@ describe('useSelection', () => { it('should clear selection', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'single', onSelectionChange }), ); act(() => { - result.current.selectRow(1); + result.current.selection.selectRow(1); }); act(() => { - result.current.clearRows(); + result.current.selection.clearRows(); }); - expect(result.current.selectedRows.size).toBe(0); + expect(result.current.selection.selectedRows.size).toBe(0); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set()); }); @@ -318,14 +334,14 @@ describe('useSelection', () => { it('should select row', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'single', onSelectionChange }), ); act(() => { - result.current.selectRow(1); + result.current.selection.selectRow(1); }); - expect(result.current.selectedRows.has(1)).toBe(true); + expect(result.current.selection.selectedRows.has(1)).toBe(true); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onSelectionChange).toHaveBeenCalledWith(new Set([1])); }); @@ -333,19 +349,19 @@ describe('useSelection', () => { it('should select another row', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'single', onSelectionChange }), ); act(() => { - result.current.selectRow(1); + result.current.selection.selectRow(1); }); act(() => { - result.current.selectRow(2); + result.current.selection.selectRow(2); }); - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.selectedRows.has(2)).toBe(true); + expect(result.current.selection.selectedRows.size).toBe(1); + expect(result.current.selection.selectedRows.has(2)).toBe(true); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set([2])); }); @@ -355,18 +371,18 @@ describe('useSelection', () => { it('should make row unselected', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'single', onSelectionChange }), ); act(() => { - result.current.selectRow(1); + result.current.selection.selectRow(1); }); act(() => { - result.current.deselectRow(1); + result.current.selection.deselectRow(1); }); - expect(result.current.selectedRows.size).toBe(0); + expect(result.current.selection.selectedRows.size).toBe(0); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set()); }); @@ -376,15 +392,15 @@ describe('useSelection', () => { it('should select unselected row', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'single', onSelectionChange }), ); act(() => { - result.current.toggleRow(1); + result.current.selection.toggleRow(1); }); - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.selectedRows.has(1)).toBe(true); + expect(result.current.selection.selectedRows.size).toBe(1); + expect(result.current.selection.selectedRows.has(1)).toBe(true); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onSelectionChange).toHaveBeenCalledWith(new Set([1])); }); @@ -392,19 +408,19 @@ describe('useSelection', () => { it('should deselect selected row', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'single', onSelectionChange }), ); act(() => { - result.current.toggleRow(1); + result.current.selection.toggleRow(1); }); act(() => { - result.current.toggleRow(2); + result.current.selection.toggleRow(2); }); - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.selectedRows.has(1)).toBe(false); + expect(result.current.selection.selectedRows.size).toBe(1); + expect(result.current.selection.selectedRows.has(1)).toBe(false); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set([2])); }); @@ -412,20 +428,20 @@ describe('useSelection', () => { it('should select another unselected row', () => { const onSelectionChange = jest.fn(); const { result } = renderHook(() => - useSelection({ selectionMode: 'single', items, getRowId, onSelectionChange }), + useSelectionState(mockTableState({ items }), { selectionMode: 'single', onSelectionChange }), ); act(() => { - result.current.toggleRow(1); + result.current.selection.toggleRow(1); }); act(() => { - result.current.toggleRow(2); + result.current.selection.toggleRow(2); }); - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.selectedRows.has(1)).toBe(false); - expect(result.current.selectedRows.has(2)).toBe(true); + expect(result.current.selection.selectedRows.size).toBe(1); + expect(result.current.selection.selectedRows.has(1)).toBe(false); + expect(result.current.selection.selectedRows.has(2)).toBe(true); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenNthCalledWith(2, new Set([2])); }); @@ -433,41 +449,41 @@ describe('useSelection', () => { describe('allRowsSelected', () => { it('should return true if there is a selected row', () => { - const { result } = renderHook(() => useSelection({ selectionMode: 'single', items, getRowId })); + const { result } = renderHook(() => useSelectionState(mockTableState({ items }), { selectionMode: 'single' })); act(() => { - result.current.selectRow(1); + result.current.selection.selectRow(1); }); - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.allRowsSelected).toBe(true); + expect(result.current.selection.selectedRows.size).toBe(1); + expect(result.current.selection.allRowsSelected).toBe(true); }); it('should return false if there is no selected row', () => { - const { result } = renderHook(() => useSelection({ selectionMode: 'single', items, getRowId })); + const { result } = renderHook(() => useSelectionState(mockTableState({ items }), { selectionMode: 'single' })); - expect(result.current.selectedRows.size).toBe(0); - expect(result.current.allRowsSelected).toBe(false); + expect(result.current.selection.selectedRows.size).toBe(0); + expect(result.current.selection.allRowsSelected).toBe(false); }); }); describe('someRowsSelected', () => { it('should return true if there is a selected row', () => { - const { result } = renderHook(() => useSelection({ selectionMode: 'single', items, getRowId })); + const { result } = renderHook(() => useSelectionState(mockTableState({ items }), { selectionMode: 'single' })); act(() => { - result.current.selectRow(1); + result.current.selection.selectRow(1); }); - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.someRowsSelected).toBe(true); + expect(result.current.selection.selectedRows.size).toBe(1); + expect(result.current.selection.someRowsSelected).toBe(true); }); it('should return false if there is no selected row', () => { - const { result } = renderHook(() => useSelection({ selectionMode: 'single', items, getRowId })); + const { result } = renderHook(() => useSelectionState(mockTableState({ items }), { selectionMode: 'single' })); - expect(result.current.selectedRows.size).toBe(0); - expect(result.current.someRowsSelected).toBe(false); + expect(result.current.selection.selectedRows.size).toBe(0); + expect(result.current.selection.someRowsSelected).toBe(false); }); }); }); diff --git a/packages/react-components/react-table/src/hooks/useSelection.ts b/packages/react-components/react-table/src/hooks/useSelection.ts index d83d59470d637a..0b3dadc35f6ab9 100644 --- a/packages/react-components/react-table/src/hooks/useSelection.ts +++ b/packages/react-components/react-table/src/hooks/useSelection.ts @@ -1,25 +1,34 @@ import * as React from 'react'; import { useControllableState, useEventCallback } from '@fluentui/react-utilities'; import { createSelectionManager } from './selectionManager'; -import type { - GetRowIdInternal, - OnSelectionChangeCallback, - RowId, - SelectionMode, - TableSelectionStateInternal, -} from './types'; +import type { RowId, TableSelectionState, TableState, UseSelectionOptions } from './types'; -interface UseSelectionOptions { - selectionMode: SelectionMode; - items: TItem[]; - getRowId: GetRowIdInternal; - defaultSelectedItems?: Set; - selectedItems?: Set; - onSelectionChange?: OnSelectionChangeCallback; +const noop = () => undefined; + +export const defaultTableSelectionState: TableSelectionState = { + allRowsSelected: false, + clearRows: noop, + deselectRow: noop, + isRowSelected: () => false, + selectRow: noop, + selectedRows: new Set(), + someRowsSelected: false, + toggleAllRows: noop, + toggleRow: noop, +}; + +export function useSelection(options: UseSelectionOptions) { + // False positive, these plugin hooks are intended to be run on every render + // eslint-disable-next-line react-hooks/rules-of-hooks + return (tableState: TableState) => useSelectionState(tableState, options); } -export function useSelection(options: UseSelectionOptions): TableSelectionStateInternal { - const { selectionMode, items, getRowId, defaultSelectedItems, selectedItems, onSelectionChange } = options; +export function useSelectionState( + tableState: TableState, + options: UseSelectionOptions, +): TableState { + const { items, getRowId } = tableState; + const { selectionMode, defaultSelectedItems, selectedItems, onSelectionChange } = options; const [selected, setSelected] = useControllableState({ initialState: new Set(), @@ -36,37 +45,40 @@ export function useSelection(options: UseSelectionOptions): TableS }); }, [onSelectionChange, selectionMode, setSelected]); - const toggleAllRows: TableSelectionStateInternal['toggleAllRows'] = useEventCallback(() => { + const toggleAllRows: TableSelectionState['toggleAllRows'] = useEventCallback(() => { selectionManager.toggleAllItems( - items.map((item, i) => getRowId(item, i)), + items.map((item, i) => getRowId?.(item) ?? i), selected, ); }); - const toggleRow: TableSelectionStateInternal['toggleRow'] = useEventCallback((rowId: RowId) => + const toggleRow: TableSelectionState['toggleRow'] = useEventCallback((rowId: RowId) => selectionManager.toggleItem(rowId, selected), ); - const deselectRow: TableSelectionStateInternal['deselectRow'] = useEventCallback((rowId: RowId) => + const deselectRow: TableSelectionState['deselectRow'] = useEventCallback((rowId: RowId) => selectionManager.deselectItem(rowId, selected), ); - const selectRow: TableSelectionStateInternal['selectRow'] = useEventCallback((rowId: RowId) => + const selectRow: TableSelectionState['selectRow'] = useEventCallback((rowId: RowId) => selectionManager.selectItem(rowId, selected), ); - const isRowSelected: TableSelectionStateInternal['isRowSelected'] = (rowId: RowId) => + const isRowSelected: TableSelectionState['isRowSelected'] = (rowId: RowId) => selectionManager.isSelected(rowId, selected); return { - someRowsSelected: selected.size > 0, - allRowsSelected: selectionMode === 'single' ? selected.size > 0 : selected.size === items.length, - selectedRows: selected, - toggleRow, - toggleAllRows, - clearRows: selectionManager.clearItems, - deselectRow, - selectRow, - isRowSelected, + ...tableState, + selection: { + someRowsSelected: selected.size > 0, + allRowsSelected: selectionMode === 'single' ? selected.size > 0 : selected.size === items.length, + selectedRows: selected, + toggleRow, + toggleAllRows, + clearRows: selectionManager.clearItems, + deselectRow, + selectRow, + isRowSelected, + }, }; } diff --git a/packages/react-components/react-table/src/hooks/useSort.test.ts b/packages/react-components/react-table/src/hooks/useSort.test.ts index a28b06e5db8b92..9637fb2ba5393a 100644 --- a/packages/react-components/react-table/src/hooks/useSort.test.ts +++ b/packages/react-components/react-table/src/hooks/useSort.test.ts @@ -1,39 +1,46 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { ColumnDefinition } from './types'; -import { useSort } from './useSort'; +import { useSortState } from './useSort'; +import { mockTableState } from '../common/mockTableState'; -describe('useSort', () => { +describe('useSortState', () => { it('should use default sort state', () => { const columnDefinition = [{ columnId: 1 }, { columnId: 2 }, { columnId: 3 }]; const { result } = renderHook(() => - useSort({ columns: columnDefinition, defaultSortState: { sortColumn: 2, sortDirection: 'descending' } }), + useSortState(mockTableState({ columns: columnDefinition }), { + defaultSortState: { sortColumn: 2, sortDirection: 'descending' }, + }), ); - expect(result.current.getSortDirection(2)).toBe('descending'); - expect(result.current.sortColumn).toBe(2); + expect(result.current.sort.getSortDirection(2)).toBe('descending'); + expect(result.current.sort.sortColumn).toBe(2); }); it('should use user sort state', () => { const columnDefinition = [{ columnId: 1 }, { columnId: 2 }, { columnId: 3 }]; const { result } = renderHook(() => - useSort({ columns: columnDefinition, sortState: { sortColumn: 2, sortDirection: 'descending' } }), + useSortState(mockTableState({ columns: columnDefinition }), { + sortState: { sortColumn: 2, sortDirection: 'descending' }, + }), ); - expect(result.current.getSortDirection(2)).toBe('descending'); - expect(result.current.sortColumn).toBe(2); + expect(result.current.sort.getSortDirection(2)).toBe('descending'); + expect(result.current.sort.sortColumn).toBe(2); }); describe('toggleColumnSort', () => { it('should sort a new column in ascending order', () => { const columnDefinition = [{ columnId: 1 }, { columnId: 2 }, { columnId: 3 }]; const onSortChange = jest.fn(); - const { result } = renderHook(() => useSort({ columns: columnDefinition, onSortChange })); + const { result } = renderHook(() => + useSortState(mockTableState({ columns: columnDefinition }), { onSortChange }), + ); act(() => { - result.current.toggleColumnSort(1); + result.current.sort.toggleColumnSort(1); }); - expect(result.current.sortColumn).toBe(1); - expect(result.current.sortDirection).toBe('ascending'); + expect(result.current.sort.sortColumn).toBe(1); + expect(result.current.sort.sortDirection).toBe('ascending'); expect(onSortChange).toHaveBeenCalledTimes(1); expect(onSortChange).toHaveBeenCalledWith({ sortColumn: 1, sortDirection: 'ascending' }); }); @@ -41,17 +48,19 @@ describe('useSort', () => { it('should toggle sort direction on a column', () => { const columnDefinition = [{ columnId: 1 }, { columnId: 2 }, { columnId: 3 }]; const onSortChange = jest.fn(); - const { result } = renderHook(() => useSort({ columns: columnDefinition, onSortChange })); + const { result } = renderHook(() => + useSortState(mockTableState({ columns: columnDefinition }), { onSortChange }), + ); act(() => { - result.current.toggleColumnSort(1); + result.current.sort.toggleColumnSort(1); }); act(() => { - result.current.toggleColumnSort(1); + result.current.sort.toggleColumnSort(1); }); - expect(result.current.sortColumn).toBe(1); - expect(result.current.sortDirection).toBe('descending'); + expect(result.current.sort.sortColumn).toBe(1); + expect(result.current.sort.sortDirection).toBe('descending'); expect(onSortChange).toHaveBeenCalledTimes(2); expect(onSortChange).toHaveBeenNthCalledWith(2, { sortColumn: 1, sortDirection: 'descending' }); }); @@ -61,13 +70,15 @@ describe('useSort', () => { it('should sort a column in ascending order', () => { const columnDefinition = [{ columnId: 1 }, { columnId: 2 }, { columnId: 3 }]; const onSortChange = jest.fn(); - const { result } = renderHook(() => useSort({ columns: columnDefinition, onSortChange })); + const { result } = renderHook(() => + useSortState(mockTableState({ columns: columnDefinition }), { onSortChange }), + ); act(() => { - result.current.setColumnSort(1, 'ascending'); + result.current.sort.setColumnSort(1, 'ascending'); }); - expect(result.current.sortColumn).toBe(1); - expect(result.current.sortDirection).toBe('ascending'); + expect(result.current.sort.sortColumn).toBe(1); + expect(result.current.sort.sortDirection).toBe('ascending'); expect(onSortChange).toHaveBeenCalledTimes(1); expect(onSortChange).toHaveBeenCalledWith({ sortColumn: 1, sortDirection: 'ascending' }); }); @@ -75,13 +86,15 @@ describe('useSort', () => { it('should sort a column in descending order', () => { const columnDefinition = [{ columnId: 1 }, { columnId: 2 }, { columnId: 3 }]; const onSortChange = jest.fn(); - const { result } = renderHook(() => useSort({ columns: columnDefinition, onSortChange })); + const { result } = renderHook(() => + useSortState(mockTableState({ columns: columnDefinition }), { onSortChange }), + ); act(() => { - result.current.setColumnSort(1, 'descending'); + result.current.sort.setColumnSort(1, 'descending'); }); - expect(result.current.sortColumn).toBe(1); - expect(result.current.sortDirection).toBe('descending'); + expect(result.current.sort.sortColumn).toBe(1); + expect(result.current.sort.sortDirection).toBe('descending'); expect(onSortChange).toHaveBeenCalledTimes(1); expect(onSortChange).toHaveBeenCalledWith({ sortColumn: 1, sortDirection: 'descending' }); }); @@ -97,12 +110,15 @@ describe('useSort', () => { { columnId: 3, compare: createMockCompare() }, ]; - const { result } = renderHook(() => useSort({ columns: columnDefinition })); + const { result } = renderHook(() => useSortState(mockTableState({ columns: columnDefinition }), {})); act(() => { - result.current.toggleColumnSort(2); + result.current.sort.toggleColumnSort(2); }); - result.current.sort([{}, {}]); + result.current.sort.sort([ + { rowId: 0, item: {} }, + { rowId: 1, item: {} }, + ]); expect(compare).toHaveBeenCalledTimes(1); }); @@ -111,30 +127,45 @@ describe('useSort', () => { { columnId: 1, compare: (a, b) => a.value - b.value }, ]; - const { result } = renderHook(() => useSort({ columns: columnDefinition })); + const { result } = renderHook(() => useSortState(mockTableState({ columns: columnDefinition }), {})); act(() => { - result.current.toggleColumnSort(1); + result.current.sort.toggleColumnSort(1); }); - const sorted = result.current.sort([{ value: 2 }, { value: 1 }]); - expect(sorted).toEqual([{ value: 1 }, { value: 2 }]); + const rows = [ + { rowId: 0, item: { value: 2 } }, + { rowId: 0, item: { value: 1 } }, + ]; + const sorted = result.current.sort.sort(rows); + expect(sorted).toEqual([ + { rowId: 0, item: { value: 1 } }, + { rowId: 0, item: { value: 2 } }, + ]); }); - it('should sort ascending', () => { + it('should sort descending', () => { const columnDefinition: ColumnDefinition<{ value: number }>[] = [ { columnId: 1, compare: (a, b) => a.value - b.value }, ]; - const { result } = renderHook(() => useSort({ columns: columnDefinition })); + const items = [{ value: 1 }, { value: 2 }]; + const { result } = renderHook(() => useSortState(mockTableState({ items, columns: columnDefinition }), {})); act(() => { - result.current.toggleColumnSort(1); + result.current.sort.toggleColumnSort(1); }); act(() => { - result.current.toggleColumnSort(1); + result.current.sort.toggleColumnSort(1); }); - const sorted = result.current.sort([{ value: 1 }, { value: 2 }]); - expect(sorted).toEqual([{ value: 2 }, { value: 1 }]); + const rows = [ + { rowId: 0, item: { value: 1 } }, + { rowId: 0, item: { value: 2 } }, + ]; + const sorted = result.current.sort.sort(rows); + expect(sorted).toEqual([ + { rowId: 0, item: { value: 2 } }, + { rowId: 0, item: { value: 1 } }, + ]); }); }); @@ -142,23 +173,23 @@ describe('useSort', () => { it('should return sort direction for the sorted column', () => { const columnDefinition: ColumnDefinition<{ value: number }>[] = [{ columnId: 1 }]; - const { result } = renderHook(() => useSort({ columns: columnDefinition })); + const { result } = renderHook(() => useSortState(mockTableState({ columns: columnDefinition }), {})); act(() => { - result.current.setColumnSort(1, 'descending'); + result.current.sort.setColumnSort(1, 'descending'); }); - expect(result.current.getSortDirection(1)).toEqual('descending'); + expect(result.current.sort.getSortDirection(1)).toEqual('descending'); }); it('should return undefined for unsorted column', () => { const columnDefinition: ColumnDefinition<{ value: number }>[] = [{ columnId: 1 }, { columnId: 2 }]; - const { result } = renderHook(() => useSort({ columns: columnDefinition })); + const { result } = renderHook(() => useSortState(mockTableState({ columns: columnDefinition }), {})); act(() => { - result.current.setColumnSort(1, 'descending'); + result.current.sort.setColumnSort(1, 'descending'); }); - expect(result.current.getSortDirection(2)).toBeUndefined(); + expect(result.current.sort.getSortDirection(2)).toBeUndefined(); }); }); }); diff --git a/packages/react-components/react-table/src/hooks/useSort.ts b/packages/react-components/react-table/src/hooks/useSort.ts index f4dec5aaf97601..fae66913632d02 100644 --- a/packages/react-components/react-table/src/hooks/useSort.ts +++ b/packages/react-components/react-table/src/hooks/useSort.ts @@ -1,15 +1,26 @@ import { useControllableState } from '@fluentui/react-utilities'; -import type { ColumnDefinition, ColumnId, OnSortChangeCallback, SortState, TableSortStateInternal } from './types'; +import type { ColumnId, RowState, SortState, TableSortState, TableState, UseSortOptions } from './types'; -interface UseSortOptions { - columns: ColumnDefinition[]; - sortState?: SortState; - defaultSortState?: SortState; - onSortChange?: OnSortChangeCallback; +const noop = () => undefined; + +export const defaultTableSortState: TableSortState = { + getSortDirection: () => 'ascending', + setColumnSort: noop, + sort: (rows: RowState[]) => [...rows], + sortColumn: undefined, + sortDirection: 'ascending', + toggleColumnSort: noop, +}; + +export function useSort(options: UseSortOptions) { + // False positive, these plugin hooks are intended to be run on every render + // eslint-disable-next-line react-hooks/rules-of-hooks + return (tableState: TableState) => useSortState(tableState, options); } -export function useSort(options: UseSortOptions): TableSortStateInternal { - const { columns, sortState, defaultSortState, onSortChange } = options; +export function useSortState(tableState: TableState, options: UseSortOptions): TableState { + const { columns } = tableState; + const { sortState, defaultSortState, onSortChange } = options; const [sorted, setSorted] = useControllableState({ initialState: { @@ -36,33 +47,37 @@ export function useSort(options: UseSortOptions): TableSortStateIn }); }; - const setColumnSort: TableSortStateInternal['setColumnSort'] = (nextSortColumn, nextSortDirection) => { + const setColumnSort: TableSortState['setColumnSort'] = (nextSortColumn, nextSortDirection) => { const newState = { sortColumn: nextSortColumn, sortDirection: nextSortDirection }; onSortChange?.(newState); setSorted(newState); }; - const sort = (items: TItem[]) => - items.slice().sort((a, b) => { + const sort = (rows: RowState[]) => { + return rows.slice().sort((a, b) => { const sortColumnDef = columns.find(column => column.columnId === sortColumn); if (!sortColumnDef?.compare) { return 0; } const mod = sortDirection === 'ascending' ? 1 : -1; - return sortColumnDef.compare(a, b) * mod; + return sortColumnDef.compare(a.item, b.item) * mod; }); + }; - const getSortDirection: TableSortStateInternal['getSortDirection'] = (columnId: ColumnId) => { + const getSortDirection: TableSortState['getSortDirection'] = (columnId: ColumnId) => { return sortColumn === columnId ? sortDirection : undefined; }; return { - sortColumn, - sortDirection, - sort, - setColumnSort, - toggleColumnSort, - getSortDirection, + ...tableState, + sort: { + sort, + sortColumn, + sortDirection, + setColumnSort, + toggleColumnSort, + getSortDirection, + }, }; } diff --git a/packages/react-components/react-table/src/hooks/useTable.test.ts b/packages/react-components/react-table/src/hooks/useTable.test.ts index b0f46d7bbee64e..bf8b72b270a165 100644 --- a/packages/react-components/react-table/src/hooks/useTable.test.ts +++ b/packages/react-components/react-table/src/hooks/useTable.test.ts @@ -1,81 +1,50 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { ColumnDefinition } from './types'; +import { renderHook } from '@testing-library/react-hooks'; +import { defaultTableSelectionState, useSelection } from './useSelection'; +import { defaultTableSortState, useSort } from './useSort'; import { useTable } from './useTable'; describe('useTable', () => { - it('should sort data with column compare function in ascending order', () => { - const columns: ColumnDefinition<{ value: number }>[] = [{ columnId: 1, compare: (a, b) => a.value - b.value }]; - const { result } = renderHook(() => - useTable({ - columns, - items: [{ value: 2 }, { value: 3 }, { value: 1 }], - getRowId: item => item.value, - }), - ); - - act(() => { - result.current.sort.toggleColumnSort(1); - }); - - expect(result.current.rows.map(row => row.rowId)).toEqual([1, 2, 3]); - }); - - it('should sort data with column compare function in descending order', () => { - const columns: ColumnDefinition<{ value: number }>[] = [{ columnId: 1, compare: (a, b) => a.value - b.value }]; - const { result } = renderHook(() => - useTable({ - columns, - items: [{ value: 2 }, { value: 3 }, { value: 1 }], - getRowId: item => item.value, - }), - ); - - act(() => { - result.current.sort.toggleColumnSort(1); - }); - - act(() => { - result.current.sort.toggleColumnSort(1); - }); - - expect(result.current.rows.map(row => row.rowId)).toEqual([3, 2, 1]); - }); - - it('should return selection state', () => { + it('should return sort state', () => { const { result } = renderHook(() => - useTable({ - columns: [{ columnId: 1 }], - items: [{}, {}, {}], - }), + useTable( + { + columns: [{ columnId: 1 }], + items: [{}, {}, {}], + }, + [useSort({})], + ), ); - expect(result.current.selection).toMatchInlineSnapshot(` + expect(result.current.sort).not.toBe(defaultTableSortState); + expect(result.current.sort).toMatchInlineSnapshot(` Object { - "allRowsSelected": false, - "clearRows": [Function], - "deselectRow": [Function], - "isRowSelected": [Function], - "selectRow": [Function], - "selectedRows": Array [], - "someRowsSelected": false, - "toggleAllRows": [Function], - "toggleRow": [Function], + "getSortDirection": [Function], + "setColumnSort": [Function], + "sort": [Function], + "sortColumn": undefined, + "sortDirection": "ascending", + "toggleColumnSort": [Function], } `); }); - it('should return sort state', () => { + it('should return selection state', () => { const { result } = renderHook(() => - useTable({ - columns: [{ columnId: 1 }], - items: [{}, {}, {}], - }), + useTable( + { + columns: [{ columnId: 1 }], + items: [{}, {}, {}], + }, + [useSelection({ selectionMode: 'multiselect' })], + ), ); + expect(result.current.sort).not.toBe(defaultTableSelectionState); expect(result.current.sort).toMatchInlineSnapshot(` Object { "getSortDirection": [Function], "setColumnSort": [Function], + "sort": [Function], "sortColumn": undefined, "sortDirection": "ascending", "toggleColumnSort": [Function], @@ -83,46 +52,52 @@ describe('useTable', () => { `); }); - describe('rowEnhancer', () => { + describe('getRows', () => { it('should enahnce rows', () => { const { result } = renderHook(() => useTable({ columns: [{ columnId: 1 }], items: [{}, {}, {}], - rowEnhancer: row => ({ ...row, foo: 'bar' }), - }), - ); - - expect(result.current.rows.map(row => row.foo)).toEqual(['bar', 'bar', 'bar']); - }); - - it('should have access to state', () => { - const { result } = renderHook(() => - useTable({ - columns: [{ columnId: 1 }], - items: [{}, {}, {}], - rowEnhancer: (row, { selection }) => ({ ...row, selectRow: () => selection.selectRow(row.rowId) }), }), ); - act(() => { - result.current.rows[1].selectRow(); - }); - - expect(result.current.selection.isRowSelected(1)); + const rows = result.current.getRows(row => ({ + ...row, + foo: 'bar', + })); + + expect(rows).toMatchInlineSnapshot(` + Array [ + Object { + "foo": "bar", + "item": Object {}, + "rowId": 0, + }, + Object { + "foo": "bar", + "item": Object {}, + "rowId": 1, + }, + Object { + "foo": "bar", + "item": Object {}, + "rowId": 2, + }, + ] + `); }); - }); - describe('rows', () => { - it('should return position index as rowId by default', () => { + it('should use custom rowId', () => { const { result } = renderHook(() => useTable({ columns: [{ columnId: 1 }], - items: [{}, {}, {}], + items: [{ value: 'a' }, { value: 'b' }, { value: 'c' }], + getRowId: item => item.value, }), ); - expect(result.current.rows.map(row => row.rowId)).toEqual([0, 1, 2]); + const rows = result.current.getRows(); + expect(rows.map(row => row.rowId)).toEqual(['a', 'b', 'c']); }); it('should return original items', () => { @@ -133,19 +108,8 @@ describe('useTable', () => { }), ); - expect(result.current.rows.map(row => row.item)).toEqual([{ value: 1 }, { value: 2 }, { value: 3 }]); - }); - - it('should use custom rowId', () => { - const { result } = renderHook(() => - useTable({ - columns: [{ columnId: 1 }], - items: [{ value: 'a' }, { value: 'b' }, { value: 'c' }], - getRowId: item => item.value, - }), - ); - - expect(result.current.rows.map(row => row.rowId)).toEqual(['a', 'b', 'c']); + const rows = result.current.getRows(); + expect(rows.map(row => row.item)).toEqual([{ value: 1 }, { value: 2 }, { value: 3 }]); }); }); }); diff --git a/packages/react-components/react-table/src/hooks/useTable.ts b/packages/react-components/react-table/src/hooks/useTable.ts index 40be03b1cc9c3c..8874dc44684fe8 100644 --- a/packages/react-components/react-table/src/hooks/useTable.ts +++ b/packages/react-components/react-table/src/hooks/useTable.ts @@ -1,114 +1,24 @@ -import * as React from 'react'; -import type { - UseTableOptions, - TableState, - RowState, - TableSelectionState, - TableSortState, - GetRowIdInternal, -} from './types'; -import { useSelection } from './useSelection'; -import { useSort } from './useSort'; +import type { UseTableOptions, TableState, RowState, RowEnhancer, TableStatePlugin, TableSortState } from './types'; +import { defaultTableSelectionState } from './useSelection'; +import { defaultTableSortState } from './useSort'; -export function useTable = RowState>( - options: UseTableOptions, -): TableState { - const { - items: baseItems, - columns, - getRowId: getUserRowId = () => undefined, - selectionMode = 'multiselect', - rowEnhancer = (row: RowState) => row as TRowState, - defaultSelectedRows, - selectedRows: userSelectedRows, - onSelectionChange, - sortState: userSortState, - defaultSortState, - onSortChange, - } = options; - - const getRowId: GetRowIdInternal = React.useCallback( - (item: TItem, index: number) => getUserRowId(item) ?? index, - [getUserRowId], - ); - const { sortColumn, sortDirection, toggleColumnSort, setColumnSort, getSortDirection, sort } = useSort({ - columns, - sortState: userSortState, - defaultSortState, - onSortChange, - }); - const sortState: TableSortState = React.useMemo( - () => ({ - sortColumn, - sortDirection, - setColumnSort, - toggleColumnSort, - getSortDirection, - }), - [sortColumn, sortDirection, setColumnSort, toggleColumnSort, getSortDirection], - ); - - const { - isRowSelected, - toggleRow, - toggleAllRows, - clearRows, - selectedRows, - allRowsSelected, - someRowsSelected, - selectRow, - deselectRow, - } = useSelection({ - selectionMode, - items: baseItems, - getRowId, - defaultSelectedItems: defaultSelectedRows, - selectedItems: userSelectedRows, - onSelectionChange, - }); +const defaultRowEnhancer: RowEnhancer> = row => row; - const selectionState: TableSelectionState = React.useMemo( - () => ({ - isRowSelected, - clearRows, - deselectRow, - selectRow, - toggleAllRows, - toggleRow, - selectedRows: Array.from(selectedRows), - allRowsSelected, - someRowsSelected, - }), - [ - isRowSelected, - clearRows, - deselectRow, - selectRow, - toggleAllRows, - toggleRow, - selectedRows, - allRowsSelected, - someRowsSelected, - ], - ); +export function useTable(options: UseTableOptions, plugins: TableStatePlugin[] = []): TableState { + const { items, getRowId, columns } = options; - const rows = React.useMemo( - () => - sort(baseItems).map((item, i) => { - return rowEnhancer( - { - item, - rowId: getRowId(item, i), - }, - { selection: selectionState, sort: sortState }, - ); - }), - [baseItems, getRowId, sort, rowEnhancer, selectionState, sortState], - ); + const getRows = >( + rowEnhancer = defaultRowEnhancer as RowEnhancer, + ) => items.map((item, i) => rowEnhancer({ item, rowId: getRowId?.(item) ?? i })); - return { - rows, - selection: selectionState, - sort: sortState, + const initialState: TableState = { + getRowId, + items, + columns, + getRows, + selection: defaultTableSelectionState, + sort: defaultTableSortState as TableSortState, }; + + return plugins.reduce((state, plugin) => plugin(state), initialState); } diff --git a/packages/react-components/react-table/src/index.ts b/packages/react-components/react-table/src/index.ts index 850473533de06d..b3f228ead8e691 100644 --- a/packages/react-components/react-table/src/index.ts +++ b/packages/react-components/react-table/src/index.ts @@ -1,11 +1,13 @@ -export { useTable } from './hooks'; +export { useTable, useSelection, useSort } from './hooks'; export type { UseTableOptions, + TableState as HeadlessTableState, TableSelectionState, TableSortState, - ColumnDefinition, + TableStatePlugin, RowState, RowId, + ColumnDefinition, ColumnId, } from './hooks'; diff --git a/packages/react-components/react-table/src/stories/Table/MultipleSelect.stories.tsx b/packages/react-components/react-table/src/stories/Table/MultipleSelect.stories.tsx index 26b03adcca052c..8fa9b95c6d89fb 100644 --- a/packages/react-components/react-table/src/stories/Table/MultipleSelect.stories.tsx +++ b/packages/react-components/react-table/src/stories/Table/MultipleSelect.stories.tsx @@ -19,7 +19,7 @@ import { TableSelectionCell, TableCellLayout, } from '../..'; -import { useTable, ColumnDefinition } from '../../hooks'; +import { useTable, ColumnDefinition, useSelection } from '../../hooks'; import { useNavigationMode } from '../../navigationModes/useNavigationMode'; type FileCell = { @@ -105,23 +105,31 @@ const columns: ColumnDefinition[] = [ export const MultipleSelect = () => { const { - rows, - selection: { allRowsSelected, someRowsSelected, toggleAllRows }, - } = useTable({ - columns, - items, - defaultSelectedRows: new Set([0, 1]), - rowEnhancer: (row, { selection }) => ({ - ...row, - onClick: () => selection.toggleRow(row.rowId), - onKeyDown: (e: React.KeyboardEvent) => { - if (e.key === ' ' || e.key === 'Enter') { - selection.toggleRow(row.rowId); - } - }, - selected: selection.isRowSelected(row.rowId), - }), - }); + getRows, + selection: { allRowsSelected, someRowsSelected, toggleAllRows, toggleRow, isRowSelected }, + } = useTable( + { + columns, + items, + }, + [ + useSelection({ + selectionMode: 'multiselect', + defaultSelectedItems: new Set([0, 1]), + }), + ], + ); + + const rows = getRows(row => ({ + ...row, + onClick: () => toggleRow(row.rowId), + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') { + toggleRow(row.rowId); + } + }, + selected: isRowSelected(row.rowId), + })); // eslint-disable-next-line deprecation/deprecation const ref = useNavigationMode('row'); diff --git a/packages/react-components/react-table/src/stories/Table/MultipleSelectControlled.stories.tsx b/packages/react-components/react-table/src/stories/Table/MultipleSelectControlled.stories.tsx index dbe12ff30fd27e..39be604ac7e1e9 100644 --- a/packages/react-components/react-table/src/stories/Table/MultipleSelectControlled.stories.tsx +++ b/packages/react-components/react-table/src/stories/Table/MultipleSelectControlled.stories.tsx @@ -19,7 +19,7 @@ import { TableSelectionCell, TableCellLayout, } from '../..'; -import { useTable, ColumnDefinition, RowId } from '../../hooks'; +import { useTable, ColumnDefinition, RowId, useSelection } from '../../hooks'; import { useNavigationMode } from '../../navigationModes/useNavigationMode'; type FileCell = { @@ -104,26 +104,37 @@ const columns: ColumnDefinition[] = [ ]; export const MultipleSelectControlled = () => { - const [selectedRows, setSelectedRows] = React.useState(new Set()); + const [selectedRows, setSelectedRows] = React.useState( + () => new Set([0, 1]), + ); + const { - rows, - selection: { allRowsSelected, someRowsSelected, toggleAllRows }, - } = useTable({ - columns, - items, - selectedRows, - onSelectionChange: setSelectedRows, - rowEnhancer: (row, { selection }) => ({ - ...row, - onClick: () => selection.toggleRow(row.rowId), - onKeyDown: (e: React.KeyboardEvent) => { - if (e.key === ' ' || e.key === 'Enter') { - selection.toggleRow(row.rowId); - } - }, - selected: selection.isRowSelected(row.rowId), - }), - }); + getRows, + selection: { allRowsSelected, someRowsSelected, toggleAllRows, toggleRow, isRowSelected }, + } = useTable( + { + columns, + items, + }, + [ + useSelection({ + selectionMode: 'multiselect', + selectedItems: selectedRows, + onSelectionChange: setSelectedRows, + }), + ], + ); + + const rows = getRows(row => ({ + ...row, + onClick: () => toggleRow(row.rowId), + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') { + toggleRow(row.rowId); + } + }, + selected: isRowSelected(row.rowId), + })); // eslint-disable-next-line deprecation/deprecation const ref = useNavigationMode('row'); diff --git a/packages/react-components/react-table/src/stories/Table/SingleSelect.stories.tsx b/packages/react-components/react-table/src/stories/Table/SingleSelect.stories.tsx index c488d00e1c57d8..133fe865f0b612 100644 --- a/packages/react-components/react-table/src/stories/Table/SingleSelect.stories.tsx +++ b/packages/react-components/react-table/src/stories/Table/SingleSelect.stories.tsx @@ -10,7 +10,7 @@ import { } from '@fluentui/react-icons'; import { PresenceBadgeStatus, Avatar } from '@fluentui/react-components'; import { TableBody, TableCell, TableRow, Table, TableHeader, TableHeaderCell, TableSelectionCell } from '../..'; -import { useTable, ColumnDefinition } from '../../hooks'; +import { useTable, ColumnDefinition, useSelection } from '../../hooks'; import { useNavigationMode } from '../../navigationModes/useNavigationMode'; import { TableCellLayout } from '../../components/TableCellLayout/TableCellLayout'; @@ -96,22 +96,32 @@ const columns: ColumnDefinition[] = [ ]; export const SingleSelect = () => { - const { rows } = useTable({ - columns, - items, - selectionMode: 'single', - defaultSelectedRows: new Set([1]), - rowEnhancer: (row, { selection }) => ({ - ...row, - selected: selection.isRowSelected(row.rowId), - onClick: () => selection.toggleRow(row.rowId), - onKeyDown: (e: React.KeyboardEvent) => { - if (e.key === ' ' || e.key === 'Enter') { - selection.toggleRow(row.rowId); - } - }, - }), - }); + const { + getRows, + selection: { toggleRow, isRowSelected }, + } = useTable( + { + columns, + items, + }, + [ + useSelection({ + selectionMode: 'single', + defaultSelectedItems: new Set([1]), + }), + ], + ); + + const rows = getRows(row => ({ + ...row, + onClick: () => toggleRow(row.rowId), + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') { + toggleRow(row.rowId); + } + }, + selected: isRowSelected(row.rowId), + })); // eslint-disable-next-line deprecation/deprecation const ref = useNavigationMode('row'); diff --git a/packages/react-components/react-table/src/stories/Table/SingleSelectControlled.stories.tsx b/packages/react-components/react-table/src/stories/Table/SingleSelectControlled.stories.tsx index 95aea34239042f..83049228284d7c 100644 --- a/packages/react-components/react-table/src/stories/Table/SingleSelectControlled.stories.tsx +++ b/packages/react-components/react-table/src/stories/Table/SingleSelectControlled.stories.tsx @@ -10,7 +10,7 @@ import { } from '@fluentui/react-icons'; import { PresenceBadgeStatus, Avatar } from '@fluentui/react-components'; import { TableBody, TableCell, TableRow, Table, TableHeader, TableHeaderCell, TableSelectionCell } from '../..'; -import { useTable, ColumnDefinition, RowId } from '../../hooks'; +import { useTable, ColumnDefinition, RowId, useSelection } from '../../hooks'; import { useNavigationMode } from '../../navigationModes/useNavigationMode'; import { TableCellLayout } from '../../components/TableCellLayout/TableCellLayout'; @@ -96,24 +96,36 @@ const columns: ColumnDefinition[] = [ ]; export const SingleSelectControlled = () => { - const [selectedRows, setSelectedRows] = React.useState(new Set()); - const { rows } = useTable({ - columns, - items, - selectionMode: 'single', - selectedRows, - onSelectionChange: setSelectedRows, - rowEnhancer: (row, { selection }) => ({ - ...row, - selected: selection.isRowSelected(row.rowId), - onClick: () => selection.toggleRow(row.rowId), - onKeyDown: (e: React.KeyboardEvent) => { - if (e.key === ' ' || e.key === 'Enter') { - selection.toggleRow(row.rowId); - } - }, - }), - }); + const [selectedRows, setSelectedRows] = React.useState( + () => new Set([1]), + ); + const { + getRows, + selection: { toggleRow, isRowSelected }, + } = useTable( + { + columns, + items, + }, + [ + useSelection({ + selectionMode: 'single', + selectedItems: selectedRows, + onSelectionChange: setSelectedRows, + }), + ], + ); + + const rows = getRows(row => ({ + ...row, + onClick: () => toggleRow(row.rowId), + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') { + toggleRow(row.rowId); + } + }, + selected: isRowSelected(row.rowId), + })); // eslint-disable-next-line deprecation/deprecation const ref = useNavigationMode('row'); diff --git a/packages/react-components/react-table/src/stories/Table/Sort.stories.tsx b/packages/react-components/react-table/src/stories/Table/Sort.stories.tsx index b8de8eb98eadf8..3523bbdc2b6f5f 100644 --- a/packages/react-components/react-table/src/stories/Table/Sort.stories.tsx +++ b/packages/react-components/react-table/src/stories/Table/Sort.stories.tsx @@ -10,7 +10,7 @@ import { } from '@fluentui/react-icons'; import { PresenceBadgeStatus, Avatar } from '@fluentui/react-components'; import { TableBody, TableCell, TableRow, Table, TableHeader, TableHeaderCell } from '../..'; -import { useTable, ColumnDefinition, ColumnId } from '../../hooks'; +import { useTable, ColumnDefinition, ColumnId, useSort } from '../../hooks'; import { TableCellLayout } from '../../components/TableCellLayout/TableCellLayout'; type FileCell = { @@ -108,9 +108,15 @@ const columns: ColumnDefinition[] = [ export const Sort = () => { const { - rows, - sort: { getSortDirection, toggleColumnSort }, - } = useTable({ columns, items, defaultSortState: { sortColumn: 'file', sortDirection: 'ascending' } }); + getRows, + sort: { getSortDirection, toggleColumnSort, sort }, + } = useTable( + { + columns, + items, + }, + [useSort({ defaultSortState: { sortColumn: 'file', sortDirection: 'ascending' } })], + ); const headerSortProps = (columnId: ColumnId) => ({ onClick: () => { @@ -119,6 +125,8 @@ export const Sort = () => { sortDirection: getSortDirection(columnId), }); + const rows = sort(getRows()); + return ( diff --git a/packages/react-components/react-table/src/stories/Table/SortControlled.stories.tsx b/packages/react-components/react-table/src/stories/Table/SortControlled.stories.tsx index 22ca7cdc51aa86..862367ad19f1c2 100644 --- a/packages/react-components/react-table/src/stories/Table/SortControlled.stories.tsx +++ b/packages/react-components/react-table/src/stories/Table/SortControlled.stories.tsx @@ -10,7 +10,7 @@ import { } from '@fluentui/react-icons'; import { PresenceBadgeStatus, Avatar } from '@fluentui/react-components'; import { TableBody, TableCell, TableRow, Table, TableHeader, TableHeaderCell } from '../..'; -import { useTable, ColumnDefinition, ColumnId, SortState } from '../../hooks'; +import { useTable, ColumnDefinition, ColumnId, SortState, useSort } from '../../hooks'; import { TableCellLayout } from '../../components/TableCellLayout/TableCellLayout'; type FileCell = { @@ -113,15 +113,23 @@ export const SortControlled = () => { }); const { - rows, - sort: { getSortDirection, toggleColumnSort }, - } = useTable({ columns, items, sortState, onSortChange: setSortState }); + getRows, + sort: { getSortDirection, toggleColumnSort, sort }, + } = useTable( + { + columns, + items, + }, + [useSort({ sortState, onSortChange: setSortState })], + ); const headerSortProps = (columnId: ColumnId) => ({ onClick: () => toggleColumnSort(columnId), sortDirection: getSortDirection(columnId), }); + const rows = sort(getRows()); + return (