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,