[Discover][EuiDataGrid] Add document selector (#94804)

Co-authored-by: Ryan Keairns <rkeairns@chef.io>
This commit is contained in:
Matthias Wilhelm 2021-04-13 15:54:42 +02:00 committed by GitHub
parent 73ccf7844a
commit 8cce4805d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 537 additions and 11 deletions

View file

@ -0,0 +1,146 @@
/*
* 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 React from 'react';
import { ReactWrapper } from 'enzyme';
import { EuiCopy } from '@elastic/eui';
import { act } from 'react-dom/test-utils';
import { findTestSubject } from '@elastic/eui/lib/test';
import { esHits } from '../../../__mocks__/es_hits';
import { indexPatternMock } from '../../../__mocks__/index_pattern';
import { mountWithIntl } from '@kbn/test/jest';
import { DiscoverGrid, DiscoverGridProps } from './discover_grid';
import { uiSettingsMock } from '../../../__mocks__/ui_settings';
import { DiscoverServices } from '../../../build_services';
import { ElasticSearchHit } from '../../doc_views/doc_views_types';
import { getDocId } from './discover_grid_document_selection';
function getProps() {
const servicesMock = {
uiSettings: uiSettingsMock,
} as DiscoverServices;
return {
ariaLabelledBy: '',
columns: [],
indexPattern: indexPatternMock,
isLoading: false,
expandedDoc: undefined,
onAddColumn: jest.fn(),
onFilter: jest.fn(),
onRemoveColumn: jest.fn(),
onResize: jest.fn(),
onSetColumns: jest.fn(),
onSort: jest.fn(),
rows: esHits,
sampleSize: 30,
searchDescription: '',
searchTitle: '',
services: servicesMock,
setExpandedDoc: jest.fn(),
settings: {},
showTimeCol: true,
sort: [],
useNewFieldsApi: true,
};
}
function getComponent() {
return mountWithIntl(<DiscoverGrid {...getProps()} />);
}
function getSelectedDocNr(component: ReactWrapper<DiscoverGridProps>) {
const gridSelectionBtn = findTestSubject(component, 'dscGridSelectionBtn');
if (!gridSelectionBtn.length) {
return 0;
}
const selectedNr = gridSelectionBtn.getDOMNode().getAttribute('data-selected-documents');
return Number(selectedNr);
}
function getDisplayedDocNr(component: ReactWrapper<DiscoverGridProps>) {
const gridSelectionBtn = findTestSubject(component, 'discoverDocTable');
if (!gridSelectionBtn.length) {
return 0;
}
const selectedNr = gridSelectionBtn.getDOMNode().getAttribute('data-document-number');
return Number(selectedNr);
}
async function toggleDocSelection(
component: ReactWrapper<DiscoverGridProps>,
document: ElasticSearchHit
) {
act(() => {
const docId = getDocId(document);
findTestSubject(component, `dscGridSelectDoc-${docId}`).simulate('change');
});
component.update();
}
describe('DiscoverGrid', () => {
describe('Document selection', () => {
let component: ReactWrapper<DiscoverGridProps>;
beforeEach(() => {
component = getComponent();
});
test('no documents are selected initially', async () => {
expect(getSelectedDocNr(component)).toBe(0);
expect(getDisplayedDocNr(component)).toBe(5);
});
test('Allows selection/deselection of multiple documents', async () => {
await toggleDocSelection(component, esHits[0]);
expect(getSelectedDocNr(component)).toBe(1);
await toggleDocSelection(component, esHits[1]);
expect(getSelectedDocNr(component)).toBe(2);
await toggleDocSelection(component, esHits[1]);
expect(getSelectedDocNr(component)).toBe(1);
});
test('deselection of all selected documents', async () => {
await toggleDocSelection(component, esHits[0]);
await toggleDocSelection(component, esHits[1]);
expect(getSelectedDocNr(component)).toBe(2);
findTestSubject(component, 'dscGridSelectionBtn').simulate('click');
findTestSubject(component, 'dscGridClearSelectedDocuments').simulate('click');
expect(getSelectedDocNr(component)).toBe(0);
});
test('showing only selected documents and undo selection', async () => {
await toggleDocSelection(component, esHits[0]);
await toggleDocSelection(component, esHits[1]);
expect(getSelectedDocNr(component)).toBe(2);
findTestSubject(component, 'dscGridSelectionBtn').simulate('click');
findTestSubject(component, 'dscGridShowSelectedDocuments').simulate('click');
expect(getDisplayedDocNr(component)).toBe(2);
findTestSubject(component, 'dscGridSelectionBtn').simulate('click');
component.update();
findTestSubject(component, 'dscGridShowAllDocuments').simulate('click');
expect(getDisplayedDocNr(component)).toBe(5);
});
test('showing only selected documents and remove filter deselecting each doc manually', async () => {
await toggleDocSelection(component, esHits[0]);
findTestSubject(component, 'dscGridSelectionBtn').simulate('click');
findTestSubject(component, 'dscGridShowSelectedDocuments').simulate('click');
expect(getDisplayedDocNr(component)).toBe(1);
await toggleDocSelection(component, esHits[0]);
expect(getDisplayedDocNr(component)).toBe(5);
await toggleDocSelection(component, esHits[0]);
expect(getDisplayedDocNr(component)).toBe(5);
});
test('copying selected documents to clipboard', async () => {
await toggleDocSelection(component, esHits[0]);
findTestSubject(component, 'dscGridSelectionBtn').simulate('click');
expect(component.find(EuiCopy).prop('textToCopy')).toMatchInlineSnapshot(
`"[{\\"_index\\":\\"i\\",\\"_id\\":\\"1\\",\\"_score\\":1,\\"_type\\":\\"_doc\\",\\"_source\\":{\\"date\\":\\"2020-20-01T12:12:12.123\\",\\"message\\":\\"test1\\",\\"bytes\\":20}}]"`
);
});
});
});

View file

@ -37,6 +37,7 @@ import { defaultPageSize, gridStyle, pageSizeArr, toolbarVisibility } from './co
import { DiscoverServices } from '../../../build_services';
import { getDisplayedColumns } from '../../helpers/columns';
import { KibanaContextProvider } from '../../../../../kibana_react/public';
import { DiscoverGridDocumentToolbarBtn, getDocId } from './discover_grid_document_selection';
interface SortObj {
id: string;
@ -158,14 +159,27 @@ export const DiscoverGrid = ({
sort,
useNewFieldsApi,
}: DiscoverGridProps) => {
const [selectedDocs, setSelectedDocs] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState(false);
const displayedColumns = getDisplayedColumns(columns, indexPattern);
const defaultColumns = displayedColumns.includes('_source');
const displayedRows = useMemo(() => {
if (!rows) {
return [];
}
if (!isFilterActive || selectedDocs.length === 0) {
return rows;
}
return rows.filter((row) => {
return selectedDocs.includes(getDocId(row));
});
}, [rows, selectedDocs, isFilterActive]);
/**
* Pagination
*/
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: defaultPageSize });
const rowCount = useMemo(() => (rows ? rows.length : 0), [rows]);
const rowCount = useMemo(() => (displayedRows ? displayedRows.length : 0), [displayedRows]);
const pageCount = useMemo(() => Math.ceil(rowCount / pagination.pageSize), [
rowCount,
pagination,
@ -207,11 +221,11 @@ export const DiscoverGrid = ({
() =>
getRenderCellValueFn(
indexPattern,
rows,
rows ? rows.map((hit) => indexPattern.flattenHit(hit)) : [],
displayedRows,
displayedRows ? displayedRows.map((hit) => indexPattern.flattenHit(hit)) : [],
useNewFieldsApi
),
[rows, indexPattern, useNewFieldsApi]
[displayedRows, indexPattern, useNewFieldsApi]
);
/**
@ -240,6 +254,20 @@ export const DiscoverGrid = ({
]);
const lead = useMemo(() => getLeadControlColumns(), []);
const additionalControls = useMemo(
() =>
selectedDocs.length ? (
<DiscoverGridDocumentToolbarBtn
isFilterActive={isFilterActive}
rows={rows!}
selectedDocs={selectedDocs}
setSelectedDocs={setSelectedDocs}
setIsFilterActive={setIsFilterActive}
/>
) : null,
[selectedDocs, isFilterActive, rows, setIsFilterActive]
);
if (!rowCount) {
return (
<div className="euiDataGrid__noResults">
@ -257,10 +285,17 @@ export const DiscoverGrid = ({
value={{
expanded: expandedDoc,
setExpanded: setExpandedDoc,
rows: rows || [],
rows: displayedRows,
onFilter,
indexPattern,
isDarkMode: services.uiSettings.get('theme:darkMode'),
selectedDocs,
setSelectedDocs: (newSelectedDocs) => {
setSelectedDocs(newSelectedDocs);
if (isFilterActive && newSelectedDocs.length === 0) {
setIsFilterActive(false);
}
},
}}
>
<span
@ -269,6 +304,7 @@ export const DiscoverGrid = ({
data-shared-item=""
data-title={searchTitle}
data-description={searchDescription}
data-document-number={displayedRows.length}
>
<KibanaContextProvider services={{ uiSettings: services.uiSettings }}>
<EuiDataGridMemoized
@ -294,8 +330,12 @@ export const DiscoverGrid = ({
? {
...toolbarVisibility,
showColumnSelector: false,
additionalControls,
}
: {
...toolbarVisibility,
additionalControls,
}
: toolbarVisibility
}
/>
</KibanaContextProvider>
@ -335,7 +375,7 @@ export const DiscoverGrid = ({
<DiscoverGridFlyout
indexPattern={indexPattern}
hit={expandedDoc}
hits={rows}
hits={displayedRows}
// if default columns are used, dont make them part of the URL - the context state handling will take care to restore them
columns={defaultColumns ? [] : displayedColumns}
onFilter={onFilter}

View file

@ -25,6 +25,8 @@ describe('Discover cell actions ', function () {
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
selectedDocs: [],
setSelectedDocs: jest.fn(),
};
const component = mountWithIntl(
@ -50,6 +52,8 @@ describe('Discover cell actions ', function () {
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
selectedDocs: [],
setSelectedDocs: jest.fn(),
};
const component = mountWithIntl(

View file

@ -14,6 +14,7 @@ import { DiscoverGridSettings } from './types';
import { IndexPattern } from '../../../../../data/common/index_patterns/index_patterns';
import { buildCellActions } from './discover_grid_cell_actions';
import { getSchemaByKbnType } from './discover_grid_schema';
import { SelectButton } from './discover_grid_document_selection';
export function getLeadControlColumns() {
return [
@ -31,6 +32,20 @@ export function getLeadControlColumns() {
),
rowCellRender: ExpandButton,
},
{
id: 'select',
width: 32,
rowCellRender: SelectButton,
headerCellRender: () => (
<EuiScreenReaderOnly>
<span>
{i18n.translate('discover.selectColumnHeader', {
defaultMessage: 'Select column',
})}
</span>
</EuiScreenReaderOnly>
),
},
];
}

View file

@ -17,6 +17,8 @@ export interface GridContext {
onFilter: DocViewFilterFn;
indexPattern: IndexPattern;
isDarkMode: boolean;
selectedDocs: string[];
setSelectedDocs: (selected: string[]) => void;
}
const defaultContext = ({} as unknown) as GridContext;

View file

@ -0,0 +1,143 @@
/*
* 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 React from 'react';
import { mountWithIntl } from '@kbn/test/jest';
import { findTestSubject } from '@elastic/eui/lib/test';
import {
DiscoverGridDocumentToolbarBtn,
getDocId,
SelectButton,
} from './discover_grid_document_selection';
import { esHits } from '../../../__mocks__/es_hits';
import { indexPatternMock } from '../../../__mocks__/index_pattern';
import { DiscoverGridContext } from './discover_grid_context';
describe('document selection', () => {
describe('getDocId', () => {
test('doc with custom routing', () => {
const doc = {
_id: 'test-id',
_index: 'test-indices',
_routing: 'why-not',
};
expect(getDocId(doc)).toMatchInlineSnapshot(`"test-indices::test-id::why-not"`);
});
test('doc without custom routing', () => {
const doc = {
_id: 'test-id',
_index: 'test-indices',
};
expect(getDocId(doc)).toMatchInlineSnapshot(`"test-indices::test-id::"`);
});
});
describe('SelectButton', () => {
test('is not checked', () => {
const contextMock = {
expanded: undefined,
setExpanded: jest.fn(),
rows: esHits,
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
selectedDocs: [],
setSelectedDocs: jest.fn(),
};
const component = mountWithIntl(
<DiscoverGridContext.Provider value={contextMock}>
<SelectButton rowIndex={0} />
</DiscoverGridContext.Provider>
);
const checkBox = findTestSubject(component, 'dscGridSelectDoc-i::1::');
expect(checkBox.props().checked).toBeFalsy();
});
test('is checked', () => {
const contextMock = {
expanded: undefined,
setExpanded: jest.fn(),
rows: esHits,
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
selectedDocs: ['i::1::'],
setSelectedDocs: jest.fn(),
};
const component = mountWithIntl(
<DiscoverGridContext.Provider value={contextMock}>
<SelectButton rowIndex={0} />
</DiscoverGridContext.Provider>
);
const checkBox = findTestSubject(component, 'dscGridSelectDoc-i::1::');
expect(checkBox.props().checked).toBeTruthy();
});
test('adding a selection', () => {
const contextMock = {
expanded: undefined,
setExpanded: jest.fn(),
rows: esHits,
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
selectedDocs: [],
setSelectedDocs: jest.fn(),
};
const component = mountWithIntl(
<DiscoverGridContext.Provider value={contextMock}>
<SelectButton rowIndex={0} />
</DiscoverGridContext.Provider>
);
const checkBox = findTestSubject(component, 'dscGridSelectDoc-i::1::');
checkBox.simulate('change');
expect(contextMock.setSelectedDocs).toHaveBeenCalledWith(['i::1::']);
});
test('removing a selection', () => {
const contextMock = {
expanded: undefined,
setExpanded: jest.fn(),
rows: esHits,
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
selectedDocs: ['i::1::'],
setSelectedDocs: jest.fn(),
};
const component = mountWithIntl(
<DiscoverGridContext.Provider value={contextMock}>
<SelectButton rowIndex={0} />
</DiscoverGridContext.Provider>
);
const checkBox = findTestSubject(component, 'dscGridSelectDoc-i::1::');
checkBox.simulate('change');
expect(contextMock.setSelectedDocs).toHaveBeenCalledWith([]);
});
});
describe('DiscoverGridDocumentToolbarBtn', () => {
test('it renders a button clickable button', () => {
const props = {
isFilterActive: false,
rows: esHits,
selectedDocs: ['i::1::'],
setIsFilterActive: jest.fn(),
setSelectedDocs: jest.fn(),
};
const component = mountWithIntl(<DiscoverGridDocumentToolbarBtn {...props} />);
const button = findTestSubject(component, 'dscGridSelectionBtn');
expect(button.length).toBe(1);
});
});
});

View file

@ -0,0 +1,170 @@
/*
* 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 React, { useCallback, useState, useContext, useMemo } from 'react';
import {
EuiButtonEmpty,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiCopy,
EuiPopover,
EuiCheckbox,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import classNames from 'classnames';
import { ElasticSearchHit } from '../../doc_views/doc_views_types';
import { DiscoverGridContext } from './discover_grid_context';
/**
* Returning a generated id of a given ES document, since `_id` can be the same
* when using different indices and shard routing
*/
export const getDocId = (doc: ElasticSearchHit & { _routing?: string }) => {
const routing = doc._routing ? doc._routing : '';
return [doc._index, doc._id, routing].join('::');
};
export const SelectButton = ({ rowIndex }: { rowIndex: number }) => {
const ctx = useContext(DiscoverGridContext);
const doc = useMemo(() => ctx.rows[rowIndex], [ctx.rows, rowIndex]);
const id = useMemo(() => getDocId(doc), [doc]);
const checked = useMemo(() => ctx.selectedDocs.includes(id), [ctx.selectedDocs, id]);
return (
<EuiCheckbox
id={id}
label=""
checked={checked}
data-test-subj={`dscGridSelectDoc-${id}`}
onChange={() => {
if (checked) {
const newSelection = ctx.selectedDocs.filter((docId) => docId !== id);
ctx.setSelectedDocs(newSelection);
} else {
ctx.setSelectedDocs([...ctx.selectedDocs, id]);
}
}}
/>
);
};
export function DiscoverGridDocumentToolbarBtn({
isFilterActive,
rows,
selectedDocs,
setIsFilterActive,
setSelectedDocs,
}: {
isFilterActive: boolean;
rows: ElasticSearchHit[];
selectedDocs: string[];
setIsFilterActive: (value: boolean) => void;
setSelectedDocs: (value: string[]) => void;
}) {
const [isSelectionPopoverOpen, setIsSelectionPopoverOpen] = useState(false);
const getMenuItems = useCallback(() => {
return [
isFilterActive ? (
<EuiContextMenuItem
data-test-subj="dscGridShowAllDocuments"
key="showAllDocuments"
icon="eye"
onClick={() => {
setIsSelectionPopoverOpen(false);
setIsFilterActive(false);
}}
>
<FormattedMessage id="discover.showAllDocuments" defaultMessage="Show all documents" />
</EuiContextMenuItem>
) : (
<EuiContextMenuItem
data-test-subj="dscGridShowSelectedDocuments"
key="showSelectedDocuments"
icon="eye"
onClick={() => {
setIsSelectionPopoverOpen(false);
setIsFilterActive(true);
}}
>
<FormattedMessage
id="discover.showSelectedDocumentsOnly"
defaultMessage="Show selected documents only"
/>
</EuiContextMenuItem>
),
<EuiContextMenuItem
data-test-subj="dscGridClearSelectedDocuments"
key="clearSelection"
icon="cross"
onClick={() => {
setIsSelectionPopoverOpen(false);
setSelectedDocs([]);
setIsFilterActive(false);
}}
>
<FormattedMessage id="discover.clearSelection" defaultMessage="Clear selection" />
</EuiContextMenuItem>,
<EuiCopy
key="copyJsonWrapper"
data-test-subj="dscGridCopySelectedDocumentsJSON"
textToCopy={
rows ? JSON.stringify(rows.filter((row) => selectedDocs.includes(getDocId(row)))) : ''
}
>
{(copy) => (
<EuiContextMenuItem key="copyJSON" icon="copyClipboard" onClick={copy}>
<FormattedMessage
id="discover.copyToClipboardJSON"
defaultMessage="Copy documents to clipboard (JSON)"
/>
</EuiContextMenuItem>
)}
</EuiCopy>,
];
}, [
isFilterActive,
rows,
selectedDocs,
setIsFilterActive,
setIsSelectionPopoverOpen,
setSelectedDocs,
]);
return (
<EuiPopover
closePopover={() => setIsSelectionPopoverOpen(false)}
isOpen={isSelectionPopoverOpen}
panelPaddingSize="none"
button={
<EuiButtonEmpty
size="xs"
color="text"
iconType="documents"
onClick={() => setIsSelectionPopoverOpen(true)}
data-selected-documents={selectedDocs.length}
data-test-subj="dscGridSelectionBtn"
isSelected={isFilterActive}
className={classNames({
// eslint-disable-next-line @typescript-eslint/naming-convention
euiDataGrid__controlBtn: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
'euiDataGrid__controlBtn--active': isFilterActive,
})}
>
<FormattedMessage
id="discover.selectedDocumentsNumber"
defaultMessage="{nr} documents selected"
values={{ nr: selectedDocs.length }}
/>
</EuiButtonEmpty>
}
>
{isSelectionPopoverOpen && <EuiContextMenuPanel items={getMenuItems()} />}
</EuiPopover>
);
}

View file

@ -23,6 +23,8 @@ describe('Discover grid view button ', function () {
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
selectedDocs: [],
setSelectedDocs: jest.fn(),
};
const component = mountWithIntl(
@ -49,6 +51,8 @@ describe('Discover grid view button ', function () {
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
selectedDocs: [],
setSelectedDocs: jest.fn(),
};
const component = mountWithIntl(
@ -75,6 +79,8 @@ describe('Discover grid view button ', function () {
onFilter: jest.fn(),
indexPattern: indexPatternMock,
isDarkMode: false,
selectedDocs: [],
setSelectedDocs: jest.fn(),
};
const component = mountWithIntl(

View file

@ -47,12 +47,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('are added when a cell filter is clicked', async function () {
await find.clickByCssSelector(`[role="gridcell"]:nth-child(3)`);
await find.clickByCssSelector(`[role="gridcell"]:nth-child(4)`);
// needs a short delay between becoming visible & being clickable
await PageObjects.common.sleep(250);
await find.clickByCssSelector(`[data-test-subj="filterOutButton"]`);
await PageObjects.header.waitUntilLoadingHasFinished();
await find.clickByCssSelector(`[role="gridcell"]:nth-child(3)`);
await find.clickByCssSelector(`[role="gridcell"]:nth-child(4)`);
await PageObjects.common.sleep(250);
await find.clickByCssSelector(`[data-test-subj="filterForButton"]`);
const filterCount = await filterBar.getFilterCount();

View file

@ -68,7 +68,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.discover.waitUntilSearchingHasFinished();
await retry.waitFor('first cell contains expected timestamp', async () => {
const cell = await dataGrid.getCellElement(1, 2);
const cell = await dataGrid.getCellElement(1, 3);
const text = await cell.getVisibleText();
return text === expectedTimeStamp;
});

View file

@ -168,7 +168,7 @@ export function DataGridProvider({ getService, getPageObjects }: FtrProviderCont
const textArr = [];
let idx = 0;
for (const cell of result) {
if (idx > 0) {
if (idx > 1) {
textArr.push(await cell.getVisibleText());
}
idx++;