From 9cfe96a140987e420f35bd3639341de25832f328 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Mon, 24 Jul 2023 14:51:27 +0100 Subject: [PATCH] [SecuritySolution] Dashboard listing UI (#160540) ## Summary 1. Align dashboard listing UI with Kibana dashboard. 2. `Security Solution` tags are selected by default and removable by users. **Prerequisite:** This PR is waiting for https://github.com/elastic/kibana/pull/160871 to be merged **Steps to verify:** 1. Visit Security > Dashboards, and create a dashboard from this page. 2. Back to Security Dashboards page, you should see the dashboard you just created and Security Solution tag should be selected by default in the tag filters. 3. Open the tag options, click the Security Solution tag. Observe that it should be removable, and it should display all the dashboards you have in the table. **Known issues:** https://github.com/elastic/kibana/pull/160540#issuecomment-1610395834 **Before:** Screenshot 2023-06-27 at 09 24 19 **After:** Screenshot 2023-06-27 at 09 22 21 ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Pablo Neves Machado --- .../dashboard_listing.test.tsx | 3 +- .../dashboard_listing/dashboard_listing.tsx | 206 ++----------- .../dashboard_listing_empty_prompt.test.tsx | 16 + .../dashboard_listing_empty_prompt.tsx | 17 +- .../dashboard_listing_table.tsx | 112 +++++++ .../use_dashboard_listing_table.test.tsx | 247 ++++++++++++++++ .../hooks/use_dashboard_listing_table.tsx | 273 ++++++++++++++++++ .../public/dashboard_listing/index.tsx | 6 +- .../public/dashboard_listing/types.ts | 34 +++ .../common/utils/get_ramdom_color.ts | 13 + .../common/containers/tags/__mocks__/api.ts | 16 +- .../public/common/containers/tags/api.ts | 29 +- .../use_fetch_security_tags.test.ts | 35 ++- .../containers/use_fetch_security_tags.ts | 26 +- .../dashboards/context/dashboard_context.tsx | 5 +- .../pages/landing_page/index.test.tsx | 124 +++++--- .../dashboards/pages/landing_page/index.tsx | 88 +++++- .../lib/tags/saved_objects/create_tag.ts | 8 +- 18 files changed, 968 insertions(+), 290 deletions(-) create mode 100644 src/plugins/dashboard/public/dashboard_listing/dashboard_listing_table.tsx create mode 100644 src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.test.tsx create mode 100644 src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx create mode 100644 src/plugins/dashboard/public/dashboard_listing/types.ts create mode 100644 x-pack/plugins/security_solution/common/utils/get_ramdom_color.ts diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.test.tsx index 99b164433fe41..4da2d77825f47 100644 --- a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.test.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.test.tsx @@ -12,7 +12,7 @@ import { mount, ReactWrapper } from 'enzyme'; import { I18nProvider } from '@kbn/i18n-react'; import { pluginServices } from '../services/plugin_services'; -import { DashboardListing, DashboardListingProps } from './dashboard_listing'; +import { DashboardListing } from './dashboard_listing'; /** * Mock Table List view. This dashboard component is a wrapper around the shared UX table List view. We @@ -20,6 +20,7 @@ import { DashboardListing, DashboardListingProps } from './dashboard_listing'; * in our tests because it is covered in its package. */ import { TableListView } from '@kbn/content-management-table-list-view'; +import { DashboardListingProps } from './types'; // import { TableListViewKibanaProvider } from '@kbn/content-management-table-list-view'; jest.mock('@kbn/content-management-table-list-view-table', () => { const originalModule = jest.requireActual('@kbn/content-management-table-list-view-table'); diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx index 1890e9ca37e5e..94f9460c8f259 100644 --- a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx @@ -7,73 +7,25 @@ */ import { FormattedRelative, I18nProvider } from '@kbn/i18n-react'; -import React, { PropsWithChildren, useCallback, useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; import { type TableListViewKibanaDependencies, TableListViewKibanaProvider, - type UserContentCommonSchema, } from '@kbn/content-management-table-list-view-table'; import { TableListView } from '@kbn/content-management-table-list-view'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; -import type { SavedObjectsFindOptionsReference } from '@kbn/core/public'; + import { toMountPoint, useExecutionContext } from '@kbn/kibana-react-plugin/public'; -import { - DASHBOARD_CONTENT_ID, - SAVED_OBJECT_DELETE_TIME, - SAVED_OBJECT_LOADED_TIME, -} from '../dashboard_constants'; -import { - dashboardListingTableStrings, - dashboardListingErrorStrings, -} from './_dashboard_listing_strings'; import { pluginServices } from '../services/plugin_services'; -import { confirmCreateWithUnsaved } from './confirm_overlays'; -import { DashboardItem } from '../../common/content_management'; -import { DashboardUnsavedListing } from './dashboard_unsaved_listing'; -import { DashboardApplicationService } from '../services/application/types'; -import { DashboardListingEmptyPrompt } from './dashboard_listing_empty_prompt'; - -// because the type of `application.capabilities.advancedSettings` is so generic, the provider -// requiring the `save` key to be part of it is causing type issues - so, creating a custom type -type TableListViewApplicationService = DashboardApplicationService & { - capabilities: { advancedSettings: { save: boolean } }; -}; - -const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit'; -const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage'; - -interface DashboardSavedObjectUserContent extends UserContentCommonSchema { - attributes: { - title: string; - description?: string; - timeRestore: boolean; - }; -} -const toTableListViewSavedObject = (hit: DashboardItem): DashboardSavedObjectUserContent => { - const { title, description, timeRestore } = hit.attributes; - return { - type: 'dashboard', - id: hit.id, - updatedAt: hit.updatedAt!, - references: hit.references, - attributes: { - title, - description, - timeRestore, - }, - }; -}; - -export type DashboardListingProps = PropsWithChildren<{ - initialFilter?: string; - useSessionStorageIntegration?: boolean; - goToDashboard: (dashboardId?: string, viewMode?: ViewMode) => void; - getDashboardUrl: (dashboardId: string, usesTimeRestore: boolean) => string; -}>; +import { DashboardUnsavedListing } from './dashboard_unsaved_listing'; +import { useDashboardListingTable } from './hooks/use_dashboard_listing_table'; +import { + DashboardListingProps, + DashboardSavedObjectUserContent, + TableListViewApplicationService, +} from './types'; export const DashboardListing = ({ children, @@ -89,123 +41,22 @@ export const DashboardListing = ({ http, chrome: { theme }, savedObjectsTagging, - dashboardSessionStorage, - settings: { uiSettings }, - notifications: { toasts }, + coreContext: { executionContext }, - dashboardCapabilities: { showWriteControls }, - dashboardContentManagement: { findDashboards, deleteDashboards }, } = pluginServices.getServices(); - const [unsavedDashboardIds, setUnsavedDashboardIds] = useState( - dashboardSessionStorage.getDashboardIdsWithUnsavedChanges() - ); - useExecutionContext(executionContext, { type: 'application', page: 'list', }); - const listingLimit = uiSettings.get(SAVED_OBJECTS_LIMIT_SETTING); - const initialPageSize = uiSettings.get(SAVED_OBJECTS_PER_PAGE_SETTING); - - const createItem = useCallback(() => { - if (useSessionStorageIntegration && dashboardSessionStorage.dashboardHasUnsavedEdits()) { - confirmCreateWithUnsaved(() => { - dashboardSessionStorage.clearState(); - goToDashboard(); - }, goToDashboard); - return; - } - goToDashboard(); - }, [dashboardSessionStorage, goToDashboard, useSessionStorageIntegration]); - - const fetchItems = useCallback( - ( - searchTerm: string, - { - references, - referencesToExclude, - }: { - references?: SavedObjectsFindOptionsReference[]; - referencesToExclude?: SavedObjectsFindOptionsReference[]; - } = {} - ) => { - const searchStartTime = window.performance.now(); - - return findDashboards - .search({ - search: searchTerm, - size: listingLimit, - hasReference: references, - hasNoReference: referencesToExclude, - }) - .then(({ total, hits }) => { - const searchEndTime = window.performance.now(); - const searchDuration = searchEndTime - searchStartTime; - reportPerformanceMetricEvent(pluginServices.getServices().analytics, { - eventName: SAVED_OBJECT_LOADED_TIME, - duration: searchDuration, - meta: { - saved_object_type: DASHBOARD_CONTENT_ID, - }, - }); - return { - total, - hits: hits.map(toTableListViewSavedObject), - }; - }); - }, - [findDashboards, listingLimit] - ); - - const deleteItems = useCallback( - async (dashboardsToDelete: Array<{ id: string }>) => { - try { - const deleteStartTime = window.performance.now(); - - await deleteDashboards( - dashboardsToDelete.map(({ id }) => { - dashboardSessionStorage.clearState(id); - return id; - }) - ); - - const deleteDuration = window.performance.now() - deleteStartTime; - reportPerformanceMetricEvent(pluginServices.getServices().analytics, { - eventName: SAVED_OBJECT_DELETE_TIME, - duration: deleteDuration, - meta: { - saved_object_type: DASHBOARD_CONTENT_ID, - total: dashboardsToDelete.length, - }, - }); - } catch (error) { - toasts.addError(error, { - title: dashboardListingErrorStrings.getErrorDeletingDashboardToast(), - }); - } - - setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges()); - }, - [dashboardSessionStorage, deleteDashboards, toasts] - ); - - const editItem = useCallback( - ({ id }: { id: string | undefined }) => goToDashboard(id, ViewMode.EDIT), - [goToDashboard] - ); - const emptyPrompt = ( - - ); - - const { getEntityName, getTableListTitle, getEntityNamePlural } = dashboardListingTableStrings; + const { unsavedDashboardIds, refreshUnsavedDashboards, tableListViewTableProps } = + useDashboardListingTable({ + goToDashboard, + getDashboardUrl, + useSessionStorageIntegration, + initialFilter, + }); const savedObjectsTaggingFakePlugin = useMemo(() => { return savedObjectsTagging.hasApi // TODO: clean up this logic once https://github.com/elastic/kibana/issues/140433 is resolved @@ -231,32 +82,13 @@ export const DashboardListing = ({ FormattedRelative, }} > - - getDetailViewLink={({ id, attributes: { timeRestore } }) => - getDashboardUrl(id, timeRestore) - } - deleteItems={!showWriteControls ? undefined : deleteItems} - createItem={!showWriteControls ? undefined : createItem} - editItem={!showWriteControls ? undefined : editItem} - entityNamePlural={getEntityNamePlural()} - title={getTableListTitle()} - headingId="dashboardListingHeading" - initialPageSize={initialPageSize} - initialFilter={initialFilter} - entityName={getEntityName()} - listingLimit={listingLimit} - emptyPrompt={emptyPrompt} - findItems={fetchItems} - id="dashboard" - > + {...tableListViewTableProps}> <> {children} - setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges()) - } + refreshUnsavedDashboards={refreshUnsavedDashboards} /> diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.test.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.test.tsx index 886d43a1db6d9..2551779752274 100644 --- a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.test.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.test.tsx @@ -34,6 +34,7 @@ const makeDefaultProps = (): DashboardListingEmptyPromptProps => ({ goToDashboard: jest.fn(), setUnsavedDashboardIds: jest.fn(), useSessionStorageIntegration: true, + disableCreateDashboardButton: false, }); function mountWith({ @@ -75,6 +76,21 @@ test('renders empty prompt with link when showWriteControls is on', async () => expect(component!.find('EuiLink').length).toBe(1); }); +test('renders disabled action button when disableCreateDashboardButton is true', async () => { + pluginServices.getServices().dashboardCapabilities.showWriteControls = true; + + let component: ReactWrapper; + await act(async () => { + ({ component } = mountWith({ props: { disableCreateDashboardButton: true } })); + }); + + component!.update(); + + expect(component!.find(`[data-test-subj="newItemButton"]`).first().prop('disabled')).toEqual( + true + ); +}); + test('renders continue button when no dashboards exist but one is in progress', async () => { pluginServices.getServices().dashboardCapabilities.showWriteControls = true; let component: ReactWrapper; diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.tsx index a518c520bcbd8..d1460c53f23e5 100644 --- a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.tsx @@ -22,13 +22,14 @@ import { getNewDashboardTitle, dashboardUnsavedListingStrings, } from './_dashboard_listing_strings'; -import { DashboardListingProps } from './dashboard_listing'; import { pluginServices } from '../services/plugin_services'; import { confirmDiscardUnsavedChanges } from './confirm_overlays'; import { DASHBOARD_PANELS_UNSAVED_ID } from '../services/dashboard_session_storage/dashboard_session_storage_service'; +import { DashboardListingProps } from './types'; export interface DashboardListingEmptyPromptProps { createItem: () => void; + disableCreateDashboardButton?: boolean; unsavedDashboardIds: string[]; goToDashboard: DashboardListingProps['goToDashboard']; setUnsavedDashboardIds: React.Dispatch>; @@ -41,6 +42,7 @@ export const DashboardListingEmptyPrompt = ({ unsavedDashboardIds, goToDashboard, createItem, + disableCreateDashboardButton, }: DashboardListingEmptyPromptProps) => { const { application, @@ -56,7 +58,13 @@ export const DashboardListingEmptyPrompt = ({ const getEmptyAction = useCallback(() => { if (!isEditingFirstDashboard) { return ( - + {noItemsStrings.getCreateNewDashboardText()} ); @@ -94,11 +102,12 @@ export const DashboardListingEmptyPrompt = ({ ); }, [ - dashboardSessionStorage, isEditingFirstDashboard, + createItem, + disableCreateDashboardButton, + dashboardSessionStorage, setUnsavedDashboardIds, goToDashboard, - createItem, ]); if (!showWriteControls) { diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_table.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_table.tsx new file mode 100644 index 0000000000000..196fd04cddf6c --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_table.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FormattedRelative, I18nProvider } from '@kbn/i18n-react'; +import React, { useMemo } from 'react'; + +import { + type TableListViewKibanaDependencies, + TableListViewKibanaProvider, + TableListViewTable, +} from '@kbn/content-management-table-list-view-table'; + +import { toMountPoint, useExecutionContext } from '@kbn/kibana-react-plugin/public'; + +import { pluginServices } from '../services/plugin_services'; + +import { DashboardUnsavedListing } from './dashboard_unsaved_listing'; +import { useDashboardListingTable } from './hooks/use_dashboard_listing_table'; +import { + DashboardListingProps, + DashboardSavedObjectUserContent, + TableListViewApplicationService, +} from './types'; + +export const DashboardListingTable = ({ + disableCreateDashboardButton, + initialFilter, + goToDashboard, + getDashboardUrl, + useSessionStorageIntegration, + urlStateEnabled, +}: DashboardListingProps) => { + const { + application, + notifications, + overlays, + http, + savedObjectsTagging, + coreContext: { executionContext }, + chrome: { theme }, + } = pluginServices.getServices(); + + useExecutionContext(executionContext, { + type: 'application', + page: 'list', + }); + + const { + unsavedDashboardIds, + refreshUnsavedDashboards, + tableListViewTableProps: { title: tableCaption, ...tableListViewTable }, + } = useDashboardListingTable({ + disableCreateDashboardButton, + goToDashboard, + getDashboardUrl, + urlStateEnabled, + useSessionStorageIntegration, + initialFilter, + }); + + const savedObjectsTaggingFakePlugin = useMemo( + () => + savedObjectsTagging.hasApi // TODO: clean up this logic once https://github.com/elastic/kibana/issues/140433 is resolved + ? ({ + ui: savedObjectsTagging, + } as TableListViewKibanaDependencies['savedObjectsTagging']) + : undefined, + [savedObjectsTagging] + ); + + const core = useMemo( + () => ({ + application: application as TableListViewApplicationService, + notifications, + overlays, + http, + theme, + }), + [application, notifications, overlays, http, theme] + ); + + return ( + + + <> + + + tableCaption={tableCaption} + {...tableListViewTable} + /> + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default DashboardListingTable; diff --git a/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.test.tsx b/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.test.tsx new file mode 100644 index 0000000000000..ceb53af4bf2eb --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.test.tsx @@ -0,0 +1,247 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useDashboardListingTable } from './use_dashboard_listing_table'; +import { pluginServices } from '../../services/plugin_services'; +import { confirmCreateWithUnsaved } from '../confirm_overlays'; +import { DashboardSavedObjectUserContent } from '../types'; +const clearStateMock = jest.fn(); +const getDashboardUrl = jest.fn(); +const goToDashboard = jest.fn(); +const deleteDashboards = jest.fn().mockResolvedValue(true); +const getUiSettingsMock = jest.fn().mockImplementation((key) => { + if (key === 'savedObjects:listingLimit') { + return 20; + } + if (key === 'savedObjects:perPage') { + return 5; + } + return null; +}); +const getPluginServices = pluginServices.getServices(); + +jest.mock('@kbn/ebt-tools', () => ({ + reportPerformanceMetricEvent: jest.fn(), +})); + +jest.mock('../confirm_overlays', () => ({ + confirmCreateWithUnsaved: jest.fn().mockImplementation((fn) => fn()), +})); + +jest.mock('../_dashboard_listing_strings', () => ({ + dashboardListingTableStrings: { + getEntityName: jest.fn().mockReturnValue('Dashboard'), + getTableListTitle: jest.fn().mockReturnValue('Dashboard List'), + getEntityNamePlural: jest.fn().mockReturnValue('Dashboards'), + }, +})); + +describe('useDashboardListingTable', () => { + beforeEach(() => { + jest.clearAllMocks(); + + getPluginServices.dashboardSessionStorage.dashboardHasUnsavedEdits = jest + .fn() + .mockReturnValue(true); + + getPluginServices.dashboardSessionStorage.getDashboardIdsWithUnsavedChanges = jest + .fn() + .mockReturnValue([]); + + getPluginServices.dashboardSessionStorage.clearState = clearStateMock; + getPluginServices.dashboardCapabilities.showWriteControls = true; + getPluginServices.dashboardContentManagement.deleteDashboards = deleteDashboards; + getPluginServices.settings.uiSettings.get = getUiSettingsMock; + getPluginServices.notifications.toasts.addError = jest.fn(); + }); + + test('should return the correct initial hasInitialFetchReturned state', () => { + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + expect(result.current.hasInitialFetchReturned).toBe(false); + }); + + test('should return the correct initial pageDataTestSubject state', () => { + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + expect(result.current.pageDataTestSubject).toBeUndefined(); + }); + + test('should return the correct refreshUnsavedDashboards function', () => { + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + expect(typeof result.current.refreshUnsavedDashboards).toBe('function'); + }); + + test('should return the correct initial unsavedDashboardIds state', () => { + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + expect(result.current.unsavedDashboardIds).toEqual([]); + }); + + test('should return the correct tableListViewTableProps', () => { + const initialFilter = 'myFilter'; + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + initialFilter, + urlStateEnabled: false, + }) + ); + + const tableListViewTableProps = result.current.tableListViewTableProps; + + const expectedProps = { + createItem: expect.any(Function), + deleteItems: expect.any(Function), + editItem: expect.any(Function), + emptyPrompt: expect.any(Object), + entityName: 'Dashboard', + entityNamePlural: 'Dashboards', + findItems: expect.any(Function), + getDetailViewLink: expect.any(Function), + headingId: 'dashboardListingHeading', + id: expect.any(String), + initialFilter: 'myFilter', + initialPageSize: 5, + listingLimit: 20, + onFetchSuccess: expect.any(Function), + setPageDataTestSubject: expect.any(Function), + title: 'Dashboard List', + urlStateEnabled: false, + }; + + expect(tableListViewTableProps).toEqual(expectedProps); + }); + + test('should call deleteDashboards when deleteItems is called', () => { + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + act(() => { + result.current.tableListViewTableProps.deleteItems?.([ + { id: 'test-id' } as DashboardSavedObjectUserContent, + ]); + }); + + expect(deleteDashboards).toHaveBeenCalled(); + }); + + test('should call goToDashboard when editItem is called', () => { + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + act(() => { + result.current.tableListViewTableProps.editItem?.({ + id: 'test-id', + } as DashboardSavedObjectUserContent); + }); + + expect(goToDashboard).toHaveBeenCalled(); + }); + + test('should call goToDashboard when createItem is called without unsaved changes', () => { + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + act(() => { + result.current.tableListViewTableProps.createItem?.(); + }); + + expect(goToDashboard).toHaveBeenCalled(); + }); + + test('should call confirmCreateWithUnsaved and clear state when createItem is called with unsaved changes', () => { + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + useSessionStorageIntegration: true, + }) + ); + + act(() => { + result.current.tableListViewTableProps.createItem?.(); + }); + + expect(confirmCreateWithUnsaved).toHaveBeenCalled(); + expect(clearStateMock).toHaveBeenCalled(); + expect(goToDashboard).toHaveBeenCalled(); + }); + + test('createItem should be undefined when showWriteControls equals false', () => { + getPluginServices.dashboardCapabilities.showWriteControls = false; + + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + expect(result.current.tableListViewTableProps.createItem).toBeUndefined(); + }); + + test('deleteItems should be undefined when showWriteControls equals false', () => { + getPluginServices.dashboardCapabilities.showWriteControls = false; + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + expect(result.current.tableListViewTableProps.deleteItems).toBeUndefined(); + }); + + test('editItem should be undefined when showWriteControls equals false', () => { + getPluginServices.dashboardCapabilities.showWriteControls = false; + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + expect(result.current.tableListViewTableProps.editItem).toBeUndefined(); + }); +}); diff --git a/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx b/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx new file mode 100644 index 0000000000000..f95f2649673b0 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx @@ -0,0 +1,273 @@ +/* + * 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, useMemo } from 'react'; +import type { SavedObjectsFindOptionsReference } from '@kbn/core/public'; +import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; +import { TableListViewTableProps } from '@kbn/content-management-table-list-view-table'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; + +import { DashboardListingEmptyPrompt } from '../dashboard_listing_empty_prompt'; +import { pluginServices } from '../../services/plugin_services'; +import { + DASHBOARD_CONTENT_ID, + SAVED_OBJECT_DELETE_TIME, + SAVED_OBJECT_LOADED_TIME, +} from '../../dashboard_constants'; +import { DashboardItem } from '../../../common/content_management'; +import { + dashboardListingErrorStrings, + dashboardListingTableStrings, +} from '../_dashboard_listing_strings'; +import { confirmCreateWithUnsaved } from '../confirm_overlays'; +import { DashboardSavedObjectUserContent } from '../types'; + +type GetDetailViewLink = + TableListViewTableProps['getDetailViewLink']; + +const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit'; +const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage'; + +const toTableListViewSavedObject = (hit: DashboardItem): DashboardSavedObjectUserContent => { + const { title, description, timeRestore } = hit.attributes; + return { + type: 'dashboard', + id: hit.id, + updatedAt: hit.updatedAt!, + references: hit.references, + attributes: { + title, + description, + timeRestore, + }, + }; +}; + +interface UseDashboardListingTableReturnType { + hasInitialFetchReturned: boolean; + pageDataTestSubject: string | undefined; + refreshUnsavedDashboards: () => void; + tableListViewTableProps: Omit< + TableListViewTableProps, + 'tableCaption' + > & { title: string }; + unsavedDashboardIds: string[]; +} + +export const useDashboardListingTable = ({ + dashboardListingId = 'dashboard', + disableCreateDashboardButton, + getDashboardUrl, + goToDashboard, + headingId = 'dashboardListingHeading', + initialFilter, + urlStateEnabled, + useSessionStorageIntegration, +}: { + dashboardListingId?: string; + disableCreateDashboardButton?: boolean; + getDashboardUrl: (dashboardId: string, usesTimeRestore: boolean) => string; + goToDashboard: (dashboardId?: string, viewMode?: ViewMode) => void; + headingId?: string; + initialFilter?: string; + urlStateEnabled?: boolean; + useSessionStorageIntegration?: boolean; +}): UseDashboardListingTableReturnType => { + const { + dashboardSessionStorage, + dashboardCapabilities: { showWriteControls }, + dashboardContentManagement: { findDashboards, deleteDashboards }, + settings: { uiSettings }, + notifications: { toasts }, + } = pluginServices.getServices(); + + const { getEntityName, getTableListTitle, getEntityNamePlural } = dashboardListingTableStrings; + const title = getTableListTitle(); + const entityName = getEntityName(); + const entityNamePlural = getEntityNamePlural(); + const [pageDataTestSubject, setPageDataTestSubject] = useState(); + const [hasInitialFetchReturned, setHasInitialFetchReturned] = useState(false); + const [unsavedDashboardIds, setUnsavedDashboardIds] = useState( + dashboardSessionStorage.getDashboardIdsWithUnsavedChanges() + ); + + const listingLimit = uiSettings.get(SAVED_OBJECTS_LIMIT_SETTING); + const initialPageSize = uiSettings.get(SAVED_OBJECTS_PER_PAGE_SETTING); + + const createItem = useCallback(() => { + if (useSessionStorageIntegration && dashboardSessionStorage.dashboardHasUnsavedEdits()) { + confirmCreateWithUnsaved(() => { + dashboardSessionStorage.clearState(); + goToDashboard(); + }, goToDashboard); + return; + } + goToDashboard(); + }, [dashboardSessionStorage, goToDashboard, useSessionStorageIntegration]); + + const emptyPrompt = useMemo( + () => ( + + ), + [ + createItem, + disableCreateDashboardButton, + goToDashboard, + unsavedDashboardIds, + useSessionStorageIntegration, + ] + ); + + const findItems = useCallback( + ( + searchTerm: string, + { + references, + referencesToExclude, + }: { + references?: SavedObjectsFindOptionsReference[]; + referencesToExclude?: SavedObjectsFindOptionsReference[]; + } = {} + ) => { + const searchStartTime = window.performance.now(); + + return findDashboards + .search({ + search: searchTerm, + size: listingLimit, + hasReference: references, + hasNoReference: referencesToExclude, + }) + .then(({ total, hits }) => { + const searchEndTime = window.performance.now(); + const searchDuration = searchEndTime - searchStartTime; + reportPerformanceMetricEvent(pluginServices.getServices().analytics, { + eventName: SAVED_OBJECT_LOADED_TIME, + duration: searchDuration, + meta: { + saved_object_type: DASHBOARD_CONTENT_ID, + }, + }); + return { + total, + hits: hits.map(toTableListViewSavedObject), + }; + }); + }, + [findDashboards, listingLimit] + ); + + const deleteItems = useCallback( + async (dashboardsToDelete: Array<{ id: string }>) => { + try { + const deleteStartTime = window.performance.now(); + + await deleteDashboards( + dashboardsToDelete.map(({ id }) => { + dashboardSessionStorage.clearState(id); + return id; + }) + ); + + const deleteDuration = window.performance.now() - deleteStartTime; + reportPerformanceMetricEvent(pluginServices.getServices().analytics, { + eventName: SAVED_OBJECT_DELETE_TIME, + duration: deleteDuration, + meta: { + saved_object_type: DASHBOARD_CONTENT_ID, + total: dashboardsToDelete.length, + }, + }); + } catch (error) { + toasts.addError(error, { + title: dashboardListingErrorStrings.getErrorDeletingDashboardToast(), + }); + } + + setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges()); + }, + [dashboardSessionStorage, deleteDashboards, toasts] + ); + + const editItem = useCallback( + ({ id }: { id: string | undefined }) => goToDashboard(id, ViewMode.EDIT), + [goToDashboard] + ); + + const onFetchSuccess = useCallback(() => { + if (!hasInitialFetchReturned) { + setHasInitialFetchReturned(true); + } + }, [hasInitialFetchReturned]); + + const getDetailViewLink: GetDetailViewLink = useCallback( + ({ id, attributes: { timeRestore } }) => getDashboardUrl(id, timeRestore), + [getDashboardUrl] + ); + + const tableListViewTableProps = useMemo( + () => ({ + createItem: !showWriteControls ? undefined : createItem, + deleteItems: !showWriteControls ? undefined : deleteItems, + editItem: !showWriteControls ? undefined : editItem, + emptyPrompt, + entityName, + entityNamePlural, + findItems, + getDetailViewLink, + headingId, + id: dashboardListingId, + initialFilter, + initialPageSize, + listingLimit, + onFetchSuccess, + setPageDataTestSubject, + title, + urlStateEnabled, + }), + [ + createItem, + dashboardListingId, + deleteItems, + editItem, + emptyPrompt, + entityName, + entityNamePlural, + findItems, + getDetailViewLink, + headingId, + initialFilter, + initialPageSize, + listingLimit, + onFetchSuccess, + showWriteControls, + title, + urlStateEnabled, + ] + ); + + const refreshUnsavedDashboards = useCallback( + () => setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges()), + [dashboardSessionStorage] + ); + + return { + hasInitialFetchReturned, + pageDataTestSubject, + refreshUnsavedDashboards, + tableListViewTableProps, + unsavedDashboardIds, + }; +}; diff --git a/src/plugins/dashboard/public/dashboard_listing/index.tsx b/src/plugins/dashboard/public/dashboard_listing/index.tsx index 92febf2904bdd..c1c9012252835 100644 --- a/src/plugins/dashboard/public/dashboard_listing/index.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/index.tsx @@ -10,7 +10,7 @@ import React, { Suspense } from 'react'; import { EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; import { servicesReady } from '../plugin'; -import { DashboardListingProps } from './dashboard_listing'; +import { DashboardListingProps } from './types'; const ListingTableLoadingIndicator = () => { return } />; @@ -18,11 +18,11 @@ const ListingTableLoadingIndicator = () => { const LazyDashboardListing = React.lazy(() => (async () => { - const modulePromise = import('./dashboard_listing'); + const modulePromise = import('./dashboard_listing_table'); const [module] = await Promise.all([modulePromise, servicesReady]); return { - default: module.DashboardListing, + default: module.DashboardListingTable, }; })().then((module) => module) ); diff --git a/src/plugins/dashboard/public/dashboard_listing/types.ts b/src/plugins/dashboard/public/dashboard_listing/types.ts new file mode 100644 index 0000000000000..c92344d4e778a --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_listing/types.ts @@ -0,0 +1,34 @@ +/* + * 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 { PropsWithChildren } from 'react'; +import { type UserContentCommonSchema } from '@kbn/content-management-table-list-view-table'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { DashboardApplicationService } from '../services/application/types'; + +export type DashboardListingProps = PropsWithChildren<{ + disableCreateDashboardButton?: boolean; + initialFilter?: string; + useSessionStorageIntegration?: boolean; + goToDashboard: (dashboardId?: string, viewMode?: ViewMode) => void; + getDashboardUrl: (dashboardId: string, usesTimeRestore: boolean) => string; + urlStateEnabled?: boolean; +}>; + +// because the type of `application.capabilities.advancedSettings` is so generic, the provider +// requiring the `save` key to be part of it is causing type issues - so, creating a custom type +export type TableListViewApplicationService = DashboardApplicationService & { + capabilities: { advancedSettings: { save: boolean } }; +}; + +export interface DashboardSavedObjectUserContent extends UserContentCommonSchema { + attributes: { + title: string; + description?: string; + timeRestore: boolean; + }; +} diff --git a/x-pack/plugins/security_solution/common/utils/get_ramdom_color.ts b/x-pack/plugins/security_solution/common/utils/get_ramdom_color.ts new file mode 100644 index 0000000000000..67ff5686f98d8 --- /dev/null +++ b/x-pack/plugins/security_solution/common/utils/get_ramdom_color.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +/** + * Returns the hex representation of a random color (e.g `#F1B7E2`) + */ +export const getRandomColor = (): string => { + return `#${String(Math.floor(Math.random() * 16777215).toString(16)).padStart(6, '0')}`; +}; diff --git a/x-pack/plugins/security_solution/public/common/containers/tags/__mocks__/api.ts b/x-pack/plugins/security_solution/public/common/containers/tags/__mocks__/api.ts index 6f87ede894dd1..d7401ff19d916 100644 --- a/x-pack/plugins/security_solution/public/common/containers/tags/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/common/containers/tags/__mocks__/api.ts @@ -6,11 +6,23 @@ */ export const MOCK_TAG_ID = 'securityTagId'; +export const MOCK_TAG_NAME = 'test tag'; export const DEFAULT_TAGS_RESPONSE = [ { id: MOCK_TAG_ID, - name: 'test tag', + attributes: { + name: MOCK_TAG_NAME, + description: 'test tag description', + color: '#2c7b82', + }, + }, +]; + +export const DEFAULT_CREATE_TAGS_RESPONSE = [ + { + id: MOCK_TAG_ID, + name: MOCK_TAG_NAME, description: 'test tag description', color: '#2c7b82', }, @@ -21,4 +33,4 @@ export const getTagsByName = jest .mockImplementation(() => Promise.resolve(DEFAULT_TAGS_RESPONSE)); export const createTag = jest .fn() - .mockImplementation(() => Promise.resolve(DEFAULT_TAGS_RESPONSE[0])); + .mockImplementation(() => Promise.resolve(DEFAULT_CREATE_TAGS_RESPONSE[0])); diff --git a/x-pack/plugins/security_solution/public/common/containers/tags/api.ts b/x-pack/plugins/security_solution/public/common/containers/tags/api.ts index 57b27318103fc..479ae07cc4eb7 100644 --- a/x-pack/plugins/security_solution/public/common/containers/tags/api.ts +++ b/x-pack/plugins/security_solution/public/common/containers/tags/api.ts @@ -6,20 +6,29 @@ */ import type { HttpSetup } from '@kbn/core/public'; -import type { Tag } from '@kbn/saved-objects-tagging-plugin/public'; -import type { TagAttributes } from '@kbn/saved-objects-tagging-plugin/common'; +import type { + ITagsClient, + TagAttributes, + Tag as TagResponse, +} from '@kbn/saved-objects-tagging-plugin/common'; import { INTERNAL_TAGS_URL } from '../../../../common/constants'; +export interface Tag { + id: string; + attributes: TagAttributes; +} + export const getTagsByName = ( { http, tagName }: { http: HttpSetup; tagName: string }, abortSignal?: AbortSignal ): Promise => http.get(INTERNAL_TAGS_URL, { query: { name: tagName }, signal: abortSignal }); -export const createTag = ( - { http, tag }: { http: HttpSetup; tag: Omit & { color?: string } }, - abortSignal?: AbortSignal -): Promise => - http.put(INTERNAL_TAGS_URL, { - body: JSON.stringify(tag), - signal: abortSignal, - }); +// Dashboard listing needs savedObjectsTaggingClient to work correctly with cache. +// https://github.com/elastic/kibana/issues/160723#issuecomment-1641904984 +export const createTag = ({ + savedObjectsTaggingClient, + tag, +}: { + savedObjectsTaggingClient: ITagsClient; + tag: TagAttributes; +}): Promise => savedObjectsTaggingClient.create(tag); diff --git a/x-pack/plugins/security_solution/public/dashboards/containers/use_fetch_security_tags.test.ts b/x-pack/plugins/security_solution/public/dashboards/containers/use_fetch_security_tags.test.ts index 6041df888dfcb..4cf79cf4ce10b 100644 --- a/x-pack/plugins/security_solution/public/dashboards/containers/use_fetch_security_tags.test.ts +++ b/x-pack/plugins/security_solution/public/dashboards/containers/use_fetch_security_tags.test.ts @@ -14,13 +14,18 @@ import { } from '../../../common/constants'; import { useKibana } from '../../common/lib/kibana'; import { useFetchSecurityTags } from './use_fetch_security_tags'; +import { DEFAULT_TAGS_RESPONSE } from '../../common/containers/tags/__mocks__/api'; +import type { ITagsClient } from '@kbn/saved-objects-tagging-plugin/common'; +import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; jest.mock('../../common/lib/kibana'); +jest.mock('../../../common/utils/get_ramdom_color', () => ({ + getRandomColor: jest.fn().mockReturnValue('#FFFFFF'), +})); const mockGet = jest.fn(); -const mockPut = jest.fn(); const mockAbortSignal = {} as unknown as AbortSignal; - +const mockCreateTag = jest.fn(); const renderUseCreateSecurityDashboardLink = () => renderHook(() => useFetchSecurityTags(), {}); const asyncRenderUseCreateSecurityDashboardLink = async () => { @@ -33,8 +38,10 @@ const asyncRenderUseCreateSecurityDashboardLink = async () => { describe('useFetchSecurityTags', () => { beforeAll(() => { - useKibana().services.http = { get: mockGet, put: mockPut } as unknown as HttpStart; - + useKibana().services.http = { get: mockGet } as unknown as HttpStart; + useKibana().services.savedObjectsTagging = { + client: { create: mockCreateTag } as unknown as ITagsClient, + } as unknown as SavedObjectsTaggingApi; global.AbortController = jest.fn().mockReturnValue({ abort: jest.fn(), signal: mockAbortSignal, @@ -59,18 +66,24 @@ describe('useFetchSecurityTags', () => { mockGet.mockResolvedValue([]); await asyncRenderUseCreateSecurityDashboardLink(); - expect(mockPut).toHaveBeenCalledWith(INTERNAL_TAGS_URL, { - body: JSON.stringify({ name: SECURITY_TAG_NAME, description: SECURITY_TAG_DESCRIPTION }), - signal: mockAbortSignal, + expect(mockCreateTag).toHaveBeenCalledWith({ + name: SECURITY_TAG_NAME, + description: SECURITY_TAG_DESCRIPTION, + color: '#FFFFFF', }); }); test('should return Security Solution tags', async () => { - const mockFoundTags = [{ id: 'tagId', name: 'Security Solution', description: '', color: '' }]; - mockGet.mockResolvedValue(mockFoundTags); + mockGet.mockResolvedValue(DEFAULT_TAGS_RESPONSE); + + const expected = DEFAULT_TAGS_RESPONSE.map((tag) => ({ + id: tag.id, + type: 'tag', + ...tag.attributes, + })); const { result } = await asyncRenderUseCreateSecurityDashboardLink(); - expect(mockPut).not.toHaveBeenCalled(); - expect(result.current.tags).toEqual(expect.objectContaining(mockFoundTags)); + expect(mockCreateTag).not.toHaveBeenCalled(); + expect(result.current.tags).toEqual(expect.objectContaining(expected)); }); }); diff --git a/x-pack/plugins/security_solution/public/dashboards/containers/use_fetch_security_tags.ts b/x-pack/plugins/security_solution/public/dashboards/containers/use_fetch_security_tags.ts index a91daf7be16eb..9bdb3f891f596 100644 --- a/x-pack/plugins/security_solution/public/dashboards/containers/use_fetch_security_tags.ts +++ b/x-pack/plugins/security_solution/public/dashboards/containers/use_fetch_security_tags.ts @@ -10,9 +10,10 @@ import { useKibana } from '../../common/lib/kibana'; import { createTag, getTagsByName } from '../../common/containers/tags/api'; import { REQUEST_NAMES, useFetch } from '../../common/hooks/use_fetch'; import { SECURITY_TAG_DESCRIPTION, SECURITY_TAG_NAME } from '../../../common/constants'; +import { getRandomColor } from '../../../common/utils/get_ramdom_color'; export const useFetchSecurityTags = () => { - const { http } = useKibana().services; + const { http, savedObjectsTagging } = useKibana().services; const tagCreated = useRef(false); const { @@ -31,20 +32,31 @@ export const useFetchSecurityTags = () => { } = useFetch(REQUEST_NAMES.SECURITY_CREATE_TAG, createTag); useEffect(() => { - if (!isLoadingTags && !errorFetchTags && tags && tags.length === 0 && !tagCreated.current) { + if ( + savedObjectsTagging && + !isLoadingTags && + !errorFetchTags && + tags && + tags.length === 0 && + !tagCreated.current + ) { tagCreated.current = true; fetchCreateTag({ - http, - tag: { name: SECURITY_TAG_NAME, description: SECURITY_TAG_DESCRIPTION }, + savedObjectsTaggingClient: savedObjectsTagging.client, + tag: { + name: SECURITY_TAG_NAME, + description: SECURITY_TAG_DESCRIPTION, + color: getRandomColor(), + }, }); } - }, [errorFetchTags, fetchCreateTag, http, isLoadingTags, tags]); + }, [errorFetchTags, fetchCreateTag, savedObjectsTagging, isLoadingTags, tags]); const tagsResult = useMemo(() => { if (tags?.length) { - return tags; + return tags.map((t) => ({ id: t.id, type: 'tag', ...t.attributes })); } - return tag ? [tag] : undefined; + return tag ? [{ type: 'tag', ...tag }] : undefined; }, [tags, tag]); return { diff --git a/x-pack/plugins/security_solution/public/dashboards/context/dashboard_context.tsx b/x-pack/plugins/security_solution/public/dashboards/context/dashboard_context.tsx index 32aea030e0632..cb8b40b1c0907 100644 --- a/x-pack/plugins/security_solution/public/dashboards/context/dashboard_context.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/context/dashboard_context.tsx @@ -9,8 +9,11 @@ import React from 'react'; import type { Tag } from '@kbn/saved-objects-tagging-plugin/common'; import { useFetchSecurityTags } from '../containers/use_fetch_security_tags'; +export interface TagReference extends Tag { + type: string; +} export interface DashboardContextType { - securityTags: Tag[] | null; + securityTags: TagReference[] | null; } const DashboardContext = React.createContext({ securityTags: null }); diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx index 46c243eebb7ca..357cf5d21670b 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx @@ -5,20 +5,29 @@ * 2.0. */ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../../app/types'; import { TestProviders } from '../../../common/mock'; import { DashboardsLandingPage } from '.'; -import type { NavigationLink } from '../../../common/links'; import { useCapabilities } from '../../../common/lib/kibana'; import * as telemetry from '../../../common/lib/telemetry'; +import { DashboardListingTable } from '@kbn/dashboard-plugin/public'; +import { MOCK_TAG_NAME } from '../../../common/containers/tags/__mocks__/api'; +import { DashboardContextProvider } from '../../context/dashboard_context'; +import { act } from 'react-dom/test-utils'; +import type { NavigationLink } from '../../../common/links/types'; +jest.mock('../../../common/containers/tags/api'); jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/utils/route/spy_routes', () => ({ SpyRoute: () => null })); -jest.mock('../../components/dashboards_table', () => ({ - DashboardsTable: () => , -})); +jest.mock('@kbn/dashboard-plugin/public', () => { + const actual = jest.requireActual('@kbn/dashboard-plugin/public'); + return { + ...actual, + DashboardListingTable: jest.fn().mockReturnValue(), + }; +}); const DEFAULT_DASHBOARD_CAPABILITIES = { show: true, createNew: true }; const mockUseCapabilities = useCapabilities as jest.Mock; @@ -63,97 +72,122 @@ jest.mock('../../hooks/use_create_security_dashboard_link', () => { }; }); -const renderDashboardLanding = () => render(, { wrapper: TestProviders }); +const TestComponent = () => ( + + + + + +); + +const renderDashboardLanding = async () => { + await act(async () => { + render(); + }); +}; describe('Dashboards landing', () => { + beforeEach(() => { + mockUseCapabilities.mockReturnValue(DEFAULT_DASHBOARD_CAPABILITIES); + mockUseCreateSecurityDashboard.mockReturnValue(CREATE_DASHBOARD_LINK); + }); + describe('Dashboards default links', () => { - it('should render items', () => { - const { queryByText } = renderDashboardLanding(); + it('should render items', async () => { + await renderDashboardLanding(); - expect(queryByText(OVERVIEW_ITEM_LABEL)).toBeInTheDocument(); - expect(queryByText(DETECTION_RESPONSE_ITEM_LABEL)).toBeInTheDocument(); + expect(screen.queryByText(OVERVIEW_ITEM_LABEL)).toBeInTheDocument(); + expect(screen.queryByText(DETECTION_RESPONSE_ITEM_LABEL)).toBeInTheDocument(); }); - it('should render items in the same order as defined', () => { + it('should render items in the same order as defined', async () => { mockAppManageLink.mockReturnValueOnce({ ...APP_DASHBOARD_LINKS, }); - const { queryAllByTestId } = renderDashboardLanding(); + await renderDashboardLanding(); - const renderedItems = queryAllByTestId('LandingImageCard-item'); + const renderedItems = screen.queryAllByTestId('LandingImageCard-item'); expect(renderedItems[0]).toHaveTextContent(OVERVIEW_ITEM_LABEL); expect(renderedItems[1]).toHaveTextContent(DETECTION_RESPONSE_ITEM_LABEL); }); - it('should not render items if all items filtered', () => { - mockAppManageLink.mockReturnValueOnce({ + it('should not render items if all items filtered', async () => { + mockAppManageLink.mockReturnValue({ ...APP_DASHBOARD_LINKS, links: [], }); - const { queryByText } = renderDashboardLanding(); + await renderDashboardLanding(); - expect(queryByText(OVERVIEW_ITEM_LABEL)).not.toBeInTheDocument(); - expect(queryByText(DETECTION_RESPONSE_ITEM_LABEL)).not.toBeInTheDocument(); + expect(screen.queryByText(OVERVIEW_ITEM_LABEL)).not.toBeInTheDocument(); + expect(screen.queryByText(DETECTION_RESPONSE_ITEM_LABEL)).not.toBeInTheDocument(); }); }); describe('Security Dashboards', () => { - it('should render dashboards table', () => { - const result = renderDashboardLanding(); + it('should render dashboards table', async () => { + await renderDashboardLanding(); + + expect(screen.getByTestId('dashboardsTable')).toBeInTheDocument(); + }); + + it('should call DashboardListingTable with correct initialFilter', async () => { + await renderDashboardLanding(); - expect(result.getByTestId('dashboardsTable')).toBeInTheDocument(); + expect((DashboardListingTable as jest.Mock).mock.calls[0][0].initialFilter).toEqual( + `tag:("${MOCK_TAG_NAME}")` + ); }); - it('should not render dashboards table if no read capability', () => { - mockUseCapabilities.mockReturnValueOnce({ + it('should not render dashboards table if no read capability', async () => { + mockUseCapabilities.mockReturnValue({ ...DEFAULT_DASHBOARD_CAPABILITIES, show: false, }); - const result = renderDashboardLanding(); + await renderDashboardLanding(); - expect(result.queryByTestId('dashboardsTable')).not.toBeInTheDocument(); + expect(screen.queryByTestId('dashboardsTable')).not.toBeInTheDocument(); }); describe('Create Security Dashboard button', () => { - it('should render', () => { - const result = renderDashboardLanding(); + it('should render', async () => { + await renderDashboardLanding(); - expect(result.getByTestId('createDashboardButton')).toBeInTheDocument(); + expect(screen.getByTestId('createDashboardButton')).toBeInTheDocument(); }); - it('should not render if no write capability', () => { - mockUseCapabilities.mockReturnValueOnce({ + it('should not render if no write capability', async () => { + mockUseCapabilities.mockReturnValue({ ...DEFAULT_DASHBOARD_CAPABILITIES, createNew: false, }); - const result = renderDashboardLanding(); + await renderDashboardLanding(); - expect(result.queryByTestId('createDashboardButton')).not.toBeInTheDocument(); + expect(screen.queryByTestId('createDashboardButton')).not.toBeInTheDocument(); }); - it('should be enabled when link loaded', () => { - const result = renderDashboardLanding(); + it('should be enabled when link loaded', async () => { + await renderDashboardLanding(); - expect(result.getByTestId('createDashboardButton')).not.toHaveAttribute('disabled'); + expect(screen.getByTestId('createDashboardButton')).not.toHaveAttribute('disabled'); }); - it('should be disabled when link is not loaded', () => { - mockUseCreateSecurityDashboard.mockReturnValueOnce({ isLoading: true, url: '' }); - const result = renderDashboardLanding(); + it('should be disabled when link is not loaded', async () => { + mockUseCreateSecurityDashboard.mockReturnValue({ isLoading: true, url: '' }); + await renderDashboardLanding(); - expect(result.getByTestId('createDashboardButton')).toHaveAttribute('disabled'); + expect(screen.getByTestId('createDashboardButton')).toHaveAttribute('disabled'); }); - it('should link to correct href', () => { - const result = renderDashboardLanding(); + it('should link to correct href', async () => { + await renderDashboardLanding(); - expect(result.getByTestId('createDashboardButton')).toHaveAttribute('href', URL); + expect(screen.getByTestId('createDashboardButton')).toHaveAttribute('href', URL); }); - it('should send telemetry', () => { - const result = renderDashboardLanding(); - result.getByTestId('createDashboardButton').click(); + it('should send telemetry', async () => { + await renderDashboardLanding(); + screen.getByTestId('createDashboardButton').click(); expect(spyTrack).toHaveBeenCalledWith( telemetry.METRIC_TYPE.CLICK, telemetry.TELEMETRY_EVENT.CREATE_DASHBOARD diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx index 513ad89c482c2..6da70442a1e3f 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx @@ -4,10 +4,18 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer, EuiTitle } from '@elastic/eui'; -import React from 'react'; +import { + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLoadingSpinner, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; import type { DashboardCapabilities } from '@kbn/dashboard-plugin/common/types'; -import { LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; +import { DashboardListingTable, LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; import { LandingImageCards } from '../../../common/components/landing_links/landing_links_images'; @@ -20,7 +28,25 @@ import * as i18n from './translations'; import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../common/lib/telemetry'; import { DASHBOARDS_PAGE_TITLE } from '../translations'; import { useCreateSecurityDashboardLink } from '../../hooks/use_create_security_dashboard_link'; -import { DashboardsTable } from '../../components/dashboards_table'; +import { useGetSecuritySolutionUrl } from '../../../common/components/link_to'; +import type { TagReference } from '../../context/dashboard_context'; +import { useSecurityTags } from '../../context/dashboard_context'; + +const getInitialFilterString = (securityTags: TagReference[] | null | undefined) => { + if (!securityTags) { + return; + } + const uniqueQuerySet = securityTags?.reduce>((acc, { name }) => { + const nameString = `"${name}"`; + if (name && !acc.has(nameString)) { + acc.add(nameString); + } + return acc; + }, new Set()); + + const query = [...uniqueQuerySet].join(' or'); + return `tag:(${query})`; +}; const Header: React.FC<{ canCreateDashboard: boolean }> = ({ canCreateDashboard }) => { const { isLoading, url } = useCreateSecurityDashboardLink(); @@ -57,7 +83,36 @@ export const DashboardsLandingPage = () => { const dashboardLinks = useRootNavLink(SecurityPageName.dashboards)?.links ?? []; const { show: canReadDashboard, createNew: canCreateDashboard } = useCapabilities(LEGACY_DASHBOARD_APP_ID); + const { navigateTo } = useNavigateTo(); + const getSecuritySolutionUrl = useGetSecuritySolutionUrl(); + const getSecuritySolutionDashboardUrl = useCallback( + (id: string) => + `${getSecuritySolutionUrl({ + deepLinkId: SecurityPageName.dashboards, + path: id, + })}`, + [getSecuritySolutionUrl] + ); + const { isLoading: loadingCreateDashboardUrl, url: createDashboardUrl } = + useCreateSecurityDashboardLink(); + const getHref = useCallback( + (id: string | undefined) => (id ? getSecuritySolutionDashboardUrl(id) : createDashboardUrl), + [createDashboardUrl, getSecuritySolutionDashboardUrl] + ); + + const goToDashboard = useCallback( + (dashboardId: string | undefined) => { + track(METRIC_TYPE.CLICK, TELEMETRY_EVENT.DASHBOARD); + navigateTo({ url: getHref(dashboardId) }); + }, + [getHref, navigateTo] + ); + + const securityTags = useSecurityTags(); + const securityTagsExist = securityTags && securityTags?.length > 0; + + const initialFilter = useMemo(() => getInitialFilterString(securityTags), [securityTags]); return (
@@ -68,17 +123,26 @@ export const DashboardsLandingPage = () => { - + - {canReadDashboard && ( + {canReadDashboard && securityTagsExist && initialFilter ? ( <> - -

{i18n.DASHBOARDS_PAGE_SECTION_CUSTOM}

-
- - - + + +

{i18n.DASHBOARDS_PAGE_SECTION_CUSTOM}

+
+ + +
+ ) : ( + } /> )} diff --git a/x-pack/plugins/security_solution/server/lib/tags/saved_objects/create_tag.ts b/x-pack/plugins/security_solution/server/lib/tags/saved_objects/create_tag.ts index 5cd1715fb4b9c..fac8e22737f12 100644 --- a/x-pack/plugins/security_solution/server/lib/tags/saved_objects/create_tag.ts +++ b/x-pack/plugins/security_solution/server/lib/tags/saved_objects/create_tag.ts @@ -11,6 +11,7 @@ import type { SavedObjectsClientContract, } from '@kbn/core/server'; import type { TagAttributes } from '@kbn/saved-objects-tagging-plugin/common'; +import { getRandomColor } from '../../../../common/utils/get_ramdom_color'; interface CreateTagParams { savedObjectsClient: SavedObjectsClientContract; @@ -20,13 +21,6 @@ interface CreateTagParams { references?: SavedObjectReference[]; } -/** - * Returns the hex representation of a random color (e.g `#F1B7E2`) - */ -const getRandomColor = (): string => { - return `#${String(Math.floor(Math.random() * 16777215).toString(16)).padStart(6, '0')}`; -}; - export const createTag = async ({ savedObjectsClient, tagName,