From a3f86a88e6b67e22f251b1cd0d510a42c9b26614 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Fri, 20 Sep 2024 18:36:42 +0200 Subject: [PATCH 1/3] [Discover] Add "Shift + Select" functionality to Discover grid --- .../src/components/data_table.tsx | 10 +++- .../data_table_document_selection.tsx | 10 ++-- .../src/hooks/use_selected_docs.ts | 47 ++++++++++++++++++- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/kbn-unified-data-table/src/components/data_table.tsx b/packages/kbn-unified-data-table/src/components/data_table.tsx index 556e445eda4b9..6e7baf4bc8550 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.tsx @@ -497,8 +497,14 @@ export const UnifiedDataTable = ({ const [isCompareActive, setIsCompareActive] = useState(false); const displayedColumns = getDisplayedColumns(columns, dataView); const defaultColumns = displayedColumns.includes('_source'); - const docMap = useMemo(() => new Map(rows?.map((row) => [row.id, row]) ?? []), [rows]); - const getDocById = useCallback((id: string) => docMap.get(id), [docMap]); + const docMap = useMemo( + () => + new Map( + rows?.map((row, docIndex) => [row.id, { doc: row, docIndex }]) ?? [] + ), + [rows] + ); + const getDocById = useCallback((id: string) => docMap.get(id)?.doc, [docMap]); const selectedDocsState = useSelectedDocs(docMap); const { isDocSelected, diff --git a/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx b/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx index ee6eee93539f9..fb367f1159f88 100644 --- a/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx @@ -38,7 +38,7 @@ export const SelectButton = (props: EuiDataGridCellValueElementProps) => { const { record, rowIndex } = useControlColumn(props); const { euiTheme } = useEuiTheme(); const { selectedDocsState } = useContext(UnifiedDataTableContext); - const { isDocSelected, toggleDocSelection } = selectedDocsState; + const { isDocSelected, toggleDocSelection, toggleMultipleDocsSelection } = selectedDocsState; const toggleDocumentSelectionLabel = i18n.translate('unifiedDataTable.grid.selectDoc', { defaultMessage: `Select document ''{rowNumber}''`, @@ -62,8 +62,12 @@ export const SelectButton = (props: EuiDataGridCellValueElementProps) => { aria-label={toggleDocumentSelectionLabel} checked={isDocSelected(record.id)} data-test-subj={`dscGridSelectDoc-${record.id}`} - onChange={() => { - toggleDocSelection(record.id); + onChange={(event) => { + if (event.nativeEvent?.shiftKey) { + toggleMultipleDocsSelection(record.id); + } else { + toggleDocSelection(record.id); + } }} /> diff --git a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts index fd839c88c6363..1347e98823e2e 100644 --- a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts +++ b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import type { DataTableRecord } from '@kbn/discover-utils'; export interface UseSelectedDocsState { @@ -17,6 +17,7 @@ export interface UseSelectedDocsState { selectedDocsCount: number; docIdsInSelectionOrder: string[]; toggleDocSelection: (docId: string) => void; + toggleMultipleDocsSelection: (toDocId: string) => void; selectAllDocs: () => void; selectMoreDocs: (docIds: string[]) => void; deselectSomeDocs: (docIds: string[]) => void; @@ -25,8 +26,11 @@ export interface UseSelectedDocsState { getSelectedDocsOrderedByRows: (rows: DataTableRecord[]) => DataTableRecord[]; } -export const useSelectedDocs = (docMap: Map): UseSelectedDocsState => { +export const useSelectedDocs = ( + docMap: Map +): UseSelectedDocsState => { const [selectedDocsSet, setSelectedDocsSet] = useState>(new Set()); + const lastCheckboxToggledDocId = useRef(); const toggleDocSelection = useCallback((docId: string) => { setSelectedDocsSet((prevSelectedRowsSet) => { @@ -38,6 +42,7 @@ export const useSelectedDocs = (docMap: Map): UseSelect } return newSelectedRowsSet; }); + lastCheckboxToggledDocId.current = docId; }, []); const replaceSelectedDocs = useCallback((docIds: string[]) => { @@ -73,6 +78,42 @@ export const useSelectedDocs = (docMap: Map): UseSelect [selectedDocsSet, docMap] ); + const toggleMultipleDocsSelection = useCallback( + (toDocId: string) => { + const shouldSelect = !isDocSelected(toDocId); + + const lastToggledDocIdIndex = docMap.get( + lastCheckboxToggledDocId.current || toDocId + )?.docIndex; + const currentToggledDocIdIndex = docMap.get(toDocId)?.docIndex; + const docIds = []; + + if ( + lastToggledDocIdIndex && + currentToggledDocIdIndex && + lastToggledDocIdIndex !== currentToggledDocIdIndex + ) { + const startIndex = Math.min(lastToggledDocIdIndex, currentToggledDocIdIndex); + const endIndex = Math.max(lastToggledDocIdIndex, currentToggledDocIdIndex); + + docMap.forEach(({ doc, docIndex }) => { + if (docIndex >= startIndex && docIndex <= endIndex) { + docIds.push(doc.id); + } + }); + } + + if (shouldSelect) { + selectMoreDocs(docIds); + } else { + deselectSomeDocs(docIds); + } + + lastCheckboxToggledDocId.current = toDocId; + }, + [selectMoreDocs, deselectSomeDocs, docMap, isDocSelected] + ); + const getSelectedDocsOrderedByRows = useCallback( (rows: DataTableRecord[]) => { return rows.filter((row) => isDocSelected(row.id)); @@ -101,6 +142,7 @@ export const useSelectedDocs = (docMap: Map): UseSelect docIdsInSelectionOrder: selectedDocIds, getCountOfFilteredSelectedDocs, toggleDocSelection, + toggleMultipleDocsSelection, selectAllDocs, selectMoreDocs, deselectSomeDocs, @@ -112,6 +154,7 @@ export const useSelectedDocs = (docMap: Map): UseSelect isDocSelected, getCountOfFilteredSelectedDocs, toggleDocSelection, + toggleMultipleDocsSelection, selectAllDocs, selectMoreDocs, deselectSomeDocs, From e6067f1f54f5d213b1220602f1918b636754a97e Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 23 Sep 2024 10:42:30 +0200 Subject: [PATCH 2/3] [Discover] Fix lint issues --- packages/kbn-unified-data-table/__mocks__/table_context.ts | 1 + packages/kbn-unified-data-table/src/components/data_table.tsx | 2 +- .../src/components/data_table_document_selection.tsx | 2 +- .../src/hooks/use_selected_docs.test.ts | 4 +++- .../kbn-unified-data-table/src/hooks/use_selected_docs.ts | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/kbn-unified-data-table/__mocks__/table_context.ts b/packages/kbn-unified-data-table/__mocks__/table_context.ts index a55bea58feebf..e47682deb4636 100644 --- a/packages/kbn-unified-data-table/__mocks__/table_context.ts +++ b/packages/kbn-unified-data-table/__mocks__/table_context.ts @@ -62,6 +62,7 @@ export function buildSelectedDocsState(selectedDocIds: string[]): UseSelectedDoc selectedDocsCount: selectedDocsSet.size, docIdsInSelectionOrder: selectedDocIds, toggleDocSelection: jest.fn(), + toggleMultipleDocsSelection: jest.fn(), selectAllDocs: jest.fn(), selectMoreDocs: jest.fn(), deselectSomeDocs: jest.fn(), diff --git a/packages/kbn-unified-data-table/src/components/data_table.tsx b/packages/kbn-unified-data-table/src/components/data_table.tsx index 6e7baf4bc8550..ed32267b749f9 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.tsx @@ -499,7 +499,7 @@ export const UnifiedDataTable = ({ const defaultColumns = displayedColumns.includes('_source'); const docMap = useMemo( () => - new Map( + new Map( rows?.map((row, docIndex) => [row.id, { doc: row, docIndex }]) ?? [] ), [rows] diff --git a/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx b/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx index fb367f1159f88..78d2450dc0bcc 100644 --- a/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx @@ -63,7 +63,7 @@ export const SelectButton = (props: EuiDataGridCellValueElementProps) => { checked={isDocSelected(record.id)} data-test-subj={`dscGridSelectDoc-${record.id}`} onChange={(event) => { - if (event.nativeEvent?.shiftKey) { + if ((event.nativeEvent as MouseEvent)?.shiftKey) { toggleMultipleDocsSelection(record.id); } else { toggleDocSelection(record.id); diff --git a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts index 743a3006d25d9..8d3c68a03dcf4 100644 --- a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts +++ b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts @@ -17,7 +17,7 @@ describe('useSelectedDocs', () => { const docs = generateEsHits(dataViewWithTimefieldMock, 5).map((hit) => buildDataTableRecord(hit, dataViewWithTimefieldMock) ); - const docsMap = new Map(docs.map((doc) => [doc.id, doc])); + const docsMap = new Map(docs.map((doc, docIndex) => [doc.id, { doc, docIndex }])); test('should have a correct default state', () => { const { result } = renderHook(() => useSelectedDocs(docsMap)); @@ -223,4 +223,6 @@ describe('useSelectedDocs', () => { expect(result.current.getCountOfFilteredSelectedDocs([docs[0].id])).toBe(0); expect(result.current.getCountOfFilteredSelectedDocs([docs[2].id, docs[3].id])).toBe(0); }); + + // TODO: add test for toggleMultipleDocsSelection }); diff --git a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts index 1347e98823e2e..65e8fbf56f34f 100644 --- a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts +++ b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts @@ -86,7 +86,7 @@ export const useSelectedDocs = ( lastCheckboxToggledDocId.current || toDocId )?.docIndex; const currentToggledDocIdIndex = docMap.get(toDocId)?.docIndex; - const docIds = []; + const docIds: string[] = []; if ( lastToggledDocIdIndex && From d7e2b2bb7baea3f7745c32d4d954313427a3f2fc Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 23 Sep 2024 13:53:31 +0200 Subject: [PATCH 3/3] [Discover] Add tests --- .../src/hooks/use_selected_docs.test.ts | 26 ++++++++- .../src/hooks/use_selected_docs.ts | 6 +-- .../_data_grid_row_selection.ts | 54 +++++++++++++++++++ test/functional/services/data_grid.ts | 13 ++++- 4 files changed, 93 insertions(+), 6 deletions(-) diff --git a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts index 8d3c68a03dcf4..3865e412bdcd2 100644 --- a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts +++ b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts @@ -224,5 +224,29 @@ describe('useSelectedDocs', () => { expect(result.current.getCountOfFilteredSelectedDocs([docs[2].id, docs[3].id])).toBe(0); }); - // TODO: add test for toggleMultipleDocsSelection + test('should toggleMultipleDocsSelection correctly', () => { + const { result } = renderHook(() => useSelectedDocs(docsMap)); + const docIds = docs.map((doc) => doc.id); + + // select `0` + act(() => { + result.current.toggleDocSelection(docs[0].id); + }); + + expect(result.current.getCountOfFilteredSelectedDocs(docIds)).toBe(1); + + // select from `0` to `4` + act(() => { + result.current.toggleMultipleDocsSelection(docs[4].id); + }); + + expect(result.current.getCountOfFilteredSelectedDocs(docIds)).toBe(5); + + // deselect from `2` to `4` + act(() => { + result.current.toggleMultipleDocsSelection(docs[2].id); + }); + + expect(result.current.getCountOfFilteredSelectedDocs(docIds)).toBe(2); + }); }); diff --git a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts index 65e8fbf56f34f..f6538f185b32f 100644 --- a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts +++ b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts @@ -83,14 +83,14 @@ export const useSelectedDocs = ( const shouldSelect = !isDocSelected(toDocId); const lastToggledDocIdIndex = docMap.get( - lastCheckboxToggledDocId.current || toDocId + lastCheckboxToggledDocId.current ?? toDocId )?.docIndex; const currentToggledDocIdIndex = docMap.get(toDocId)?.docIndex; const docIds: string[] = []; if ( - lastToggledDocIdIndex && - currentToggledDocIdIndex && + typeof lastToggledDocIdIndex === 'number' && + typeof currentToggledDocIdIndex === 'number' && lastToggledDocIdIndex !== currentToggledDocIdIndex ) { const startIndex = Math.min(lastToggledDocIdIndex, currentToggledDocIdIndex); diff --git a/test/functional/apps/discover/group2_data_grid3/_data_grid_row_selection.ts b/test/functional/apps/discover/group2_data_grid3/_data_grid_row_selection.ts index 97e56a1de868c..66d3f1323650e 100644 --- a/test/functional/apps/discover/group2_data_grid3/_data_grid_row_selection.ts +++ b/test/functional/apps/discover/group2_data_grid3/_data_grid_row_selection.ts @@ -84,6 +84,60 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); + it('should be able to select multiple rows holding Shift key', async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(false); + + // select 1 row + await dataGrid.selectRow(1); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(1); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(1); + }); + + // select 3 more + await dataGrid.selectRow(4, { pressShiftKey: true }); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(4); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(4); + }); + + // deselect index 3 and 4 + await dataGrid.selectRow(3, { pressShiftKey: true }); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(2); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(2); + }); + + // select from index 3 to 0 + await dataGrid.selectRow(0, { pressShiftKey: true }); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(4); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(4); + }); + + // select from both pages + await testSubjects.click('pagination-button-1'); + await retry.try(async () => { + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(0); + }); + + await dataGrid.selectRow(2, { pressShiftKey: true }); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(3); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(8); + }); + }); + it('should be able to bulk select rows', async () => { expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(false); expect(await testSubjects.getAttribute('selectAllDocsOnPageToggle', 'title')).to.be( diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index a280c6556bbd7..f908903a2cfa2 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -689,12 +689,21 @@ export class DataGridService extends FtrService { await this.checkCurrentRowsPerPageToBe(newValue); } - public async selectRow(rowIndex: number) { + public async selectRow(rowIndex: number, { pressShiftKey }: { pressShiftKey?: boolean } = {}) { const checkbox = await this.find.byCssSelector( `.euiDataGridRow[data-grid-visible-row-index="${rowIndex}"] [data-gridcell-column-id="select"] .euiCheckbox__input` ); - await checkbox.click(); + if (pressShiftKey) { + await this.browser + .getActions() + .keyDown(Key.SHIFT) + .click(checkbox._webElement) + .keyUp(Key.SHIFT) + .perform(); + } else { + await checkbox.click(); + } } public async getNumberOfSelectedRows() {