[Discover] Allow to select/deselect all rows in the grid at once (#184241)

- Closes https://github.com/elastic/kibana/issues/175943

## Summary

This PR adds a checkbox which allows now to select all rows at once (or
deselect all) on the current page.

- [x] A new checkbox was added to the grid header
- [x] "Compare documents" button was moved under "Selected" menu
- [x] "Compare documents" button gets disabled if user selects more than
100 rows
- [x] "Selected" menu button got a new look
- [x] A new "Select all X" button was added next too "Selected" menu
button

<img width="1554" alt="Screenshot 2024-07-18 at 14 45 00"
src="https://github.com/user-attachments/assets/631cd350-be7d-43be-bc07-c0f6a943bacb">
<img width="563" alt="Screenshot 2024-07-18 at 14 45 10"
src="https://github.com/user-attachments/assets/d49d18f2-d255-401f-b157-3892e6f78d7c">
<img width="443" alt="Screenshot 2024-07-18 at 14 47 02"
src="https://github.com/user-attachments/assets/154c8292-c9b3-409d-b9f6-f78ac83527e9">


### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Davis McPhee <davismcphee@hotmail.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Julia Rechkunova 2024-07-31 13:25:03 +02:00 committed by GitHub
parent c0b60fef4c
commit 22de72d022
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1276 additions and 314 deletions

View file

@ -15,6 +15,7 @@ import { DataTableContext } from '../src/table_context';
import { convertValueToString } from '../src/utils/convert_value_to_string';
import { buildDataTableRecord } from '@kbn/discover-utils';
import type { EsHitRecord } from '@kbn/discover-utils/types';
import type { UseSelectedDocsState } from '../src/hooks/use_selected_docs';
const buildTableContext = (dataView: DataView, rows: EsHitRecord[]): DataTableContext => {
const usedRows = rows.map((row) => {
@ -28,8 +29,9 @@ const buildTableContext = (dataView: DataView, rows: EsHitRecord[]): DataTableCo
onFilter: jest.fn(),
dataView,
isDarkMode: false,
selectedDocs: [],
setSelectedDocs: jest.fn(),
selectedDocsState: buildSelectedDocsState([]),
pageIndex: 0,
pageSize: 10,
valueToStringConverter: (rowIndex, columnId, options) =>
convertValueToString({
rowIndex,
@ -45,3 +47,21 @@ const buildTableContext = (dataView: DataView, rows: EsHitRecord[]): DataTableCo
export const dataTableContextMock = buildTableContext(dataViewMock, esHitsMock);
export const dataTableContextComplexMock = buildTableContext(dataViewComplexMock, esHitsComplex);
export function buildSelectedDocsState(selectedDocIds: string[]): UseSelectedDocsState {
const selectedDocsSet = new Set(selectedDocIds);
return {
isDocSelected: (docId: string) => selectedDocsSet.has(docId),
getCountOfSelectedDocs: (docIds: string[]) =>
docIds.reduce((acc, docId) => (selectedDocsSet.has(docId) ? acc + 1 : acc), 0),
hasSelectedDocs: selectedDocsSet.size > 0,
selectedDocIds,
toggleDocSelection: jest.fn(),
selectAllDocs: jest.fn(),
selectMoreDocs: jest.fn(),
deselectSomeDocs: jest.fn(),
replaceSelectedDocs: jest.fn(),
clearAllSelectedDocs: jest.fn(),
};
}

View file

@ -52,7 +52,7 @@ const getDocById = (id: string) => docs.find((doc) => doc.raw._id === id);
const renderCompareDocuments = ({
forceShowAllFields = false,
}: { forceShowAllFields?: boolean } = {}) => {
const setSelectedDocs = jest.fn();
const replaceSelectedDocs = jest.fn();
const getCompareDocuments = (props?: Partial<CompareDocumentsProps>) => (
<CompareDocuments
id="test"
@ -63,20 +63,20 @@ const renderCompareDocuments = ({
dataView={dataViewWithTimefieldMock}
isPlainRecord={false}
selectedFieldNames={['message', 'extension', 'bytes']}
selectedDocs={['0', '1', '2']}
selectedDocIds={['0', '1', '2']}
schemaDetectors={[]}
forceShowAllFields={forceShowAllFields}
showFullScreenButton={true}
fieldFormats={{} as any}
getDocById={getDocById}
setSelectedDocs={setSelectedDocs}
replaceSelectedDocs={replaceSelectedDocs}
setIsCompareActive={jest.fn()}
{...props}
/>
);
const { rerender } = render(getCompareDocuments());
return {
setSelectedDocs,
replaceSelectedDocs,
rerender: (props?: Partial<CompareDocumentsProps>) => rerender(getCompareDocuments(props)),
};
};
@ -146,10 +146,10 @@ describe('CompareDocuments', () => {
});
it('should set selected docs when columns change', () => {
const { setSelectedDocs } = renderCompareDocuments();
const { replaceSelectedDocs } = renderCompareDocuments();
const visibleColumns = ['fields_generated-id', '0', '1', '2'];
mockDataGridProps?.columnVisibility.setVisibleColumns(visibleColumns);
expect(setSelectedDocs).toHaveBeenCalledWith(visibleColumns.slice(1));
expect(replaceSelectedDocs).toHaveBeenCalledWith(visibleColumns.slice(1));
});
it('should force show all fields when prop is true', () => {

View file

@ -42,13 +42,13 @@ export interface CompareDocumentsProps {
dataView: DataView;
isPlainRecord: boolean;
selectedFieldNames: string[];
selectedDocs: string[];
selectedDocIds: string[];
schemaDetectors: EuiDataGridSchemaDetector[];
forceShowAllFields: boolean;
showFullScreenButton?: boolean;
fieldFormats: FieldFormatsStart;
getDocById: (id: string) => DataTableRecord | undefined;
setSelectedDocs: (selectedDocs: string[]) => void;
replaceSelectedDocs: (docIds: string[]) => void;
setIsCompareActive: (isCompareActive: boolean) => void;
additionalFieldGroups?: AdditionalFieldGroups;
}
@ -69,13 +69,13 @@ const CompareDocuments = ({
isPlainRecord,
selectedFieldNames,
additionalFieldGroups,
selectedDocs,
selectedDocIds,
schemaDetectors,
forceShowAllFields,
showFullScreenButton,
fieldFormats,
getDocById,
setSelectedDocs,
replaceSelectedDocs,
setIsCompareActive,
}: CompareDocumentsProps) => {
// Memoize getDocById to ensure we don't lose access to the comparison docs if, for example,
@ -104,7 +104,7 @@ const CompareDocuments = ({
dataView,
selectedFieldNames,
additionalFieldGroups,
selectedDocs,
selectedDocIds,
showAllFields: Boolean(forceShowAllFields || showAllFields),
showMatchingValues: Boolean(showMatchingValues),
getDocById: memoizedGetDocById,
@ -113,25 +113,25 @@ const CompareDocuments = ({
wrapper,
isPlainRecord,
fieldColumnId,
selectedDocs,
selectedDocIds,
getDocById: memoizedGetDocById,
setSelectedDocs,
replaceSelectedDocs,
});
const comparisonColumnVisibility = useMemo<EuiDataGridColumnVisibility>(
() => ({
visibleColumns: comparisonColumns.map(({ id: columnId }) => columnId),
setVisibleColumns: (visibleColumns) => {
const [_fieldColumnId, ...newSelectedDocs] = visibleColumns;
setSelectedDocs(newSelectedDocs);
replaceSelectedDocs(newSelectedDocs);
},
}),
[comparisonColumns, setSelectedDocs]
[comparisonColumns, replaceSelectedDocs]
);
const additionalControls = useMemo(
() => (
<ComparisonControls
isPlainRecord={isPlainRecord}
selectedDocs={selectedDocs}
selectedDocIds={selectedDocIds}
showDiff={showDiff}
diffMode={diffMode}
showDiffDecorations={showDiffDecorations}
@ -150,7 +150,7 @@ const CompareDocuments = ({
diffMode,
forceShowAllFields,
isPlainRecord,
selectedDocs,
selectedDocIds,
setDiffMode,
setIsCompareActive,
setShowAllFields,
@ -184,7 +184,7 @@ const CompareDocuments = ({
dataView,
comparisonFields,
fieldColumnId,
selectedDocs,
selectedDocIds,
diffMode: showDiff ? diffMode : undefined,
fieldFormats,
getDocById: memoizedGetDocById,

View file

@ -20,7 +20,7 @@ const renderComparisonControls = ({
isPlainRecord?: ComparisonControlsProps['isPlainRecord'];
forceShowAllFields?: ComparisonControlsProps['forceShowAllFields'];
} = {}) => {
const selectedDocs = ['0', '1', '2'];
const selectedDocIds = ['0', '1', '2'];
const Wrapper = () => {
const [showDiff, setShowDiff] = useState(true);
const [diffMode, setDiffMode] = useState<DocumentDiffMode>('basic');
@ -34,7 +34,7 @@ const renderComparisonControls = ({
<IntlProvider locale="en">
<ComparisonControls
isPlainRecord={isPlainRecord}
selectedDocs={selectedDocs}
selectedDocIds={selectedDocIds}
showDiff={showDiff}
diffMode={diffMode}
showDiffDecorations={showDiffDecorations}
@ -67,7 +67,7 @@ const renderComparisonControls = ({
return {
getComparisonCountDisplay: () =>
screen.getByText(
`Comparing ${selectedDocs.length} ${isPlainRecord ? 'results' : 'documents'}`
`Comparing ${selectedDocIds.length} ${isPlainRecord ? 'results' : 'documents'}`
),
getComparisonSettingsButton,
clickComparisonSettingsButton: () => userEvent.click(getComparisonSettingsButton()),

View file

@ -30,7 +30,7 @@ import type { DocumentDiffMode } from './types';
export interface ComparisonControlsProps {
isPlainRecord?: boolean;
selectedDocs: string[];
selectedDocIds: string[];
showDiff: boolean | undefined;
diffMode: DocumentDiffMode | undefined;
showDiffDecorations: boolean | undefined;
@ -47,7 +47,7 @@ export interface ComparisonControlsProps {
export const ComparisonControls = ({
isPlainRecord,
selectedDocs,
selectedDocIds,
showDiff,
diffMode,
showDiffDecorations,
@ -72,13 +72,13 @@ export const ComparisonControls = ({
<FormattedMessage
id="unifiedDataTable.comparingResults"
defaultMessage="Comparing {documentCount} results"
values={{ documentCount: selectedDocs.length }}
values={{ documentCount: selectedDocIds.length }}
/>
) : (
<FormattedMessage
id="unifiedDataTable.comparingDocuments"
defaultMessage="Comparing {documentCount} documents"
values={{ documentCount: selectedDocs.length }}
values={{ documentCount: selectedDocIds.length }}
/>
)}
</strong>

View file

@ -55,7 +55,7 @@ const renderComparisonCellValue = (props: Partial<UseComparisonCellValueProps> =
dataView: dataViewWithTimefieldMock,
comparisonFields: ['message', 'extension', 'bytes'],
fieldColumnId,
selectedDocs: ['0', '1', '2'],
selectedDocIds: ['0', '1', '2'],
diffMode: undefined,
fieldFormats: fieldFormatsMock,
getDocById,
@ -408,7 +408,7 @@ describe('useComparisonCellValue', () => {
expect(calculateDiff).toHaveBeenCalledTimes(2);
renderComparisonCell(cellProps2);
expect(calculateDiff).toHaveBeenCalledTimes(2);
rerender({ diffMode: 'words', selectedDocs: ['1', '2', '0'] });
rerender({ diffMode: 'words', selectedDocIds: ['1', '2', '0'] });
const cellProps3 = {
...cellProps1,
columnId: '2',
@ -425,7 +425,7 @@ describe('useComparisonCellValue', () => {
expect(calculateDiff).toHaveBeenCalledTimes(4);
renderComparisonCell(cellProps4);
expect(calculateDiff).toHaveBeenCalledTimes(4);
rerender({ diffMode: 'lines', selectedDocs: ['2', '0', '1'] });
rerender({ diffMode: 'lines', selectedDocIds: ['2', '0', '1'] });
const cellProps5 = {
...cellProps1,
columnId: '0',

View file

@ -39,7 +39,7 @@ export interface UseComparisonCellValueProps {
dataView: DataView;
comparisonFields: string[];
fieldColumnId: string;
selectedDocs: string[];
selectedDocIds: string[];
diffMode: DocumentDiffMode | undefined;
fieldFormats: FieldFormatsStart;
getDocById: (id: string) => DataTableRecord | undefined;
@ -50,13 +50,13 @@ export const useComparisonCellValue = ({
dataView,
comparisonFields,
fieldColumnId,
selectedDocs,
selectedDocIds,
diffMode,
fieldFormats,
getDocById,
additionalFieldGroups,
}: UseComparisonCellValueProps) => {
const baseDocId = selectedDocs[0];
const baseDocId = selectedDocIds[0];
const baseDoc = useMemo(() => getDocById(baseDocId)?.flattened, [baseDocId, getDocById]);
const [calculateDiffMemoized] = useState(() => createCalculateDiffMemoized());
@ -92,7 +92,7 @@ export const useComparisonCellValue = ({
);
};
type CellValueProps = Omit<UseComparisonCellValueProps, 'selectedDocs'> &
type CellValueProps = Omit<UseComparisonCellValueProps, 'selectedDocIds'> &
EuiDataGridCellValueElementProps & {
baseDocId: string;
baseDoc: DataTableRecord['flattened'] | undefined;

View file

@ -78,7 +78,7 @@ const docs = generateEsHits(dataViewWithTimefieldMock, 4).map((hit) =>
const defaultGetDocById = (id: string) => docs.find((doc) => doc.raw._id === id);
const fieldColumnId = 'fieldColumnId';
const selectedDocs = ['0', '1', '2', '3'];
const selectedDocIds = ['0', '1', '2', '3'];
const renderColumns = ({
wrapperWidth,
@ -93,7 +93,7 @@ const renderColumns = ({
if (wrapperWidth) {
Object.defineProperty(wrapper, 'offsetWidth', { value: wrapperWidth });
}
const setSelectedDocs = jest.fn();
const replaceSelectedDocs = jest.fn();
const {
result: { current: columns },
} = renderHook(() =>
@ -101,17 +101,17 @@ const renderColumns = ({
wrapper,
isPlainRecord,
fieldColumnId,
selectedDocs,
selectedDocIds,
getDocById,
setSelectedDocs,
replaceSelectedDocs,
})
);
return { columns, setSelectedDocs };
return { columns, replaceSelectedDocs };
};
describe('useComparisonColumns', () => {
it('should return comparison columns', () => {
const { columns, setSelectedDocs } = renderColumns();
const { columns, replaceSelectedDocs } = renderColumns();
expect(columns).toEqual([
{
id: fieldColumnId,
@ -123,17 +123,17 @@ describe('useComparisonColumns', () => {
},
getComparisonColumn({
column: {
id: selectedDocs[0],
id: selectedDocIds[0],
display: expect.anything(),
displayAsText: `Pinned document: ${selectedDocs[0]}`,
displayAsText: `Pinned document: ${selectedDocIds[0]}`,
},
includeRemoveAction: true,
}),
getComparisonColumn({
column: {
id: selectedDocs[1],
display: selectedDocs[1],
displayAsText: `Comparison document: ${selectedDocs[1]}`,
id: selectedDocIds[1],
display: selectedDocIds[1],
displayAsText: `Comparison document: ${selectedDocIds[1]}`,
actions: {
showMoveRight: true,
},
@ -143,9 +143,9 @@ describe('useComparisonColumns', () => {
}),
getComparisonColumn({
column: {
id: selectedDocs[2],
display: selectedDocs[2],
displayAsText: `Comparison document: ${selectedDocs[2]}`,
id: selectedDocIds[2],
display: selectedDocIds[2],
displayAsText: `Comparison document: ${selectedDocIds[2]}`,
actions: {
showMoveLeft: true,
showMoveRight: true,
@ -156,9 +156,9 @@ describe('useComparisonColumns', () => {
}),
getComparisonColumn({
column: {
id: selectedDocs[3],
display: selectedDocs[3],
displayAsText: `Comparison document: ${selectedDocs[3]}`,
id: selectedDocIds[3],
display: selectedDocIds[3],
displayAsText: `Comparison document: ${selectedDocIds[3]}`,
actions: {
showMoveLeft: true,
},
@ -192,17 +192,17 @@ describe('useComparisonColumns', () => {
const removeAction = actions.additional?.[1].onClick;
render(<button onClick={pinAction} data-test-subj="pin" />);
userEvent.click(screen.getByTestId('pin'));
expect(setSelectedDocs).toHaveBeenCalledTimes(1);
expect(setSelectedDocs).toHaveBeenLastCalledWith(['1', '0', '2', '3']);
expect(replaceSelectedDocs).toHaveBeenCalledTimes(1);
expect(replaceSelectedDocs).toHaveBeenLastCalledWith(['1', '0', '2', '3']);
render(<button onClick={removeAction} data-test-subj="remove" />);
userEvent.click(screen.getByTestId('remove'));
expect(setSelectedDocs).toHaveBeenCalledTimes(2);
expect(setSelectedDocs).toHaveBeenLastCalledWith(['0', '2', '3']);
expect(replaceSelectedDocs).toHaveBeenCalledTimes(2);
expect(replaceSelectedDocs).toHaveBeenLastCalledWith(['0', '2', '3']);
});
it('should not set column widths if there is sufficient space', () => {
const { columns } = renderColumns({
wrapperWidth: FIELD_COLUMN_WIDTH + selectedDocs.length * DEFAULT_COLUMN_WIDTH,
wrapperWidth: FIELD_COLUMN_WIDTH + selectedDocIds.length * DEFAULT_COLUMN_WIDTH,
});
expect(columns[0].initialWidth).toBe(FIELD_COLUMN_WIDTH);
expect(columns[1].initialWidth).toBe(undefined);
@ -220,11 +220,12 @@ describe('useComparisonColumns', () => {
});
it('should skip columns for missing docs', () => {
const getDocById = (id: string) => (id === selectedDocs[1] ? undefined : defaultGetDocById(id));
const getDocById = (id: string) =>
id === selectedDocIds[1] ? undefined : defaultGetDocById(id);
const { columns } = renderColumns({ getDocById });
expect(columns).toHaveLength(4);
expect(columns[1].displayAsText).toBe(`Pinned document: ${selectedDocs[0]}`);
expect(columns[2].displayAsText).toBe(`Comparison document: ${selectedDocs[2]}`);
expect(columns[3].displayAsText).toBe(`Comparison document: ${selectedDocs[3]}`);
expect(columns[1].displayAsText).toBe(`Pinned document: ${selectedDocIds[0]}`);
expect(columns[2].displayAsText).toBe(`Comparison document: ${selectedDocIds[2]}`);
expect(columns[3].displayAsText).toBe(`Comparison document: ${selectedDocIds[3]}`);
});
});

View file

@ -22,9 +22,9 @@ export interface UseComparisonColumnsProps {
wrapper: HTMLElement | null;
isPlainRecord: boolean;
fieldColumnId: string;
selectedDocs: string[];
selectedDocIds: string[];
getDocById: (docId: string) => DataTableRecord | undefined;
setSelectedDocs: (selectedDocs: string[]) => void;
replaceSelectedDocs: (docIds: string[]) => void;
}
export const DEFAULT_COLUMN_WIDTH = 300;
@ -37,9 +37,9 @@ export const useComparisonColumns = ({
wrapper,
isPlainRecord,
fieldColumnId,
selectedDocs,
selectedDocIds,
getDocById,
setSelectedDocs,
replaceSelectedDocs,
}: UseComparisonColumnsProps) => {
const comparisonColumns = useMemo<EuiDataGridColumn[]>(() => {
const fieldsColumn: EuiDataGridColumn = {
@ -54,11 +54,11 @@ export const useComparisonColumns = ({
const currentColumns = [fieldsColumn];
const wrapperWidth = wrapper?.offsetWidth ?? 0;
const columnWidth =
DEFAULT_COLUMN_WIDTH * selectedDocs.length + FIELD_COLUMN_WIDTH > wrapperWidth
DEFAULT_COLUMN_WIDTH * selectedDocIds.length + FIELD_COLUMN_WIDTH > wrapperWidth
? DEFAULT_COLUMN_WIDTH
: undefined;
selectedDocs.forEach((docId, docIndex) => {
selectedDocIds.forEach((docId, docIndex) => {
const doc = getDocById(docId);
if (!doc) {
@ -75,19 +75,19 @@ export const useComparisonColumns = ({
}),
size: 'xs',
onClick: () => {
const newSelectedDocs = [...selectedDocs];
const newSelectedDocs = [...selectedDocIds];
const index = newSelectedDocs.indexOf(docId);
const [baseDocId] = newSelectedDocs;
newSelectedDocs[0] = docId;
newSelectedDocs[index] = baseDocId;
setSelectedDocs(newSelectedDocs);
replaceSelectedDocs(newSelectedDocs);
},
});
}
if (selectedDocs.length > 2) {
if (selectedDocIds.length > 2) {
additional.push({
iconType: 'cross',
label: i18n.translate('unifiedDataTable.removeFromComparison', {
@ -95,7 +95,7 @@ export const useComparisonColumns = ({
}),
size: 'xs',
onClick: () => {
setSelectedDocs(selectedDocs.filter((id) => id !== docId));
replaceSelectedDocs(selectedDocIds.filter((id) => id !== docId));
},
});
}
@ -151,7 +151,7 @@ export const useComparisonColumns = ({
actions: {
showHide: false,
showMoveLeft: docIndex > 1,
showMoveRight: docIndex > 0 && docIndex < selectedDocs.length - 1,
showMoveRight: docIndex > 0 && docIndex < selectedDocIds.length - 1,
showSortAsc: false,
showSortDesc: false,
additional,
@ -164,8 +164,8 @@ export const useComparisonColumns = ({
fieldColumnId,
getDocById,
isPlainRecord,
selectedDocs,
setSelectedDocs,
selectedDocIds,
replaceSelectedDocs,
wrapper?.offsetWidth,
]);

View file

@ -50,7 +50,7 @@ const renderFields = ({
useComparisonFields({
dataView,
selectedFieldNames: ['message', 'extension', 'bytes'],
selectedDocs: ['0', '1', '2'],
selectedDocIds: ['0', '1', '2'],
showAllFields: true,
showMatchingValues: true,
getDocById,

View file

@ -17,7 +17,7 @@ export const MAX_COMPARISON_FIELDS = 250;
export interface UseComparisonFieldsProps {
dataView: DataView;
selectedFieldNames: string[];
selectedDocs: string[];
selectedDocIds: string[];
showAllFields: boolean;
showMatchingValues: boolean;
getDocById: (id: string) => DataTableRecord | undefined;
@ -27,14 +27,14 @@ export interface UseComparisonFieldsProps {
export const useComparisonFields = ({
dataView,
selectedFieldNames,
selectedDocs,
selectedDocIds,
showAllFields,
showMatchingValues,
getDocById,
additionalFieldGroups,
}: UseComparisonFieldsProps) => {
const { baseDoc, comparisonDocs } = useMemo(() => {
const [baseDocId, ...comparisonDocIds] = selectedDocs;
const [baseDocId, ...comparisonDocIds] = selectedDocIds;
return {
baseDoc: getDocById(baseDocId),
@ -42,7 +42,7 @@ export const useComparisonFields = ({
.map((docId) => getDocById(docId))
.filter((doc): doc is DataTableRecord => Boolean(doc)),
};
}, [getDocById, selectedDocs]);
}, [getDocById, selectedDocIds]);
return useMemo(() => {
let comparisonFields = convertFieldsToFallbackFields({

View file

@ -87,9 +87,7 @@ exports[`renderCustomToolbar should render correctly with an element 1`] = `
<EuiFlexItem
grow={false}
>
<div
className="unifiedDataTableToolbarControlButton"
>
<div>
additional
</div>
</EuiFlexItem>
@ -190,9 +188,7 @@ exports[`renderCustomToolbar should render successfully 1`] = `
<EuiFlexItem
grow={false}
>
<div
className="unifiedDataTableToolbarControlButton"
>
<div>
additional
</div>
</EuiFlexItem>

View file

@ -1,58 +1,68 @@
.unifiedDataTableToolbar {
padding: $euiSizeS $euiSizeS $euiSizeXS;
}
.unifiedDataTableToolbarControlButton .euiDataGridToolbarControl {
block-size: $euiSizeXL;
border: $euiBorderThin;
border-radius: $euiBorderRadiusSmall;
.unifiedDataTableToolbarControlButton .euiDataGridToolbarControl {
block-size: $euiSizeXL;
border: $euiBorderThin;
border-radius: $euiBorderRadiusSmall;
// making the icons larger than the default size
& svg {
inline-size: $euiSize;
block-size: $euiSize;
// making the icons larger than the default size
& svg {
inline-size: $euiSize;
block-size: $euiSize;
}
// cancel default background changes
&:active, &:focus {
background: transparent;
}
// add toolbar control animation
transition: transform $euiAnimSpeedNormal ease-in-out;
&:hover {
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
// cancel default background changes
&:active, &:focus {
background: transparent;
.unifiedDataTableToolbarControlGroup {
box-shadow: inset 0 0 0 $euiBorderWidthThin $euiBorderColor;
border-radius: $euiBorderRadiusSmall;
display: inline-flex;
align-items: stretch;
flex-direction: row;
& .unifiedDataTableToolbarControlButton .euiDataGridToolbarControl {
border-radius: 0;
border: none;
}
& .unifiedDataTableToolbarControlButton + .unifiedDataTableToolbarControlButton {
border-inline-start: $euiBorderThin;
border-radius: 0;
}
}
// add toolbar control animation
transition: transform $euiAnimSpeedNormal ease-in-out;
&:hover {
transform: translateY(-1px);
.unifiedDataTableToolbarControlIconButton .euiButtonIcon {
inline-size: $euiSizeXL;
block-size: $euiSizeXL;
// cancel default behaviour
&:hover, &:active, &:focus {
background: transparent;
animation: none !important;
transform: none !important;
}
.unifiedDataTableToolbarControlIconButton + & {
border-inline-start: $euiBorderThin;
border-radius: 0;
}
}
&:active {
transform: translateY(0);
.unifiedDataTableToolbarBottom {
position: relative; // for placing a loading indicator correctly
}
}
.unifiedDataTableToolbarControlGroup {
box-shadow: inset 0 0 0 $euiBorderWidthThin $euiBorderColor;
border-radius: $euiBorderRadiusSmall;
display: inline-flex;
align-items: stretch;
flex-direction: row;
}
.unifiedDataTableToolbarControlIconButton .euiButtonIcon {
inline-size: $euiSizeXL;
block-size: $euiSizeXL;
// cancel default behaviour
&:hover, &:active, &:focus {
background: transparent;
animation: none !important;
transform: none !important;
}
.unifiedDataTableToolbarControlIconButton + & {
border-inline-start: $euiBorderThin;
border-radius: 0;
}
}
.unifiedDataTableToolbarBottom {
position: relative; // for placing a loading indicator correctly
}

View file

@ -52,7 +52,7 @@ export const internalRenderCustomToolbar = (
<>
{leftSide && additionalControls && (
<EuiFlexItem grow={false}>
<div className="unifiedDataTableToolbarControlButton">{additionalControls}</div>
<div>{additionalControls}</div>
</EuiFlexItem>
)}
{columnControl && (
@ -67,7 +67,7 @@ export const internalRenderCustomToolbar = (
)}
{!leftSide && additionalControls && (
<EuiFlexItem grow={false}>
<div className="unifiedDataTableToolbarControlButton">{additionalControls}</div>
<div>{additionalControls}</div>
</EuiFlexItem>
)}
</>

View file

@ -60,6 +60,11 @@
padding-right: 0;
}
.euiDataGridHeaderCell.euiDataGridHeaderCell--controlColumn[data-gridcell-column-id='select'] {
padding-left: $euiSizeXS;
padding-right: 0;
}
.euiDataGridHeaderCell.euiDataGridHeaderCell--controlColumn[data-gridcell-column-id='colorIndicator'],
.euiDataGridRowCell.euiDataGridRowCell--controlColumn[data-gridcell-column-id='colorIndicator'] {
padding: 0;

View file

@ -691,7 +691,7 @@ describe('UnifiedDataTable', () => {
// additional controls become available after selecting a document
act(() => {
component
.find('[data-gridcell-column-id="select"] .euiCheckbox__input')
.find('.euiDataGridRowCell[data-gridcell-column-id="select"] .euiCheckbox__input')
.first()
.simulate('change');
});
@ -767,17 +767,28 @@ describe('UnifiedDataTable', () => {
);
};
const getSelectedDocumentsButton = () => screen.queryByRole('button', { name: /Selected/ });
const getSelectedDocumentsButton = () => screen.queryByTestId('unifiedDataTableSelectionBtn');
const selectDocument = (document: EsHitRecord) =>
userEvent.click(screen.getByTestId(`dscGridSelectDoc-${getDocId(document)}`));
const getCompareDocumentsButton = () => screen.queryByRole('button', { name: /Compare/ });
const openSelectedRowsMenu = async () => {
userEvent.click(await screen.findByTestId('unifiedDataTableSelectionBtn'));
await screen.findAllByText('Clear selection');
};
const closeSelectedRowsMenu = async () => {
userEvent.click(await screen.findByTestId('unifiedDataTableSelectionBtn'));
};
const getCompareDocumentsButton = () =>
screen.queryByTestId('unifiedDataTableCompareSelectedDocuments');
const goToComparisonMode = async () => {
selectDocument(esHitsMock[0]);
selectDocument(esHitsMock[1]);
userEvent.click(getCompareDocumentsButton()!);
await openSelectedRowsMenu();
userEvent.click(await screen.findByTestId('unifiedDataTableCompareSelectedDocuments'));
await screen.findByText('Comparing 2 documents');
};
@ -796,22 +807,28 @@ describe('UnifiedDataTable', () => {
const getCellValues = () =>
Array.from(document.querySelectorAll(`.${CELL_CLASS}`)).map(({ textContent }) => textContent);
it('should not allow comparison if less than 2 documents are selected', () => {
it('should not allow comparison if less than 2 documents are selected', async () => {
renderDataTable({ enableComparisonMode: true });
expect(getSelectedDocumentsButton()).not.toBeInTheDocument();
selectDocument(esHitsMock[0]);
expect(getSelectedDocumentsButton()).toBeInTheDocument();
await openSelectedRowsMenu();
expect(getCompareDocumentsButton()).not.toBeInTheDocument();
await closeSelectedRowsMenu();
selectDocument(esHitsMock[1]);
expect(getSelectedDocumentsButton()).toBeInTheDocument();
await openSelectedRowsMenu();
expect(getCompareDocumentsButton()).toBeInTheDocument();
await closeSelectedRowsMenu();
});
it('should not allow comparison if comparison mode is disabled', () => {
it('should not allow comparison if comparison mode is disabled', async () => {
renderDataTable({ enableComparisonMode: false });
selectDocument(esHitsMock[0]);
selectDocument(esHitsMock[1]);
await openSelectedRowsMenu();
expect(getCompareDocumentsButton()).not.toBeInTheDocument();
await closeSelectedRowsMenu();
});
it('should allow comparison if 2 or more documents are selected and comparison mode is enabled', async () => {

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { useCallback, useMemo, useState, useRef, useEffect } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classnames from 'classnames';
import { FormattedMessage } from '@kbn/i18n-react';
import { of } from 'rxjs';
@ -29,8 +29,6 @@ import {
EuiDataGridToolBarVisibilityDisplaySelectorOptions,
EuiDataGridStyle,
EuiDataGridProps,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import type { DataView } from '@kbn/data-views-plugin/public';
import {
@ -68,10 +66,7 @@ import {
} from './data_table_columns';
import { UnifiedDataTableContext } from '../table_context';
import { getSchemaDetectors } from './data_table_schema';
import {
DataTableCompareToolbarBtn,
DataTableDocumentToolbarBtn,
} from './data_table_document_selection';
import { DataTableDocumentToolbarBtn } from './data_table_document_selection';
import { useRowHeightsOptions } from '../hooks/use_row_heights_options';
import {
DEFAULT_ROWS_PER_PAGE,
@ -86,6 +81,7 @@ import { CompareDocuments } from './compare_documents';
import { useFullScreenWatcher } from '../hooks/use_full_screen_watcher';
import { UnifiedDataTableRenderCustomToolbar } from './custom_toolbar/render_custom_toolbar';
import { getCustomCellPopoverRenderer } from '../utils/get_render_cell_popover';
import { useSelectedDocs } from '../hooks/use_selected_docs';
import {
getColorIndicatorControlColumn,
type ColorIndicatorControlColumnParams,
@ -472,39 +468,35 @@ export const UnifiedDataTable = ({
services;
const { darkMode } = useObservable(services.theme?.theme$ ?? of(themeDefault), themeDefault);
const dataGridRef = useRef<EuiDataGridRefProps>(null);
const [selectedDocs, setSelectedDocs] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState(false);
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 usedSelectedDocs = useMemo(() => {
if (!selectedDocs.length || !rows?.length) {
return [];
}
// filter out selected docs that are no longer part of the current data
const result = selectedDocs.filter((docId) => !!getDocById(docId));
if (result.length === 0 && isFilterActive) {
const selectedDocsState = useSelectedDocs(docMap);
const { isDocSelected, hasSelectedDocs, selectedDocIds, replaceSelectedDocs } = selectedDocsState;
useEffect(() => {
if (!hasSelectedDocs && isFilterActive) {
setIsFilterActive(false);
}
return result;
}, [selectedDocs, rows?.length, isFilterActive, getDocById]);
}, [isFilterActive, hasSelectedDocs, setIsFilterActive]);
const displayedRows = useMemo(() => {
if (!rows) {
return [];
}
if (!isFilterActive || usedSelectedDocs.length === 0) {
if (!isFilterActive || !hasSelectedDocs) {
return rows;
}
const rowsFiltered = rows.filter((row) => usedSelectedDocs.includes(row.id));
const rowsFiltered = rows.filter((row) => isDocSelected(row.id));
if (!rowsFiltered.length) {
// in case the selected docs are no longer part of the sample of 500, show all docs
return rows;
}
return rowsFiltered;
}, [rows, usedSelectedDocs, isFilterActive]);
}, [rows, isFilterActive, hasSelectedDocs, isDocSelected]);
const valueToStringConverter: ValueToStringConverter = useCallback(
(rowIndex, columnId, options) => {
@ -520,40 +512,6 @@ export const UnifiedDataTable = ({
[displayedRows, dataView, fieldFormats]
);
const unifiedDataTableContextValue = useMemo(
() => ({
expanded: expandedDoc,
setExpanded: setExpandedDoc,
rows: displayedRows,
onFilter,
dataView,
isDarkMode: darkMode,
selectedDocs: usedSelectedDocs,
setSelectedDocs: (newSelectedDocs: React.SetStateAction<string[]>) => {
setSelectedDocs(newSelectedDocs);
if (isFilterActive && newSelectedDocs.length === 0) {
setIsFilterActive(false);
}
},
valueToStringConverter,
componentsTourSteps,
isPlainRecord,
}),
[
componentsTourSteps,
darkMode,
dataView,
isPlainRecord,
displayedRows,
expandedDoc,
isFilterActive,
onFilter,
setExpandedDoc,
usedSelectedDocs,
valueToStringConverter,
]
);
/**
* Pagination
*/
@ -605,6 +563,36 @@ export const UnifiedDataTable = ({
);
}, [currentPageSize, setPagination]);
const unifiedDataTableContextValue = useMemo(
() => ({
expanded: expandedDoc,
setExpanded: setExpandedDoc,
rows: displayedRows,
onFilter,
dataView,
isDarkMode: darkMode,
selectedDocsState,
valueToStringConverter,
componentsTourSteps,
isPlainRecord,
pageIndex: paginationObj?.pageIndex,
pageSize: paginationObj?.pageSize,
}),
[
componentsTourSteps,
darkMode,
dataView,
isPlainRecord,
displayedRows,
expandedDoc,
onFilter,
setExpandedDoc,
selectedDocsState,
paginationObj,
valueToStringConverter,
]
);
const shouldShowFieldHandler = useMemo(() => {
const dataViewFields = dataView.fields.getAll().map((fld) => fld.name);
return getShouldShowFieldHandler(dataViewFields, dataView, showMultiFields);
@ -885,44 +873,41 @@ export const UnifiedDataTable = ({
controlColumnsConfig?.trailingControlColumns ?? trailingControlColumns;
const additionalControls = useMemo(() => {
if (!externalAdditionalControls && !usedSelectedDocs.length) {
if (!externalAdditionalControls && !selectedDocIds.length) {
return null;
}
return (
<>
{Boolean(usedSelectedDocs.length) && (
<EuiFlexGroup gutterSize="s" responsive={false}>
{enableComparisonMode && usedSelectedDocs.length > 1 && (
<EuiFlexItem grow={false}>
<DataTableCompareToolbarBtn
selectedDocs={usedSelectedDocs}
setIsCompareActive={setIsCompareActive}
/>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<DataTableDocumentToolbarBtn
isPlainRecord={isPlainRecord}
isFilterActive={isFilterActive}
rows={rows!}
selectedDocs={usedSelectedDocs}
setSelectedDocs={setSelectedDocs}
setIsFilterActive={setIsFilterActive}
/>
</EuiFlexItem>
</EuiFlexGroup>
{Boolean(selectedDocIds.length) && (
<DataTableDocumentToolbarBtn
isPlainRecord={isPlainRecord}
isFilterActive={isFilterActive}
rows={rows!}
setIsFilterActive={setIsFilterActive}
selectedDocsState={selectedDocsState}
enableComparisonMode={enableComparisonMode}
setIsCompareActive={setIsCompareActive}
fieldFormats={fieldFormats}
pageIndex={unifiedDataTableContextValue.pageIndex}
pageSize={unifiedDataTableContextValue.pageSize}
/>
)}
{externalAdditionalControls}
</>
);
}, [
selectedDocIds,
selectedDocsState,
externalAdditionalControls,
usedSelectedDocs,
isPlainRecord,
isFilterActive,
setIsFilterActive,
enableComparisonMode,
rows,
fieldFormats,
unifiedDataTableContextValue.pageIndex,
unifiedDataTableContextValue.pageSize,
]);
const renderCustomToolbarFn: EuiDataGridProps['renderCustomToolbar'] | undefined = useMemo(
@ -1079,13 +1064,13 @@ export const UnifiedDataTable = ({
isPlainRecord={isPlainRecord}
selectedFieldNames={visibleColumns}
additionalFieldGroups={additionalFieldGroups}
selectedDocs={selectedDocs}
selectedDocIds={selectedDocIds}
schemaDetectors={schemaDetectors}
forceShowAllFields={defaultColumns}
showFullScreenButton={showFullScreenButton}
fieldFormats={fieldFormats}
getDocById={getDocById}
setSelectedDocs={setSelectedDocs}
replaceSelectedDocs={replaceSelectedDocs}
setIsCompareActive={setIsCompareActive}
/>
) : (
@ -1117,9 +1102,9 @@ export const UnifiedDataTable = ({
)}
</div>
{loadingState !== DataLoadingState.loading &&
!usedSelectedDocs.length && // hide footer when showing selected documents
isPaginationEnabled &&
!isCompareActive && ( // we hide the footer for Surrounding Documents page
isPaginationEnabled && // we hide the footer for Surrounding Documents page
!isFilterActive && // hide footer when showing selected documents
!isCompareActive && (
<UnifiedDataTableFooter
isLoadingMore={loadingState === DataLoadingState.loadingMore}
rowCount={rowCount}

View file

@ -21,7 +21,7 @@ import { ControlColumns, CustomGridColumnsConfiguration, UnifiedDataTableSetting
import type { ValueToStringConverter, DataTableColumnsMeta } from '../types';
import { buildCellActions } from './default_cell_actions';
import { getSchemaByKbnType } from './data_table_schema';
import { SelectButton } from './data_table_document_selection';
import { SelectButton, SelectAllButton } from './data_table_document_selection';
import { defaultTimeColumnWidth, ROWS_HEIGHT_OPTIONS } from '../constants';
import { buildCopyColumnNameButton, buildCopyColumnValuesButton } from './build_copy_column_button';
import { buildEditFieldButton } from './build_edit_field_button';
@ -70,15 +70,7 @@ const select = {
id: SELECT_ROW,
width: 24,
rowCellRender: SelectButton,
headerCellRender: () => (
<EuiScreenReaderOnly>
<span>
{i18n.translate('unifiedDataTable.selectColumnHeader', {
defaultMessage: 'Select column',
})}
</span>
</EuiScreenReaderOnly>
),
headerCellRender: SelectAllButton,
};
export function getAllControlColumns(): ControlColumns {

View file

@ -6,18 +6,21 @@
* Side Public License, v 1.
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import {
DataTableCompareToolbarBtn,
DataTableDocumentToolbarBtn,
SelectButton,
SelectAllButton,
} from './data_table_document_selection';
import { dataTableContextMock } from '../../__mocks__/table_context';
import { buildSelectedDocsState, dataTableContextMock } from '../../__mocks__/table_context';
import { UnifiedDataTableContext } from '../table_context';
import { getDocId } from '@kbn/discover-utils';
import { render, screen } from '@testing-library/react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { servicesMock } from '../../__mocks__/services';
describe('document selection', () => {
describe('getDocId', () => {
@ -39,6 +42,39 @@ describe('document selection', () => {
});
});
describe('SelectAllButton', () => {
test('is not checked', () => {
const contextMock = {
...dataTableContextMock,
};
const component = mountWithIntl(
<UnifiedDataTableContext.Provider value={contextMock}>
<SelectAllButton />
</UnifiedDataTableContext.Provider>
);
const checkBox = findTestSubject(component, 'selectAllDocsOnPageToggle');
expect(checkBox.props().checked).toBeFalsy();
});
test('is checked correctly', () => {
const contextMock = {
...dataTableContextMock,
selectedDocsState: buildSelectedDocsState(['i::1::']),
};
const component = mountWithIntl(
<UnifiedDataTableContext.Provider value={contextMock}>
<SelectAllButton />
</UnifiedDataTableContext.Provider>
);
const checkBox = findTestSubject(component, 'selectAllDocsOnPageToggle');
expect(checkBox.props().checked).toBeTruthy();
});
});
describe('SelectButton', () => {
test('is not checked', () => {
const contextMock = {
@ -63,13 +99,13 @@ describe('document selection', () => {
expect(checkBox.props().checked).toBeFalsy();
});
test('is checked', () => {
test('is checked correctly', () => {
const contextMock = {
...dataTableContextMock,
selectedDocs: ['i::1::'],
selectedDocsState: buildSelectedDocsState(['i::1::']),
};
const component = mountWithIntl(
const component1 = mountWithIntl(
<UnifiedDataTableContext.Provider value={contextMock}>
<SelectButton
rowIndex={0}
@ -83,8 +119,25 @@ describe('document selection', () => {
</UnifiedDataTableContext.Provider>
);
const checkBox = findTestSubject(component, 'dscGridSelectDoc-i::1::');
expect(checkBox.props().checked).toBeTruthy();
const checkBox1 = findTestSubject(component1, 'dscGridSelectDoc-i::1::');
expect(checkBox1.props().checked).toBeTruthy();
const component2 = mountWithIntl(
<UnifiedDataTableContext.Provider value={contextMock}>
<SelectButton
rowIndex={1}
colIndex={0}
setCellProps={jest.fn()}
columnId="test"
isExpanded={false}
isDetails={false}
isExpandable={false}
/>
</UnifiedDataTableContext.Provider>
);
const checkBox2 = findTestSubject(component2, 'dscGridSelectDoc-i::2::');
expect(checkBox2.props().checked).toBeFalsy();
});
test('adding a selection', () => {
@ -108,13 +161,13 @@ describe('document selection', () => {
const checkBox = findTestSubject(component, 'dscGridSelectDoc-i::1::');
checkBox.simulate('change');
expect(contextMock.setSelectedDocs).toHaveBeenCalledWith(['i::1::']);
expect(contextMock.selectedDocsState.toggleDocSelection).toHaveBeenCalledWith('i::1::');
});
test('removing a selection', () => {
const contextMock = {
...dataTableContextMock,
selectedDocs: ['i::1::'],
selectedDocsState: buildSelectedDocsState(['i::1::']),
};
const component = mountWithIntl(
@ -133,55 +186,192 @@ describe('document selection', () => {
const checkBox = findTestSubject(component, 'dscGridSelectDoc-i::1::');
checkBox.simulate('change');
expect(contextMock.setSelectedDocs).toHaveBeenCalledWith([]);
expect(contextMock.selectedDocsState.toggleDocSelection).toHaveBeenCalledWith('i::1::');
});
});
describe('DataTableDocumentToolbarBtn', () => {
test('it renders a button clickable button', () => {
test('it renders the button and its menu correctly', () => {
const props = {
isPlainRecord: false,
isFilterActive: false,
rows: dataTableContextMock.rows,
selectedDocs: ['i::1::'],
selectedDocsState: buildSelectedDocsState(['i::1::', 'i::2::']),
setIsFilterActive: jest.fn(),
setSelectedDocs: jest.fn(),
enableComparisonMode: true,
setIsCompareActive: jest.fn(),
fieldFormats: servicesMock.fieldFormats,
pageIndex: 0,
pageSize: 2,
};
const component = mountWithIntl(<DataTableDocumentToolbarBtn {...props} />);
const button = findTestSubject(component, 'unifiedDataTableSelectionBtn');
expect(button.length).toBe(1);
expect(button.text()).toBe('Selected2');
act(() => {
button.simulate('click');
});
component.update();
expect(findTestSubject(component, 'dscGridShowSelectedDocuments').length).toBe(1);
expect(findTestSubject(component, 'unifiedDataTableCompareSelectedDocuments').length).toBe(1);
expect(findTestSubject(component, 'dscGridSelectAllDocs').text()).toBe('Select all 5');
act(() => {
findTestSubject(component, 'dscGridClearSelectedDocuments').simulate('click');
});
expect(props.selectedDocsState.clearAllSelectedDocs).toHaveBeenCalled();
});
test('it should not render "Select all X" button if less than pageSize is selected', () => {
const props = {
isPlainRecord: false,
isFilterActive: false,
rows: dataTableContextMock.rows,
selectedDocsState: buildSelectedDocsState(['i::1::']),
setIsFilterActive: jest.fn(),
enableComparisonMode: true,
setIsCompareActive: jest.fn(),
fieldFormats: servicesMock.fieldFormats,
pageIndex: 0,
pageSize: 2,
};
const component = mountWithIntl(<DataTableDocumentToolbarBtn {...props} />);
expect(findTestSubject(component, 'unifiedDataTableSelectionBtn').text()).toBe('Selected1');
expect(findTestSubject(component, 'dscGridSelectAllDocs').exists()).toBe(false);
});
test('it should render "Select all X" button if all rows on the page are selected', () => {
const props = {
isPlainRecord: false,
isFilterActive: false,
rows: dataTableContextMock.rows,
selectedDocsState: buildSelectedDocsState(['i::1::', 'i::2::']),
setIsFilterActive: jest.fn(),
enableComparisonMode: true,
setIsCompareActive: jest.fn(),
fieldFormats: servicesMock.fieldFormats,
pageIndex: 0,
pageSize: 2,
};
const component = mountWithIntl(<DataTableDocumentToolbarBtn {...props} />);
expect(findTestSubject(component, 'unifiedDataTableSelectionBtn').text()).toBe('Selected2');
const button = findTestSubject(component, 'dscGridSelectAllDocs');
expect(button.exists()).toBe(true);
act(() => {
button.simulate('click');
});
expect(props.selectedDocsState.selectAllDocs).toHaveBeenCalled();
});
test('it should render "Select all X" button even if on another page', () => {
const props = {
isPlainRecord: false,
isFilterActive: false,
rows: dataTableContextMock.rows,
selectedDocsState: buildSelectedDocsState(['i::1::', 'i::2::']),
setIsFilterActive: jest.fn(),
enableComparisonMode: true,
setIsCompareActive: jest.fn(),
fieldFormats: servicesMock.fieldFormats,
pageIndex: 1,
pageSize: 2,
};
const component = mountWithIntl(<DataTableDocumentToolbarBtn {...props} />);
expect(findTestSubject(component, 'unifiedDataTableSelectionBtn').text()).toBe('Selected2');
expect(findTestSubject(component, 'dscGridSelectAllDocs').exists()).toBe(true);
});
test('it should not render "Select all X" button if all rows are selected', () => {
const props = {
isPlainRecord: false,
isFilterActive: false,
rows: dataTableContextMock.rows,
selectedDocsState: buildSelectedDocsState(dataTableContextMock.rows.map((row) => row.id)),
setIsFilterActive: jest.fn(),
enableComparisonMode: true,
setIsCompareActive: jest.fn(),
fieldFormats: servicesMock.fieldFormats,
pageIndex: 1,
pageSize: 2,
};
const component = mountWithIntl(<DataTableDocumentToolbarBtn {...props} />);
expect(findTestSubject(component, 'unifiedDataTableSelectionBtn').text()).toBe(
`Selected${dataTableContextMock.rows.length}`
);
expect(findTestSubject(component, 'dscGridSelectAllDocs').exists()).toBe(false);
});
});
describe('DataTableCompareToolbarBtn', () => {
const props = {
isPlainRecord: false,
isFilterActive: false,
rows: dataTableContextMock.rows,
selectedDocsState: buildSelectedDocsState([]),
setIsFilterActive: jest.fn(),
enableComparisonMode: true,
setIsCompareActive: jest.fn(),
fieldFormats: servicesMock.fieldFormats,
pageIndex: 0,
pageSize: 2,
};
const renderCompareBtn = ({
selectedDocs = ['1', '2'],
selectedDocIds = ['1', '2'],
setIsCompareActive = jest.fn(),
}: Partial<Parameters<typeof DataTableCompareToolbarBtn>[0]> = {}) => {
render(
<IntlProvider locale="en">
<DataTableCompareToolbarBtn
selectedDocs={selectedDocs}
<DataTableDocumentToolbarBtn
{...props}
selectedDocsState={buildSelectedDocsState(selectedDocIds)}
setIsCompareActive={setIsCompareActive}
/>
</IntlProvider>
);
return {
getButton: () => screen.queryByRole('button', { name: /Compare/ }),
getButton: async () => {
const menuButton = await screen.findByTestId('unifiedDataTableSelectionBtn');
menuButton.click();
return screen.queryByRole('button', { name: /Compare/ });
},
};
};
it('should render the compare button', () => {
it('should render the compare button', async () => {
const { getButton } = renderCompareBtn();
expect(getButton()).toBeInTheDocument();
expect(await getButton()).toBeInTheDocument();
});
it('should call setIsCompareActive when the button is clicked', () => {
it('should call setIsCompareActive when the button is clicked', async () => {
const setIsCompareActive = jest.fn();
const { getButton } = renderCompareBtn({ setIsCompareActive });
getButton()?.click();
const button = await getButton();
expect(button).toBeInTheDocument();
expect(button?.getAttribute('disabled')).toBeNull();
button?.click();
expect(setIsCompareActive).toHaveBeenCalledWith(true);
});
it('should disable the button if limit is reached', async () => {
const selectedDocIds = Array.from({ length: 500 }, (_, i) => i.toString());
const setIsCompareActive = jest.fn();
const { getButton } = renderCompareBtn({ selectedDocIds, setIsCompareActive });
const button = await getButton();
expect(button).toBeInTheDocument();
expect(button?.getAttribute('disabled')).toBe('');
button?.click();
expect(setIsCompareActive).not.toHaveBeenCalled();
});
});
});

View file

@ -6,6 +6,8 @@
* Side Public License, v 1.
*/
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '@kbn/field-types';
import {
EuiCheckbox,
EuiContextMenuItem,
@ -16,20 +18,22 @@ import {
EuiPopover,
EuiFlexGroup,
EuiFlexItem,
EuiToolTip,
useEuiTheme,
EuiScreenReaderOnly,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import type { DataTableRecord } from '@kbn/discover-utils/types';
import type { UseSelectedDocsState } from '../hooks/use_selected_docs';
import { UnifiedDataTableContext } from '../table_context';
export const SelectButton = ({ rowIndex, setCellProps }: EuiDataGridCellValueElementProps) => {
const { euiTheme } = useEuiTheme();
const { selectedDocs, expanded, rows, isDarkMode, setSelectedDocs } =
useContext(UnifiedDataTableContext);
const { selectedDocsState, expanded, rows, isDarkMode } = useContext(UnifiedDataTableContext);
const { isDocSelected, toggleDocSelection } = selectedDocsState;
const doc = useMemo(() => rows[rowIndex], [rows, rowIndex]);
const checked = useMemo(() => selectedDocs.includes(doc.id), [selectedDocs, doc.id]);
const toggleDocumentSelectionLabel = i18n.translate('unifiedDataTable.grid.selectDoc', {
defaultMessage: `Select document ''{rowNumber}''`,
@ -42,7 +46,7 @@ export const SelectButton = ({ rowIndex, setCellProps }: EuiDataGridCellValueEle
className: 'unifiedDataTable__cell--selected',
});
} else {
setCellProps({ style: undefined });
setCellProps({ className: '' });
}
}, [expanded, doc, setCellProps, isDarkMode]);
@ -61,15 +65,10 @@ export const SelectButton = ({ rowIndex, setCellProps }: EuiDataGridCellValueEle
<EuiCheckbox
id={doc.id}
aria-label={toggleDocumentSelectionLabel}
checked={checked}
checked={isDocSelected(doc.id)}
data-test-subj={`dscGridSelectDoc-${doc.id}`}
onChange={() => {
if (checked) {
const newSelection = selectedDocs.filter((docId) => docId !== doc.id);
setSelectedDocs(newSelection);
} else {
setSelectedDocs([...selectedDocs, doc.id]);
}
toggleDocSelection(doc.id);
}}
/>
</EuiFlexItem>
@ -77,26 +76,149 @@ export const SelectButton = ({ rowIndex, setCellProps }: EuiDataGridCellValueEle
);
};
export const SelectAllButton = () => {
const { selectedDocsState, pageIndex, pageSize, rows } = useContext(UnifiedDataTableContext);
const { getCountOfSelectedDocs, deselectSomeDocs, selectMoreDocs } = selectedDocsState;
const docIdsFromCurrentPage = useMemo(() => {
return getDocIdsForCurrentPage(rows, pageIndex, pageSize);
}, [rows, pageIndex, pageSize]);
const countOfSelectedDocs = useMemo(() => {
return docIdsFromCurrentPage?.length ? getCountOfSelectedDocs(docIdsFromCurrentPage) : 0;
}, [docIdsFromCurrentPage, getCountOfSelectedDocs]);
const isIndeterminateForCurrentPage = useMemo(() => {
if (docIdsFromCurrentPage?.length) {
return countOfSelectedDocs > 0 && countOfSelectedDocs < docIdsFromCurrentPage.length;
}
return false;
}, [docIdsFromCurrentPage, countOfSelectedDocs]);
const areDocsSelectedForCurrentPage = useMemo(() => {
if (docIdsFromCurrentPage?.length) {
return countOfSelectedDocs > 0;
}
return false;
}, [docIdsFromCurrentPage, countOfSelectedDocs]);
if (!docIdsFromCurrentPage) {
return null;
}
const title =
isIndeterminateForCurrentPage || areDocsSelectedForCurrentPage
? i18n.translate('unifiedDataTable.deselectAllRowsOnPageColumnHeader', {
defaultMessage: 'Deselect all visible rows',
})
: i18n.translate('unifiedDataTable.selectAllRowsOnPageColumnHeader', {
defaultMessage: 'Select all visible rows',
});
return (
<>
<EuiScreenReaderOnly>
<span>
{i18n.translate('unifiedDataTable.selectColumnHeader', {
defaultMessage: 'Select column',
})}
</span>
</EuiScreenReaderOnly>
<EuiCheckbox
data-test-subj="selectAllDocsOnPageToggle"
id="select-all-docs-on-page-toggle"
aria-label={title}
title={title}
indeterminate={isIndeterminateForCurrentPage}
checked={areDocsSelectedForCurrentPage}
onChange={(e) => {
const shouldClearSelection = isIndeterminateForCurrentPage || !e.target.checked;
if (shouldClearSelection) {
deselectSomeDocs(docIdsFromCurrentPage);
} else {
selectMoreDocs(docIdsFromCurrentPage);
}
}}
/>
</>
);
};
export function DataTableDocumentToolbarBtn({
isPlainRecord,
isFilterActive,
rows,
selectedDocs,
setIsFilterActive,
setSelectedDocs,
selectedDocsState,
enableComparisonMode,
setIsCompareActive,
fieldFormats,
pageIndex,
pageSize,
}: {
isPlainRecord: boolean;
isFilterActive: boolean;
rows: DataTableRecord[];
selectedDocs: string[];
setIsFilterActive: (value: boolean) => void;
setSelectedDocs: (value: string[]) => void;
selectedDocsState: UseSelectedDocsState;
enableComparisonMode: boolean | undefined;
setIsCompareActive: (value: boolean) => void;
fieldFormats: FieldFormatsStart;
pageIndex: number | undefined;
pageSize: number | undefined;
}) {
const [isSelectionPopoverOpen, setIsSelectionPopoverOpen] = useState(false);
const { selectAllDocs, clearAllSelectedDocs, isDocSelected, selectedDocIds } = selectedDocsState;
const shouldSuggestToSelectAll = useMemo(() => {
const canSelectMore = selectedDocIds.length < rows.length && rows.length > 1;
if (typeof pageSize !== 'number' || isFilterActive || !canSelectMore) {
return false;
}
return selectedDocIds.length >= pageSize;
}, [rows, pageSize, selectedDocIds.length, isFilterActive]);
const getMenuItems = useCallback(() => {
return [
// Compare selected documents
...(enableComparisonMode && selectedDocIds.length > 1
? [
<DataTableCompareToolbarBtn
key="compareSelected"
selectedDocIds={selectedDocIds}
setIsCompareActive={setIsCompareActive}
/>,
]
: []),
// Copy results to clipboard (JSON)
<EuiCopy
key="copyJsonWrapper"
data-test-subj="dscGridCopySelectedDocumentsJSON"
textToCopy={
rows
? JSON.stringify(rows.filter((row) => isDocSelected(row.id)).map((row) => row.raw))
: ''
}
>
{(copy) => (
<EuiContextMenuItem key="copyJSON" icon="copyClipboard" onClick={copy}>
{isPlainRecord ? (
<FormattedMessage
id="unifiedDataTable.copyResultsToClipboardJSON"
defaultMessage="Copy results to clipboard (JSON)"
/>
) : (
<FormattedMessage
id="unifiedDataTable.copyToClipboardJSON"
defaultMessage="Copy documents to clipboard (JSON)"
/>
)}
</EuiContextMenuItem>
)}
</EuiCopy>,
isFilterActive ? (
// Show all documents
<EuiContextMenuItem
data-test-subj="dscGridShowAllDocuments"
key="showAllDocuments"
@ -119,6 +241,7 @@ export function DataTableDocumentToolbarBtn({
)}
</EuiContextMenuItem>
) : (
// Show selected documents only
<EuiContextMenuItem
data-test-subj="dscGridShowSelectedDocuments"
key="showSelectedDocuments"
@ -141,66 +264,58 @@ export function DataTableDocumentToolbarBtn({
)}
</EuiContextMenuItem>
),
<EuiCopy
key="copyJsonWrapper"
data-test-subj="dscGridCopySelectedDocumentsJSON"
textToCopy={
rows
? JSON.stringify(
rows.filter((row) => selectedDocs.includes(row.id)).map((row) => row.raw)
)
: ''
}
>
{(copy) => (
<EuiContextMenuItem key="copyJSON" icon="copyClipboard" onClick={copy}>
{isPlainRecord ? (
<FormattedMessage
id="unifiedDataTable.copyResultsToClipboardJSON"
defaultMessage="Copy results to clipboard (JSON)"
/>
) : (
<FormattedMessage
id="unifiedDataTable.copyToClipboardJSON"
defaultMessage="Copy documents to clipboard (JSON)"
/>
)}
</EuiContextMenuItem>
)}
</EuiCopy>,
// Clear selection
<EuiContextMenuItem
data-test-subj="dscGridClearSelectedDocuments"
key="clearSelection"
icon="cross"
onClick={() => {
setIsSelectionPopoverOpen(false);
setSelectedDocs([]);
clearAllSelectedDocs();
setIsFilterActive(false);
}}
>
<FormattedMessage id="unifiedDataTable.clearSelection" defaultMessage="Clear selection" />
</EuiContextMenuItem>,
];
}, [isFilterActive, isPlainRecord, rows, selectedDocs, setIsFilterActive, setSelectedDocs]);
}, [
isFilterActive,
isPlainRecord,
rows,
setIsFilterActive,
isDocSelected,
clearAllSelectedDocs,
selectedDocIds,
enableComparisonMode,
setIsCompareActive,
]);
const toggleSelectionToolbar = useCallback(
() => setIsSelectionPopoverOpen((prevIsOpen) => !prevIsOpen),
[]
);
return (
const selectedRowsMenuButton = (
<EuiPopover
closePopover={() => setIsSelectionPopoverOpen(false)}
isOpen={isSelectionPopoverOpen}
panelPaddingSize="none"
button={
<EuiDataGridToolbarControl
iconType="documents"
iconSide="left"
iconType="arrowDown"
onClick={toggleSelectionToolbar}
data-selected-documents={selectedDocs.length}
data-selected-documents={selectedDocIds.length}
data-test-subj="unifiedDataTableSelectionBtn"
isSelected={isFilterActive}
badgeContent={selectedDocs.length}
badgeContent={fieldFormats
.getDefaultInstance(KBN_FIELD_TYPES.NUMBER, [ES_FIELD_TYPES.INTEGER])
.convert(selectedDocIds.length)}
css={css`
.euiButtonEmpty__content {
flex-direction: row-reverse;
}
`}
>
{isPlainRecord ? (
<FormattedMessage
@ -226,28 +341,92 @@ export function DataTableDocumentToolbarBtn({
)}
</EuiPopover>
);
return (
<EuiFlexGroup
responsive={false}
gutterSize="none"
wrap={false}
className="unifiedDataTableToolbarControlGroup"
>
<EuiFlexItem className="unifiedDataTableToolbarControlButton" grow={false}>
{selectedRowsMenuButton}
</EuiFlexItem>
{shouldSuggestToSelectAll ? (
<EuiFlexItem className="unifiedDataTableToolbarControlButton" grow={false}>
<EuiDataGridToolbarControl
data-test-subj="dscGridSelectAllDocs"
onClick={() => {
setIsSelectionPopoverOpen(false);
selectAllDocs();
}}
>
<FormattedMessage
id="unifiedDataTable.selectAllDocs"
defaultMessage="Select all {rowsCount}"
values={{
rowsCount: fieldFormats
.getDefaultInstance(KBN_FIELD_TYPES.NUMBER, [ES_FIELD_TYPES.INTEGER])
.convert(rows.length),
}}
/>
</EuiDataGridToolbarControl>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
);
}
const MAX_SELECTED_DOCS_FOR_COMPARE = 100;
export const DataTableCompareToolbarBtn = ({
selectedDocs,
selectedDocIds,
setIsCompareActive,
}: {
selectedDocs: string[];
selectedDocIds: string[];
setIsCompareActive: (value: boolean) => void;
}) => {
const isDisabled = selectedDocIds.length > MAX_SELECTED_DOCS_FOR_COMPARE;
const label = (
<FormattedMessage
id="unifiedDataTable.compareSelectedRowsButtonLabel"
defaultMessage="Compare selected"
/>
);
return (
<EuiDataGridToolbarControl
iconType="diff"
badgeContent={selectedDocs.length}
<EuiContextMenuItem
data-test-subj="unifiedDataTableCompareSelectedDocuments"
disabled={isDisabled}
icon="diff"
onClick={() => {
setIsCompareActive(true);
}}
>
<FormattedMessage
id="unifiedDataTable.compareSelectedRowsButtonLabel"
defaultMessage="Compare"
/>
</EuiDataGridToolbarControl>
{isDisabled ? (
<EuiToolTip
content={i18n.translate('unifiedDataTable.compareSelectedRowsButtonDisabledTooltip', {
defaultMessage: 'Comparison is limited to {limit} rows',
values: { limit: MAX_SELECTED_DOCS_FOR_COMPARE },
})}
>
{label}
</EuiToolTip>
) : (
label
)}
</EuiContextMenuItem>
);
};
function getDocIdsForCurrentPage(
rows: DataTableRecord[],
pageIndex: number | undefined,
pageSize: number | undefined
): string[] | undefined {
if (typeof pageIndex === 'number' && typeof pageSize === 'number') {
const start = pageIndex * pageSize;
const end = start + pageSize;
return rows.slice(start, end).map((row) => row.id);
}
return undefined; // pagination is disabled
}

View file

@ -33,7 +33,7 @@ export const ExpandButton = ({ rowIndex, setCellProps }: EuiDataGridCellValueEle
className: 'unifiedDataTable__cell--expanded',
});
} else {
setCellProps({ style: undefined });
setCellProps({ className: '' });
}
}, [expanded, current, setCellProps, isDarkMode]);

View file

@ -0,0 +1,253 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { buildDataTableRecord } from '@kbn/discover-utils';
import { useSelectedDocs } from './use_selected_docs';
import { generateEsHits } from '@kbn/discover-utils/src/__mocks__';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
describe('useSelectedDocs', () => {
const docs = generateEsHits(dataViewWithTimefieldMock, 5).map((hit) =>
buildDataTableRecord(hit, dataViewWithTimefieldMock)
);
const docsMap = new Map(docs.map((doc) => [doc.id, doc]));
test('should have a correct default state', () => {
const { result } = renderHook(() => useSelectedDocs(docsMap));
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: [],
hasSelectedDocs: false,
})
);
});
test('should toggleDocSelection correctly', () => {
const { result } = renderHook(() => useSelectedDocs(docsMap));
act(() => {
result.current.toggleDocSelection(docs[0].id);
});
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: [docs[0].id],
hasSelectedDocs: true,
})
);
expect(result.current.isDocSelected(docs[0].id)).toBe(true);
expect(result.current.isDocSelected(docs[1].id)).toBe(false);
act(() => {
result.current.toggleDocSelection(docs[1].id);
});
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: [docs[0].id, docs[1].id],
hasSelectedDocs: true,
})
);
expect(result.current.isDocSelected(docs[0].id)).toBe(true);
expect(result.current.isDocSelected(docs[1].id)).toBe(true);
act(() => {
result.current.toggleDocSelection(docs[0].id);
});
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: [docs[1].id],
hasSelectedDocs: true,
})
);
expect(result.current.isDocSelected(docs[0].id)).toBe(false);
expect(result.current.isDocSelected(docs[1].id)).toBe(true);
act(() => {
result.current.toggleDocSelection(docs[1].id);
});
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: [],
hasSelectedDocs: false,
})
);
expect(result.current.isDocSelected(docs[0].id)).toBe(false);
expect(result.current.isDocSelected(docs[1].id)).toBe(false);
});
test('should replaceSelectedDocs correctly', () => {
const { result } = renderHook(() => useSelectedDocs(docsMap));
act(() => {
result.current.toggleDocSelection(docs[0].id);
result.current.toggleDocSelection(docs[1].id);
});
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: [docs[0].id, docs[1].id],
hasSelectedDocs: true,
})
);
act(() => {
result.current.replaceSelectedDocs([docs[1].id, docs[2].id]);
});
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: [docs[1].id, docs[2].id],
hasSelectedDocs: true,
})
);
expect(result.current.isDocSelected(docs[0].id)).toBe(false);
expect(result.current.isDocSelected(docs[1].id)).toBe(true);
expect(result.current.isDocSelected(docs[2].id)).toBe(true);
});
test('should selectAllDocs correctly', () => {
const { result } = renderHook(() => useSelectedDocs(docsMap));
act(() => {
result.current.selectAllDocs();
});
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: docs.map((doc) => doc.id),
hasSelectedDocs: true,
})
);
expect(result.current.isDocSelected(docs[0].id)).toBe(true);
expect(result.current.isDocSelected(docs[docs.length - 1].id)).toBe(true);
});
test('should selectMoreDocs correctly', () => {
const { result } = renderHook(() => useSelectedDocs(docsMap));
act(() => {
result.current.toggleDocSelection(docs[0].id);
result.current.toggleDocSelection(docs[1].id);
});
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: [docs[0].id, docs[1].id],
hasSelectedDocs: true,
})
);
act(() => {
result.current.selectMoreDocs([docs[1].id, docs[2].id]);
});
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: [docs[0].id, docs[1].id, docs[2].id],
hasSelectedDocs: true,
})
);
expect(result.current.isDocSelected(docs[0].id)).toBe(true);
expect(result.current.isDocSelected(docs[1].id)).toBe(true);
expect(result.current.isDocSelected(docs[2].id)).toBe(true);
});
test('should deselectSomeDocs correctly', () => {
const { result } = renderHook(() => useSelectedDocs(docsMap));
act(() => {
result.current.toggleDocSelection(docs[0].id);
result.current.toggleDocSelection(docs[1].id);
result.current.toggleDocSelection(docs[2].id);
});
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: [docs[0].id, docs[1].id, docs[2].id],
hasSelectedDocs: true,
})
);
act(() => {
result.current.deselectSomeDocs([docs[0].id, docs[2].id]);
});
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: [docs[1].id],
hasSelectedDocs: true,
})
);
expect(result.current.isDocSelected(docs[0].id)).toBe(false);
expect(result.current.isDocSelected(docs[1].id)).toBe(true);
expect(result.current.isDocSelected(docs[2].id)).toBe(false);
});
test('should clearAllSelectedDocs correctly', () => {
const { result } = renderHook(() => useSelectedDocs(docsMap));
act(() => {
result.current.toggleDocSelection(docs[0].id);
result.current.toggleDocSelection(docs[1].id);
});
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: [docs[0].id, docs[1].id],
hasSelectedDocs: true,
})
);
act(() => {
result.current.clearAllSelectedDocs();
});
expect(result.current).toEqual(
expect.objectContaining({
selectedDocIds: [],
hasSelectedDocs: false,
})
);
expect(result.current.isDocSelected(docs[0].id)).toBe(false);
expect(result.current.isDocSelected(docs[1].id)).toBe(false);
});
test('should getCountOfSelectedDocs correctly', () => {
const { result } = renderHook(() => useSelectedDocs(docsMap));
act(() => {
result.current.toggleDocSelection(docs[0].id);
result.current.toggleDocSelection(docs[1].id);
});
expect(result.current.getCountOfSelectedDocs([docs[0].id, docs[1].id])).toBe(2);
expect(result.current.getCountOfSelectedDocs([docs[2].id, docs[3].id])).toBe(0);
act(() => {
result.current.toggleDocSelection(docs[0].id);
});
expect(result.current.getCountOfSelectedDocs([docs[0].id, docs[1].id])).toBe(1);
expect(result.current.getCountOfSelectedDocs([docs[1].id])).toBe(1);
expect(result.current.getCountOfSelectedDocs([docs[0].id])).toBe(0);
expect(result.current.getCountOfSelectedDocs([docs[2].id, docs[3].id])).toBe(0);
});
});

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useCallback, useMemo, useState } from 'react';
import type { DataTableRecord } from '@kbn/discover-utils';
export interface UseSelectedDocsState {
isDocSelected: (docId: string) => boolean;
getCountOfSelectedDocs: (docIds: string[]) => number;
hasSelectedDocs: boolean;
selectedDocIds: string[];
toggleDocSelection: (docId: string) => void;
selectAllDocs: () => void;
selectMoreDocs: (docIds: string[]) => void;
deselectSomeDocs: (docIds: string[]) => void;
replaceSelectedDocs: (docIds: string[]) => void;
clearAllSelectedDocs: () => void;
}
export const useSelectedDocs = (docMap: Map<string, DataTableRecord>): UseSelectedDocsState => {
const [selectedDocsSet, setSelectedDocsSet] = useState<Set<string>>(new Set());
const toggleDocSelection = useCallback((docId: string) => {
setSelectedDocsSet((prevSelectedRowsSet) => {
const newSelectedRowsSet = new Set(prevSelectedRowsSet);
if (newSelectedRowsSet.has(docId)) {
newSelectedRowsSet.delete(docId);
} else {
newSelectedRowsSet.add(docId);
}
return newSelectedRowsSet;
});
}, []);
const replaceSelectedDocs = useCallback((docIds: string[]) => {
setSelectedDocsSet(new Set(docIds));
}, []);
const selectAllDocs = useCallback(() => {
setSelectedDocsSet(new Set(docMap.keys()));
}, [docMap]);
const selectMoreDocs = useCallback((docIds: string[]) => {
setSelectedDocsSet((prevSelectedRowsSet) => new Set([...prevSelectedRowsSet, ...docIds]));
}, []);
const deselectSomeDocs = useCallback((docIds: string[]) => {
setSelectedDocsSet(
(prevSelectedRowsSet) =>
new Set([...prevSelectedRowsSet].filter((docId) => !docIds.includes(docId)))
);
}, []);
const clearAllSelectedDocs = useCallback(() => {
setSelectedDocsSet(new Set());
}, []);
const selectedDocIds = useMemo(
() => Array.from(selectedDocsSet).filter((docId) => docMap.has(docId)),
[selectedDocsSet, docMap]
);
const isDocSelected = useCallback(
(docId: string) => selectedDocsSet.has(docId) && docMap.has(docId),
[selectedDocsSet, docMap]
);
const usedSelectedDocsCount = selectedDocIds.length;
const getCountOfSelectedDocs = useCallback(
(docIds) => {
if (!usedSelectedDocsCount) {
return 0;
}
return docIds.filter(isDocSelected).length;
},
[usedSelectedDocsCount, isDocSelected]
);
return useMemo(
() => ({
isDocSelected,
hasSelectedDocs: usedSelectedDocsCount > 0,
getCountOfSelectedDocs,
selectedDocIds,
toggleDocSelection,
selectAllDocs,
selectMoreDocs,
deselectSomeDocs,
replaceSelectedDocs,
clearAllSelectedDocs,
}),
[
isDocSelected,
getCountOfSelectedDocs,
toggleDocSelection,
selectAllDocs,
selectMoreDocs,
deselectSomeDocs,
replaceSelectedDocs,
clearAllSelectedDocs,
usedSelectedDocsCount,
selectedDocIds,
]
);
};

View file

@ -11,6 +11,7 @@ import type { DataView } from '@kbn/data-views-plugin/public';
import type { DataTableRecord } from '@kbn/discover-utils/types';
import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import type { ValueToStringConverter } from './types';
import type { UseSelectedDocsState } from './hooks/use_selected_docs';
export interface DataTableContext {
expanded?: DataTableRecord | undefined;
@ -19,11 +20,12 @@ export interface DataTableContext {
onFilter?: DocViewFilterFn;
dataView: DataView;
isDarkMode: boolean;
selectedDocs: string[];
setSelectedDocs: (selected: string[]) => void;
selectedDocsState: UseSelectedDocsState;
valueToStringConverter: ValueToStringConverter;
componentsTourSteps?: Record<string, string>;
isPlainRecord?: boolean;
pageIndex: number | undefined; // undefined when the pagination is disabled
pageSize: number | undefined;
}
const defaultContext = {} as unknown as DataTableContext;

View file

@ -0,0 +1,155 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const dataGrid = getService('dataGrid');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const retry = getService('retry');
const security = getService('security');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker', 'dashboard']);
const PAGE_SIZE = 5;
const defaultSettings = {
defaultIndex: 'logstash-*',
'discover:sampleRowsPerPage': PAGE_SIZE,
hideAnnouncements: true,
};
describe('discover data grid row selection', function describeIndexTests() {
before(async function () {
await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']);
await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover');
await kibanaServer.uiSettings.replace(defaultSettings);
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
});
beforeEach(async () => {
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.waitUntilSearchingHasFinished();
});
after(async function () {
await kibanaServer.uiSettings.replace({});
await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover');
await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional');
await kibanaServer.savedObjects.cleanStandardList();
});
it('should be able to select rows manually', async () => {
expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(false);
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);
});
await dataGrid.selectRow(2);
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);
});
// deselect
await dataGrid.selectRow(2);
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);
});
});
it('should be able to bulk select rows', async () => {
expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(false);
expect(await testSubjects.getAttribute('selectAllDocsOnPageToggle', 'title')).to.be(
'Select all visible rows'
);
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);
expect(await testSubjects.getAttribute('selectAllDocsOnPageToggle', 'title')).to.be(
'Deselect all visible rows'
);
});
await dataGrid.toggleSelectAllRowsOnCurrentPage();
await retry.try(async () => {
expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(false);
expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(0);
expect(await testSubjects.getAttribute('selectAllDocsOnPageToggle', 'title')).to.be(
'Select all visible rows'
);
});
await dataGrid.toggleSelectAllRowsOnCurrentPage();
await retry.try(async () => {
expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true);
expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(PAGE_SIZE);
expect(await dataGrid.getNumberOfSelectedRows()).to.be(PAGE_SIZE);
expect(await testSubjects.getAttribute('selectAllDocsOnPageToggle', 'title')).to.be(
'Deselect all visible rows'
);
expect(await testSubjects.getVisibleText('dscGridSelectAllDocs')).to.be('Select all 500');
});
await dataGrid.selectAllRows();
await retry.try(async () => {
expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true);
expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(PAGE_SIZE);
expect(await dataGrid.getNumberOfSelectedRows()).to.be(500);
expect(await testSubjects.getAttribute('selectAllDocsOnPageToggle', 'title')).to.be(
'Deselect all visible rows'
);
await testSubjects.missingOrFail('dscGridSelectAllDocs');
});
await dataGrid.toggleSelectAllRowsOnCurrentPage();
await retry.try(async () => {
expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true);
expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(0);
expect(await dataGrid.getNumberOfSelectedRows()).to.be(500 - PAGE_SIZE);
expect(await testSubjects.getAttribute('selectAllDocsOnPageToggle', 'title')).to.be(
'Select all visible rows'
);
await testSubjects.existOrFail('dscGridSelectAllDocs');
});
await dataGrid.openSelectedRowsMenu();
await testSubjects.click('dscGridClearSelectedDocuments');
await retry.try(async () => {
expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(false);
expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(0);
expect(await testSubjects.getAttribute('selectAllDocsOnPageToggle', 'title')).to.be(
'Select all visible rows'
);
await testSubjects.missingOrFail('dscGridSelectAllDocs');
});
});
});
}

View file

@ -22,6 +22,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./_data_grid_row_navigation'));
loadTestFile(require.resolve('./_data_grid_row_height'));
loadTestFile(require.resolve('./_data_grid_row_selection'));
loadTestFile(require.resolve('./_data_grid_sample_size'));
loadTestFile(require.resolve('./_data_grid_pagination'));
});

View file

@ -584,6 +584,36 @@ export class DataGridService extends FtrService {
await checkbox.click();
}
public async getNumberOfSelectedRows() {
const label = await this.find.byCssSelector(
'[data-test-subj=unifiedDataTableSelectionBtn] .euiNotificationBadge'
);
return Number(await label.getVisibleText());
}
public async getNumberOfSelectedRowsOnCurrentPage() {
const selectedRows = await this.find.allByCssSelector(
'.euiDataGridRow [data-gridcell-column-id="select"] .euiCheckbox__input:checked'
);
return selectedRows.length;
}
public async toggleSelectAllRowsOnCurrentPage() {
const checkbox = await this.testSubjects.find('selectAllDocsOnPageToggle');
await checkbox.click();
}
public async selectAllRows() {
const button = await this.testSubjects.find('dscGridSelectAllDocs');
await button.click();
}
public async isSelectedRowsMenuVisible() {
return await this.testSubjects.exists('unifiedDataTableSelectionBtn');
}
public async openSelectedRowsMenu() {
await this.testSubjects.click('unifiedDataTableSelectionBtn');
await this.retry.try(async () => {
@ -591,11 +621,22 @@ export class DataGridService extends FtrService {
});
}
public async closeSelectedRowsMenu() {
await this.testSubjects.click('unifiedDataTableSelectionBtn');
await this.retry.try(async () => {
return !(await this.testSubjects.exists('unifiedDataTableSelectionMenu'));
});
}
public async compareSelectedButtonExists() {
return await this.testSubjects.exists('unifiedDataTableCompareSelectedDocuments');
await this.openSelectedRowsMenu();
const exists = await this.testSubjects.exists('unifiedDataTableCompareSelectedDocuments');
await this.closeSelectedRowsMenu();
return exists;
}
public async clickCompareSelectedButton() {
await this.openSelectedRowsMenu();
await this.testSubjects.click('unifiedDataTableCompareSelectedDocuments');
}

View file

@ -73,7 +73,10 @@ export const StyledTimelineUnifiedDataTable = styled.div.attrs(({ className = ''
margin-top: 3px;
}
.udtTimeline .euiDataGridHeaderCell.euiDataGridHeaderCell--controlColumn {
.udtTimeline
.euiDataGridHeaderCell.euiDataGridHeaderCell--controlColumn:not(
[data-gridcell-column-id='select']
) {
padding: 0;
position: relative;
}