From 02a6a76a3350adfb4fd95cdfc178864801c8e2bf Mon Sep 17 00:00:00 2001 From: ling1726 Date: Wed, 17 Aug 2022 14:56:16 +0200 Subject: [PATCH] feat(useTable): selection manager to avoid calling multiple hooks (#24377) * feat(useTable): selection manager to avoid calling multiple hooks Implements a `createSelectionManager` function to manage selection state independently of react and avoids calling state hooks twice on each render * changefile * pr suggestions * add node env test * deSelect to deselect * update md --- ...-8af09ced-5374-4d8e-ab4f-6fd3d444e0e6.json | 7 + .../react-table/etc/react-table.api.md | 10 +- .../react-table/src/hooks/selectionManager.ts | 113 ++++++ .../react-table/src/hooks/types.ts | 19 +- .../src/hooks/useMultipleSelection.test.ts | 199 ---------- .../src/hooks/useMultipleSelection.ts | 83 ---- .../src/hooks/useSelection.test.ts | 364 ++++++++++++++++++ .../react-table/src/hooks/useSelection.ts | 38 +- .../src/hooks/useSingleSelection.test.ts | 157 -------- .../src/hooks/useSingleSelection.ts | 32 -- .../react-table/src/hooks/useTable.test.ts | 8 +- .../react-table/src/hooks/useTable.ts | 24 +- .../stories/Table/MultipleSelect.stories.tsx | 10 +- .../stories/Table/SingleSelect.stories.tsx | 6 +- 14 files changed, 554 insertions(+), 516 deletions(-) create mode 100644 change/@fluentui-react-table-8af09ced-5374-4d8e-ab4f-6fd3d444e0e6.json create mode 100644 packages/react-components/react-table/src/hooks/selectionManager.ts delete mode 100644 packages/react-components/react-table/src/hooks/useMultipleSelection.test.ts delete mode 100644 packages/react-components/react-table/src/hooks/useMultipleSelection.ts create mode 100644 packages/react-components/react-table/src/hooks/useSelection.test.ts delete mode 100644 packages/react-components/react-table/src/hooks/useSingleSelection.test.ts delete mode 100644 packages/react-components/react-table/src/hooks/useSingleSelection.ts diff --git a/change/@fluentui-react-table-8af09ced-5374-4d8e-ab4f-6fd3d444e0e6.json b/change/@fluentui-react-table-8af09ced-5374-4d8e-ab4f-6fd3d444e0e6.json new file mode 100644 index 0000000000000..8a47982700f33 --- /dev/null +++ b/change/@fluentui-react-table-8af09ced-5374-4d8e-ab4f-6fd3d444e0e6.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "refactor(useTable): selection manager to avoid calling multiple hooks", + "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 1f17dc618949f..f80ec68a76cd5 100644 --- a/packages/react-components/react-table/etc/react-table.api.md +++ b/packages/react-components/react-table/etc/react-table.api.md @@ -70,14 +70,14 @@ export interface RowState { // @public (undocumented) export interface SelectionState { allRowsSelected: boolean; - clearSelection: () => void; - deSelectRow: (rowId: RowId) => void; + clearRows: () => void; + deselectRow: (rowId: RowId) => void; isRowSelected: (rowId: RowId) => boolean; selectedRows: RowId[]; selectRow: (rowId: RowId) => void; someRowsSelected: boolean; - toggleRowSelect: (rowId: RowId) => void; - toggleSelectAllRows: () => void; + toggleAllRows: () => void; + toggleRow: (rowId: RowId) => void; } // @public (undocumented) @@ -349,7 +349,7 @@ export interface UseTableOptions = RowS // (undocumented) rowEnhancer?: RowEnhancer; // (undocumented) - selectionMode?: 'single' | 'multiselect'; + selectionMode?: SelectionMode_2; } // @public diff --git a/packages/react-components/react-table/src/hooks/selectionManager.ts b/packages/react-components/react-table/src/hooks/selectionManager.ts new file mode 100644 index 0000000000000..87fa5a9b2c40c --- /dev/null +++ b/packages/react-components/react-table/src/hooks/selectionManager.ts @@ -0,0 +1,113 @@ +import { SelectionMode } from './types'; + +type OnSelectionChangeCallback = (selectedItems: Set) => void; + +export interface SelectionManager { + toggleItem(id: SelectionItemId): void; + selectItem(id: SelectionItemId): void; + deselectItem(id: SelectionItemId): void; + clearItems(): void; + isSelected(id: SelectionItemId): boolean; + toggleAllItems(itemIds: SelectionItemId[]): void; +} + +export type SelectionItemId = string | number; + +export function createSelectionManager( + mode: SelectionMode, + onSelectionChange: OnSelectionChangeCallback = () => undefined, +): SelectionManager { + const managerFactory = mode === 'multiselect' ? createMultipleSelectionManager : createSingleSelectionManager; + + return managerFactory(onSelectionChange); +} + +function createMultipleSelectionManager(onSelectionChange: OnSelectionChangeCallback): SelectionManager { + const selectedItems = new Set(); + const toggleAllItems = (itemIds: SelectionItemId[]) => { + const allItemsSelected = itemIds.every(itemId => selectedItems.has(itemId)); + + if (allItemsSelected) { + selectedItems.clear(); + } else { + itemIds.forEach(itemId => selectedItems.add(itemId)); + } + + onSelectionChange(new Set(selectedItems)); + }; + + const toggleItem = (itemId: SelectionItemId) => { + if (selectedItems.has(itemId)) { + selectedItems.delete(itemId); + } else { + selectedItems.add(itemId); + } + + onSelectionChange(new Set(selectedItems)); + }; + + const selectItem = (itemId: SelectionItemId) => { + selectedItems.add(itemId); + onSelectionChange(new Set(selectedItems)); + }; + + const deselectItem = (itemId: SelectionItemId) => { + selectedItems.delete(itemId); + onSelectionChange(new Set(selectedItems)); + }; + + const clearItems = () => { + selectedItems.clear(); + onSelectionChange(new Set(selectedItems)); + }; + + const isSelected = (itemId: SelectionItemId) => { + return selectedItems.has(itemId); + }; + + return { + toggleItem, + selectItem, + deselectItem, + clearItems, + isSelected, + toggleAllItems, + }; +} + +function createSingleSelectionManager(onSelectionChange: OnSelectionChangeCallback): SelectionManager { + let selectedItem: SelectionItemId | undefined = undefined; + const toggleItem = (itemId: SelectionItemId) => { + selectedItem = itemId; + onSelectionChange(new Set([selectedItem])); + }; + + const clearItems = () => { + selectedItem = undefined; + onSelectionChange(new Set()); + }; + + const isSelected = (itemId: SelectionItemId) => { + return selectedItem === itemId; + }; + + const selectItem = (itemId: SelectionItemId) => { + selectedItem = itemId; + onSelectionChange(new Set([selectedItem])); + }; + + return { + deselectItem: clearItems, + selectItem, + toggleAllItems: () => { + if (process.env.NODE_ENV !== 'production') { + throw new Error('[react-table]: `toggleAllItems` should not be used in single selection mode'); + } + + return undefined; + }, + toggleItem, + clearItems, + isSelected, + }; +} diff --git a/packages/react-components/react-table/src/hooks/types.ts b/packages/react-components/react-table/src/hooks/types.ts index 8bd90f8929d84..324ec6950eed7 100644 --- a/packages/react-components/react-table/src/hooks/types.ts +++ b/packages/react-components/react-table/src/hooks/types.ts @@ -3,6 +3,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 interface ColumnDefinition { columnId: ColumnId; @@ -29,17 +30,17 @@ export interface SortStateInternal { export interface UseTableOptions = RowState> { columns: ColumnDefinition[]; items: TItem[]; - selectionMode?: 'single' | 'multiselect'; + selectionMode?: SelectionMode; getRowId?: (item: TItem) => RowId; rowEnhancer?: RowEnhancer; } export interface SelectionStateInternal { - clearSelection: () => void; - deSelectRow: (rowId: RowId) => void; + clearRows: () => void; + deselectRow: (rowId: RowId) => void; selectRow: (rowId: RowId) => void; - toggleSelectAllRows: () => void; - toggleRowSelect: (rowId: RowId) => void; + toggleAllRows: () => void; + toggleRow: (rowId: RowId) => void; isRowSelected: (rowId: RowId) => boolean; selectedRows: Set; allRowsSelected: boolean; @@ -74,7 +75,7 @@ export interface SelectionState { /** * Clears all selected rows */ - clearSelection: () => void; + clearRows: () => void; /** * Selects single row */ @@ -82,15 +83,15 @@ export interface SelectionState { /** * De-selects single row */ - deSelectRow: (rowId: RowId) => void; + deselectRow: (rowId: RowId) => void; /** * Toggle selection of all rows */ - toggleSelectAllRows: () => void; + toggleAllRows: () => void; /** * Toggle selection of single row */ - toggleRowSelect: (rowId: RowId) => void; + toggleRow: (rowId: RowId) => void; /** * Collection of row ids corresponding to selected rows */ diff --git a/packages/react-components/react-table/src/hooks/useMultipleSelection.test.ts b/packages/react-components/react-table/src/hooks/useMultipleSelection.test.ts deleted file mode 100644 index 70125f694b502..0000000000000 --- a/packages/react-components/react-table/src/hooks/useMultipleSelection.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { useMultipleSelection } from './useMultipleSelection'; - -describe('useMultipleSelection', () => { - const items = [{ value: 'a' }, { value: 'b' }, { value: 'c' }, { value: 'd' }]; - - const getRowId = (item: {}, index: number) => index; - - it('should use custom row id', () => { - const { result } = renderHook(() => useMultipleSelection(items, (item: { value: string }) => item.value)); - act(() => { - result.current.toggleSelectAllRows(); - }); - - expect(Array.from(result.current.selectedRows)).toEqual(items.map(item => item.value)); - }); - - describe('toggleSelectAllRows', () => { - it('should select all rows', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - act(() => { - result.current.toggleSelectAllRows(); - }); - expect(result.current.selectedRows.size).toBe(items.length); - expect(Array.from(result.current.selectedRows)).toEqual(items.map((_, i) => i)); - }); - - it('should deSelect all rows', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - act(() => { - result.current.toggleSelectAllRows(); - }); - - act(() => { - result.current.toggleSelectAllRows(); - }); - - expect(result.current.selectedRows.size).toBe(0); - }); - }); - describe('clearSelection', () => { - it('should clear selection', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - act(() => { - result.current.toggleSelectAllRows(); - }); - act(() => { - result.current.clearSelection(); - }); - - expect(result.current.selectedRows.size).toBe(0); - }); - }); - - describe('selectRow', () => { - it('should select row', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - act(() => { - result.current.selectRow(1); - }); - - expect(result.current.selectedRows.has(1)).toBe(true); - }); - - it('should select multiple rows', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - act(() => { - result.current.selectRow(1); - }); - - act(() => { - result.current.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); - }); - }); - - describe('deSelectRow', () => { - it('should make row unselected', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - act(() => { - result.current.selectRow(1); - }); - - act(() => { - result.current.deSelectRow(1); - }); - - expect(result.current.selectedRows.size).toBe(0); - }); - }); - - describe('toggleRowSelect', () => { - it('should select unselected row', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - act(() => { - result.current.toggleRowSelect(1); - }); - - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.selectedRows.has(1)).toBe(true); - }); - - it('should deselect selected row', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - act(() => { - result.current.toggleRowSelect(1); - }); - - act(() => { - result.current.toggleRowSelect(1); - }); - - expect(result.current.selectedRows.size).toBe(0); - expect(result.current.selectedRows.has(1)).toBe(false); - }); - - it('should select another unselected row', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - act(() => { - result.current.toggleRowSelect(1); - }); - - act(() => { - result.current.toggleRowSelect(2); - }); - - expect(result.current.selectedRows.size).toBe(2); - expect(result.current.selectedRows.has(1)).toBe(true); - expect(result.current.selectedRows.has(2)).toBe(true); - }); - }); - - describe('allRowsSelected', () => { - it('should return true if all rows are selected', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - act(() => { - result.current.toggleSelectAllRows(); - }); - - expect(result.current.allRowsSelected).toBe(true); - }); - - it('should return false if there is no selected row', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - expect(result.current.selectedRows.size).toBe(0); - expect(result.current.allRowsSelected).toBe(false); - }); - - it('should return false if not all rows are selected', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - act(() => { - result.current.toggleSelectAllRows(); - }); - - act(() => { - result.current.deSelectRow(1); - }); - - expect(result.current.selectedRows.size).toBe(3); - expect(result.current.allRowsSelected).toBe(false); - }); - }); - - describe('someRowsSelected', () => { - it('should return true if there is a selected row', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - act(() => { - result.current.selectRow(1); - }); - - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.someRowsSelected).toBe(true); - }); - - it('should return false if there is no selected row', () => { - const { result } = renderHook(() => useMultipleSelection(items, getRowId)); - - expect(result.current.selectedRows.size).toBe(0); - expect(result.current.someRowsSelected).toBe(false); - }); - }); -}); diff --git a/packages/react-components/react-table/src/hooks/useMultipleSelection.ts b/packages/react-components/react-table/src/hooks/useMultipleSelection.ts deleted file mode 100644 index e38df8401d7fa..0000000000000 --- a/packages/react-components/react-table/src/hooks/useMultipleSelection.ts +++ /dev/null @@ -1,83 +0,0 @@ -import * as React from 'react'; -import { useEventCallback } from '@fluentui/react-utilities'; -import type { SelectionStateInternal, RowId, GetRowIdInternal } from './types'; - -export function useMultipleSelection(items: TItem[], getRowId: GetRowIdInternal): SelectionStateInternal { - const [selected, setSelected] = React.useState(new Set()); - const allRowIds = React.useMemo(() => new Set(items.map((item, i) => getRowId(item, i))), [items, getRowId]); - - const toggleSelectAllRows: SelectionStateInternal['toggleSelectAllRows'] = useEventCallback(() => { - setSelected(s => { - if (s.size === items.length) { - return new Set(); - } - - return new Set(items.map((item, i) => getRowId(item, i))); - }); - }); - - const toggleRowSelect: SelectionStateInternal['toggleRowSelect'] = React.useCallback( - (rowId: RowId) => { - if (!allRowIds.has(rowId)) { - return; - } - - setSelected(s => { - const newState = new Set(s); - if (newState.has(rowId)) { - newState.delete(rowId); - } else { - newState.add(rowId); - } - - return newState; - }); - }, - [allRowIds], - ); - - const selectRow: SelectionStateInternal['selectRow'] = React.useCallback( - (rowId: RowId) => { - if (!allRowIds.has(rowId)) { - return; - } - setSelected(s => { - const newState = new Set(s); - newState.add(rowId); - return newState; - }); - }, - [allRowIds], - ); - - const deSelectRow: SelectionStateInternal['selectRow'] = React.useCallback((rowId: RowId) => { - setSelected(s => { - const newState = new Set(s); - newState.delete(rowId); - return newState; - }); - }, []); - - const clearSelection: SelectionStateInternal['clearSelection'] = React.useCallback(() => { - setSelected(new Set()); - }, []); - - const isRowSelected: SelectionStateInternal['isRowSelected'] = React.useCallback( - (rowId: RowId) => { - return selected.has(rowId); - }, - [selected], - ); - - return { - isRowSelected, - clearSelection, - deSelectRow, - selectRow, - toggleRowSelect, - toggleSelectAllRows, - selectedRows: selected, - allRowsSelected: selected.size === items.length, - someRowsSelected: selected.size > 0, - }; -} diff --git a/packages/react-components/react-table/src/hooks/useSelection.test.ts b/packages/react-components/react-table/src/hooks/useSelection.test.ts new file mode 100644 index 0000000000000..768a8d318b6ff --- /dev/null +++ b/packages/react-components/react-table/src/hooks/useSelection.test.ts @@ -0,0 +1,364 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelection } from './useSelection'; + +describe('useSelection', () => { + const items = [{ value: 'a' }, { value: 'b' }, { value: 'c' }, { value: 'd' }]; + + const getRowId = (item: {}, index: number) => index; + + describe('multiselect', () => { + it('should use custom row id', () => { + const { result } = renderHook(() => useSelection('multiselect', items, (item: { value: string }) => item.value)); + act(() => { + result.current.toggleAllRows(); + }); + + expect(Array.from(result.current.selectedRows)).toEqual(items.map(item => item.value)); + }); + + describe('toggleAllRows', () => { + it('should select all rows', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + act(() => { + result.current.toggleAllRows(); + }); + expect(result.current.selectedRows.size).toBe(items.length); + expect(Array.from(result.current.selectedRows)).toEqual(items.map((_, i) => i)); + }); + + it('should deselect all rows', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + act(() => { + result.current.toggleAllRows(); + }); + + act(() => { + result.current.toggleAllRows(); + }); + + expect(result.current.selectedRows.size).toBe(0); + }); + }); + describe('clearRows', () => { + it('should clear selection', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + act(() => { + result.current.toggleAllRows(); + }); + act(() => { + result.current.clearRows(); + }); + + expect(result.current.selectedRows.size).toBe(0); + }); + }); + + describe('selectRow', () => { + it('should select row', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + act(() => { + result.current.selectRow(1); + }); + + expect(result.current.selectedRows.has(1)).toBe(true); + }); + + it('should select multiple rows', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + act(() => { + result.current.selectRow(1); + }); + + act(() => { + result.current.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); + }); + }); + + describe('deselectRow', () => { + it('should make row unselected', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + act(() => { + result.current.selectRow(1); + }); + + act(() => { + result.current.deselectRow(1); + }); + + expect(result.current.selectedRows.size).toBe(0); + }); + }); + + describe('toggleRow', () => { + it('should select unselected row', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + act(() => { + result.current.toggleRow(1); + }); + + expect(result.current.selectedRows.size).toBe(1); + expect(result.current.selectedRows.has(1)).toBe(true); + }); + + it('should deselect selected row', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + act(() => { + result.current.toggleRow(1); + }); + + act(() => { + result.current.toggleRow(1); + }); + + expect(result.current.selectedRows.size).toBe(0); + expect(result.current.selectedRows.has(1)).toBe(false); + }); + + it('should select another unselected row', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + act(() => { + result.current.toggleRow(1); + }); + + act(() => { + result.current.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); + }); + }); + + describe('allRowsSelected', () => { + it('should return true if all rows are selected', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + act(() => { + result.current.toggleAllRows(); + }); + + expect(result.current.allRowsSelected).toBe(true); + }); + + it('should return false if there is no selected row', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + expect(result.current.selectedRows.size).toBe(0); + expect(result.current.allRowsSelected).toBe(false); + }); + + it('should return false if not all rows are selected', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + act(() => { + result.current.toggleAllRows(); + }); + + act(() => { + result.current.deselectRow(1); + }); + + expect(result.current.selectedRows.size).toBe(3); + expect(result.current.allRowsSelected).toBe(false); + }); + }); + + describe('someRowsSelected', () => { + it('should return true if there is a selected row', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + act(() => { + result.current.selectRow(1); + }); + + expect(result.current.selectedRows.size).toBe(1); + expect(result.current.someRowsSelected).toBe(true); + }); + + it('should return false if there is no selected row', () => { + const { result } = renderHook(() => useSelection('multiselect', items, getRowId)); + + expect(result.current.selectedRows.size).toBe(0); + expect(result.current.someRowsSelected).toBe(false); + }); + }); + }); + + describe('single select', () => { + describe('toggleAllRows', () => { + it('should throw when not in production', () => { + const { result } = renderHook(() => useSelection('single', items, getRowId)); + + expect(result.current.toggleAllRows).toThrowErrorMatchingInlineSnapshot( + `"[react-table]: \`toggleAllItems\` should not be used in single selection mode"`, + ); + }); + + it('should be a noop in production', () => { + const nodeEnv = (process.env.NODE_ENV = 'production'); + const { result } = renderHook(() => useSelection('single', items, getRowId)); + + result.current.toggleAllRows; + expect(result.current.selectedRows.size).toBe(0); + + process.env.NODE_ENV = nodeEnv; + }); + }); + describe('clearRows', () => { + it('should clear selection', () => { + const { result } = renderHook(() => useSelection('single', items, getRowId)); + + act(() => { + result.current.selectRow(1); + }); + act(() => { + result.current.clearRows(); + }); + + expect(result.current.selectedRows.size).toBe(0); + }); + }); + + describe('selectRow', () => { + it('should select row', () => { + const { result } = renderHook(() => useSelection('single', items, getRowId)); + + act(() => { + result.current.selectRow(1); + }); + + expect(result.current.selectedRows.has(1)).toBe(true); + }); + + it('should select another row', () => { + const { result } = renderHook(() => useSelection('single', items, getRowId)); + + act(() => { + result.current.selectRow(1); + }); + + act(() => { + result.current.selectRow(2); + }); + + expect(result.current.selectedRows.size).toBe(1); + expect(result.current.selectedRows.has(2)).toBe(true); + }); + }); + + describe('deselectRow', () => { + it('should make row unselected', () => { + const { result } = renderHook(() => useSelection('single', items, getRowId)); + + act(() => { + result.current.selectRow(1); + }); + + act(() => { + result.current.deselectRow(1); + }); + + expect(result.current.selectedRows.size).toBe(0); + }); + }); + + describe('toggleRow', () => { + it('should select unselected row', () => { + const { result } = renderHook(() => useSelection('single', items, getRowId)); + + act(() => { + result.current.toggleRow(1); + }); + + expect(result.current.selectedRows.size).toBe(1); + expect(result.current.selectedRows.has(1)).toBe(true); + }); + + it('should deselect selected row', () => { + const { result } = renderHook(() => useSelection('single', items, getRowId)); + + act(() => { + result.current.toggleRow(1); + }); + + act(() => { + result.current.toggleRow(2); + }); + + expect(result.current.selectedRows.size).toBe(1); + expect(result.current.selectedRows.has(1)).toBe(false); + }); + + it('should select another unselected row', () => { + const { result } = renderHook(() => useSelection('single', items, getRowId)); + + act(() => { + result.current.toggleRow(1); + }); + + act(() => { + result.current.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); + }); + }); + + describe('allRowsSelected', () => { + it('should return true if there is a selected row', () => { + const { result } = renderHook(() => useSelection('single', items, getRowId)); + + act(() => { + result.current.selectRow(1); + }); + + expect(result.current.selectedRows.size).toBe(1); + expect(result.current.allRowsSelected).toBe(true); + }); + + it('should return false if there is no selected row', () => { + const { result } = renderHook(() => useSelection('single', items, getRowId)); + + expect(result.current.selectedRows.size).toBe(0); + expect(result.current.allRowsSelected).toBe(false); + }); + }); + + describe('someRowsSelected', () => { + it('should return true if there is a selected row', () => { + const { result } = renderHook(() => useSelection('single', items, getRowId)); + + act(() => { + result.current.selectRow(1); + }); + + expect(result.current.selectedRows.size).toBe(1); + expect(result.current.someRowsSelected).toBe(true); + }); + + it('should return false if there is no selected row', () => { + const { result } = renderHook(() => useSelection('single', items, getRowId)); + + expect(result.current.selectedRows.size).toBe(0); + expect(result.current.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 5a043abb58524..cff6a47e6d2fc 100644 --- a/packages/react-components/react-table/src/hooks/useSelection.ts +++ b/packages/react-components/react-table/src/hooks/useSelection.ts @@ -1,14 +1,38 @@ -import { GetRowIdInternal, SelectionStateInternal, UseTableOptions } from './types'; -import { useMultipleSelection } from './useMultipleSelection'; -import { useSingleSelection } from './useSingleSelection'; +import * as React from 'react'; +import { useEventCallback, usePrevious } from '@fluentui/react-utilities'; +import { createSelectionManager } from './selectionManager'; +import { GetRowIdInternal, RowId, SelectionMode, SelectionStateInternal } from './types'; export function useSelection( - selectionMode: UseTableOptions['selectionMode'], + selectionMode: SelectionMode, items: TItem[], getRowId: GetRowIdInternal, ): SelectionStateInternal { - const multipleSelectionState = useMultipleSelection(items, getRowId); - const singleSelectionState = useSingleSelection(); + const prevSelectionMode = usePrevious(selectionMode); + const [selected, setSelected] = React.useState(() => new Set()); + const [selectionManager, setSelectionManager] = React.useState(() => + createSelectionManager(selectionMode, setSelected), + ); - return selectionMode === 'multiselect' ? multipleSelectionState : singleSelectionState; + React.useEffect(() => { + if (prevSelectionMode !== selectionMode) { + setSelectionManager(createSelectionManager(selectionMode, setSelected)); + } + }, [selectionMode, prevSelectionMode]); + + const toggleAllRows: SelectionStateInternal['toggleAllRows'] = useEventCallback(() => { + selectionManager.toggleAllItems(items.map((item, i) => getRowId(item, i))); + }); + + return { + someRowsSelected: selected.size > 0, + allRowsSelected: selectionMode === 'single' ? selected.size > 0 : selected.size === items.length, + selectedRows: selected, + toggleRow: selectionManager.toggleItem, + toggleAllRows, + clearRows: selectionManager.clearItems, + deselectRow: selectionManager.deselectItem, + selectRow: selectionManager.selectItem, + isRowSelected: selectionManager.isSelected, + }; } diff --git a/packages/react-components/react-table/src/hooks/useSingleSelection.test.ts b/packages/react-components/react-table/src/hooks/useSingleSelection.test.ts deleted file mode 100644 index 4d38b5c68037f..0000000000000 --- a/packages/react-components/react-table/src/hooks/useSingleSelection.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { useSingleSelection } from './useSingleSelection'; - -describe('useSingleSelection', () => { - describe('toggleSelectAllRows', () => { - it('should be a noop', () => { - const { result } = renderHook(() => useSingleSelection()); - - result.current.toggleSelectAllRows(); - expect(result.current.selectedRows.size).toBe(0); - - result.current.toggleSelectAllRows(); - expect(result.current.selectedRows.size).toBe(0); - }); - }); - describe('clearSelection', () => { - it('should clear selection', () => { - const { result } = renderHook(() => useSingleSelection()); - - act(() => { - result.current.selectRow(1); - }); - act(() => { - result.current.clearSelection(); - }); - - expect(result.current.selectedRows.size).toBe(0); - }); - }); - - describe('selectRow', () => { - it('should select row', () => { - const { result } = renderHook(() => useSingleSelection()); - - act(() => { - result.current.selectRow(1); - }); - - expect(result.current.selectedRows.has(1)).toBe(true); - }); - - it('should select another row', () => { - const { result } = renderHook(() => useSingleSelection()); - - act(() => { - result.current.selectRow(1); - }); - - act(() => { - result.current.selectRow(2); - }); - - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.selectedRows.has(2)).toBe(true); - }); - }); - - describe('deSelectRow', () => { - it('should make row unselected', () => { - const { result } = renderHook(() => useSingleSelection()); - - act(() => { - result.current.selectRow(1); - }); - - act(() => { - result.current.deSelectRow(1); - }); - - expect(result.current.selectedRows.size).toBe(0); - }); - }); - - describe('toggleRowSelect', () => { - it('should select unselected row', () => { - const { result } = renderHook(() => useSingleSelection()); - - act(() => { - result.current.toggleRowSelect(1); - }); - - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.selectedRows.has(1)).toBe(true); - }); - - it('should deselect selected row', () => { - const { result } = renderHook(() => useSingleSelection()); - - act(() => { - result.current.toggleRowSelect(1); - }); - - act(() => { - result.current.toggleRowSelect(2); - }); - - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.selectedRows.has(1)).toBe(false); - }); - - it('should select another unselected row', () => { - const { result } = renderHook(() => useSingleSelection()); - - act(() => { - result.current.toggleRowSelect(1); - }); - - act(() => { - result.current.toggleRowSelect(2); - }); - - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.selectedRows.has(1)).toBe(false); - expect(result.current.selectedRows.has(2)).toBe(true); - }); - }); - - describe('allRowsSelected', () => { - it('should return true if there is a selected row', () => { - const { result } = renderHook(() => useSingleSelection()); - - act(() => { - result.current.selectRow(1); - }); - - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.allRowsSelected).toBe(true); - }); - - it('should return false if there is no selected row', () => { - const { result } = renderHook(() => useSingleSelection()); - - expect(result.current.selectedRows.size).toBe(0); - expect(result.current.allRowsSelected).toBe(false); - }); - }); - - describe('someRowsSelected', () => { - it('should return true if there is a selected row', () => { - const { result } = renderHook(() => useSingleSelection()); - - act(() => { - result.current.selectRow(1); - }); - - expect(result.current.selectedRows.size).toBe(1); - expect(result.current.someRowsSelected).toBe(true); - }); - - it('should return false if there is no selected row', () => { - const { result } = renderHook(() => useSingleSelection()); - - expect(result.current.selectedRows.size).toBe(0); - expect(result.current.someRowsSelected).toBe(false); - }); - }); -}); diff --git a/packages/react-components/react-table/src/hooks/useSingleSelection.ts b/packages/react-components/react-table/src/hooks/useSingleSelection.ts deleted file mode 100644 index 515fea864d555..0000000000000 --- a/packages/react-components/react-table/src/hooks/useSingleSelection.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from 'react'; -import type { SelectionStateInternal, RowId } from './types'; - -export function useSingleSelection(): SelectionStateInternal { - const [selected, setSelected] = React.useState(); - const toggleRowSelect: SelectionStateInternal['toggleRowSelect'] = React.useCallback(rowId => { - setSelected(rowId); - }, []); - - const clearSelection = React.useCallback(() => { - setSelected(undefined); - }, []); - - const isRowSelected: SelectionStateInternal['isRowSelected'] = React.useCallback( - (rowId: RowId) => { - return selected === rowId; - }, - [selected], - ); - - return { - isRowSelected, - deSelectRow: clearSelection, - clearSelection, - selectRow: toggleRowSelect, - toggleSelectAllRows: () => undefined, - toggleRowSelect, - selectedRows: new Set(selected !== undefined ? [selected] : []), - allRowsSelected: !!selected, - someRowsSelected: !!selected, - }; -} 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 1bd00737203fa..b0f46d7bbee64 100644 --- a/packages/react-components/react-table/src/hooks/useTable.test.ts +++ b/packages/react-components/react-table/src/hooks/useTable.test.ts @@ -52,14 +52,14 @@ describe('useTable', () => { expect(result.current.selection).toMatchInlineSnapshot(` Object { "allRowsSelected": false, - "clearSelection": [Function], - "deSelectRow": [Function], + "clearRows": [Function], + "deselectRow": [Function], "isRowSelected": [Function], "selectRow": [Function], "selectedRows": Array [], "someRowsSelected": false, - "toggleRowSelect": [Function], - "toggleSelectAllRows": [Function], + "toggleAllRows": [Function], + "toggleRow": [Function], } `); }); diff --git a/packages/react-components/react-table/src/hooks/useTable.ts b/packages/react-components/react-table/src/hooks/useTable.ts index c0939c939483e..e9ddf64618112 100644 --- a/packages/react-components/react-table/src/hooks/useTable.ts +++ b/packages/react-components/react-table/src/hooks/useTable.ts @@ -29,35 +29,35 @@ export function useTable = RowState ({ isRowSelected, - clearSelection, - deSelectRow, + clearRows, + deselectRow, selectRow, - toggleSelectAllRows, - toggleRowSelect, + toggleAllRows, + toggleRow, selectedRows: Array.from(selectedRows), allRowsSelected, someRowsSelected, }), [ isRowSelected, - clearSelection, - deSelectRow, + clearRows, + deselectRow, selectRow, - toggleSelectAllRows, - toggleRowSelect, + toggleAllRows, + toggleRow, selectedRows, allRowsSelected, someRowsSelected, 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 0c8a974841574..5ebd000c26c1a 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 @@ -96,13 +96,13 @@ const columns: ColumnDefinition[] = [ export const MultipleSelect = () => { const { rows, - selection: { allRowsSelected, someRowsSelected, toggleSelectAllRows }, + selection: { allRowsSelected, someRowsSelected, toggleAllRows }, } = useTable({ columns, items, rowEnhancer: (row, { selection }) => ({ ...row, - toggleSelect: () => selection.toggleRowSelect(row.rowId), + toggle: () => selection.toggleRow(row.rowId), selected: selection.isRowSelected(row.rowId), }), }); @@ -113,7 +113,7 @@ export const MultipleSelect = () => { File Author @@ -122,8 +122,8 @@ export const MultipleSelect = () => { - {rows.map(({ item, selected, toggleSelect }) => ( - + {rows.map(({ item, selected, toggle }) => ( + {item.file.label} }>{item.author.label} 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 b7eeba6519ad2..fbd52a290f613 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 @@ -101,7 +101,7 @@ export const SingleSelect = () => { rowEnhancer: (row, { selection }) => ({ ...row, selected: selection.isRowSelected(row.rowId), - toggleSelect: () => selection.toggleRowSelect(row.rowId), + toggle: () => selection.toggleRow(row.rowId), }), }); @@ -117,8 +117,8 @@ export const SingleSelect = () => { - {rows.map(({ item, toggleSelect, selected }) => ( - + {rows.map(({ item, toggle, selected }) => ( + {item.file.label} }>{item.author.label}