diff --git a/x-pack/plugins/cloud_security_posture/kibana.jsonc b/x-pack/plugins/cloud_security_posture/kibana.jsonc index b6dfd66931b1f..9237ed70104ad 100644 --- a/x-pack/plugins/cloud_security_posture/kibana.jsonc +++ b/x-pack/plugins/cloud_security_posture/kibana.jsonc @@ -11,6 +11,7 @@ "requiredPlugins": [ "navigation", "data", + "dataViews", "fleet", "unifiedSearch", "taskManager", @@ -19,7 +20,8 @@ "discover", "cloud", "licensing", - "share" + "share", + "kibanaUtils" ], "optionalPlugins": ["usageCollection"], "requiredBundles": ["kibanaReact", "usageCollection"] diff --git a/x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts b/x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts deleted file mode 100644 index cabc449b1e3bd..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * 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 { useQuery } from '@tanstack/react-query'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import type { DataView } from '@kbn/data-plugin/common'; -import { DATA_VIEW_INDEX_PATTERN } from '../../../common/constants'; -import { CspClientPluginStartDeps } from '../../types'; - -/** - * Returns the common logs-* data view with fields filtered by - * fields present in the given index pattern - */ -export const useFilteredDataView = (indexPattern: string) => { - const { - data: { dataViews }, - } = useKibana().services; - - const findDataView = async (): Promise => { - const dataView = (await dataViews.find(DATA_VIEW_INDEX_PATTERN))?.[0]; - if (!dataView) { - throw new Error('Findings data view not found'); - } - - const indexPatternFields = await dataViews.getFieldsForWildcard({ - pattern: indexPattern, - }); - - if (!indexPatternFields) { - throw new Error('Error fetching fields for the index pattern'); - } - - // Filter out fields that are not present in the index pattern passed as a parameter - dataView.fields = dataView.fields.filter((field) => - indexPatternFields.some((indexPatternField) => indexPatternField.name === field.name) - ) as DataView['fields']; - - // Insert fields that are present in the index pattern but not in the data view - indexPatternFields.forEach((indexPatternField) => { - if (!dataView.fields.some((field) => field.name === indexPatternField.name)) { - dataView.fields.push(indexPatternField as DataView['fields'][0]); - } - }); - - return dataView; - }; - - return useQuery(['latest_findings_data_view', indexPattern], findDataView); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts b/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts index 2ab22ff4dd092..86b9692cbfc43 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts @@ -8,8 +8,45 @@ import { useQuery } from '@tanstack/react-query'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import type { DataView } from '@kbn/data-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { LATEST_FINDINGS_INDEX_PATTERN } from '../../../common/constants'; import { CspClientPluginStartDeps } from '../../types'; +const cloudSecurityFieldLabels: Record = { + 'result.evaluation': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.resultColumnLabel', + { defaultMessage: 'Result' } + ), + 'resource.id': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.resourceIdColumnLabel', + { defaultMessage: 'Resource ID' } + ), + 'resource.name': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.resourceNameColumnLabel', + { defaultMessage: 'Resource Name' } + ), + 'resource.sub_type': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.resourceTypeColumnLabel', + { defaultMessage: 'Resource Type' } + ), + 'rule.benchmark.rule_number': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.ruleNumberColumnLabel', + { defaultMessage: 'Rule Number' } + ), + 'rule.name': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.ruleNameColumnLabel', + { defaultMessage: 'Rule Name' } + ), + 'rule.section': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.ruleSectionColumnLabel', + { defaultMessage: 'CIS Section' } + ), + '@timestamp': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.lastCheckedColumnLabel', + { defaultMessage: 'Last Checked' } + ), +} as const; + /** * TODO: use perfected kibana data views */ @@ -19,11 +56,23 @@ export const useLatestFindingsDataView = (dataView: string) => { } = useKibana().services; const findDataView = async (): Promise => { - const dataViewObj = (await dataViews.find(dataView))?.[0]; + const [dataViewObj] = await dataViews.find(dataView); if (!dataViewObj) { throw new Error(`Data view not found [Name: {${dataView}}]`); } + if (dataView === LATEST_FINDINGS_INDEX_PATTERN) { + Object.entries(cloudSecurityFieldLabels).forEach(([field, label]) => { + if ( + !dataViewObj.getFieldAttrs()[field]?.customLabel || + dataViewObj.getFieldAttrs()[field]?.customLabel === field + ) { + dataViewObj.setFieldCustomLabel(field, label); + } + }); + await dataViews.updateSavedObject(dataViewObj); + } + return dataViewObj; }; diff --git a/x-pack/plugins/cloud_security_posture/public/common/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/constants.ts index bec25a70dbd1e..9f267e07569c2 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/constants.ts @@ -38,6 +38,8 @@ export const CSP_MOMENT_FORMAT = 'MMMM D, YYYY @ HH:mm:ss.SSS'; export const MAX_FINDINGS_TO_LOAD = 500; export const DEFAULT_VISIBLE_ROWS_PER_PAGE = 25; +export const LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY = 'cloudPosture:dataTable:pageSize'; +export const LOCAL_STORAGE_DATA_TABLE_COLUMNS_KEY = 'cloudPosture:dataTable:columns'; export const LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY = 'cloudPosture:findings:pageSize'; export const LOCAL_STORAGE_PAGE_SIZE_BENCHMARK_KEY = 'cloudPosture:benchmark:pageSize'; export const LOCAL_STORAGE_PAGE_SIZE_RULES_KEY = 'cloudPosture:rules:pageSize'; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts index 2d42c2a8303d6..0becb56e6ec22 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts @@ -8,19 +8,18 @@ import { Dispatch, SetStateAction, useCallback } from 'react'; import { type DataView } from '@kbn/data-views-plugin/common'; import { BoolQuery } from '@kbn/es-query'; import { CriteriaWithPagination } from '@elastic/eui'; +import { DataTableRecord } from '@kbn/discover-utils/types'; import { useUrlQuery } from '../use_url_query'; import { usePageSize } from '../use_page_size'; import { getDefaultQuery, useBaseEsQuery, usePersistedQuery } from './utils'; - -interface QuerySort { - direction: string; - id: string; -} +import { LOCAL_STORAGE_DATA_TABLE_COLUMNS_KEY } from '../../constants'; export interface CloudPostureTableResult { + // TODO: Remove any when all finding tables are converted to CloudSecurityDataTable setUrlQuery: (query: any) => void; - // TODO: remove any, this sorting is used for both EuiGrid and EuiTable which uses different types of sorts + // TODO: Remove any when all finding tables are converted to CloudSecurityDataTable sort: any; + // TODO: Remove any when all finding tables are converted to CloudSecurityDataTable filters: any[]; query?: { bool: BoolQuery }; queryError?: Error; @@ -28,13 +27,17 @@ export interface CloudPostureTableResult { // TODO: remove any, urlQuery is an object with query fields but we also add custom fields to it, need to assert usages urlQuery: any; setTableOptions: (options: CriteriaWithPagination) => void; + // TODO: Remove any when all finding tables are converted to CloudSecurityDataTable handleUpdateQuery: (query: any) => void; pageSize: number; setPageSize: Dispatch>; onChangeItemsPerPage: (newPageSize: number) => void; onChangePage: (newPageIndex: number) => void; - onSort: (sort: QuerySort[]) => void; + // TODO: Remove any when all finding tables are converted to CloudSecurityDataTable + onSort: (sort: any) => void; onResetFilters: () => void; + columnsLocalStorageKey: string; + getRowsFromPages: (data: Array<{ page: DataTableRecord[] }> | undefined) => DataTableRecord[]; } /* @@ -44,10 +47,13 @@ export const useCloudPostureTable = ({ defaultQuery = getDefaultQuery, dataView, paginationLocalStorageKey, + columnsLocalStorageKey, }: { + // TODO: Remove any when all finding tables are converted to CloudSecurityDataTable defaultQuery?: (params: any) => any; dataView: DataView; paginationLocalStorageKey: string; + columnsLocalStorageKey?: string; }): CloudPostureTableResult => { const getPersistedDefaultQuery = usePersistedQuery(defaultQuery); const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery); @@ -120,6 +126,13 @@ export const useCloudPostureTable = ({ [setUrlQuery] ); + const getRowsFromPages = (data: Array<{ page: DataTableRecord[] }> | undefined) => + data + ?.map(({ page }: { page: DataTableRecord[] }) => { + return page; + }) + .flat() || []; + return { setUrlQuery, sort: urlQuery.sort, @@ -136,5 +149,7 @@ export const useCloudPostureTable = ({ onChangePage, onSort, onResetFilters, + columnsLocalStorageKey: columnsLocalStorageKey || LOCAL_STORAGE_DATA_TABLE_COLUMNS_KEY, + getRowsFromPages, }; }; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx new file mode 100644 index 0000000000000..2318a16f2efbb --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx @@ -0,0 +1,275 @@ +/* + * 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 { UnifiedDataTableSettings, useColumns } from '@kbn/unified-data-table'; +import { type DataView } from '@kbn/data-views-plugin/common'; +import { UnifiedDataTable, DataLoadingState } from '@kbn/unified-data-table'; +import { CellActionsProvider } from '@kbn/cell-actions'; +import { + ROW_HEIGHT_OPTION, + SHOW_MULTIFIELDS, + SORT_DEFAULT_ORDER_SETTING, +} from '@kbn/discover-utils'; +import { DataTableRecord } from '@kbn/discover-utils/types'; +import { + EuiDataGridCellValueElementProps, + EuiDataGridStyle, + EuiFlexItem, + 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'; + +export interface CloudSecurityDefaultColumn { + id: string; +} + +const formatNumber = (value: number) => { + return value < 1000 ? value : numeral(value).format('0.0a'); +}; + +const gridStyle: EuiDataGridStyle = { + border: 'horizontal', + cellPadding: 'l', + stripes: false, + header: 'underline', +}; + +const useNewFieldsApi = true; + +interface CloudSecurityDataGridProps { + dataView: DataView; + isLoading: boolean; + defaultColumns: CloudSecurityDefaultColumn[]; + rows: DataTableRecord[]; + total: number; + /** + * This is the component that will be rendered in the flyout when a row is expanded. + * This component will receive the row data and a function to close the flyout. + */ + flyoutComponent: (hit: DataTableRecord, onCloseFlyout: () => void) => JSX.Element; + /** + * This is the object that contains all the data and functions from the useCloudPostureTable hook. + * This is also used to manage the table state from the parent component. + */ + cloudPostureTable: CloudPostureTableResult; + title: string; + /** + * This is a function that returns a map of column ids to custom cell renderers. + * This is useful for rendering custom components for cells in the table. + */ + customCellRenderer?: (rows: DataTableRecord[]) => { + [key: string]: (props: EuiDataGridCellValueElementProps) => JSX.Element; + }; + /** + * Function to load more rows once the max number of rows has been reached. + */ + loadMore: () => void; + 'data-test-subj'?: string; +} + +export const CloudSecurityDataTable = ({ + dataView, + isLoading, + defaultColumns, + rows, + total, + flyoutComponent, + cloudPostureTable, + loadMore, + title, + customCellRenderer, + ...rest +}: CloudSecurityDataGridProps) => { + const { + columnsLocalStorageKey, + pageSize, + onChangeItemsPerPage, + setUrlQuery, + onSort, + onResetFilters, + filters, + sort, + } = cloudPostureTable; + + const [columns, setColumns] = useLocalStorage( + columnsLocalStorageKey, + defaultColumns.map((c) => c.id) + ); + const [settings, setSettings] = useLocalStorage( + `${columnsLocalStorageKey}:settings`, + { + columns: defaultColumns.reduce((prev, curr) => { + const newColumn = { [curr.id]: {} }; + return { ...prev, ...newColumn }; + }, {} as UnifiedDataTableSettings['columns']), + } + ); + + const [expandedDoc, setExpandedDoc] = useState(undefined); + + const renderDocumentView = (hit: DataTableRecord) => + flyoutComponent(hit, () => setExpandedDoc(undefined)); + + // services needed for unified-data-table package + const { + uiSettings, + uiActions, + dataViews, + data, + application, + theme, + fieldFormats, + toastNotifications, + storage, + dataViewFieldEditor, + } = useKibana().services; + + const styles = useStyles(); + + const { capabilities } = application; + const { filterManager } = data.query; + + const services = { + theme, + fieldFormats, + uiSettings, + toastNotifications, + storage, + data, + dataViewFieldEditor, + }; + + const { columns: currentColumns, onSetColumns } = useColumns({ + capabilities, + defaultOrder: uiSettings.get(SORT_DEFAULT_ORDER_SETTING), + dataView, + dataViews, + setAppState: (props) => setColumns(props.columns), + useNewFieldsApi, + columns, + sort, + }); + + const onAddFilter: AddFieldFilterHandler | undefined = useMemo( + () => + filterManager && dataView + ? (clickedField, values, operation) => { + const newFilters = generateFilters( + filterManager, + clickedField, + values, + operation, + dataView + ); + filterManager.addFilters(newFilters); + setUrlQuery({ + filters: filterManager.getFilters(), + }); + } + : undefined, + [dataView, filterManager, setUrlQuery] + ); + + const onResize = (colSettings: { columnId: string; width: number }) => { + const grid = settings || {}; + const newColumns = { ...(grid.columns || {}) }; + newColumns[colSettings.columnId] = { + width: Math.round(colSettings.width), + }; + const newGrid = { ...grid, columns: newColumns }; + setSettings(newGrid); + }; + + const externalCustomRenderers = useMemo(() => { + if (!customCellRenderer) { + return undefined; + } + return customCellRenderer(rows); + }, [customCellRenderer, rows]); + + if (!isLoading && !rows.length) { + return ; + } + + return ( + +
0 ? 454 : 414}px)`, + }} + > + + } + gridStyleOverride={gridStyle} + /> +
+
+ ); +}; + +const AdditionalControls = ({ total, title }: { total: number; title: string }) => { + const styles = useStyles(); + return ( + <> + + {`${formatNumber(total)} ${title}`} + + + + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/index.ts b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/index.ts new file mode 100644 index 0000000000000..b2abf6bd4b8bd --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from './cloud_security_data_table'; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/use_styles.ts b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/use_styles.ts new file mode 100644 index 0000000000000..200ea5dbe7330 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/use_styles.ts @@ -0,0 +1,85 @@ +/* + * 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 { useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const gridContainer = css` + min-height: 400px; + `; + + const gridStyle = css` + & .euiDataGridHeaderCell__icon { + display: none; + } + & .euiDataGrid__controls { + border-bottom: none; + margin-bottom: ${euiTheme.size.s}; + border-top: none; + & .euiButtonEmpty { + font-weight: ${euiTheme.font.weight.bold}; + } + } + & .euiDataGrid--headerUnderline .euiDataGridHeaderCell { + border-bottom: ${euiTheme.border.width.thick} solid ${euiTheme.colors.fullShade}; + } + & .euiDataGridRowCell__contentByHeight + .euiDataGridRowCell__expandActions { + padding: 0; + } + & .euiButtonIcon[data-test-subj='docTableExpandToggleColumn'] { + color: ${euiTheme.colors.primary}; + } + + & .euiDataGridRowCell { + font-size: ${euiTheme.size.m}; + } + & .euiDataGridRowCell__expandFlex { + align-items: center; + } + & .euiDataGridRowCell.euiDataGridRowCell--numeric { + text-align: left; + } + & .euiDataGrid__controls { + gap: ${euiTheme.size.s}; + } + & .euiDataGrid__leftControls { + display: flex; + align-items: center; + width: 100%; + } + & .cspDataTableTotal { + font-size: ${euiTheme.size.m}; + font-weight: ${euiTheme.font.weight.bold}; + } + & .euiDataGrid__rightControls { + display: none; + } + + & [data-test-subj='docTableExpandToggleColumn'] svg { + inline-size: 16px; + block-size: 16px; + } + + & .unifiedDataTable__cellValue { + font-family: ${euiTheme.font.family}; + } + `; + + const groupBySelector = css` + width: 188px; + margin-left: auto; + `; + + return { + gridStyle, + groupBySelector, + gridContainer, + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx index 96e5b2a964f94..d1b35ab617a96 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx @@ -12,13 +12,10 @@ import { useLatestFindingsDataView } from '../../common/api/use_latest_findings_ import { Configurations } from './configurations'; import { TestProvider } from '../../test/test_provider'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; -import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; import { createStubDataView } from '@kbn/data-views-plugin/public/data_views/data_view.stub'; import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../common/constants'; import * as TEST_SUBJECTS from './test_subjects'; import type { DataView } from '@kbn/data-plugin/common'; -import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; -import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks'; import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api'; import { useSubscriptionStatus } from '../../common/hooks/use_subscription_status'; import { createReactQueryResponse } from '../../test/fixtures/react_query'; @@ -27,11 +24,9 @@ import { useCspIntegrationLink } from '../../common/navigation/use_csp_integrati import { NO_FINDINGS_STATUS_TEST_SUBJ } from '../../components/test_subjects'; import { render } from '@testing-library/react'; import { expectIdsInDoc } from '../../test/utils'; -import { fleetMock } from '@kbn/fleet-plugin/public/mocks'; -import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { PACKAGE_NOT_INSTALLED_TEST_SUBJECT } from '../../components/cloud_posture_page'; -import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { useLicenseManagementLocatorApi } from '../../common/api/use_license_management_locator_api'; +import { useCloudPostureTable } from '../../common/hooks/use_cloud_posture_table'; jest.mock('../../common/api/use_latest_findings_data_view'); jest.mock('../../common/api/use_setup_status_api'); @@ -39,6 +34,7 @@ jest.mock('../../common/api/use_license_management_locator_api'); jest.mock('../../common/hooks/use_subscription_status'); jest.mock('../../common/navigation/use_navigate_to_cis_integration_policies'); jest.mock('../../common/navigation/use_csp_integration_link'); +jest.mock('../../common/hooks/use_cloud_posture_table'); const chance = new Chance(); @@ -58,21 +54,18 @@ beforeEach(() => { data: true, }) ); + + (useCloudPostureTable as jest.Mock).mockImplementation(() => ({ + getRowsFromPages: jest.fn(), + columnsLocalStorageKey: 'test', + filters: [], + sort: [], + })); }); const renderFindingsPage = () => { render( - + ); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/findings_flyout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/findings_flyout.tsx index 2c59f360850d8..524e893092e53 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/findings_flyout.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/findings_flyout.tsx @@ -76,9 +76,9 @@ type FindingsTab = typeof tabs[number]; interface FindingFlyoutProps { onClose(): void; findings: CspFinding; - flyoutIndex: number; - findingsCount: number; - onPaginate: (pageIndex: number) => void; + flyoutIndex?: number; + findingsCount?: number; + onPaginate?: (pageIndex: number) => void; } export const CodeBlock: React.FC> = (props) => ( @@ -166,16 +166,22 @@ export const FindingsRuleFlyout = ({ - - - - + + {onPaginate && ( + + + + )} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.test.tsx deleted file mode 100644 index 100c42b6520cb..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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 } from '@testing-library/react'; -import { LatestFindingsContainer, getDefaultQuery } from './latest_findings_container'; -import { createStubDataView } from '@kbn/data-views-plugin/common/mocks'; -import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../../common/constants'; -import { DEFAULT_VISIBLE_ROWS_PER_PAGE } from '../../../common/constants'; -import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; -import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; -import { TestProvider } from '../../../test/test_provider'; -import { getFindingsQuery } from './use_latest_findings'; -import { encodeQuery } from '../../../common/navigation/query_utils'; -import { useLocation } from 'react-router-dom'; -import { buildEsQuery } from '@kbn/es-query'; -import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; -import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks'; -import { fleetMock } from '@kbn/fleet-plugin/public/mocks'; -import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; -import { getPaginationQuery } from '../../../common/hooks/use_cloud_posture_table/utils'; -import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; - -jest.mock('../../../common/api/use_latest_findings_data_view'); -jest.mock('../../../common/api/use_cis_kubernetes_integration'); - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useHistory: () => ({ push: jest.fn(), location: { pathname: '' } }), - useLocation: jest.fn(), -})); - -beforeEach(() => { - jest.restoreAllMocks(); -}); - -describe('', () => { - it('data#search.search fn called with URL query', () => { - const query = getDefaultQuery({ - filters: [], - query: { language: 'kuery', query: '' }, - }); - const pageSize = DEFAULT_VISIBLE_ROWS_PER_PAGE; - const dataMock = dataPluginMock.createStartContract(); - const dataView = createStubDataView({ - spec: { - id: CSP_LATEST_FINDINGS_DATA_VIEW, - }, - }); - - (useLocation as jest.Mock).mockReturnValue({ - search: encodeQuery(query), - }); - - render( - - - - ); - - const baseQuery = { - query: buildEsQuery(dataView, query.query, query.filters), - }; - - expect(dataMock.search.search).toHaveBeenNthCalledWith(1, { - params: getFindingsQuery({ - ...baseQuery, - ...getPaginationQuery({ ...query, pageSize }), - sort: query.sort, - enabled: true, - }), - }); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx index 3c5443d652c08..049010126837c 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx @@ -4,80 +4,140 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useCallback } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { EuiDataGridCellValueElementProps, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { DataTableRecord } from '@kbn/discover-utils/types'; +import { Filter, Query } from '@kbn/es-query'; +import { TimestampTableCell } from '../../../components/timestamp_table_cell'; +import { CspEvaluationBadge } from '../../../components/csp_evaluation_badge'; import type { Evaluation } from '../../../../common/types'; import type { FindingsBaseProps, FindingsBaseURLQuery } from '../../../common/types'; -import { FindingsTable } from './latest_findings_table'; import { FindingsSearchBar } from '../layout/findings_search_bar'; import * as TEST_SUBJECTS from '../test_subjects'; import { useLatestFindings } from './use_latest_findings'; -import type { FindingsGroupByNoneQuery } from './use_latest_findings'; import { FindingsDistributionBar } from '../layout/findings_distribution_bar'; -import { getFindingsPageSizeInfo, getFilters } from '../utils/utils'; -import { LimitedResultsBar } from '../layout/findings_layout'; -import { FindingsGroupBySelector } from '../layout/findings_group_by_selector'; -import { usePageSlice } from '../../../common/hooks/use_page_slice'; +import { getFilters } from '../utils/utils'; import { ErrorCallout } from '../layout/error_callout'; -import { useLimitProperties } from '../../../common/utils/get_limit_properties'; -import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../common/constants'; +import { LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY } from '../../../common/constants'; import { CspFinding } from '../../../../common/schemas/csp_finding'; import { useCloudPostureTable } from '../../../common/hooks/use_cloud_posture_table'; -import { getPaginationTableParams } from '../../../common/hooks/use_cloud_posture_table/utils'; +import { + CloudSecurityDataTable, + CloudSecurityDefaultColumn, +} from '../../../components/cloud_security_data_table'; +import { FindingsRuleFlyout } from '../findings_flyout/findings_flyout'; -export const getDefaultQuery = ({ +const getDefaultQuery = ({ query, filters, -}: FindingsBaseURLQuery): FindingsBaseURLQuery & - FindingsGroupByNoneQuery & { findingIndex: number } => ({ +}: { + query: Query; + filters: Filter[]; +}): FindingsBaseURLQuery & { + sort: string[][]; +} => ({ query, filters, - sort: { field: '@timestamp', direction: 'desc' }, - pageIndex: 0, - findingIndex: -1, + sort: [['@timestamp', 'desc']], +}); + +const defaultColumns: CloudSecurityDefaultColumn[] = [ + { id: 'result.evaluation' }, + { id: 'resource.id' }, + { id: 'resource.name' }, + { id: 'resource.sub_type' }, + { id: 'rule.benchmark.rule_number' }, + { id: 'rule.name' }, + { id: 'rule.section' }, + { id: '@timestamp' }, +]; + +/** + * Type Guard for checking if the given source is a CspFinding + */ +const isCspFinding = (source: Record | undefined): source is CspFinding => { + return source?.result?.evaluation !== undefined; +}; + +/** + * This Wrapper component renders the children if the given row is a CspFinding + * it uses React's Render Props pattern + */ +const CspFindingRenderer = ({ + row, + children, +}: { + row: DataTableRecord; + children: ({ finding }: { finding: CspFinding }) => JSX.Element; +}) => { + const source = row.raw._source; + const finding = isCspFinding(source) && (source as CspFinding); + if (!finding) return <>; + return children({ finding }); +}; + +/** + * Flyout component for the latest findings table + */ +const flyoutComponent = (row: DataTableRecord, onCloseFlyout: () => void): JSX.Element => { + return ( + + {({ finding }) => } + + ); +}; + +const columnsLocalStorageKey = 'cloudSecurityPostureLatestFindingsColumns'; + +const title = i18n.translate('xpack.csp.findings.latestFindings.tableRowTypeLabel', { + defaultMessage: 'Findings', +}); + +const customCellRenderer = (rows: DataTableRecord[]) => ({ + 'result.evaluation': ({ rowIndex }: EuiDataGridCellValueElementProps) => ( + + {({ finding }) => } + + ), + '@timestamp': ({ rowIndex }: EuiDataGridCellValueElementProps) => ( + + {({ finding }) => } + + ), }); export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { - const { - pageIndex, - query, - sort, - queryError, - pageSize, - setTableOptions, - urlQuery, - setUrlQuery, - filters, - onResetFilters, - } = useCloudPostureTable({ + const cloudPostureTable = useCloudPostureTable({ dataView, + paginationLocalStorageKey: LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY, + columnsLocalStorageKey, defaultQuery: getDefaultQuery, - paginationLocalStorageKey: LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY, }); - /** - * Page ES query result - */ - const findingsGroupByNone = useLatestFindings({ + const { query, sort, queryError, setUrlQuery, filters, getRowsFromPages } = cloudPostureTable; + + const { + data, + error: fetchError, + isFetching, + fetchNextPage, + } = useLatestFindings({ query, sort, enabled: !queryError, }); - const slicedPage = usePageSlice(findingsGroupByNone.data?.page, pageIndex, pageSize); + const rows = useMemo(() => getRowsFromPages(data?.pages), [data?.pages, getRowsFromPages]); - const error = findingsGroupByNone.error || queryError; + const error = fetchError || queryError; - const { isLastLimitedPage, limitedTotalItemCount } = useLimitProperties({ - total: findingsGroupByNone.data?.total, - pageIndex, - pageSize, - }); + const passed = data?.pages[0].count.passed || 0; + const failed = data?.pages[0].count.failed || 0; + const total = data?.pages[0].total || 0; const handleDistributionClick = (evaluation: Evaluation) => { setUrlQuery({ - pageIndex: 0, filters: getFilters({ filters, dataView, @@ -88,117 +148,36 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { }); }; - const flyoutFindingIndex = urlQuery?.findingIndex; - - const pagination = getPaginationTableParams({ - pageSize, - pageIndex, - totalItemCount: limitedTotalItemCount, - }); - - const onOpenFlyout = useCallback( - (flyoutFinding: CspFinding) => { - setUrlQuery({ - findingIndex: slicedPage.findIndex( - (finding) => - finding.resource.id === flyoutFinding?.resource.id && - finding.rule.id === flyoutFinding?.rule.id - ), - }); - }, - [slicedPage, setUrlQuery] - ); - - const onCloseFlyout = () => - setUrlQuery({ - findingIndex: -1, - }); - - const onPaginateFlyout = useCallback( - (nextFindingIndex: number) => { - // the index of the finding in the current page - const newFindingIndex = nextFindingIndex % pageSize; - - // if the finding is not in the current page, we need to change the page - const flyoutPageIndex = Math.floor(nextFindingIndex / pageSize); - - setUrlQuery({ - pageIndex: flyoutPageIndex, - findingIndex: newFindingIndex, - }); - }, - [pageSize, setUrlQuery] - ); - return ( -
- { - setUrlQuery({ ...newQuery, pageIndex: 0 }); - }} - loading={findingsGroupByNone.isFetching} - /> + + - {!error && ( - - - - - - - )} {error && } {!error && ( <> - {findingsGroupByNone.isSuccess && !!findingsGroupByNone.data.page.length && ( + {total > 0 && ( )} - - setUrlQuery({ - pageIndex: 0, - filters: getFilters({ - filters, - dataView, - field, - value, - negate, - }), - }) - } + )} - {isLastLimitedPage && } -
+ ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.test.tsx deleted file mode 100644 index 31b2db9592f63..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/* - * 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 userEvent from '@testing-library/user-event'; -import { render, screen, within } from '@testing-library/react'; -import * as TEST_SUBJECTS from '../test_subjects'; -import { FindingsTable } from './latest_findings_table'; -import type { PropsOf } from '@elastic/eui'; -import Chance from 'chance'; -import { TestProvider } from '../../../test/test_provider'; -import { getFindingsFixture } from '../../../test/fixtures/findings_fixture'; -import { EMPTY_STATE_TEST_SUBJ } from '../../../components/test_subjects'; - -const chance = new Chance(); - -type TableProps = PropsOf; - -const onAddFilter = jest.fn(); -const onOpenFlyout = jest.fn(); -const onCloseFlyout = jest.fn(); - -describe('', () => { - const TestComponent = ({ ...overrideProps }) => ( - - - - ); - - const renderWrapper = (overrideProps: Partial = {}) => { - return render(); - }; - - it('opens/closes the flyout when clicked on expand/close buttons ', async () => { - const props = { - items: [getFindingsFixture()], - }; - const { rerender } = renderWrapper(props); - - expect(screen.queryByTestId(TEST_SUBJECTS.FINDINGS_FLYOUT)).not.toBeInTheDocument(); - expect(screen.queryByTestId(TEST_SUBJECTS.FINDINGS_TABLE_EXPAND_COLUMN)).toBeInTheDocument(); - - userEvent.click(screen.getByTestId(TEST_SUBJECTS.FINDINGS_TABLE_EXPAND_COLUMN)); - expect(onOpenFlyout).toHaveBeenCalled(); - rerender(); - - userEvent.click(screen.getByTestId('euiFlyoutCloseButton')); - expect(onCloseFlyout).toHaveBeenCalled(); - rerender(); - expect(screen.queryByTestId(TEST_SUBJECTS.FINDINGS_FLYOUT)).not.toBeInTheDocument(); - }); - - it('renders the zero state when status success and data has a length of zero ', async () => { - renderWrapper({ items: [] }); - - expect(screen.getByTestId(EMPTY_STATE_TEST_SUBJ)).toBeInTheDocument(); - }); - - it('renders the table with provided items', () => { - const names = chance.unique(chance.sentence, 10); - const data = names.map((name) => { - const fixture = getFindingsFixture(); - return { ...fixture, rule: { ...fixture.rule, name } }; - }); - - renderWrapper({ items: data }); - - data.forEach((item) => { - expect(screen.getAllByText(item.rule.name)[0]).toBeInTheDocument(); - }); - }); - - it('adds filter with a cell button click', () => { - const names = chance.unique(chance.sentence, 10); - const data = names.map((name) => { - const fixture = getFindingsFixture(); - return { ...fixture, rule: { ...fixture.rule, name } }; - }); - - renderWrapper({ items: data }); - - const row = data[0]; - - const columns = [ - 'result.evaluation', - 'resource.id', - 'resource.name', - 'resource.sub_type', - 'rule.name', - ]; - - columns.forEach((field) => { - const cellElement = screen.getByTestId( - TEST_SUBJECTS.getFindingsTableCellTestId(field, row.resource.id) - ); - userEvent.hover(cellElement); - const addFilterElement = within(cellElement).getByTestId( - TEST_SUBJECTS.FINDINGS_TABLE_CELL_ADD_FILTER - ); - const addNegatedFilterElement = within(cellElement).getByTestId( - TEST_SUBJECTS.FINDINGS_TABLE_CELL_ADD_NEGATED_FILTER - ); - - // We need to account for values like resource.id (deep.nested.values) - const value = field.split('.').reduce((a, c) => a[c], row); - - expect(addFilterElement).toBeVisible(); - expect(addNegatedFilterElement).toBeVisible(); - - userEvent.click(addFilterElement); - expect(onAddFilter).toHaveBeenCalledWith(field, value, false); - - userEvent.click(addNegatedFilterElement); - expect(onAddFilter).toHaveBeenCalledWith(field, value, true); - }); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx deleted file mode 100644 index 3ad8deb346998..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx +++ /dev/null @@ -1,120 +0,0 @@ -/* - * 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, { useMemo } from 'react'; -import { - EuiBasicTable, - useEuiTheme, - type Pagination, - type EuiBasicTableProps, - type CriteriaWithPagination, - type EuiTableActionsColumnType, - type EuiTableFieldDataColumnType, -} from '@elastic/eui'; -import { CspFinding } from '../../../../common/schemas/csp_finding'; -import * as TEST_SUBJECTS from '../test_subjects'; -import { FindingsRuleFlyout } from '../findings_flyout/findings_flyout'; -import { - baseFindingsColumns, - createColumnWithFilters, - getExpandColumn, - type OnAddFilter, -} from '../layout/findings_layout'; -import { getSelectedRowStyle } from '../utils/utils'; -import { EmptyState } from '../../../components/empty_state'; - -type TableProps = Required>; - -interface Props { - loading: boolean; - items: CspFinding[]; - pagination: Pagination & { pageSize: number }; - sorting: TableProps['sorting']; - setTableOptions(options: CriteriaWithPagination): void; - onAddFilter: OnAddFilter; - onPaginateFlyout: (pageIndex: number) => void; - onCloseFlyout: () => void; - onOpenFlyout: (finding: CspFinding) => void; - flyoutFindingIndex: number; - onResetFilters: () => void; -} - -const FindingsTableComponent = ({ - loading, - items, - pagination, - sorting, - setTableOptions, - onAddFilter, - onOpenFlyout, - flyoutFindingIndex, - onPaginateFlyout, - onCloseFlyout, - onResetFilters, -}: Props) => { - const { euiTheme } = useEuiTheme(); - - const selectedFinding = items[flyoutFindingIndex]; - - const getRowProps = (row: CspFinding) => ({ - 'data-test-subj': TEST_SUBJECTS.getFindingsTableRowTestId(row.resource.id), - style: getSelectedRowStyle(euiTheme, row, selectedFinding), - }); - - const getCellProps = (row: CspFinding, column: EuiTableFieldDataColumnType) => ({ - 'data-test-subj': TEST_SUBJECTS.getFindingsTableCellTestId(column.field, row.resource.id), - }); - - const columns: [ - EuiTableActionsColumnType, - ...Array> - ] = useMemo( - () => [ - getExpandColumn({ onClick: onOpenFlyout }), - createColumnWithFilters(baseFindingsColumns['result.evaluation'], { onAddFilter }), - createColumnWithFilters(baseFindingsColumns['resource.id'], { onAddFilter }), - createColumnWithFilters(baseFindingsColumns['resource.name'], { onAddFilter }), - createColumnWithFilters(baseFindingsColumns['resource.sub_type'], { onAddFilter }), - baseFindingsColumns['rule.benchmark.rule_number'], - createColumnWithFilters(baseFindingsColumns['rule.name'], { onAddFilter }), - createColumnWithFilters(baseFindingsColumns['rule.section'], { onAddFilter }), - baseFindingsColumns['@timestamp'], - ], - [onOpenFlyout, onAddFilter] - ); - - if (!loading && !items.length) { - return ; - } - - return ( - <> - - {selectedFinding && ( - - )} - - ); -}; - -export const FindingsTable = React.memo(FindingsTableComponent); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts index 00aa0d817e955..9ce0292175839 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts @@ -4,28 +4,30 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery } from '@tanstack/react-query'; import { number } from 'io-ts'; import { lastValueFrom } from 'rxjs'; import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common'; import type { Pagination } from '@elastic/eui'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { buildDataTableRecord } from '@kbn/discover-utils'; +import { EsHitRecord } from '@kbn/discover-utils/types'; import { CspFinding } from '../../../../common/schemas/csp_finding'; import { useKibana } from '../../../common/hooks/use_kibana'; -import type { Sort, FindingsBaseEsQuery } from '../../../common/types'; +import type { FindingsBaseEsQuery } from '../../../common/types'; import { getAggregationCount, getFindingsCountAggQuery } from '../utils/utils'; import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../../common/constants'; import { MAX_FINDINGS_TO_LOAD } from '../../../common/constants'; import { showErrorToast } from '../../../common/utils/show_error_toast'; interface UseFindingsOptions extends FindingsBaseEsQuery { - sort: Sort; + sort: string[][]; enabled: boolean; } export interface FindingsGroupByNoneQuery { pageIndex: Pagination['pageIndex']; - sort: Sort; + sort: any; } type LatestFindingsRequest = IKibanaSearchRequest; @@ -37,15 +39,24 @@ interface FindingsAggs { count: estypes.AggregationsMultiBucketAggregateBase; } -export const getFindingsQuery = ({ query, sort }: UseFindingsOptions) => ({ +export const getFindingsQuery = ({ query, sort }: UseFindingsOptions, pageParam: any) => ({ index: CSP_LATEST_FINDINGS_DATA_VIEW, query, - sort: getSortField(sort), + sort: getMultiFieldsSort(sort), size: MAX_FINDINGS_TO_LOAD, aggs: getFindingsCountAggQuery(), ignore_unavailable: false, + ...(pageParam ? { search_after: pageParam } : {}), }); +const getMultiFieldsSort = (sort: string[][]) => { + return sort.map(([id, direction]) => { + return { + ...getSortField({ field: id, direction }), + }; + }); +}; + /** * By default, ES will sort keyword fields in case-sensitive format, the * following fields are required to have a case-insensitive sorting. @@ -60,7 +71,7 @@ const fieldsRequiredSortingByPainlessScript = [ * Generates Painless sorting if the given field is matched or returns default sorting * This painless script will sort the field in case-insensitive manner */ -const getSortField = ({ field, direction }: Sort) => { +const getSortField = ({ field, direction }: { field: string; direction: string }) => { if (fieldsRequiredSortingByPainlessScript.includes(field)) { return { _script: { @@ -81,14 +92,14 @@ export const useLatestFindings = (options: UseFindingsOptions) => { data, notifications: { toasts }, } = useKibana().services; - return useQuery( + return useInfiniteQuery( ['csp_findings', { params: options }], - async () => { + async ({ pageParam }) => { const { rawResponse: { hits, aggregations }, } = await lastValueFrom( data.search.search({ - params: getFindingsQuery(options), + params: getFindingsQuery(options, pageParam), }) ); if (!aggregations) throw new Error('expected aggregations to be an defined'); @@ -96,7 +107,7 @@ export const useLatestFindings = (options: UseFindingsOptions) => { throw new Error('expected buckets to be an array'); return { - page: hits.hits.map((hit) => hit._source!), + page: hits.hits.map((hit) => buildDataTableRecord(hit as EsHitRecord)), total: number.is(hits.total) ? hits.total : 0, count: getAggregationCount(aggregations.count.buckets), }; @@ -105,6 +116,10 @@ export const useLatestFindings = (options: UseFindingsOptions) => { enabled: options.enabled, keepPreviousData: true, onError: (err: Error) => showErrorToast(toasts, err), + getNextPageParam: (lastPage) => { + if (lastPage.page.length === 0) return undefined; + return lastPage.page[lastPage.page.length - 1].raw.sort; + }, } ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx index 1ac0470229282..7f483c3ee0847 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx @@ -16,13 +16,13 @@ import * as TEST_SUBJECTS from '../test_subjects'; import { usePageSlice } from '../../../common/hooks/use_page_slice'; import { FindingsByResourceQuery, useFindingsByResource } from './use_findings_by_resource'; import { FindingsByResourceTable } from './findings_by_resource_table'; -import { getFindingsPageSizeInfo, getFilters } from '../utils/utils'; +import { getFilters } from '../utils/utils'; import { LimitedResultsBar } from '../layout/findings_layout'; import { FindingsGroupBySelector } from '../layout/findings_group_by_selector'; import { findingsNavigation } from '../../../common/navigation/constants'; import { ResourceFindings } from './resource_findings/resource_findings_container'; import { ErrorCallout } from '../layout/error_callout'; -import { FindingsDistributionBar } from '../layout/findings_distribution_bar'; +import { CurrentPageOfTotal, FindingsDistributionBar } from '../layout/findings_distribution_bar'; import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../common/constants'; import type { FindingsBaseURLQuery, FindingsBaseProps } from '../../../common/types'; import { useCloudPostureTable } from '../../../common/hooks/use_cloud_posture_table'; @@ -111,34 +111,42 @@ const LatestFindingsByResource = ({ dataView }: FindingsBaseProps) => { loading={findingsGroupByResource.isFetching} /> - {!error && ( - - - - - - - )} + {error && } {!error && ( <> {findingsGroupByResource.isSuccess && !!findingsGroupByResource.data.page.length && ( - + <> + + + + + + + + + + + )} { {!error && ( <> {resourceFindings.isSuccess && !!resourceFindings.data.page.length && ( - + <> + + + + + + + + )} void; - pageStart: number; - pageEnd: number; - type: string; } const formatNumber = (value: number) => (value < 1000 ? value : numeral(value).format('0.0a')); +export const CurrentPageOfTotal = ({ + pageEnd, + pageStart, + total, + type, +}: { + pageEnd: number; + pageStart: number; + total: number; + type: string; +}) => ( + + {pageStart}, + pageEnd: {pageEnd}, + total: {formatNumber(total)}, + type, + }} + /> + +); + export const FindingsDistributionBar = (props: Props) => (
- {} +
); const Counters = (props: Props) => ( - + - - - - + + + ); @@ -86,26 +101,6 @@ const PassedFailedCounters = ({ passed, failed }: Pick) => ( - - {pageStart}, - pageEnd: {pageEnd}, - total: {formatNumber(total)}, - type, - }} - /> - -); - const DistributionBar: React.FC> = ({ passed, failed, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts index e18f501cbeb9c..245138775e5b9 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts @@ -67,6 +67,8 @@ export const useStyles = () => { const groupBySelector = css` width: 188px; + display: inline-block; + margin-left: 8px; `; return { diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx index a9b8fdaa2f190..6c1aa59cfab4c 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx @@ -29,7 +29,6 @@ import type { VulnerabilitiesQueryData } from './types'; import { LATEST_VULNERABILITIES_INDEX_PATTERN } from '../../../common/constants'; import { ErrorCallout } from '../configurations/layout/error_callout'; import { FindingsSearchBar } from '../configurations/layout/findings_search_bar'; -import { useFilteredDataView } from '../../common/api/use_filtered_data_view'; import { CVSScoreBadge, SeverityStatusBadge } from '../../components/vulnerability_badges'; import { EmptyState } from '../../components/empty_state'; import { VulnerabilityFindingFlyout } from './vulnerabilities_finding_flyout/vulnerability_finding_flyout'; @@ -55,6 +54,7 @@ import { findingsNavigation } from '../../common/navigation/constants'; import { VulnerabilitiesByResource } from './vulnerabilities_by_resource/vulnerabilities_by_resource'; import { ResourceVulnerabilities } from './vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities'; import { getVulnerabilitiesGridCellActions } from './utils/get_vulnerabilities_grid_cell_actions'; +import { useLatestFindingsDataView } from '../../common/api/use_latest_findings_data_view'; const getDefaultQuery = ({ query, filters }: any): any => ({ query, @@ -163,6 +163,11 @@ const VulnerabilitiesDataGrid = ({ }); }, [data?.page, dataView, pageSize, setUrlQuery, urlQuery.filters]); + // Column visibility + const [visibleColumns, setVisibleColumns] = useState( + columns.map(({ id }) => id) // initialize to the full set of columns + ); + const flyoutVulnerabilityIndex = urlQuery?.vulnerabilityIndex; const selectedVulnerabilityIndex = flyoutVulnerabilityIndex @@ -298,10 +303,7 @@ const VulnerabilitiesDataGrid = ({ className={cx({ [styles.gridStyle]: true }, { [styles.highlightStyle]: showHighlight })} aria-label={VULNERABILITIES} columns={columns} - columnVisibility={{ - visibleColumns: columns.map(({ id }) => id), - setVisibleColumns: () => {}, - }} + columnVisibility={{ visibleColumns, setVisibleColumns }} schemaDetectors={[severitySchemaConfig]} rowCount={limitedTotalItemCount} toolbarVisibility={{ @@ -311,7 +313,7 @@ const VulnerabilitiesDataGrid = ({ showFullScreenSelector: false, additionalControls: { left: { - prepend: ( + append: ( <> {i18n.translate('xpack.csp.vulnerabilities.totalVulnerabilities', { @@ -451,7 +453,10 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => { }; export const Vulnerabilities = () => { - const { data, isLoading, error } = useFilteredDataView(LATEST_VULNERABILITIES_INDEX_PATTERN); + const { data, isLoading, error } = useLatestFindingsDataView( + LATEST_VULNERABILITIES_INDEX_PATTERN + ); + const getSetupStatus = useCspSetupStatusApi(); if (getSetupStatus?.data?.vuln_mgmt?.status !== 'indexed') return ; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilties.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilties.test.tsx index 6eab4ba03f682..24c12405c1436 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilties.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilties.test.tsx @@ -6,16 +6,14 @@ */ import React from 'react'; import Chance from 'chance'; -import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; -import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; import { Vulnerabilities } from './vulnerabilities'; import { + CSP_LATEST_FINDINGS_DATA_VIEW, LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, VULN_MGMT_POLICY_TEMPLATE, } from '../../../common/constants'; -import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; -import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks'; import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api'; +import { useLatestFindingsDataView } from '../../common/api/use_latest_findings_data_view'; import { useSubscriptionStatus } from '../../common/hooks/use_subscription_status'; import { createReactQueryResponse } from '../../test/fixtures/react_query'; import { useCISIntegrationPoliciesLink } from '../../common/navigation/use_navigate_to_cis_integration_policies'; @@ -26,11 +24,9 @@ import { } from '../../components/test_subjects'; import { render } from '@testing-library/react'; import { expectIdsInDoc } from '../../test/utils'; -import { fleetMock } from '@kbn/fleet-plugin/public/mocks'; -import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { TestProvider } from '../../test/test_provider'; -import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { useLicenseManagementLocatorApi } from '../../common/api/use_license_management_locator_api'; +import { createStubDataView } from '@kbn/data-views-plugin/common/stubs'; jest.mock('../../common/api/use_latest_findings_data_view'); jest.mock('../../common/api/use_setup_status_api'); @@ -57,21 +53,20 @@ beforeEach(() => { data: true, }) ); + + (useLatestFindingsDataView as jest.Mock).mockReturnValue({ + status: 'success', + data: createStubDataView({ + spec: { + id: CSP_LATEST_FINDINGS_DATA_VIEW, + }, + }), + }); }); const renderVulnerabilitiesPage = () => { render( - + ); diff --git a/x-pack/plugins/cloud_security_posture/public/plugin.tsx b/x-pack/plugins/cloud_security_posture/public/plugin.tsx index 32e5ee577e40e..f215841b30cea 100755 --- a/x-pack/plugins/cloud_security_posture/public/plugin.tsx +++ b/x-pack/plugins/cloud_security_posture/public/plugin.tsx @@ -7,8 +7,8 @@ import React, { lazy, Suspense } from 'react'; import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; -import { SubscriptionTrackingProvider } from '@kbn/subscription-tracking'; import { CspLoadingState } from './components/csp_loading_state'; import type { CspRouterProps } from './application/csp_router'; import type { @@ -68,20 +68,17 @@ export class CspPlugin Component: LazyCspCustomAssets, }); + const storage = new Storage(localStorage); + // Keep as constant to prevent remounts https://github.com/elastic/kibana/issues/146773 const App = (props: CspRouterProps) => ( - + - -
- - - -
-
+
+ + + +
); diff --git a/x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx b/x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx index 57fc2935e5708..3f89c934e5dd4 100755 --- a/x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx +++ b/x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx @@ -21,11 +21,13 @@ import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks'; import { fleetMock } from '@kbn/fleet-plugin/public/mocks'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; +import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; +import { sessionStorageMock } from '@kbn/core-http-server-mocks'; import type { CspClientPluginStartDeps } from '../types'; interface CspAppDeps { core: CoreStart; - deps: CspClientPluginStartDeps; + deps: Partial; params: AppMountParameters; } @@ -38,6 +40,8 @@ export const TestProvider: React.FC> = ({ discover: discoverPluginMock.createStartContract(), fleet: fleetMock.createStartMock(), licensing: licensingMock.createStart(), + uiActions: uiActionsPluginMock.createStartContract(), + storage: sessionStorageMock.create(), }, params = coreMock.createAppMountParameters(), children, diff --git a/x-pack/plugins/cloud_security_posture/public/types.ts b/x-pack/plugins/cloud_security_posture/public/types.ts index c888496a0b157..6766067df67e0 100755 --- a/x-pack/plugins/cloud_security_posture/public/types.ts +++ b/x-pack/plugins/cloud_security_posture/public/types.ts @@ -7,9 +7,16 @@ import type { CloudSetup } from '@kbn/cloud-plugin/public'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; +import { DataViewsServicePublic } from '@kbn/data-views-plugin/public'; import type { ComponentType, ReactNode } from 'react'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; +import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; +import { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public'; import type { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { ToastsStart } from '@kbn/core/public'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; + import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; import type { DiscoverStart } from '@kbn/discover-plugin/public'; import type { FleetSetup, FleetStart } from '@kbn/fleet-plugin/public'; @@ -40,6 +47,7 @@ export interface CspClientPluginSetupDeps { data: DataPublicPluginSetup; fleet: FleetSetup; cloud: CloudSetup; + uiActions: UiActionsSetup; // optional usageCollection?: UsageCollectionSetup; } @@ -47,12 +55,19 @@ export interface CspClientPluginSetupDeps { export interface CspClientPluginStartDeps { // required data: DataPublicPluginStart; + dataViews: DataViewsServicePublic; + dataViewFieldEditor: IndexPatternFieldEditorStart; unifiedSearch: UnifiedSearchPublicPluginStart; + uiActions: UiActionsStart; + fieldFormats: FieldFormatsStart; + toastNotifications: ToastsStart; charts: ChartsPluginStart; discover: DiscoverStart; fleet: FleetStart; licensing: LicensingPluginStart; share: SharePluginStart; + storage: Storage; + // optional usageCollection?: UsageCollectionStart; } diff --git a/x-pack/plugins/cloud_security_posture/tsconfig.json b/x-pack/plugins/cloud_security_posture/tsconfig.json index 307394e41d84b..113ddcb92202a 100755 --- a/x-pack/plugins/cloud_security_posture/tsconfig.json +++ b/x-pack/plugins/cloud_security_posture/tsconfig.json @@ -50,7 +50,17 @@ "@kbn/share-plugin", "@kbn/core-http-server", "@kbn/core-http-browser", - "@kbn/subscription-tracking" + "@kbn/subscription-tracking", + "@kbn/discover-utils", + "@kbn/unified-data-table", + "@kbn/cell-actions", + "@kbn/unified-field-list", + "@kbn/unified-doc-viewer", + "@kbn/kibana-utils-plugin", + "@kbn/ui-actions-plugin", + "@kbn/core-http-server-mocks", + "@kbn/field-formats-plugin", + "@kbn/data-view-field-editor-plugin" ], "exclude": ["target/**/*"] } diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts index bd3a43951bf25..49f4ab0a6d12f 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts @@ -55,13 +55,7 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider refresh: true, }), ]), - add: async < - T extends { - '@timestamp'?: string; - } - >( - findingsMock: T[] - ) => { + add: async (findingsMock: Array>) => { await Promise.all([ ...findingsMock.map((finding) => es.index({ @@ -124,6 +118,110 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider }, }); + const createDataTableObject = (tableTestSubject: string) => ({ + getElement() { + return testSubjects.find(tableTestSubject); + }, + + async getHeaders() { + const element = await this.getElement(); + return await element.findAllByCssSelector('.euiDataGridHeader'); + }, + + async getColumnIndex(columnName: string) { + const element = await this.getElement(); + const columnIndex = await ( + await element.findByCssSelector(`[data-gridcell-column-id="${columnName}"]`) + ).getAttribute('data-gridcell-column-index'); + expect(columnIndex).to.be.greaterThan(-1); + return columnIndex; + }, + + async getColumnHeaderCell(columnName: string) { + const headers = await this.getHeaders(); + const headerIndexes = await Promise.all(headers.map((header) => header.getVisibleText())); + const columnIndex = headerIndexes.findIndex((i) => i === columnName); + return headers[columnIndex]; + }, + + async getRowsCount() { + const element = await this.getElement(); + const rows = await element.findAllByCssSelector('.euiDataGridRow'); + return rows.length; + }, + + async getFindingsCount(type: 'passed' | 'failed') { + const element = await this.getElement(); + const items = await element.findAllByCssSelector(`span[data-test-subj="${type}_finding"]`); + return items.length; + }, + + async getRowIndexForValue(columnName: string, value: string) { + const values = await this.getColumnValues(columnName); + const rowIndex = values.indexOf(value); + expect(rowIndex).to.be.greaterThan(-1); + return rowIndex; + }, + + async getFilterElementButton(rowIndex: number, columnIndex: number | string, negated = false) { + const tableElement = await this.getElement(); + const button = negated ? 'filterOutButton' : 'filterForButton'; + const selector = `[data-gridcell-row-index="${rowIndex}"][data-gridcell-column-index="${columnIndex}"] button[data-test-subj="${button}"]`; + return tableElement.findByCssSelector(selector); + }, + + async addCellFilter(columnName: string, cellValue: string, negated = false) { + const columnIndex = await this.getColumnIndex(columnName); + const rowIndex = await this.getRowIndexForValue(columnName, cellValue); + const filterElement = await this.getFilterElementButton(rowIndex, columnIndex, negated); + await filterElement.click(); + }, + + async getColumnValues(columnName: string) { + const tableElement = await this.getElement(); + const selector = `.euiDataGridRowCell[data-gridcell-column-id="${columnName}"]`; + const columnCells = await tableElement.findAllByCssSelector(selector); + + return await Promise.all(columnCells.map((cell) => cell.getVisibleText())); + }, + + async hasColumnValue(columnName: string, value: string) { + const values = await this.getColumnValues(columnName); + return values.includes(value); + }, + + async toggleColumnSort(columnName: string, direction: 'asc' | 'desc') { + const currentSorting = await testSubjects.find('dataGridColumnSortingButton'); + const currentSortingText = await currentSorting.getVisibleText(); + await currentSorting.click(); + + if (currentSortingText !== 'Sort fields') { + const clearSortButton = await testSubjects.find('dataGridColumnSortingClearButton'); + await clearSortButton.click(); + } + + const selectSortFieldButton = await testSubjects.find('dataGridColumnSortingSelectionButton'); + await selectSortFieldButton.click(); + + const sortField = await testSubjects.find( + `dataGridColumnSortingPopoverColumnSelection-${columnName}` + ); + await sortField.click(); + + const sortDirection = await testSubjects.find( + `euiDataGridColumnSorting-sortColumn-${columnName}-${direction}` + ); + await sortDirection.click(); + await currentSorting.click(); + }, + + async openFlyoutAt(rowIndex: number) { + const table = await this.getElement(); + const flyoutButton = await table.findAllByTestSubject('docTableExpandToggleColumn'); + await flyoutButton[rowIndex].click(); + }, + }); + const createTableObject = (tableTestSubject: string) => ({ getElement() { return testSubjects.find(tableTestSubject); @@ -255,7 +353,7 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider ); }; - const latestFindingsTable = createTableObject('latest_findings_table'); + const latestFindingsTable = createDataTableObject('latest_findings_table'); const resourceFindingsTable = createTableObject('resource_findings_table'); const findingsByResourceTable = { ...createTableObject('findings_by_resource_table'), diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings.ts b/x-pack/test/cloud_security_posture_functional/pages/findings.ts index 5caedd4a6e7f2..2dbee8496998a 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/findings.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/findings.ts @@ -122,6 +122,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await findings.index.add(data); await findings.navigateToLatestFindingsPage(); + await retry.waitFor( 'Findings table to be loaded', async () => (await latestFindingsTable.getRowsCount()) === data.length @@ -135,10 +136,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('SearchBar', () => { it('add filter', async () => { - await filterBar.addFilter({ field: 'rule.name', operation: 'is', value: ruleName1 }); + // Filter bar uses the field's customLabel in the DataView + await filterBar.addFilter({ field: 'Rule Name', operation: 'is', value: ruleName1 }); expect(await filterBar.hasFilter('rule.name', ruleName1)).to.be(true); - expect(await latestFindingsTable.hasColumnValue('Rule Name', ruleName1)).to.be(true); + expect(await latestFindingsTable.hasColumnValue('rule.name', ruleName1)).to.be(true); }); it('remove filter', async () => { @@ -152,8 +154,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await queryBar.setQuery(ruleName1); await queryBar.submitQuery(); - expect(await latestFindingsTable.hasColumnValue('Rule Name', ruleName1)).to.be(true); - expect(await latestFindingsTable.hasColumnValue('Rule Name', ruleName2)).to.be(false); + expect(await latestFindingsTable.hasColumnValue('rule.name', ruleName1)).to.be(true); + expect(await latestFindingsTable.hasColumnValue('rule.name', ruleName2)).to.be(false); await queryBar.setQuery(''); await queryBar.submitQuery(); @@ -162,25 +164,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('Table Filters', () => { - it('add cell value filter', async () => { - await latestFindingsTable.addCellFilter('Rule Name', ruleName1, false); - - expect(await filterBar.hasFilter('rule.name', ruleName1)).to.be(true); - expect(await latestFindingsTable.hasColumnValue('Rule Name', ruleName1)).to.be(true); - }); - - it('add negated cell value filter', async () => { - await latestFindingsTable.addCellFilter('Rule Name', ruleName1, true); - - expect(await filterBar.hasFilter('rule.name', ruleName1, true, false, true)).to.be(true); - expect(await latestFindingsTable.hasColumnValue('Rule Name', ruleName1)).to.be(false); - expect(await latestFindingsTable.hasColumnValue('Rule Name', ruleName2)).to.be(true); - - await filterBar.removeFilter('rule.name'); - }); - }); - describe('Table Sort', () => { type SortingMethod = (a: string, b: string) => number; type SortDirection = 'asc' | 'desc'; @@ -195,14 +178,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('sorts by a column, should be case sensitive/insensitive depending on the column', async () => { type TestCase = [string, SortDirection, SortingMethod]; const testCases: TestCase[] = [ - ['CIS Section', 'asc', sortByAlphabeticalOrder], - ['CIS Section', 'desc', sortByAlphabeticalOrder], - ['Resource ID', 'asc', compareStringByLexicographicOrder], - ['Resource ID', 'desc', compareStringByLexicographicOrder], - ['Resource Name', 'asc', sortByAlphabeticalOrder], - ['Resource Name', 'desc', sortByAlphabeticalOrder], - ['Resource Type', 'asc', sortByAlphabeticalOrder], - ['Resource Type', 'desc', sortByAlphabeticalOrder], + ['rule.section', 'asc', sortByAlphabeticalOrder], + ['rule.section', 'desc', sortByAlphabeticalOrder], + ['resource.id', 'asc', compareStringByLexicographicOrder], + ['resource.id', 'desc', compareStringByLexicographicOrder], + ['resource.name', 'asc', sortByAlphabeticalOrder], + ['resource.name', 'desc', sortByAlphabeticalOrder], + ['resource.sub_type', 'asc', sortByAlphabeticalOrder], + ['resource.sub_type', 'desc', sortByAlphabeticalOrder], ]; for (const [columnName, dir, sortingMethod] of testCases) { await latestFindingsTable.toggleColumnSort(columnName, dir);