diff --git a/.changeset/flat-days-camp.md b/.changeset/flat-days-camp.md new file mode 100644 index 000000000..730376520 --- /dev/null +++ b/.changeset/flat-days-camp.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Renamed selectionMap to selection in the useMultiSelect hook API. diff --git a/.changeset/real-paws-accept.md b/.changeset/real-paws-accept.md new file mode 100644 index 000000000..db47182ce --- /dev/null +++ b/.changeset/real-paws-accept.md @@ -0,0 +1,7 @@ +--- +'hostd': minor +'renterd': minor +'walletd': minor +--- + +Multi-select now supports single select, toggle select, and range selection interactions, with click, ctrl-click, and shift-click. diff --git a/.changeset/wicked-radios-tie.md b/.changeset/wicked-radios-tie.md new file mode 100644 index 000000000..4b5b42cba --- /dev/null +++ b/.changeset/wicked-radios-tie.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Table now prevents default on any mouse down with shift held. This ensures the user does not highlight text while shift selecting a range of rows. diff --git a/apps/hostd-e2e/src/specs/contracts.spec.ts b/apps/hostd-e2e/src/specs/contracts.spec.ts index d197bd0d0..414c69ec6 100644 --- a/apps/hostd-e2e/src/specs/contracts.spec.ts +++ b/apps/hostd-e2e/src/specs/contracts.spec.ts @@ -16,9 +16,8 @@ test.afterEach(async () => { test('contracts bulk integrity check', async ({ page }) => { await navigateToContracts(page) const rows = await getContractRowsAll(page) - for (const row of rows) { - await row.click() - } + rows.at(0).click() + rows.at(-1).click({ modifiers: ['Shift'] }) const menu = page.getByLabel('contract multi-select menu') diff --git a/apps/hostd/components/Contracts/ContractsBulkMenu/ContractsBulkIntegrityCheck.tsx b/apps/hostd/components/Contracts/ContractsBulkMenu/ContractsBulkIntegrityCheck.tsx index 8f2b1b135..17af4b826 100644 --- a/apps/hostd/components/Contracts/ContractsBulkMenu/ContractsBulkIntegrityCheck.tsx +++ b/apps/hostd/components/Contracts/ContractsBulkMenu/ContractsBulkIntegrityCheck.tsx @@ -17,8 +17,8 @@ export function ContractsBulkIntegrityCheck() { const integrityCheck = useContractsIntegrityCheck() const ids = useMemo( - () => Object.entries(multiSelect.selectionMap).map(([_, item]) => item.id), - [multiSelect.selectionMap] + () => Object.entries(multiSelect.selection).map(([_, item]) => item.id), + [multiSelect.selection] ) const checkAll = useCallback(async () => { await handleBatchOperation( diff --git a/apps/hostd/contexts/contracts/index.tsx b/apps/hostd/contexts/contracts/index.tsx index 26c8ea334..a8881ecd3 100644 --- a/apps/hostd/contexts/contracts/index.tsx +++ b/apps/hostd/contexts/contracts/index.tsx @@ -110,7 +110,7 @@ function useContractsMain() { ...datum, onClick: (e: React.MouseEvent) => multiSelect.onSelect(datum.id, e), - isSelected: !!multiSelect.selectionMap[datum.id], + isSelected: !!multiSelect.selection[datum.id], } }) }, [_datasetPage, multiSelect]) diff --git a/apps/renterd-e2e/src/specs/contracts.spec.ts b/apps/renterd-e2e/src/specs/contracts.spec.ts index 2c3ae4ffe..7253e4205 100644 --- a/apps/renterd-e2e/src/specs/contracts.spec.ts +++ b/apps/renterd-e2e/src/specs/contracts.spec.ts @@ -66,9 +66,8 @@ test('contracts prunable size', async ({ page }) => { test('contracts bulk delete', async ({ page }) => { await navigateToContracts({ page }) const rows = await getContractRowsAll(page) - for (const row of rows) { - await row.click() - } + rows.at(0).click() + rows.at(-1).click({ modifiers: ['Shift'] }) // Delete selected contracts. const menu = page.getByLabel('contract multi-select menu') @@ -82,9 +81,8 @@ test('contracts bulk delete', async ({ page }) => { test('contracts bulk allowlist', async ({ page }) => { await navigateToContracts({ page }) const rows = await getContractRowsAll(page) - for (const row of rows) { - await row.click() - } + rows.at(0).click() + rows.at(-1).click({ modifiers: ['Shift'] }) const menu = page.getByLabel('contract multi-select menu') const dialog = page.getByRole('dialog') @@ -101,9 +99,8 @@ test('contracts bulk allowlist', async ({ page }) => { ).toHaveCount(3) await dialog.getByLabel('close').click() - for (const row of rows) { - await row.click() - } + rows.at(0).click() + rows.at(-1).click({ modifiers: ['Shift'] }) // Remove selected contract hosts from the allowlist. await menu.getByLabel('remove host public keys from allowlist').click() @@ -118,9 +115,8 @@ test('contracts bulk allowlist', async ({ page }) => { test('contracts bulk blocklist', async ({ page }) => { await navigateToContracts({ page }) const rows = await getContractRowsAll(page) - for (const row of rows) { - await row.click() - } + rows.at(0).click() + rows.at(-1).click({ modifiers: ['Shift'] }) const menu = page.getByLabel('contract multi-select menu') const dialog = page.getByRole('dialog') @@ -137,9 +133,8 @@ test('contracts bulk blocklist', async ({ page }) => { await expect(dialog.getByText('The allowlist is empty')).toBeVisible() await dialog.getByLabel('close').click() - for (const row of rows) { - await row.click() - } + rows.at(0).click() + rows.at(-1).click({ modifiers: ['Shift'] }) // Remove selected contract hosts from the blocklist. await menu.getByLabel('remove host addresses from blocklist').click() diff --git a/apps/renterd-e2e/src/specs/files.spec.ts b/apps/renterd-e2e/src/specs/files.spec.ts index d61e53491..e8d02765a 100644 --- a/apps/renterd-e2e/src/specs/files.spec.ts +++ b/apps/renterd-e2e/src/specs/files.spec.ts @@ -242,14 +242,14 @@ test('bulk delete across nested directories', async ({ page }) => { // Select entire dir1. const dir1 = await getFileRowById(page, 'bucket1/dir1/') - await dir1.click() + await dir1.click({ modifiers: ['ControlOrMeta'] }) await openDirectory(page, 'bucket1/dir2/') // Select file3 and file4. const file3 = await getFileRowById(page, 'bucket1/dir2/file3.txt') - await file3.click() + await file3.click({ modifiers: ['ControlOrMeta'] }) const file4 = await getFileRowById(page, 'bucket1/dir2/file4.txt') - await file4.click() + await file4.click({ modifiers: ['ControlOrMeta'] }) const menu = page.getByLabel('file multi-select menu') // Delete selected files. @@ -289,12 +289,12 @@ test('bulk delete using the all files explorer mode', async ({ page }) => { // Select entire dir1. const dir1 = await getFileRowById(page, 'bucket1/dir1/') - await dir1.click() + await dir1.click({ modifiers: ['ControlOrMeta'] }) // Select file3 and file4. const file3 = await getFileRowById(page, 'bucket1/dir2/file3.txt') - await file3.click() + await file3.click({ modifiers: ['ControlOrMeta'] }) const file4 = await getFileRowById(page, 'bucket1/dir2/file4.txt') - await file4.click() + await file4.click({ modifiers: ['ControlOrMeta'] }) const menu = page.getByLabel('file multi-select menu') // Delete selected files. diff --git a/apps/renterd-e2e/src/specs/filesMove.spec.ts b/apps/renterd-e2e/src/specs/filesMove.spec.ts index b0d5eb0b0..dd51162c5 100644 --- a/apps/renterd-e2e/src/specs/filesMove.spec.ts +++ b/apps/renterd-e2e/src/specs/filesMove.spec.ts @@ -50,7 +50,7 @@ test('move two files by selecting and dragging from one directory out to another const file3 = await getFileRowById(page, 'bucket1/dir2/file3.txt', true) await file3.click() const dir3 = await getFileRowById(page, 'bucket1/dir2/dir3/', true) - await dir3.click() + await dir3.click({ modifiers: ['ControlOrMeta'] }) // Move all selected files by dragging one of them. await moveMouseOver(page, file3) @@ -108,7 +108,7 @@ test('move a file via drag and drop while leaving a separate set of selected fil const file3 = await getFileRowById(page, 'bucket1/dir2/file3.txt', true) await file3.click() const file4 = await getFileRowById(page, 'bucket1/dir2/file4.txt', true) - await file4.click() + await file4.click({ modifiers: ['ControlOrMeta'] }) // Move file5 which is not in the selection. const file5 = await getFileRowById(page, 'bucket1/dir2/file5.txt', true) @@ -165,7 +165,7 @@ test('move files by selecting and using the docked menu bulk action', async ({ const file3 = await getFileRowById(page, 'bucket1/dir2/file3.txt', true) await file3.click() const dir3 = await getFileRowById(page, 'bucket1/dir2/dir3/', true) - await dir3.click() + await dir3.click({ modifiers: ['ControlOrMeta'] }) await navigateToParentDirectory(page) diff --git a/apps/renterd-e2e/src/specs/hosts.spec.ts b/apps/renterd-e2e/src/specs/hosts.spec.ts index 865eadaa2..641a40d21 100644 --- a/apps/renterd-e2e/src/specs/hosts.spec.ts +++ b/apps/renterd-e2e/src/specs/hosts.spec.ts @@ -32,9 +32,9 @@ test('hosts explorer shows all hosts', async ({ page }) => { test('hosts bulk allowlist', async ({ page }) => { await navigateToHosts({ page }) const rows = await getHostRowsAll(page) - for (const row of rows) { - await row.click() - } + // Range select the rows, add position as default location is a context menu button. + rows.at(0).click() + rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) const menu = page.getByLabel('host multi-select menu') const dialog = page.getByRole('dialog') @@ -57,9 +57,8 @@ test('hosts bulk allowlist', async ({ page }) => { getHostRows(page).getByTestId('allow').getByTestId('allowed') ).toHaveCount(3) - for (const row of rows) { - await row.click() - } + rows.at(0).click() + rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) // Remove selected hosts from the allowlist. await menu.getByLabel('remove host public keys from allowlist').click() @@ -81,9 +80,9 @@ test('hosts bulk allowlist', async ({ page }) => { test('hosts bulk blocklist', async ({ page }) => { await navigateToHosts({ page }) const rows = await getHostRowsAll(page) - for (const row of rows) { - await row.click() - } + // Range select the rows, add position as default location is a context menu button. + rows.at(0).click() + rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) const menu = page.getByLabel('host multi-select menu') const dialog = page.getByRole('dialog') @@ -106,9 +105,8 @@ test('hosts bulk blocklist', async ({ page }) => { getHostRows(page).getByTestId('allow').getByTestId('allowed') ).toHaveCount(0) - for (const row of rows) { - await row.click() - } + rows.at(0).click() + rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) // Remove selected hosts from the blocklist. await menu.getByLabel('remove host addresses from blocklist').click() diff --git a/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsAddAllowlist.tsx b/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsAddAllowlist.tsx index 8cbb081d0..50a96c1e1 100644 --- a/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsAddAllowlist.tsx +++ b/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsAddAllowlist.tsx @@ -7,8 +7,8 @@ export function ContractsAddAllowlist() { const publicKeys = useMemo( () => - Object.entries(multiSelect.selectionMap).map(([_, item]) => item.hostKey), - [multiSelect.selectionMap] + Object.entries(multiSelect.selection).map(([_, item]) => item.hostKey), + [multiSelect.selection] ) return diff --git a/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsAddBlocklist.tsx b/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsAddBlocklist.tsx index ce930c9ac..6ce7aee73 100644 --- a/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsAddBlocklist.tsx +++ b/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsAddBlocklist.tsx @@ -6,9 +6,8 @@ export function ContractsAddBlocklist() { const { multiSelect } = useContracts() const hostAddresses = useMemo( - () => - Object.entries(multiSelect.selectionMap).map(([_, item]) => item.hostIp), - [multiSelect.selectionMap] + () => Object.entries(multiSelect.selection).map(([_, item]) => item.hostIp), + [multiSelect.selection] ) return ( diff --git a/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsBulkDelete.tsx b/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsBulkDelete.tsx index 7288c40a2..ca39be798 100644 --- a/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsBulkDelete.tsx +++ b/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsBulkDelete.tsx @@ -14,8 +14,8 @@ export function ContractsBulkDelete() { const { multiSelect } = useContracts() const ids = useMemo( - () => Object.entries(multiSelect.selectionMap).map(([_, item]) => item.id), - [multiSelect.selectionMap] + () => Object.entries(multiSelect.selection).map(([_, item]) => item.id), + [multiSelect.selection] ) const { openConfirmDialog } = useDialog() const deleteContract = useContractDelete() diff --git a/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsRemoveAllowlist.tsx b/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsRemoveAllowlist.tsx index 9da6e927e..1c6dce69f 100644 --- a/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsRemoveAllowlist.tsx +++ b/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsRemoveAllowlist.tsx @@ -7,8 +7,8 @@ export function ContractsRemoveAllowlist() { const publicKeys = useMemo( () => - Object.entries(multiSelect.selectionMap).map(([_, item]) => item.hostKey), - [multiSelect.selectionMap] + Object.entries(multiSelect.selection).map(([_, item]) => item.hostKey), + [multiSelect.selection] ) return ( diff --git a/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsRemoveBlocklist.tsx b/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsRemoveBlocklist.tsx index 84bd74b87..b66ff06fa 100644 --- a/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsRemoveBlocklist.tsx +++ b/apps/renterd/components/Contracts/ContractsBulkMenu/ContractsRemoveBlocklist.tsx @@ -6,9 +6,8 @@ export function ContractsRemoveBlocklist() { const { multiSelect } = useContracts() const hostAddresses = useMemo( - () => - Object.entries(multiSelect.selectionMap).map(([_, item]) => item.hostIp), - [multiSelect.selectionMap] + () => Object.entries(multiSelect.selection).map(([_, item]) => item.hostIp), + [multiSelect.selection] ) return ( diff --git a/apps/renterd/components/Files/bulkActions/FilesBulkDelete.tsx b/apps/renterd/components/Files/bulkActions/FilesBulkDelete.tsx index c5d44c4f9..c10ade338 100644 --- a/apps/renterd/components/Files/bulkActions/FilesBulkDelete.tsx +++ b/apps/renterd/components/Files/bulkActions/FilesBulkDelete.tsx @@ -18,11 +18,11 @@ export function FilesBulkDelete({ }) { const filesToDelete = useMemo( () => - Object.entries(multiSelect.selectionMap).map(([_, item]) => ({ + Object.entries(multiSelect.selection).map(([_, item]) => ({ bucket: item.bucket.name, prefix: item.key, })), - [multiSelect.selectionMap] + [multiSelect.selection] ) const { openConfirmDialog } = useDialog() const objectsRemove = useObjectsRemove() diff --git a/apps/renterd/components/Hosts/HostsBulkMenu/HostsAddAllowlist.tsx b/apps/renterd/components/Hosts/HostsBulkMenu/HostsAddAllowlist.tsx index 3fbe9f637..7de106c79 100644 --- a/apps/renterd/components/Hosts/HostsBulkMenu/HostsAddAllowlist.tsx +++ b/apps/renterd/components/Hosts/HostsBulkMenu/HostsAddAllowlist.tsx @@ -7,10 +7,8 @@ export function HostsAddAllowlist() { const publicKeys = useMemo( () => - Object.entries(multiSelect.selectionMap).map( - ([_, item]) => item.publicKey - ), - [multiSelect.selectionMap] + Object.entries(multiSelect.selection).map(([_, item]) => item.publicKey), + [multiSelect.selection] ) return diff --git a/apps/renterd/components/Hosts/HostsBulkMenu/HostsAddBlocklist.tsx b/apps/renterd/components/Hosts/HostsBulkMenu/HostsAddBlocklist.tsx index 2b0d74d4e..640e3ba0b 100644 --- a/apps/renterd/components/Hosts/HostsBulkMenu/HostsAddBlocklist.tsx +++ b/apps/renterd/components/Hosts/HostsBulkMenu/HostsAddBlocklist.tsx @@ -7,10 +7,8 @@ export function HostsAddBlocklist() { const hostAddresses = useMemo( () => - Object.entries(multiSelect.selectionMap).map( - ([_, item]) => item.netAddress - ), - [multiSelect.selectionMap] + Object.entries(multiSelect.selection).map(([_, item]) => item.netAddress), + [multiSelect.selection] ) return ( diff --git a/apps/renterd/components/Hosts/HostsBulkMenu/HostsRemoveAllowlist.tsx b/apps/renterd/components/Hosts/HostsBulkMenu/HostsRemoveAllowlist.tsx index bde238dd2..a87d42961 100644 --- a/apps/renterd/components/Hosts/HostsBulkMenu/HostsRemoveAllowlist.tsx +++ b/apps/renterd/components/Hosts/HostsBulkMenu/HostsRemoveAllowlist.tsx @@ -7,10 +7,8 @@ export function HostsRemoveAllowlist() { const publicKeys = useMemo( () => - Object.entries(multiSelect.selectionMap).map( - ([_, item]) => item.publicKey - ), - [multiSelect.selectionMap] + Object.entries(multiSelect.selection).map(([_, item]) => item.publicKey), + [multiSelect.selection] ) return ( diff --git a/apps/renterd/components/Hosts/HostsBulkMenu/HostsRemoveBlocklist.tsx b/apps/renterd/components/Hosts/HostsBulkMenu/HostsRemoveBlocklist.tsx index e1543136e..0d55aad2f 100644 --- a/apps/renterd/components/Hosts/HostsBulkMenu/HostsRemoveBlocklist.tsx +++ b/apps/renterd/components/Hosts/HostsBulkMenu/HostsRemoveBlocklist.tsx @@ -7,10 +7,8 @@ export function HostsRemoveBlocklist() { const hostAddresses = useMemo( () => - Object.entries(multiSelect.selectionMap).map( - ([_, item]) => item.netAddress - ), - [multiSelect.selectionMap] + Object.entries(multiSelect.selection).map(([_, item]) => item.netAddress), + [multiSelect.selection] ) return ( diff --git a/apps/renterd/components/Hosts/HostsBulkMenu/HostsResetLostSectorCount.tsx b/apps/renterd/components/Hosts/HostsBulkMenu/HostsResetLostSectorCount.tsx index 978c7db6d..6a0e6cdab 100644 --- a/apps/renterd/components/Hosts/HostsBulkMenu/HostsResetLostSectorCount.tsx +++ b/apps/renterd/components/Hosts/HostsBulkMenu/HostsResetLostSectorCount.tsx @@ -11,10 +11,8 @@ export function HostsResetLostSectorCount() { const publicKeys = useMemo( () => - Object.entries(multiSelect.selectionMap).map( - ([_, item]) => item.publicKey - ), - [multiSelect.selectionMap] + Object.entries(multiSelect.selection).map(([_, item]) => item.publicKey), + [multiSelect.selection] ) const resetAll = useCallback(async () => { await handleBatchOperation( diff --git a/apps/renterd/components/Keys/KeysBulkMenu/KeysBulkDelete.tsx b/apps/renterd/components/Keys/KeysBulkMenu/KeysBulkDelete.tsx index f7e7b465e..8408dc602 100644 --- a/apps/renterd/components/Keys/KeysBulkMenu/KeysBulkDelete.tsx +++ b/apps/renterd/components/Keys/KeysBulkMenu/KeysBulkDelete.tsx @@ -18,8 +18,8 @@ export function KeysBulkDelete() { const { multiSelect } = useKeys() const keys = useMemo( - () => Object.entries(multiSelect.selectionMap).map(([_, item]) => item.key), - [multiSelect.selectionMap] + () => Object.entries(multiSelect.selection).map(([_, item]) => item.key), + [multiSelect.selection] ) const { openConfirmDialog } = useDialog() const settingsS3 = useSettingsS3() diff --git a/apps/renterd/contexts/contracts/index.tsx b/apps/renterd/contexts/contracts/index.tsx index c1d640d4e..bcf732558 100644 --- a/apps/renterd/contexts/contracts/index.tsx +++ b/apps/renterd/contexts/contracts/index.tsx @@ -130,7 +130,7 @@ function useContractsMain() { ...datum, onClick: (e: React.MouseEvent) => multiSelect.onSelect(datum.id, e), - isSelected: !!multiSelect.selectionMap[datum.id], + isSelected: !!multiSelect.selection[datum.id], } }) }, [_datasetPage, multiSelect]) diff --git a/apps/renterd/contexts/filesDirectory/index.tsx b/apps/renterd/contexts/filesDirectory/index.tsx index ea6241ac0..3401c8877 100644 --- a/apps/renterd/contexts/filesDirectory/index.tsx +++ b/apps/renterd/contexts/filesDirectory/index.tsx @@ -95,7 +95,7 @@ function useFilesDirectoryMain() { } return { ...datum, - isSelected: !!multiSelect.selectionMap[datum.id], + isSelected: !!multiSelect.selection[datum.id], onClick: (e: MouseEvent) => multiSelect.onSelect(datum.id, e), } diff --git a/apps/renterd/contexts/filesDirectory/move.tsx b/apps/renterd/contexts/filesDirectory/move.tsx index 091d8dad1..ebd304dbf 100644 --- a/apps/renterd/contexts/filesDirectory/move.tsx +++ b/apps/renterd/contexts/filesDirectory/move.tsx @@ -148,7 +148,7 @@ export function useMove({ const id = String(e.active.id) if (multiSelect.selectedIds.includes(id)) { setDraggingObjects( - Object.entries(multiSelect.selectionMap).map(([, obj]) => obj) + Object.entries(multiSelect.selection).map(([, obj]) => obj) ) } else { const ob = dataset?.find((d) => d.id === e.active.id) diff --git a/apps/renterd/contexts/filesFlat/index.tsx b/apps/renterd/contexts/filesFlat/index.tsx index eea515d48..dcc0bfaa8 100644 --- a/apps/renterd/contexts/filesFlat/index.tsx +++ b/apps/renterd/contexts/filesFlat/index.tsx @@ -65,7 +65,7 @@ function useFilesFlatMain() { return _datasetPage.map((datum) => { return { ...datum, - isSelected: !!multiSelect.selectionMap[datum.id], + isSelected: !!multiSelect.selection[datum.id], onClick: (e: MouseEvent) => multiSelect.onSelect(datum.id, e), } diff --git a/apps/renterd/contexts/hosts/index.tsx b/apps/renterd/contexts/hosts/index.tsx index dca55b327..46657eab9 100644 --- a/apps/renterd/contexts/hosts/index.tsx +++ b/apps/renterd/contexts/hosts/index.tsx @@ -190,7 +190,7 @@ function useHostsMain() { ...datum, onClick: (e: React.MouseEvent) => multiSelect.onSelect(datum.id, e), - isSelected: !!multiSelect.selectionMap[datum.id], + isSelected: !!multiSelect.selection[datum.id], } }) }, [dataset, multiSelect]) diff --git a/apps/renterd/contexts/keys/index.tsx b/apps/renterd/contexts/keys/index.tsx index cda8f7b6f..4e0bc55f1 100644 --- a/apps/renterd/contexts/keys/index.tsx +++ b/apps/renterd/contexts/keys/index.tsx @@ -97,7 +97,7 @@ function useKeysMain() { ...datum, onClick: (e: React.MouseEvent) => multiSelect.onSelect(datum.id, e), - isSelected: !!multiSelect.selectionMap[datum.id], + isSelected: !!multiSelect.selection[datum.id], } }) }, [_datasetPage, multiSelect]) diff --git a/libs/design-system/src/components/Table/index.tsx b/libs/design-system/src/components/Table/index.tsx index 45c973327..106fa474d 100644 --- a/libs/design-system/src/components/Table/index.tsx +++ b/libs/design-system/src/components/Table/index.tsx @@ -207,6 +207,11 @@ export function Table< { + if (e.shiftKey) { + e.preventDefault() + } + }} data-loading={show === 'skeleton'} className="relative z-10 table-auto border-collapse w-full" > diff --git a/libs/design-system/src/multi/MultiSelectionMenu.tsx b/libs/design-system/src/multi/MultiSelectionMenu.tsx index 0966d7758..b6f0fce26 100644 --- a/libs/design-system/src/multi/MultiSelectionMenu.tsx +++ b/libs/design-system/src/multi/MultiSelectionMenu.tsx @@ -5,16 +5,16 @@ import { Panel } from '../core/Panel' import { Text } from '../core/Text' import { pluralize } from '@siafoundation/units' import { Close16 } from '@siafoundation/react-icons' -import { MultiSelect, MultiSelectItem } from './useMultiSelect' +import { MultiSelect, MultiSelectRow } from './useMultiSelect' import { AppDockedControl } from '../app/AppDockedControl' -export function MultiSelectionMenu({ +export function MultiSelectionMenu({ multiSelect, children, entityWord, entityWordPlural, }: { - multiSelect: MultiSelect + multiSelect: MultiSelect children: React.ReactNode entityWord: string entityWordPlural?: string @@ -36,13 +36,13 @@ export function MultiSelectionMenu({ {`${pluralize(multiSelect.selectionCount, entityWord, { plural: entityWordPlural, })} selected${ - multiSelect.someSelectedItemsOutsideCurrentPage && + multiSelect.someSelectedRowsOutsideCurrentPage && multiSelect.someSelectedOnCurrentPage ? ' on this and other pages' - : !multiSelect.someSelectedItemsOutsideCurrentPage && + : !multiSelect.someSelectedRowsOutsideCurrentPage && multiSelect.someSelectedOnCurrentPage ? '' - : multiSelect.someSelectedItemsOutsideCurrentPage && + : multiSelect.someSelectedRowsOutsideCurrentPage && !multiSelect.someSelectedOnCurrentPage ? ' on other pages' : '' diff --git a/libs/design-system/src/multi/useMultiSelect.spec.tsx b/libs/design-system/src/multi/useMultiSelect.spec.tsx index 15b8c7204..2a83d1fec 100644 --- a/libs/design-system/src/multi/useMultiSelect.spec.tsx +++ b/libs/design-system/src/multi/useMultiSelect.spec.tsx @@ -2,315 +2,531 @@ import { renderHook, act } from '@testing-library/react' import { useMultiSelect } from './useMultiSelect' import { MouseEvent } from 'react' -interface Item { +type Row = { id: string } -describe('useMultiSelect hook', () => { - const dataset: Item[] = [ - { id: '1' }, - { id: '2' }, - { id: '3' }, - { id: '4' }, - { id: '5' }, - ] - - test('should select an item when onSelect is called', () => { - const { result } = renderHook(() => useMultiSelect(dataset)) - - act(() => { - const mockEvent = { shiftKey: false } as MouseEvent - result.current.onSelect('1', mockEvent) +const normalClick = {} as MouseEvent +const shiftClick = { shiftKey: true } as MouseEvent +const ctrlClick = { ctrlKey: true } as MouseEvent + +describe('useMultiSelect', () => { + describe('core behaviour', () => { + const dataset: Row[] = [ + { id: '1' }, + { id: '2' }, + { id: '3' }, + { id: '4' }, + { id: '5' }, + ] + + describe('normal click', () => { + test('selects a single row', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('1', normalClick) + }) + expect(result.current.selectedIds).toEqual(['1']) + expect(result.current.selectionCount).toBe(1) + }) + + test('retains the only selected row on second click', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('1', normalClick) + }) + act(() => { + result.current.onSelect('1', normalClick) + }) + expect(result.current.selectedIds).toEqual(['1']) + expect(result.current.selectionCount).toBe(1) + }) + + test('reduces selection to one row if multiple selected and clicked row is selected', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('2', normalClick) + }) + act(() => { + result.current.onSelect('4', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '3', '4']) + act(() => { + result.current.onSelect('3', normalClick) + }) + expect(result.current.selectedIds).toEqual(['3']) + }) + + test('reduces selection to one row if multiple selected and clicked row is not selected', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('2', normalClick) + }) + act(() => { + result.current.onSelect('4', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '3', '4']) + act(() => { + result.current.onSelect('1', normalClick) + }) + expect(result.current.selectedIds).toEqual(['1']) + }) }) - expect(result.current.selectionMap).toHaveProperty('1') - expect(result.current.selectionCount).toBe(1) - }) - - test('should deselect an item when onSelect is called on a selected item', () => { - const { result } = renderHook(() => useMultiSelect(dataset)) - - act(() => { - const mockEvent = { shiftKey: false } as MouseEvent - result.current.onSelect('1', mockEvent) - result.current.onSelect('1', mockEvent) + describe('shift click (range selection)', () => { + test('if no anchor, use top row as anchor and select range', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('3', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['1', '2', '3']) + expect(result.current.selectionCount).toBe(3) + act(() => { + result.current.onSelect('2', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '3']) + expect(result.current.selectionCount).toBe(2) + }) + + test('selects a continuous range from anchor to clicked row', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('2', normalClick) + }) + act(() => { + result.current.onSelect('4', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '3', '4']) + expect(result.current.selectionCount).toBe(3) + }) + + test('can select range upwards', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('4', normalClick) + }) + act(() => { + result.current.onSelect('2', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '3', '4']) + expect(result.current.selectionCount).toBe(3) + }) + + test('multiple shift-clicks maintain same anchor until a normal click changes it', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('2', normalClick) + }) + act(() => { + result.current.onSelect('4', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '3', '4']) + + // Extend the range. + act(() => { + result.current.onSelect('5', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '3', '4', '5']) + + // Normal click resets anchor. + act(() => { + result.current.onSelect('1', normalClick) + }) + expect(result.current.selectedIds).toEqual(['1']) + + // New shift-click range from new anchor. + act(() => { + result.current.onSelect('3', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['1', '2', '3']) + + // Shift-click a selected row. + act(() => { + result.current.onSelect('2', shiftClick) + }) + expect(result.current.selectedIds).toEqual(['1', '2']) + }) + + test('shift-click selects range and clears any previous range selection from the current anchor', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('3', normalClick) + }) + act(() => { + result.current.onSelect('1', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['1', '2', '3']) + + // Shift-click a non-overlapping range. + act(() => { + result.current.onSelect('5', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['3', '4', '5']) + }) }) - expect(result.current.selectionMap).not.toHaveProperty('1') - expect(result.current.selectionCount).toBe(0) - }) - - test('should select a range of items when shiftKey is held', () => { - const { result } = renderHook(() => useMultiSelect(dataset)) - - act(() => { - const firstClickEvent = { - shiftKey: false, - } as MouseEvent - const shiftClickEvent = { - shiftKey: true, - } as MouseEvent - - result.current.onSelect('2', firstClickEvent) - result.current.onSelect('4', shiftClickEvent) + describe('ctrl/cmd click (toggle)', () => { + test('adds a new row or removes row without removing others', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('1', normalClick) + }) + act(() => { + result.current.onSelect('3', ctrlClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['1', '3']) + act(() => { + result.current.onSelect('1', ctrlClick) + }) + expect(result.current.selectedIds).toEqual(['3']) + }) }) - expect(Object.keys(result.current.selectionMap)).toEqual(['2', '3', '4']) - expect(result.current.selectionCount).toBe(3) - }) - - test('should select all items on the page when onSelectPage is called', () => { - const { result } = renderHook(() => useMultiSelect(dataset)) - - act(() => { - result.current.onSelectPage() + describe('ctrl/cmd click anchor', () => { + test('deselect with remaining selection at a higher index', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('2', normalClick) + }) + act(() => { + result.current.onSelect('4', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '3', '4']) + act(() => { + result.current.onSelect('3', ctrlClick) + }) + // Anchor will now be next selected index greater than that of id 3. + // Anchor is now 4. + act(() => { + result.current.onSelect('5', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '4', '5']) + act(() => { + result.current.onSelect('1', shiftClick) + }) + // Anchor is 4 and last shift end was 5. So 5 gets deselected. + expect(result.current.selectedIds.sort()).toEqual(['1', '2', '3', '4']) + }) + + test('deselect with remaining selection at a lower index', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.deselectAll() + }) + act(() => { + result.current.onSelect('2', normalClick) + }) + act(() => { + result.current.onSelect('4', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '3', '4']) + act(() => { + result.current.onSelect('3', ctrlClick) + }) + act(() => { + result.current.onSelect('4', ctrlClick) + }) + // Anchor will now be next selected index smaller than that of id 3. + act(() => { + result.current.onSelect('5', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '3', '4', '5']) + act(() => { + result.current.onSelect('1', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['1', '2']) + }) + + test('deselect last remaining selection', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.deselectAll() + }) + act(() => { + result.current.onSelect('2', normalClick) + }) + act(() => { + result.current.onSelect('2', ctrlClick) + }) + // Anchor will now be reset to undefined. First shift-selection sets anchor + // since there is none yet. + act(() => { + result.current.onSelect('3', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['1', '2', '3']) + act(() => { + result.current.onSelect('2', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '3']) + }) + + test('ctrl-click to select or deselect both reset the anchor', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('1', ctrlClick) + }) + act(() => { + result.current.onSelect('3', ctrlClick) + }) + act(() => { + result.current.onSelect('5', ctrlClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['1', '3', '5']) + act(() => { + result.current.onSelect('3', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['1', '3', '4', '5']) + act(() => { + result.current.onSelect('3', ctrlClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['1', '4', '5']) + act(() => { + result.current.onSelect('1', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual([ + '1', + '2', + '3', + '4', + '5', + ]) + act(() => { + result.current.onSelect('5', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['4', '5']) + }) }) - expect(Object.keys(result.current.selectionMap)).toEqual([ - '1', - '2', - '3', - '4', - '5', - ]) - expect(result.current.selectionCount).toBe(5) - expect(result.current.isPageAllSelected).toBe(true) - }) - - test('should deselect all items on the page when onSelectPage is called again', () => { - const { result } = renderHook(() => useMultiSelect(dataset)) - - act(() => { - result.current.onSelectPage() - result.current.onSelectPage() + describe('page selection', () => { + test('onSelectPage selects all rows if not all selected, else deselects all', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelectPage() + }) + expect(result.current.selectedIds.sort()).toEqual([ + '1', + '2', + '3', + '4', + '5', + ]) + expect(result.current.selectionCount).toBe(5) + expect(result.current.isPageAllSelected).toBe(true) + + // Deselect all by calling onSelectPage again. + act(() => { + result.current.onSelectPage() + }) + expect(result.current.selectedIds).toEqual([]) + expect(result.current.selectionCount).toBe(0) + expect(result.current.isPageAllSelected).toBe(false) + }) + + test('is indeterminate when some but not all rows are selected', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('1', normalClick) + result.current.onSelect('3', normalClick) + }) + expect(result.current.isPageAllSelected).toBe('indeterminate') + }) }) - expect(Object.keys(result.current.selectionMap)).toEqual([]) - expect(result.current.selectionCount).toBe(0) - expect(result.current.isPageAllSelected).toBe(false) - }) - - test('should return indeterminate when some items are selected', () => { - const { result } = renderHook(() => useMultiSelect(dataset)) - - act(() => { - const mockEvent = { shiftKey: false } as MouseEvent - result.current.onSelect('1', mockEvent) - result.current.onSelect('3', mockEvent) + describe('deselect and deselectAll', () => { + test('deselect specific rows', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelectPage() + result.current.deselect(['2', '4']) + }) + expect(result.current.selectedIds.sort()).toEqual(['1', '3', '5']) + expect(result.current.selectionCount).toBe(3) + }) + + test('deselectAll clears all', () => { + const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelectPage() + result.current.deselectAll() + }) + expect(result.current.selectedIds).toEqual([]) + expect(result.current.selectionCount).toBe(0) + expect(result.current.isPageAllSelected).toBe(false) + }) }) - - expect(result.current.isPageAllSelected).toBe('indeterminate') }) - test('should deselect specific items when deselect is called', () => { - const { result } = renderHook(() => useMultiSelect(dataset)) - - act(() => { - result.current.onSelectPage() - result.current.deselect(['2', '4']) + describe('pagination and persistence', () => { + const fullDataset: Row[] = [ + { id: '1' }, + { id: '2' }, + { id: '3' }, + { id: '4' }, + { id: '5' }, + { id: '6' }, + { id: '7' }, + { id: '8' }, + { id: '9' }, + { id: '10' }, + ] + const pageSize = 5 + const page1 = fullDataset.slice(0, pageSize) + const page2 = fullDataset.slice(pageSize) + + test('preserve selections across pages', () => { + const { result, rerender } = renderHook( + ({ dataset }) => useMultiSelect(dataset), + { initialProps: { dataset: page1 } } + ) + + act(() => { + result.current.onSelect('2', normalClick) + }) + act(() => { + result.current.onSelect('4', ctrlClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '4']) + expect(result.current.isPageAllSelected).toBe('indeterminate') + + rerender({ dataset: page2 }) + // Selections persist even after dataset changes. + expect(result.current.selectedIds.sort()).toEqual(['2', '4']) + + act(() => { + result.current.onSelect('7', ctrlClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '4', '7']) + expect(result.current.isPageAllSelected).toBe('indeterminate') }) - expect(Object.keys(result.current.selectionMap)).toEqual(['1', '3', '5']) - expect(result.current.selectionCount).toBe(3) - }) - - test('should deselect all items when deselectAll is called', () => { - const { result } = renderHook(() => useMultiSelect(dataset)) - - act(() => { - result.current.onSelectPage() - result.current.deselectAll() + test('onSelectPage affects only current page', () => { + const { result, rerender } = renderHook( + ({ dataset }) => useMultiSelect(dataset), + { initialProps: { dataset: page1 } } + ) + + // Select all on page 1. + act(() => { + result.current.onSelectPage() + }) + expect(result.current.selectedIds.sort()).toEqual([ + '1', + '2', + '3', + '4', + '5', + ]) + expect(result.current.isPageAllSelected).toBe(true) + + // Switch to page 2. + rerender({ dataset: page2 }) + expect(result.current.isPageAllSelected).toBe(false) + + // Select all on page 2 (now all 10 are selected). + act(() => { + result.current.onSelectPage() + }) + expect(result.current.selectedIds.sort()).toEqual( + ['1', '10', '2', '3', '4', '5', '6', '7', '8', '9'].sort() + ) + expect(result.current.isPageAllSelected).toBe(true) + + // Deselect page 2 only. + act(() => { + result.current.onSelectPage() + }) + expect(result.current.selectedIds.sort()).toEqual([ + '1', + '2', + '3', + '4', + '5', + ]) + expect(result.current.isPageAllSelected).toBe(false) + + // Switch back to page 1: all on page 1 still selected. + rerender({ dataset: page1 }) + expect(result.current.isPageAllSelected).toBe(true) }) - expect(Object.keys(result.current.selectionMap)).toEqual([]) - expect(result.current.selectionCount).toBe(0) - expect(result.current.isPageAllSelected).toBe(false) - }) + test('deselectAll clears selections across pages', () => { + const { result, rerender } = renderHook( + ({ dataset }) => useMultiSelect(dataset), + { initialProps: { dataset: page1 } } + ) - test('should handle shift-click selection upwards', () => { - const { result } = renderHook(() => useMultiSelect(dataset)) + act(() => { + result.current.onSelect('2', normalClick) + }) - act(() => { - const firstClickEvent = { - shiftKey: false, - } as MouseEvent - const shiftClickEvent = { - shiftKey: true, - } as MouseEvent + rerender({ dataset: page2 }) - result.current.onSelect('4', firstClickEvent) - result.current.onSelect('2', shiftClickEvent) - }) + act(() => { + result.current.onSelect('7', ctrlClick) + }) - expect(Object.keys(result.current.selectionMap)).toEqual(['2', '3', '4']) - expect(result.current.selectionCount).toBe(3) - }) -}) + expect(result.current.selectedIds.sort()).toEqual(['2', '7']) + act(() => { + result.current.deselectAll() + }) + expect(result.current.selectedIds).toEqual([]) -describe('useMultiSelect hook across pagination', () => { - // Full dataset across all pages. - const fullDataset: Item[] = [ - { id: '1' }, - { id: '2' }, - { id: '3' }, - { id: '4' }, - { id: '5' }, - { id: '6' }, - { id: '7' }, - { id: '8' }, - { id: '9' }, - { id: '10' }, - ] - - // Simulated pages. - const pageSize = 5 - const page1 = fullDataset.slice(0, pageSize) - const page2 = fullDataset.slice(pageSize) - - test('should preserve selections across pages', () => { - const { result, rerender } = renderHook( - ({ dataset }) => useMultiSelect(dataset), - { - initialProps: { dataset: page1 }, - } - ) - - // Select items on page 1. - act(() => { - const mockEvent = { shiftKey: false } as MouseEvent - result.current.onSelect('2', mockEvent) - result.current.onSelect('4', mockEvent) + // Switch back to page 1: still no selections. + rerender({ dataset: page1 }) + expect(result.current.isPageAllSelected).toBe(false) }) - - expect(Object.keys(result.current.selectionMap)).toEqual(['2', '4']) - expect(result.current.selectionCount).toBe(2) - expect(result.current.isPageAllSelected).toBe('indeterminate') - - // Move to page 2. - rerender({ dataset: page2 }) - - // Selections from page 1 should persist. - expect(Object.keys(result.current.selectionMap)).toEqual(['2', '4']) - expect(result.current.selectionCount).toBe(2) - - // Select items on page 2. - act(() => { - const mockEvent = { shiftKey: false } as MouseEvent - result.current.onSelect('7', mockEvent) - }) - - expect(Object.keys(result.current.selectionMap)).toEqual(['2', '4', '7']) - expect(result.current.selectionCount).toBe(3) - expect(result.current.isPageAllSelected).toBe('indeterminate') - }) - - test('onSelectPage should select/deselect items only on the current page', () => { - const { result, rerender } = renderHook( - ({ dataset }) => useMultiSelect(dataset), - { - initialProps: { dataset: page1 }, - } - ) - - // Select all items on page 1. - act(() => { - result.current.onSelectPage() - }) - - expect(Object.keys(result.current.selectionMap)).toEqual([ - '1', - '2', - '3', - '4', - '5', - ]) - expect(result.current.selectionCount).toBe(5) - expect(result.current.isPageAllSelected).toBe(true) - - // Move to page 2. - rerender({ dataset: page2 }) - - // Page 2 items should not be selected. - expect(result.current.isPageAllSelected).toBe(false) - - // Select all items on page 2. - act(() => { - result.current.onSelectPage() - }) - - expect(Object.keys(result.current.selectionMap)).toEqual([ - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - ]) - expect(result.current.selectionCount).toBe(10) - expect(result.current.isPageAllSelected).toBe(true) - - // Deselect all items on page 2. - act(() => { - result.current.onSelectPage() - }) - - expect(Object.keys(result.current.selectionMap)).toEqual([ - '1', - '2', - '3', - '4', - '5', - ]) - expect(result.current.selectionCount).toBe(5) - expect(result.current.isPageAllSelected).toBe(false) - - // Move back to page 1. - rerender({ dataset: page1 }) - - expect(result.current.isPageAllSelected).toBe(true) }) - test('deselectAll should clear selections across all pages', () => { - const { result, rerender } = renderHook( - ({ dataset }) => useMultiSelect(dataset), - { - initialProps: { dataset: page1 }, - } - ) - - // Select items on page 1. - act(() => { - const mockEvent = { shiftKey: false } as MouseEvent - result.current.onSelect('2', mockEvent) - }) - - // Move to page 2. - rerender({ dataset: page2 }) - - // Select items on page 2. - act(() => { - const mockEvent = { shiftKey: false } as MouseEvent - result.current.onSelect('7', mockEvent) - }) - - expect(Object.keys(result.current.selectionMap)).toEqual(['2', '7']) - expect(result.current.selectionCount).toBe(2) - - // Deselect all selections. - act(() => { - result.current.deselectAll() + describe('dataset changes', () => { + const initialDataset: Row[] = [ + { id: '1' }, + { id: '2' }, + { id: '3' }, + { id: '4' }, + { id: '5' }, + ] + + test('anchor resets after dataset change', () => { + const { result, rerender } = renderHook( + ({ dataset }) => useMultiSelect(dataset), + { initialProps: { dataset: initialDataset } } + ) + + act(() => { + result.current.onSelect('2', normalClick) + }) + expect(result.current.selectedIds).toEqual(['2']) + + act(() => { + result.current.onSelect('4', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['2', '3', '4']) + + // Change dataset. + const changedDataset: Row[] = [...initialDataset, { id: '6' }] + + act(() => { + rerender({ dataset: changedDataset }) + }) + + // Anchor resets, now acts as if no anchor existed, using top row. + act(() => { + result.current.onSelect('6', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual([ + '1', + '2', + '3', + '4', + '5', + '6', + ]) + + // Anchor is now 6, last range end is 1. + act(() => { + result.current.onSelect('3', shiftClick) + }) + expect(result.current.selectedIds.sort()).toEqual(['3', '4', '5', '6']) }) - - expect(Object.keys(result.current.selectionMap)).toEqual([]) - expect(result.current.selectionCount).toBe(0) - expect(result.current.isPageAllSelected).toBe(false) - - // Move back to page 1 and check selections. - rerender({ dataset: page1 }) - expect(result.current.isPageAllSelected).toBe(false) }) }) diff --git a/libs/design-system/src/multi/useMultiSelect.tsx b/libs/design-system/src/multi/useMultiSelect.tsx index 0738a5be2..1b0273f46 100644 --- a/libs/design-system/src/multi/useMultiSelect.tsx +++ b/libs/design-system/src/multi/useMultiSelect.tsx @@ -1,86 +1,189 @@ 'use client' -import { MouseEvent, useCallback, useMemo, useState } from 'react' +import { mapValues } from '@technically/lodash' +import { MouseEvent, useCallback, useEffect, useMemo, useState } from 'react' -export type MultiSelectItem = { id: string } +export type MultiSelectRow = { id: string } -export type MultiSelect = ReturnType< - typeof useMultiSelect +type SelectionRow = { + index: number + row: Row +} + +export type MultiSelect = ReturnType< + typeof useMultiSelect > -export function useMultiSelect(dataset?: Item[]) { - const [selectionMap, setSelectionMap] = useState>({}) - const [, setLastSelectedItem] = useState<{ - item: Item - index: number - }>() +/** + * ### normal click (single select): + * + * - The selection is reduced to the clicked row, and the range selection anchor is updated. + * - Clicking the only selected row does not deselect it. + * + * ### ctrl/cmd-click (toggle select): + * + * - Toggles selection of an individual row without affecting others. + * - If selected, updates the anchor to that row. + * - If deselected, the anchor is updated to the nearest selected index, first checking higher indices, then lower. If no index is found reset the anchor. + * + * ### shift-click (range select): + * + * - Always select the full continuous range between the anchor and the clicked row, deselect all rows between the anchor and the last range end from the same anchor. + * - Do not update the anchor on shift-click, unless there is none set yet. + * - If no anchor is set, the range select will use the top row as the anchor and set the range end to the selected row. + * + * ### anchor + * + * - Whenever the anchor is updated, the range end is reset. + * - Whenever the anchor is reset the range end is also reset. + * + * @param dataset The current visible page of row data that the user cna interact with. + */ +export function useMultiSelect(dataset?: Row[]) { + const [selectionMap, setSelectionMap] = useState< + Record> + >({}) + const selectedIds = useMemo(() => Object.keys(selectionMap), [selectionMap]) + const selectedList = useMemo( + () => + Object.entries(selectionMap) + .map(([_, s]) => s) + .sort((a, b) => a.index - b.index), + [selectionMap] + ) + const [[anchor, rangeEnd], _setAnchor] = useState< + | [SelectionRow, SelectionRow] + | [SelectionRow, undefined] + | [undefined, undefined] + >([undefined, undefined]) + + const resetAnchor = useCallback(() => { + _setAnchor([undefined, undefined]) + }, []) + + const setAnchor = useCallback( + (anchor: SelectionRow, rangeEnd: SelectionRow | undefined) => { + _setAnchor([anchor, rangeEnd]) + }, + [] + ) + + // Page change could be pagination, refetch with new results, filtering, etc. + const datasetKey = useMemo( + () => dataset?.map((d) => d.id).join(','), + [dataset] + ) + // Reset anchor when page changes. + useEffect(() => { + resetAnchor() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [datasetKey]) const onSelect = useCallback( (id: string, e?: MouseEvent) => { if (!dataset) { return } - const selectedItem = dataset.find((datum) => datum.id === id) - const selectedIndex = dataset.findIndex((datum) => datum.id === id) - if (!selectedItem || selectedIndex === -1) { + + const idx = dataset.findIndex((d) => d.id === id) + if (idx === -1) { return } - const selected = { - item: selectedItem, - index: selectedIndex, + + const currentRow = dataset[idx] + const currentSelectionRow = { row: currentRow, index: idx } + const isCtrl = !!e?.ctrlKey || !!e?.metaKey + const isShift = !!e?.shiftKey + + let nextSelection = { ...selectionMap } + + // ctrl-click: toggle row. + if (isCtrl) { + if (nextSelection[currentRow.id]) { + delete nextSelection[currentRow.id] + // ctrl-deselect set the anchor to the nearest selected index, first + // checking higher indicies, then lower. If no index is found reset + // the anchor to none. + const nextAnchor = findNextAnchor(selectedList, idx) + if (!nextAnchor) { + resetAnchor() + } else { + setAnchor(nextAnchor, undefined) + } + } else { + nextSelection[currentRow.id] = currentSelectionRow + setAnchor(currentSelectionRow, undefined) + } + setSelectionMap(nextSelection) + return } - setSelectionMap((prevSelectionMap) => { - const newSelection = { ...prevSelectionMap } - setLastSelectedItem((prevSelection) => { - // If shift click, select all items between current and last selection indices. - if (e?.shiftKey && prevSelection) { - if (prevSelection.index < selected.index) { - for (let i = prevSelection.index; i <= selected.index; i++) { - const item = dataset[i] - newSelection[item.id] = item - } - } else { - for (let i = selected.index; i <= prevSelection.index; i++) { - const item = dataset[i] - newSelection[item.id] = item - } - } - return selected // Update prevSelection + // shift-click: select range. + if (isShift) { + const currentAnchorOrDefault = anchor || { + row: dataset[0], + index: 0, + } + + // If there was a previous anchor range end, deselect it. + if (rangeEnd) { + const start = Math.min(currentAnchorOrDefault.index, rangeEnd.index) + const end = Math.max(currentAnchorOrDefault.index, rangeEnd.index) + for (let i = start; i <= end; i++) { + const row = dataset[i] + delete nextSelection[row.id] } - // If no shift click, just select or deselect the current item. - if (newSelection[selected.item.id]) { - delete newSelection[selected.item.id] - return undefined // Reset prevSelection - } else { - newSelection[selected.item.id] = selected.item - return selected // Update prevSelection + } + const start = Math.min(currentAnchorOrDefault.index, idx) + const end = Math.max(currentAnchorOrDefault.index, idx) + for (let i = start; i <= end; i++) { + const row = dataset[i] + nextSelection[row.id] = { + row, + index: i, } - }) - return newSelection - }) + } + + // If no anchor is set yet, set the anchor to the clicked row. + if (!anchor) { + setAnchor(currentSelectionRow, { + row: dataset[0], + index: 0, + }) + } else { + setAnchor(anchor, currentSelectionRow) + } + setSelectionMap(nextSelection) + return + } + + // normal click. + // Reduce selection to the clicked row. + nextSelection = { + [currentRow.id]: currentSelectionRow, + } + setAnchor(currentSelectionRow, undefined) + setSelectionMap(nextSelection) }, - [dataset] + [ + dataset, + selectionMap, + setAnchor, + selectedList, + resetAnchor, + anchor, + rangeEnd, + ] ) - const isPageAllSelected = useMemo(() => { - return getIsPageAllSelected({ dataset, selectionMap }) - }, [dataset, selectionMap]) - - const selectedIds = useMemo( - () => - Object.entries(selectionMap) - .filter(([_, item]) => !!item) - .map(([id]) => id), - [selectionMap] + const isPageAllSelected = useMemo( + () => getIsPageAllSelected({ dataset, selectionMap }), + [dataset, selectionMap] ) - const someSelectedItemsOutsideCurrentPage = useMemo(() => { + const someSelectedRowsOutsideCurrentPage = useMemo(() => { if (!dataset) { - if (selectedIds.length === 0) { - return false - } - return true + return selectedIds.length > 0 } return selectedIds.some((id) => !dataset.some((datum) => datum.id === id)) }, [dataset, selectedIds]) @@ -96,24 +199,17 @@ export function useMultiSelect(dataset?: Item[]) { if (!dataset) { return } - setSelectionMap((prevSelectionMap) => { - const newSelection: Record = { - ...prevSelectionMap, - } - const isPageAllSelected = getIsPageAllSelected({ - dataset, - selectionMap: prevSelectionMap, - }) - // If not all items are selected, add all the items. - if ( - isPageAllSelected === false || - isPageAllSelected === 'indeterminate' - ) { - dataset.forEach((datum) => { - newSelection[datum.id] = datum + setSelectionMap((prev) => { + const newSelection = { ...prev } + const allState = getIsPageAllSelected({ dataset, selectionMap: prev }) + if (allState === false || allState === 'indeterminate') { + dataset.forEach((datum, i) => { + newSelection[datum.id] = { + row: datum, + index: i, + } }) } else { - // If all items are selected, remove all the items. dataset.forEach((datum) => { delete newSelection[datum.id] }) @@ -122,70 +218,80 @@ export function useMultiSelect(dataset?: Item[]) { }) }, [dataset]) - const deselect = useCallback((ids: string[]) => { - setSelectionMap((prevSelectionMap) => { - const newSelection: Record = { - ...prevSelectionMap, - } - ids.forEach((id) => { - delete newSelection[id] + const deselect = useCallback( + (ids: string[]) => { + setSelectionMap((prev) => { + const newSelection = { ...prev } + ids.forEach((id) => { + delete newSelection[id] + }) + return newSelection }) - return newSelection - }) - }, []) + if (ids.find((id) => id === anchor?.row.id)) { + resetAnchor() + } + }, + [anchor?.row.id, resetAnchor] + ) const deselectAll = useCallback(() => { setSelectionMap({}) - }, []) + resetAnchor() + }, [resetAnchor]) + + const selectionCount = useMemo(() => selectedIds.length, [selectedIds]) - const selectionCount = useMemo( - () => Object.keys(selectionMap).length, + const selection = useMemo( + () => mapValues(selectionMap, (s) => s.row), [selectionMap] ) - return useMemo( - () => ({ - onSelect, - onSelectPage, - selectionMap, - selectedIds, - isPageAllSelected, - selectionCount, - someSelectedItemsOutsideCurrentPage, - someSelectedOnCurrentPage, - deselect, - deselectAll, - }), - [ - onSelect, - onSelectPage, - selectionMap, - selectedIds, - isPageAllSelected, - selectionCount, - someSelectedItemsOutsideCurrentPage, - someSelectedOnCurrentPage, - deselect, - deselectAll, - ] - ) + return { + onSelect, + onSelectPage, + selection, + selectedIds, + isPageAllSelected, + selectionCount, + someSelectedRowsOutsideCurrentPage, + someSelectedOnCurrentPage, + deselect, + deselectAll, + } } -function getIsPageAllSelected({ +function getIsPageAllSelected({ dataset, selectionMap, }: { - dataset?: Item[] - selectionMap: Record + dataset?: Row[] + selectionMap: Record }) { - if (!dataset) { - return false - } - if (dataset.every((datum) => selectionMap[datum.id])) { - return true - } - if (dataset.some((datum) => selectionMap[datum.id])) { - return 'indeterminate' as const - } + if (!dataset) return false + const allSelected = dataset.every((d) => selectionMap[d.id]) + if (allSelected) return true + const someSelected = dataset.some((d) => selectionMap[d.id]) + if (someSelected) return 'indeterminate' as const return false } + +/** + * Find the nearest selected index, first checking higher indicies, then lower. + * If no index is found return undefined. + * + * @param selectedList List of selected rows. + * @param idx Index of the row to find the next anchor from. + * @returns SelectionRow | undefined + */ +function findNextAnchor( + selectedList: SelectionRow[], + idx: number +): SelectionRow | undefined { + const nextHighest = selectedList.find((s) => s.index > idx) + if (nextHighest) { + return nextHighest + } else { + const nextLowest = [...selectedList].reverse().find((s) => s.index < idx) + return nextLowest + } +} diff --git a/package-lock.json b/package-lock.json index c2504f5be..83a68ac38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,7 +128,7 @@ "@nx/rollup": "18.0.3", "@nx/webpack": "18.0.3", "@nx/workspace": "18.0.3", - "@playwright/test": "^1.36.0", + "@playwright/test": "^1.49.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", "@rollup/plugin-wasm": "^6.2.2", "@svgr/webpack": "8.1.0", @@ -167,7 +167,7 @@ "jest": "29.4.3", "msw": "^2.4.9", "nx": "18.0.3", - "playwright": "^1.42.1", + "playwright": "^1.49.1", "postcss": "8.4.21", "prettier": "2.7.1", "react-refresh": "^0.10.0", @@ -6418,17 +6418,18 @@ } }, "node_modules/@playwright/test": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.1.tgz", - "integrity": "sha512-Gq9rmS54mjBL/7/MvBaNOBwbfnh7beHvS6oS4srqXFcQHpQCV1+c8JXWE8VLPyRDhgS3H8x8A7hztqI9VnwrAQ==", + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", + "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "license": "Apache-2.0", "dependencies": { - "playwright": "1.42.1" + "playwright": "1.49.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { @@ -25269,31 +25270,33 @@ } }, "node_modules/playwright": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz", - "integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==", + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.42.1" + "playwright-core": "1.49.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz", - "integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==", + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/playwright/node_modules/fsevents": { @@ -36039,11 +36042,11 @@ } }, "@playwright/test": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.1.tgz", - "integrity": "sha512-Gq9rmS54mjBL/7/MvBaNOBwbfnh7beHvS6oS4srqXFcQHpQCV1+c8JXWE8VLPyRDhgS3H8x8A7hztqI9VnwrAQ==", + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", + "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", "requires": { - "playwright": "1.42.1" + "playwright": "1.49.1" } }, "@pmmmwh/react-refresh-webpack-plugin": { @@ -48892,12 +48895,12 @@ } }, "playwright": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz", - "integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==", + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", "requires": { "fsevents": "2.3.2", - "playwright-core": "1.42.1" + "playwright-core": "1.49.1" }, "dependencies": { "fsevents": { @@ -48909,9 +48912,9 @@ } }, "playwright-core": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz", - "integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==" + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==" }, "polished": { "version": "4.2.2", diff --git a/package.json b/package.json index c3b2c6c96..2be134e0c 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "@nx/rollup": "18.0.3", "@nx/webpack": "18.0.3", "@nx/workspace": "18.0.3", - "@playwright/test": "^1.36.0", + "@playwright/test": "^1.49.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", "@rollup/plugin-wasm": "^6.2.2", "@svgr/webpack": "8.1.0", @@ -179,7 +179,7 @@ "jest": "29.4.3", "msw": "^2.4.9", "nx": "18.0.3", - "playwright": "^1.42.1", + "playwright": "^1.49.1", "postcss": "8.4.21", "prettier": "2.7.1", "react-refresh": "^0.10.0",