[Cloud Security] Add Fields selector to the CloudSecurityDataTable (#167844)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: @Omolola-Akinleye
This commit is contained in:
Paulo Henrique 2023-10-05 14:40:59 -07:00 committed by GitHub
parent 6f62f7b5a6
commit 0c71076f92
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 483 additions and 101 deletions

View file

@ -0,0 +1,74 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonEmpty, EuiFlexItem } from '@elastic/eui';
import { type DataView } from '@kbn/data-views-plugin/common';
import numeral from '@elastic/numeral';
import { FieldsSelectorModal } from './fields_selector';
import { FindingsGroupBySelector } from '../../pages/configurations/layout/findings_group_by_selector';
import { useStyles } from './use_styles';
const formatNumber = (value: number) => {
return value < 1000 ? value : numeral(value).format('0.0a');
};
export const AdditionalControls = ({
total,
title,
dataView,
columns,
onAddColumn,
onRemoveColumn,
}: {
total: number;
title: string;
dataView: DataView;
columns: string[];
onAddColumn: (column: string) => void;
onRemoveColumn: (column: string) => void;
}) => {
const styles = useStyles();
const [isFieldSelectorModalVisible, setIsFieldSelectorModalVisible] = useState(false);
const closeModal = () => setIsFieldSelectorModalVisible(false);
const showModal = () => setIsFieldSelectorModalVisible(true);
return (
<>
{isFieldSelectorModalVisible && (
<FieldsSelectorModal
columns={columns}
dataView={dataView}
closeModal={closeModal}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
/>
)}
<EuiFlexItem grow={0}>
<span className="cspDataTableTotal">{`${formatNumber(total)} ${title}`}</span>
</EuiFlexItem>
<EuiFlexItem grow={0}>
<EuiButtonEmpty
className="cspDataTableFields"
iconType="tableOfContents"
onClick={showModal}
size="xs"
color="text"
>
{i18n.translate('xpack.csp.dataTable.fields', {
defaultMessage: 'Fields',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false} className={styles.groupBySelector}>
<FindingsGroupBySelector type="default" />
</EuiFlexItem>
</>
);
};

View file

@ -15,32 +15,23 @@ import {
SORT_DEFAULT_ORDER_SETTING,
} from '@kbn/discover-utils';
import { DataTableRecord } from '@kbn/discover-utils/types';
import {
EuiDataGridCellValueElementProps,
EuiDataGridStyle,
EuiFlexItem,
EuiProgress,
} from '@elastic/eui';
import { EuiDataGridCellValueElementProps, EuiDataGridStyle, EuiProgress } from '@elastic/eui';
import { AddFieldFilterHandler } from '@kbn/unified-field-list';
import { generateFilters } from '@kbn/data-plugin/public';
import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import numeral from '@elastic/numeral';
import { useKibana } from '../../common/hooks/use_kibana';
import { CloudPostureTableResult } from '../../common/hooks/use_cloud_posture_table';
import { FindingsGroupBySelector } from '../../pages/configurations/layout/findings_group_by_selector';
import { EmptyState } from '../empty_state';
import { MAX_FINDINGS_TO_LOAD } from '../../common/constants';
import { useStyles } from './use_styles';
import { AdditionalControls } from './additional_controls';
export interface CloudSecurityDefaultColumn {
id: string;
width?: number;
}
const formatNumber = (value: number) => {
return value < 1000 ? value : numeral(value).format('0.0a');
};
const gridStyle: EuiDataGridStyle = {
border: 'horizontal',
cellPadding: 'l',
@ -50,6 +41,9 @@ const gridStyle: EuiDataGridStyle = {
const useNewFieldsApi = true;
// Hide Checkbox, enable open details Flyout
const controlColumnIds = ['openDetails'];
interface CloudSecurityDataGridProps {
dataView: DataView;
isLoading: boolean;
@ -113,7 +107,8 @@ export const CloudSecurityDataTable = ({
`${columnsLocalStorageKey}:settings`,
{
columns: defaultColumns.reduce((prev, curr) => {
const newColumn = { [curr.id]: {} };
const columnDefaultSettings = curr.width ? { width: curr.width } : {};
const newColumn = { [curr.id]: columnDefaultSettings };
return { ...prev, ...newColumn };
}, {} as UnifiedDataTableSettings['columns']),
}
@ -153,7 +148,12 @@ export const CloudSecurityDataTable = ({
dataViewFieldEditor,
};
const { columns: currentColumns, onSetColumns } = useColumns({
const {
columns: currentColumns,
onSetColumns,
onAddColumn,
onRemoveColumn,
} = useColumns({
capabilities,
defaultOrder: uiSettings.get(SORT_DEFAULT_ORDER_SETTING),
dataView,
@ -205,25 +205,39 @@ export const CloudSecurityDataTable = ({
return <EmptyState onResetFilters={onResetFilters} />;
}
const externalAdditionalControls = (
<AdditionalControls
total={total}
dataView={dataView}
title={title}
columns={currentColumns}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
/>
);
const dataTableStyle = {
// Change the height of the grid to fit the page
// If there are filters, leave space for the filter bar
// Todo: Replace this component with EuiAutoSizer
height: `calc(100vh - ${filters.length > 0 ? 443 : 403}px)`,
};
const rowHeightState =
uiSettings.get(ROW_HEIGHT_OPTION) === -1 ? 0 : uiSettings.get(ROW_HEIGHT_OPTION);
const loadingStyle = {
opacity: isLoading ? 1 : 0,
};
return (
<CellActionsProvider getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions}>
<div
data-test-subj={rest['data-test-subj']}
className={styles.gridContainer}
style={{
// Change the height of the grid to fit the page
// If there are filters, leave space for the filter bar
// Todo: Replace this component with EuiAutoSizer
height: `calc(100vh - ${filters.length > 0 ? 454 : 414}px)`,
}}
style={dataTableStyle}
>
<EuiProgress
size="xs"
color="accent"
style={{
opacity: isLoading ? 1 : 0,
}}
/>
<EuiProgress size="xs" color="accent" style={loadingStyle} />
<UnifiedDataTable
className={styles.gridStyle}
ariaLabelledBy={title}
@ -245,31 +259,18 @@ export const CloudSecurityDataTable = ({
services={services}
useNewFieldsApi
onUpdateRowsPerPage={onChangeItemsPerPage}
configRowHeight={uiSettings.get(ROW_HEIGHT_OPTION)}
rowHeightState={rowHeightState}
showMultiFields={uiSettings.get(SHOW_MULTIFIELDS)}
showTimeCol={false}
settings={settings}
onFetchMoreRecords={loadMore}
externalCustomRenderers={externalCustomRenderers}
rowHeightState={uiSettings.get(ROW_HEIGHT_OPTION)}
externalAdditionalControls={<AdditionalControls total={total} title={title} />}
externalAdditionalControls={externalAdditionalControls}
gridStyleOverride={gridStyle}
rowLineHeightOverride="24px"
controlColumnIds={controlColumnIds}
/>
</div>
</CellActionsProvider>
);
};
const AdditionalControls = ({ total, title }: { total: number; title: string }) => {
const styles = useStyles();
return (
<>
<EuiFlexItem>
<span className="cspDataTableTotal">{`${formatNumber(total)} ${title}`}</span>
</EuiFlexItem>
<EuiFlexItem grow={false} className={styles.groupBySelector}>
<FindingsGroupBySelector type="default" />
</EuiFlexItem>
</>
);
};

View file

@ -0,0 +1,69 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { FieldsSelectorTable, FieldsSelectorCommonProps } from './fields_selector';
import { TestProvider } from '../../test/test_provider';
const mockDataView = {
fields: {
getAll: () => [
{ id: 'field1', name: 'field1', customLabel: 'Label 1', visualizable: true },
{ id: 'field2', name: 'field2', customLabel: 'Label 2', visualizable: true },
],
},
} as any;
const renderFieldsTable = (props: Partial<FieldsSelectorCommonProps> = {}) => {
const defaultProps: FieldsSelectorCommonProps = {
dataView: mockDataView,
columns: [],
onAddColumn: jest.fn(),
onRemoveColumn: jest.fn(),
};
return render(
<TestProvider>
<FieldsSelectorTable title="Fields" {...defaultProps} {...props} />
</TestProvider>
);
};
describe('FieldsSelectorTable', () => {
it('renders the table with data correctly', () => {
const { getByText } = renderFieldsTable();
expect(getByText('Label 1')).toBeInTheDocument();
expect(getByText('Label 2')).toBeInTheDocument();
});
it('calls onAddColumn when a checkbox is checked', () => {
const onAddColumn = jest.fn();
const { getAllByRole } = renderFieldsTable({
onAddColumn,
});
const checkbox = getAllByRole('checkbox')[0];
fireEvent.click(checkbox);
expect(onAddColumn).toHaveBeenCalledWith('field1');
});
it('calls onRemoveColumn when a checkbox is unchecked', () => {
const onRemoveColumn = jest.fn();
const { getAllByRole } = renderFieldsTable({
columns: ['field1', 'field2'],
onRemoveColumn,
});
const checkbox = getAllByRole('checkbox')[1];
fireEvent.click(checkbox);
expect(onRemoveColumn).toHaveBeenCalledWith('field2');
});
});

View file

@ -0,0 +1,219 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useMemo } from 'react';
import {
EuiBasicTableColumn,
EuiButton,
EuiCheckbox,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSearchBarProps,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { type DataView } from '@kbn/data-views-plugin/common';
interface Field {
id: string;
name: string;
displayName: string;
}
export interface FieldsSelectorCommonProps {
dataView: DataView;
columns: string[];
onAddColumn: (column: string) => void;
onRemoveColumn: (column: string) => void;
}
const ACTION_COLUMN_WIDTH = '24px';
const defaultSorting = {
sort: {
field: 'name',
direction: 'asc',
},
} as const;
export const FieldsSelectorTable = ({
title,
dataView,
columns,
onAddColumn,
onRemoveColumn,
}: FieldsSelectorCommonProps & {
title: string;
}) => {
const dataViewFields = useMemo<Field[]>(() => {
return dataView.fields
.getAll()
.filter((field) => {
return field.name !== '@timestamp' && field.name !== '_index' && field.visualizable;
})
.map((field) => ({
id: field.name,
name: field.name,
displayName: field.customLabel || '',
}));
}, [dataView.fields]);
const [fields, setFields] = useState(dataViewFields);
let debounceTimeoutId: ReturnType<typeof setTimeout>;
const onQueryChange: EuiSearchBarProps['onChange'] = ({ query }) => {
clearTimeout(debounceTimeoutId);
debounceTimeoutId = setTimeout(() => {
const filteredItems = dataViewFields.filter((field) => {
const normalizedName = `${field.name} ${field.displayName}`.toLowerCase();
const normalizedQuery = query?.text.toLowerCase() || '';
return normalizedName.indexOf(normalizedQuery) !== -1;
});
setFields(filteredItems);
}, 300);
};
const [fieldsSelected, setFieldsSelected] = useState<string[]>(columns);
const tableColumns: Array<EuiBasicTableColumn<Field>> = [
{
field: 'action',
name: '',
width: ACTION_COLUMN_WIDTH,
sortable: false,
render: (_, { id }: Field) => {
return (
<EuiCheckbox
checked={fieldsSelected.includes(id)}
id={id}
onChange={(e) => {
const isChecked = e.target.checked;
setFieldsSelected(
isChecked ? [...fieldsSelected, id] : fieldsSelected.filter((f) => f !== id)
);
return isChecked ? onAddColumn(id) : onRemoveColumn(id);
}}
/>
);
},
},
{
field: 'name',
name: i18n.translate('xpack.csp.dataTable.fieldsModalName', {
defaultMessage: 'Name',
}),
sortable: true,
},
{
field: 'displayName',
name: i18n.translate('xpack.csp.dataTable.fieldsModalCustomLabel', {
defaultMessage: 'Custom Label',
}),
sortable: (field: Field) => field.displayName.toLowerCase(),
},
];
const error = useMemo(() => {
if (!dataView || dataView.fields.length === 0) {
return i18n.translate('xpack.csp.dataTable.fieldsModalError', {
defaultMessage: 'No fields found in the data view',
});
}
return '';
}, [dataView]);
const search: EuiSearchBarProps = {
onChange: onQueryChange,
box: {
incremental: true,
placeholder: i18n.translate('xpack.csp.dataTable.fieldsModalSearch', {
defaultMessage: 'Search field name',
}),
},
};
const tableHeader = useMemo(() => {
const totalFields = fields.length;
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiText data-test-subj="csp:dataTable:fieldsModal:fieldsShowing" size="xs">
<FormattedMessage
id="xpack.csp.dataTable.fieldsModalFieldsShowing"
defaultMessage="Showing"
/>{' '}
<strong data-test-subj="csp:dataTable:fieldsModal:fieldsCount">{totalFields}</strong>{' '}
<FormattedMessage
id="xpack.csp.dataTable.fieldsModalFieldsCount"
defaultMessage="{totalFields, plural, one {field} other {fields}}"
values={{
totalFields,
}}
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}, [fields.length]);
return (
<EuiInMemoryTable
tableCaption={title}
items={fields}
columns={tableColumns}
search={search}
pagination
sorting={defaultSorting}
error={error}
childrenBetween={tableHeader}
/>
);
};
export const FieldsSelectorModal = ({
closeModal,
dataView,
columns,
onAddColumn,
onRemoveColumn,
}: FieldsSelectorCommonProps & {
closeModal: () => void;
}) => {
const title = i18n.translate('xpack.csp.dataTable.fieldsModalTitle', {
defaultMessage: 'Fields',
});
return (
<EuiModal onClose={closeModal}>
<EuiModalHeader>
<EuiModalHeaderTitle>{title}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<FieldsSelectorTable
title={title}
dataView={dataView}
columns={columns}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
/>
</EuiModalBody>
<EuiModalFooter>
<EuiButton onClick={closeModal} fill>
Close
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -57,6 +57,9 @@ export const useStyles = () => {
& .cspDataTableTotal {
font-size: ${euiTheme.size.m};
font-weight: ${euiTheme.font.weight.bold};
border-right: ${euiTheme.border.thin};
margin-right: ${euiTheme.size.s};
padding-right: ${euiTheme.size.m};
}
& .euiDataGrid__rightControls {
display: none;

View file

@ -43,7 +43,7 @@ const getDefaultQuery = ({
});
const defaultColumns: CloudSecurityDefaultColumn[] = [
{ id: 'result.evaluation' },
{ id: 'result.evaluation', width: 80 },
{ id: 'resource.id' },
{ id: 'resource.name' },
{ id: 'resource.sub_type' },
@ -88,7 +88,7 @@ const flyoutComponent = (row: DataTableRecord, onCloseFlyout: () => void): JSX.E
);
};
const columnsLocalStorageKey = 'cloudSecurityPostureLatestFindingsColumns';
const columnsLocalStorageKey = 'cloudPosture:latestFindings:columns';
const title = i18n.translate('xpack.csp.findings.latestFindings.tableRowTypeLabel', {
defaultMessage: 'Findings',
@ -162,7 +162,7 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => {
failed={failed}
/>
)}
<EuiSpacer />
<EuiSpacer size="xs" />
<CloudSecurityDataTable
data-test-subj={TEST_SUBJECTS.LATEST_FINDINGS_TABLE}
dataView={dataView}

View file

@ -303,7 +303,7 @@ const FilterableCell: React.FC<{
export const LimitedResultsBar = () => (
<>
<EuiSpacer size="xxl" />
<EuiBottomBar data-test-subj="test-bottom-bar">
<EuiBottomBar data-test-subj="test-bottom-bar" paddingSize="s">
<EuiText textAlign="center">
<FormattedMessage
id="xpack.csp.findings..bottomBarLabel"

View file

@ -19,6 +19,9 @@ export const useStyles = () => {
`;
const gridStyle = css`
& .euiDataGrid__content {
background: transparent;
}
& .euiDataGridHeaderCell__icon {
display: none;
}

View file

@ -290,6 +290,14 @@ const VulnerabilitiesDataGrid = ({
return <EmptyState onResetFilters={onResetFilters} />;
}
const dataTableStyle = {
// Change the height of the grid to fit the page
// If there are filters, leave space for the filter bar
// Todo: Replace this component with EuiAutoSizer
height: `calc(100vh - ${urlQuery.filters.length > 0 ? 403 : 363}px)`,
minHeight: 400,
};
return (
<>
<EuiProgress
@ -299,61 +307,66 @@ const VulnerabilitiesDataGrid = ({
opacity: isFetching ? 1 : 0,
}}
/>
<EuiDataGrid
className={cx({ [styles.gridStyle]: true }, { [styles.highlightStyle]: showHighlight })}
aria-label={VULNERABILITIES}
columns={columns}
columnVisibility={{ visibleColumns, setVisibleColumns }}
schemaDetectors={[severitySchemaConfig]}
rowCount={limitedTotalItemCount}
toolbarVisibility={{
showColumnSelector: false,
showDisplaySelector: false,
showKeyboardShortcuts: false,
showFullScreenSelector: false,
additionalControls: {
left: {
append: (
<>
<EuiButtonEmpty size="xs" color="text">
{i18n.translate('xpack.csp.vulnerabilities.totalVulnerabilities', {
defaultMessage:
'{total, plural, one {# Vulnerability} other {# Vulnerabilities}}',
values: { total: data?.total },
})}
</EuiButtonEmpty>
</>
<div style={dataTableStyle}>
<EuiDataGrid
className={cx({ [styles.gridStyle]: true }, { [styles.highlightStyle]: showHighlight })}
aria-label={VULNERABILITIES}
columns={columns}
columnVisibility={{ visibleColumns, setVisibleColumns }}
schemaDetectors={[severitySchemaConfig]}
rowCount={limitedTotalItemCount}
toolbarVisibility={{
showColumnSelector: false,
showDisplaySelector: false,
showKeyboardShortcuts: false,
showFullScreenSelector: false,
additionalControls: {
left: {
append: (
<>
<EuiButtonEmpty size="xs" color="text">
{i18n.translate('xpack.csp.vulnerabilities.totalVulnerabilities', {
defaultMessage:
'{total, plural, one {# Vulnerability} other {# Vulnerabilities}}',
values: { total: data?.total },
})}
</EuiButtonEmpty>
</>
),
},
right: (
<EuiFlexItem grow={false} className={styles.groupBySelector}>
<FindingsGroupBySelector
type="default"
pathnameHandler={vulnerabilitiesPathnameHandler}
/>
</EuiFlexItem>
),
},
right: (
<EuiFlexItem grow={false} className={styles.groupBySelector}>
<FindingsGroupBySelector
type="default"
pathnameHandler={vulnerabilitiesPathnameHandler}
/>
</EuiFlexItem>
),
},
}}
gridStyle={{
border: 'horizontal',
cellPadding: 'l',
stripes: false,
rowHover: 'none',
header: 'underline',
}}
renderCellValue={renderCellValue}
inMemory={{ level: 'enhancements' }}
sorting={{ columns: sort, onSort: onSortHandler }}
pagination={{
pageIndex,
pageSize,
pageSizeOptions: [10, 25, 100],
onChangeItemsPerPage,
onChangePage,
}}
/>
{isLastLimitedPage && <LimitedResultsBar />}
}}
gridStyle={{
border: 'horizontal',
cellPadding: 'l',
stripes: false,
rowHover: 'none',
header: 'underline',
}}
renderCellValue={renderCellValue}
inMemory={{ level: 'enhancements' }}
sorting={{ columns: sort, onSort: onSortHandler }}
pagination={{
pageIndex,
pageSize,
pageSizeOptions: [10, 25, 100],
onChangeItemsPerPage,
onChangePage,
}}
virtualizationOptions={{
overscanRowCount: 20,
}}
/>
{isLastLimitedPage && <LimitedResultsBar />}
</div>
{showVulnerabilityFlyout && selectedVulnerability && (
<VulnerabilityFindingFlyout
flyoutIndex={selectedVulnerabilityIndex}