diff --git a/src/platform/packages/shared/dashboards/dashboards-selector/components/dashboards_selector.test.tsx b/src/platform/packages/shared/dashboards/dashboards-selector/components/dashboards_selector.test.tsx index c1ec883a70290..4a9d5657cd27f 100644 --- a/src/platform/packages/shared/dashboards/dashboards-selector/components/dashboards_selector.test.tsx +++ b/src/platform/packages/shared/dashboards/dashboards-selector/components/dashboards_selector.test.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { DashboardsSelector } from './dashboards_selector'; -import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks'; +import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import userEvent from '@testing-library/user-event'; const MOCK_FIRST_DASHBOARD_ID = 'dashboard-1'; @@ -20,57 +20,99 @@ const MOCK_SECOND_DASHBOARD_TITLE = 'Second Dashboard'; const MOCK_PLACEHOLDER = 'Select a dashboard'; const MOCK_FIRST_DASHBOARD = { - status: 'success', id: MOCK_FIRST_DASHBOARD_ID, - attributes: { title: MOCK_FIRST_DASHBOARD_TITLE }, - references: [], + isManaged: false, + title: MOCK_FIRST_DASHBOARD_TITLE, }; const MOCK_SECOND_DASHBOARD = { - status: 'success', id: MOCK_SECOND_DASHBOARD_ID, - attributes: { title: MOCK_SECOND_DASHBOARD_TITLE }, - references: [], + isManaged: false, + title: MOCK_SECOND_DASHBOARD_TITLE, }; -const mockFetchDashboard = jest.fn(); -const mockFetchDashboards = jest - .fn() - .mockResolvedValue([MOCK_FIRST_DASHBOARD, MOCK_SECOND_DASHBOARD]); +const mockSearchExecute = jest.fn((context: any) => { + if (context.onResults) { + context.onResults([MOCK_FIRST_DASHBOARD, MOCK_SECOND_DASHBOARD]); + } +}); + +const mockGetByIdExecute = jest.fn((context: any) => { + if (context.onResults && context.ids) { + const requestedDashboards = context.ids + .map((id: string) => { + if (id === MOCK_FIRST_DASHBOARD_ID) return MOCK_FIRST_DASHBOARD; + if (id === MOCK_SECOND_DASHBOARD_ID) return MOCK_SECOND_DASHBOARD; + return null; + }) + .filter(Boolean); + context.onResults(requestedDashboards); + } +}); -// Mock the dashboard service -jest.mock('../services/dashboard_service', () => ({ - dashboardServiceProvider: jest.fn(() => ({ - fetchDashboards: (options: { limit: number; text: string }) => mockFetchDashboards(options), - fetchDashboard: (id: string) => mockFetchDashboard(id), - })), -})); +const mockSearchAction = { + execute: mockSearchExecute, +}; + +const mockGetDashboardsByIdsAction = { + execute: mockGetByIdExecute, +}; + +const mockGetAction = jest.fn((actionId: string) => { + if (actionId === 'getDashboardsByIdsAction') { + return Promise.resolve(mockGetDashboardsByIdsAction); + } + return Promise.resolve(mockSearchAction); +}); const mockOnChange = jest.fn(); describe('DashboardsSelector', () => { beforeEach(() => { - mockFetchDashboard.mockResolvedValueOnce(MOCK_FIRST_DASHBOARD); - mockFetchDashboard.mockResolvedValueOnce(MOCK_SECOND_DASHBOARD); + jest.clearAllMocks(); + mockGetAction.mockImplementation((actionId: string) => { + if (actionId === 'getDashboardsByIdsAction') { + return Promise.resolve(mockGetDashboardsByIdsAction); + } + return Promise.resolve(mockSearchAction); + }); + mockSearchExecute.mockImplementation((context: any) => { + if (context.onResults) { + context.onResults([MOCK_FIRST_DASHBOARD, MOCK_SECOND_DASHBOARD]); + } + }); + mockGetByIdExecute.mockImplementation((context: any) => { + if (context.onResults && context.ids) { + const requestedDashboards = context.ids + .map((id: string) => { + if (id === MOCK_FIRST_DASHBOARD_ID) return MOCK_FIRST_DASHBOARD; + if (id === MOCK_SECOND_DASHBOARD_ID) return MOCK_SECOND_DASHBOARD; + return null; + }) + .filter(Boolean); + context.onResults(requestedDashboards); + } + }); }); afterEach(() => { jest.clearAllMocks(); }); - const contentManagement = contentManagementMock.createStartContract(); + const mockUiActions = { + getAction: mockGetAction, + } as unknown as UiActionsStart; it('renders the component', () => { render( ); - // Check that the component renders with the placeholder text expect(screen.getByTestId('dashboardsSelector')).toBeInTheDocument(); expect(screen.getByPlaceholderText(MOCK_PLACEHOLDER)).toBeInTheDocument(); }); @@ -78,28 +120,25 @@ describe('DashboardsSelector', () => { it('displays selected dashboard titles from dashboardsFormData', async () => { render( ); - // Wait for the dashboard titles to be fetched and displayed await waitFor(() => { expect(screen.getByText(MOCK_FIRST_DASHBOARD_TITLE)).toBeInTheDocument(); expect(screen.getByText(MOCK_SECOND_DASHBOARD_TITLE)).toBeInTheDocument(); }); - // Verify that fetchDashboard was called for each dashboard ID - expect(mockFetchDashboard).toHaveBeenCalledWith(MOCK_FIRST_DASHBOARD_ID); - expect(mockFetchDashboard).toHaveBeenCalledWith(MOCK_SECOND_DASHBOARD_ID); + expect(mockGetAction).toHaveBeenCalledWith('getDashboardsByIdsAction'); }); it('debounces and triggers dashboard search with user input in the ComboBox', async () => { render( { const searchInput = screen.getByPlaceholderText(MOCK_PLACEHOLDER); await userEvent.type(searchInput, MOCK_FIRST_DASHBOARD_TITLE); - // Assert that fetchDashboards was called with the correct search value - // Wait for the next tick to allow state update and effect to run await waitFor(() => { expect(searchInput).toHaveValue(MOCK_FIRST_DASHBOARD_TITLE); - expect(mockFetchDashboards).toHaveBeenCalledWith( - expect.objectContaining({ limit: 100, text: `${MOCK_FIRST_DASHBOARD_TITLE}*` }) + expect(mockSearchExecute).toHaveBeenCalledWith( + expect.objectContaining({ + search: { + search: MOCK_FIRST_DASHBOARD_TITLE, + per_page: 100, + }, + trigger: { id: 'searchDashboards' }, + }) ); expect(screen.getByText(MOCK_FIRST_DASHBOARD_TITLE)).toBeInTheDocument(); @@ -125,7 +168,7 @@ describe('DashboardsSelector', () => { it('fetches dashboard list when combobox is focused', async () => { render( { fireEvent.focus(searchInput); await waitFor(() => { - expect(mockFetchDashboards).toHaveBeenCalledWith(expect.objectContaining({ limit: 100 })); + expect(mockSearchExecute).toHaveBeenCalledWith( + expect.objectContaining({ + search: { + search: '', + per_page: 100, + }, + }) + ); }); }); it('does not fetch dashboard list when combobox is not focused', async () => { render( ); - expect(mockFetchDashboards).not.toHaveBeenCalled(); + expect(mockSearchExecute).not.toHaveBeenCalled(); }); it('dispatches selected dashboards on change', async () => { render( ); - // Click on the combobox to open it const searchInput = screen.getByPlaceholderText(MOCK_PLACEHOLDER); fireEvent.focus(searchInput); - // Wait for the dropdown to open and options to load await waitFor(() => { - expect(mockFetchDashboards).toHaveBeenCalledWith( - expect.objectContaining({ limit: 100, text: '*' }) + expect(mockSearchExecute).toHaveBeenCalled(); + expect(mockSearchExecute).toHaveBeenLastCalledWith( + expect.objectContaining({ + search: { + search: '', + per_page: 100, + }, + }) ); }); - // Wait for the second dashboard option to appear in the dropdown await waitFor(() => { expect(screen.getByText(MOCK_SECOND_DASHBOARD_TITLE)).toBeInTheDocument(); }); - // Click on the second dashboard option to select it await userEvent.click(screen.getByText(MOCK_SECOND_DASHBOARD_TITLE)); - // Verify that the onChange callback was called with both dashboards await waitFor(() => { expect(mockOnChange).toHaveBeenCalledWith([ { label: MOCK_FIRST_DASHBOARD_TITLE, value: MOCK_FIRST_DASHBOARD_ID }, @@ -189,7 +240,6 @@ describe('DashboardsSelector', () => { ]); }); - // Verify that both selected options are now displayed expect(screen.getByText(MOCK_FIRST_DASHBOARD_TITLE)).toBeInTheDocument(); expect(screen.getByText(MOCK_SECOND_DASHBOARD_TITLE)).toBeInTheDocument(); }); diff --git a/src/platform/packages/shared/dashboards/dashboards-selector/components/dashboards_selector.tsx b/src/platform/packages/shared/dashboards/dashboards-selector/components/dashboards_selector.tsx index 35d6b78d7096f..e8ca061018232 100644 --- a/src/platform/packages/shared/dashboards/dashboards-selector/components/dashboards_selector.tsx +++ b/src/platform/packages/shared/dashboards/dashboards-selector/components/dashboards_selector.tsx @@ -7,25 +7,66 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ import { i18n } from '@kbn/i18n'; -import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import type { UiActionsStart, ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiComboBox } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { debounce } from 'lodash'; -import { dashboardServiceProvider, type DashboardItem } from '../services/dashboard_service'; interface DashboardOption { value: string; label: string; } +interface Dashboard { + id: string; + isManaged: boolean; + title: string; +} + +async function searchDashboards( + uiActions: UiActionsStart, + options: { search?: string; perPage?: number } = {} +): Promise { + const { search, perPage = 100 } = options; + const searchAction = await uiActions.getAction('searchDashboardAction'); + return new Promise(function (resolve) { + searchAction.execute({ + onResults(dashboards: Dashboard[]) { + resolve(dashboards); + }, + search: { + search, + per_page: perPage, + }, + trigger: { id: 'searchDashboards' }, + } as ActionExecutionContext); + }); +} + +async function getDashboardsById(uiActions: UiActionsStart, ids: string[]): Promise { + if (!ids.length) { + return []; + } + const getDashboardsByIdsAction = await uiActions.getAction('getDashboardsByIdsAction'); + return new Promise(function (resolve) { + getDashboardsByIdsAction.execute({ + onResults(dashboards: Dashboard[]) { + resolve(dashboards); + }, + ids, + trigger: { id: 'getDashboardsById' }, + } as ActionExecutionContext); + }); +} + export function DashboardsSelector({ - contentManagement, + uiActions, dashboardsFormData, onChange, placeholder, }: { - contentManagement: ContentManagementPublicStart; + uiActions: UiActionsStart; dashboardsFormData: { id: string }[]; onChange: (selectedOptions: Array>) => void; placeholder?: string; @@ -41,50 +82,25 @@ export function DashboardsSelector({ const [isComboBoxOpen, setIsComboBoxOpen] = useState(false); const fetchDashboardTitles = useCallback(async () => { - if (!dashboardsFormData?.length || !contentManagement) { + if (!dashboardsFormData?.length) { return; } try { - const dashboardPromises = dashboardsFormData.map(async (dashboard) => { - try { - const fetchedDashboard = await dashboardServiceProvider(contentManagement).fetchDashboard( - dashboard.id - ); - - // Only return the dashboard if it exists, fetch was successful, and has a title - if ( - fetchedDashboard && - fetchedDashboard.status === 'success' && - fetchedDashboard.attributes?.title - ) { - return { - label: fetchedDashboard.attributes.title, - value: dashboard.id, - }; - } - // Return null if dashboard doesn't have required data - return null; - } catch (dashboardError) { - /** - * Swallow the error that is thrown, since this just means the selected dashboard was deleted - * Return null when dashboard fetch fails - */ - return null; - } - }); - - const results = await Promise.all(dashboardPromises); + const dashboardIds = dashboardsFormData.map((dashboard) => dashboard.id); + const dashboards = await getDashboardsById(uiActions, dashboardIds); - // Filter out null results and cast to the expected type - const validDashboards = results.filter(Boolean) as Array>; + const validDashboards = dashboards.map((dashboard) => ({ + label: dashboard.title, + value: dashboard.id, + })); setSelectedDashboards(validDashboards); } catch (error) { // Set empty array or handle the error appropriately setSelectedDashboards([]); } - }, [dashboardsFormData, contentManagement]); + }, [dashboardsFormData, uiActions]); useEffect(() => { fetchDashboardTitles(); @@ -105,24 +121,25 @@ export function DashboardsSelector({ [] ); - const getDashboardItem = (dashboard: DashboardItem) => ({ - value: dashboard.id, - label: dashboard.attributes.title, - }); - const loadDashboards = useCallback(async () => { - if (contentManagement) { - setLoading(true); - const dashboards = await dashboardServiceProvider(contentManagement) - .fetchDashboards({ limit: 100, text: `${searchValue}*` }) - .catch(() => {}); - const dashboardOptions = (dashboards ?? []).map((dashboard: DashboardItem) => - getDashboardItem(dashboard) - ); + setLoading(true); + try { + const trimmedSearch = searchValue.trim(); + const dashboards = await searchDashboards(uiActions, { + search: trimmedSearch, + perPage: 100, + }); + const dashboardOptions = dashboards.map((dashboard) => ({ + value: dashboard.id, + label: dashboard.title, + })); setDashboardList(dashboardOptions); + } catch (error) { + setDashboardList([]); + } finally { setLoading(false); } - }, [contentManagement, searchValue]); + }, [uiActions, searchValue]); useEffect(() => { if (isComboBoxOpen) { diff --git a/src/platform/packages/shared/dashboards/dashboards-selector/moon.yml b/src/platform/packages/shared/dashboards/dashboards-selector/moon.yml index e69b2da4acf0d..8098ff8c7bc7b 100644 --- a/src/platform/packages/shared/dashboards/dashboards-selector/moon.yml +++ b/src/platform/packages/shared/dashboards/dashboards-selector/moon.yml @@ -18,10 +18,8 @@ project: metadata: sourceRoot: src/platform/packages/shared/dashboards/dashboards-selector dependsOn: - - '@kbn/content-management-plugin' - - '@kbn/content-management-utils' - - '@kbn/core' - '@kbn/i18n' + - '@kbn/ui-actions-plugin' tags: - shared-browser - package diff --git a/src/platform/packages/shared/dashboards/dashboards-selector/services/dashboard_service.test.ts b/src/platform/packages/shared/dashboards/dashboards-selector/services/dashboard_service.test.ts deleted file mode 100644 index 5c1b2506988a4..0000000000000 --- a/src/platform/packages/shared/dashboards/dashboards-selector/services/dashboard_service.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { dashboardServiceProvider } from './dashboard_service'; -import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks'; - -describe('DashboardService', () => { - const contentManagement = contentManagementMock.createStartContract(); - const dashboardService = dashboardServiceProvider(contentManagement); - - test('should fetch dashboards', async () => { - // arrange - const searchMock = jest.spyOn(contentManagement.client, 'search').mockResolvedValue({ - total: 0, - hits: [], - }); - - const resp = await dashboardService.fetchDashboards({ text: 'test*' }); - - expect(searchMock).toHaveBeenCalledWith({ - contentTypeId: 'dashboard', - query: { - text: 'test*', - }, - options: { - fields: ['title', 'description'], - includeReferences: ['tag'], - }, - }); - expect(resp).toEqual([]); - - searchMock.mockRestore(); - }); - - test('should fetch dashboard by id', async () => { - // mock get to resolve with a dashboard - const getMock = jest.spyOn(contentManagement.client, 'get').mockResolvedValue({ - item: { - error: null, - attributes: { - title: 'Dashboard 1', - }, - references: [], - }, - }); - - // act - const resp = await dashboardService.fetchDashboard('1'); - - // assert - expect(getMock).toHaveBeenCalledWith({ contentTypeId: 'dashboard', id: '1' }); - expect(resp).toEqual({ - status: 'success', - id: '1', - attributes: { - title: 'Dashboard 1', - }, - references: [], - }); - - getMock.mockRestore(); - }); - - test('should return an error if dashboard id is not found', async () => { - const getMock = jest.spyOn(contentManagement.client, 'get').mockRejectedValue({ - message: 'Dashboard not found', - }); - - const resp = await dashboardService.fetchDashboard('2'); - expect(getMock).toHaveBeenCalledWith({ contentTypeId: 'dashboard', id: '2' }); - expect(resp).toEqual({ - status: 'error', - id: '2', - error: 'Dashboard not found', - }); - }); -}); diff --git a/src/platform/packages/shared/dashboards/dashboards-selector/services/dashboard_service.ts b/src/platform/packages/shared/dashboards/dashboards-selector/services/dashboard_service.ts deleted file mode 100644 index fbe7adc7f68d9..0000000000000 --- a/src/platform/packages/shared/dashboards/dashboards-selector/services/dashboard_service.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { SearchQuery } from '@kbn/content-management-plugin/common'; -import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; -import type { Reference, ContentManagementCrudTypes } from '@kbn/content-management-utils'; -import type { SavedObjectError } from '@kbn/core/public'; -import type { GetIn } from '@kbn/content-management-plugin/common'; - -const DASHBOARD_CONTENT_TYPE_ID = 'dashboard'; -export type DashboardGetIn = GetIn; - -export type FindDashboardsByIdResponse = { id: string } & ( - | { status: 'success'; attributes: any; references: Reference[] } - | { status: 'error'; error: SavedObjectError } -); - -export interface DashboardItem { - id: string; - attributes: any; // DashboardAttributes is exported in the Dashboard plugin and this causes a cycle dependency. Get feedback on the best approach here -} - -export type DashboardService = ReturnType; -export type DashboardItems = Awaited>; - -export function dashboardServiceProvider(contentManagementService: ContentManagementPublicStart) { - return { - /** - * Fetch dashboards - * @param query - The query to search for dashboards - * @returns - The dashboards that match the query - */ - async fetchDashboards(query: SearchQuery = {}): Promise { - const response = await contentManagementService.client.search({ - contentTypeId: 'dashboard', - query, - options: { fields: ['title', 'description'], includeReferences: ['tag'] }, - }); - - // Assert the type of response to access hits property - return (response as { hits: DashboardItem[] }).hits; - }, - /** - * Fetch dashboard by id - * @param id - The id of the dashboard to fetch - * @returns - The dashboard with the given id - * @throws - An error if the dashboard does not exist - */ - async fetchDashboard(id: string): Promise { - try { - const response = await contentManagementService.client.get< - DashboardGetIn, - ContentManagementCrudTypes< - typeof DASHBOARD_CONTENT_TYPE_ID, - any, - object, - object, - object - >['GetOut'] - >({ - contentTypeId: 'dashboard', - id, - }); - if (response.item.error) { - throw response.item.error; - } - - return { - id, - status: 'success', - attributes: response.item.attributes, - references: response.item.references, - }; - } catch (error) { - return { - status: 'error', - error: error.body || error.message, - id, - }; - } - }, - - async fetchDashboardsByIds(ids: string[]) { - const findPromises = ids.map((id) => this.fetchDashboard(id)); - const results = await Promise.all(findPromises); - return results as FindDashboardsByIdResponse[]; - }, - /** - * Fetch only the dashboards that still exist - * @param ids - The ids of the dashboards to fetch - * @returns - The dashboards that exist - */ - async fetchValidDashboards(ids: string[]) { - const responses = await this.fetchDashboardsByIds(ids); - const existingDashboards = responses.filter(({ status }) => status === 'success'); - return existingDashboards; - }, - }; -} diff --git a/src/platform/packages/shared/dashboards/dashboards-selector/tsconfig.json b/src/platform/packages/shared/dashboards/dashboards-selector/tsconfig.json index c1af2d18f1b0a..5d239155faa79 100644 --- a/src/platform/packages/shared/dashboards/dashboards-selector/tsconfig.json +++ b/src/platform/packages/shared/dashboards/dashboards-selector/tsconfig.json @@ -16,9 +16,7 @@ "target/**/*" ], "kbn_references": [ - "@kbn/content-management-plugin", - "@kbn/content-management-utils", - "@kbn/core", "@kbn/i18n", + "@kbn/ui-actions-plugin", ] } diff --git a/src/platform/packages/shared/response-ops/rule_form/moon.yml b/src/platform/packages/shared/response-ops/rule_form/moon.yml index 08bbb672a1979..2e52cb2f51ad0 100644 --- a/src/platform/packages/shared/response-ops/rule_form/moon.yml +++ b/src/platform/packages/shared/response-ops/rule_form/moon.yml @@ -45,6 +45,7 @@ dependsOn: - '@kbn/content-management-plugin' - '@kbn/dashboards-selector' - '@kbn/react-query' + - '@kbn/ui-actions-plugin' tags: - shared-browser - package diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_dashboards.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_dashboards.tsx index b441075a0b144..386356e5b6bc0 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_dashboards.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_dashboards.tsx @@ -10,7 +10,7 @@ import React, { useMemo } from 'react'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer } from '@elastic/eui'; -import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { DashboardsSelector } from '@kbn/dashboards-selector'; import { OptionalFieldLabel } from '../optional_field_label'; import { useRuleFormState, useRuleFormDispatch } from '../hooks'; @@ -22,10 +22,10 @@ import { import { LabelWithTooltip } from './label_with_tooltip'; export interface Props { - contentManagement: ContentManagementPublicStart; + uiActions: UiActionsStart; } -export const RuleDashboards = ({ contentManagement }: Props) => { +export const RuleDashboards = ({ uiActions }: Props) => { const { formData } = useRuleFormState(); const dispatch = useRuleFormDispatch(); const dashboardsFormData = useMemo( @@ -64,7 +64,7 @@ export const RuleDashboards = ({ contentManagement }: Props) => { labelAppend={OptionalFieldLabel} > { const { formData, baseErrors, plugins } = useRuleFormState(); - const { contentManagement } = plugins; + const { uiActions } = plugins; const dispatch = useRuleFormDispatch(); @@ -160,7 +160,7 @@ export const RuleDetails = () => { value={formData.artifacts?.investigation_guide?.blob ?? ''} /> - {contentManagement && } + {uiActions && } ); diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx index 8ba134fd44428..6f526d1cba189 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx @@ -90,6 +90,7 @@ export const RuleForm = ( actionTypeRegistry, fieldsMetadata, contentManagement, + uiActions, } = _plugins; const ruleFormComponent = useMemo(() => { @@ -110,6 +111,7 @@ export const RuleForm = ( actionTypeRegistry, fieldsMetadata, contentManagement, + uiActions, }; // Passing the MetaData type all the way down the component hierarchy is unnecessary, this type is @@ -186,6 +188,7 @@ export const RuleForm = ( actionTypeRegistry, fieldsMetadata, contentManagement, + uiActions, onChangeMetaData, id, ruleTypeId, @@ -196,6 +199,7 @@ export const RuleForm = ( connectorFeatureId, initialMetadata, initialEditStep, + focusTrapProps, consumer, multiConsumerSelection, hideInterval, @@ -204,7 +208,6 @@ export const RuleForm = ( shouldUseRuleProducer, canShowConsumerSelection, initialValues, - focusTrapProps, ]); return ( diff --git a/src/platform/packages/shared/response-ops/rule_form/src/types.ts b/src/platform/packages/shared/response-ops/rule_form/src/types.ts index 677676a760764..c785594ae1ced 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/types.ts +++ b/src/platform/packages/shared/response-ops/rule_form/src/types.ts @@ -25,6 +25,7 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/ import type { ActionConnector, ActionTypeRegistryContract } from '@kbn/alerts-ui-shared'; import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { MinimumScheduleInterval, Rule, @@ -73,6 +74,7 @@ export interface RuleFormPlugins { actionTypeRegistry: ActionTypeRegistryContract; fieldsMetadata: FieldsMetadataPublicStart; contentManagement?: ContentManagementPublicStart; + uiActions?: UiActionsStart; } export interface RuleFormState< @@ -111,4 +113,4 @@ export interface ValidationResult { errors: Record; } -export type RuleDashboardsPlugins = Pick; +export type RuleDashboardsPlugins = Pick; diff --git a/src/platform/packages/shared/response-ops/rule_form/tsconfig.json b/src/platform/packages/shared/response-ops/rule_form/tsconfig.json index 0e0ed485a6f29..c7b68718c2cef 100644 --- a/src/platform/packages/shared/response-ops/rule_form/tsconfig.json +++ b/src/platform/packages/shared/response-ops/rule_form/tsconfig.json @@ -34,5 +34,6 @@ "@kbn/content-management-plugin", "@kbn/dashboards-selector", "@kbn/react-query", + "@kbn/ui-actions-plugin", ] } diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_client/get_dashboard_by_id_action.ts b/src/platform/plugins/shared/dashboard/public/dashboard_client/get_dashboard_by_id_action.ts new file mode 100644 index 0000000000000..74a0e06016d7d --- /dev/null +++ b/src/platform/plugins/shared/dashboard/public/dashboard_client/get_dashboard_by_id_action.ts @@ -0,0 +1,41 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ActionDefinition } from '@kbn/ui-actions-plugin/public/actions'; +import { dashboardClient } from './dashboard_client'; + +interface Context { + onResults: (dashboards: Array<{ id: string; isManaged: boolean; title: string }>) => void; + ids: string[]; +} + +export const getDashboardsByIdsAction: ActionDefinition = { + id: 'getDashboardsByIdsAction', + execute: async (context: Context) => { + const dashboards = await Promise.all( + context.ids.map(async (id) => { + try { + return await dashboardClient.get(id); + } catch { + return null; + } + }) + ); + + context.onResults( + dashboards + .filter((dashboard): dashboard is NonNullable => dashboard !== null) + .map(({ id, data, meta }) => ({ + id, + isManaged: Boolean(meta.managed), + title: data.title ?? '', + })) + ); + }, +}; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_client/index.ts b/src/platform/plugins/shared/dashboard/public/dashboard_client/index.ts index 427874c37bd98..fbca782cfd8e9 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_client/index.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_client/index.ts @@ -11,5 +11,6 @@ export { checkForDuplicateDashboardTitle } from './check_for_duplicate_dashboard export { dashboardClient } from './dashboard_client'; export { findService } from './find_service'; export { searchAction } from './search_action'; +export { getDashboardsByIdsAction } from './get_dashboard_by_id_action'; export type { FindDashboardsByIdResponse, FindDashboardsService } from './types'; diff --git a/src/platform/plugins/shared/dashboard/public/plugin.tsx b/src/platform/plugins/shared/dashboard/public/plugin.tsx index a20a6e72255f5..0c1c389b472bc 100644 --- a/src/platform/plugins/shared/dashboard/public/plugin.tsx +++ b/src/platform/plugins/shared/dashboard/public/plugin.tsx @@ -292,6 +292,11 @@ export class DashboardPlugin return searchAction; }); + plugins.uiActions.registerActionAsync('getDashboardsByIdsAction', async () => { + const { getDashboardsByIdsAction } = await import('./dashboard_client'); + return getDashboardsByIdsAction; + }); + return { registerDashboardPanelSettings, findDashboardsService: async () => { diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/connectors_app.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/connectors_app.tsx index 145ae24fe62ab..4f4f80f187fe7 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/connectors_app.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/connectors_app.tsx @@ -25,6 +25,7 @@ import { QueryClientProvider } from '@kbn/react-query'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; import type { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { suspendedComponentWithProps } from './lib/suspended_component_with_props'; import type { ActionTypeRegistryContract, RuleTypeRegistryContract } from '../types'; @@ -59,6 +60,7 @@ export interface TriggersAndActionsUiServices extends CoreStart { unifiedSearch: UnifiedSearchPublicPluginStart; share: SharePluginStart; isServerless: boolean; + uiActions?: UiActionsStart; } export const renderApp = (deps: TriggersAndActionsUiServices) => { diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/rules_app.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/rules_app.tsx index b908b3de0cdfc..dc48c804cce05 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/rules_app.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/rules_app.tsx @@ -42,6 +42,7 @@ import type { CloudSetup } from '@kbn/cloud-plugin/public'; import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { suspendedComponentWithProps } from './lib/suspended_component_with_props'; import type { ActionTypeRegistryContract, RuleTypeRegistryContract } from '../types'; import type { Section } from './constants'; @@ -87,6 +88,7 @@ export interface TriggersAndActionsUiServices extends CoreStart { fieldsMetadata: FieldsMetadataPublicStart; share?: SharePluginStart; contentManagement?: ContentManagementPublicStart; + uiActions?: UiActionsStart; } export const renderApp = (deps: TriggersAndActionsUiServices) => { diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx index cc89c8189baf0..a9655b511101d 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx @@ -31,6 +31,7 @@ export const RuleFormRoute = () => { ruleTypeRegistry, actionTypeRegistry, contentManagement, + uiActions, chrome, setBreadcrumbs, ...startServices @@ -104,6 +105,7 @@ export const RuleFormRoute = () => { ruleTypeRegistry, actionTypeRegistry, contentManagement, + uiActions, ...startServices, }} initialValues={ruleTemplate} diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/plugin.ts b/x-pack/platform/plugins/shared/triggers_actions_ui/public/plugin.ts index 90a30c0fcd629..a98c54759bad6 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/plugin.ts +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/plugin.ts @@ -319,6 +319,7 @@ export class Plugin fieldsMetadata: pluginsStart.fieldsMetadata, contentManagement: pluginsStart.contentManagement, share: pluginsStart.share, + uiActions: pluginsStart.uiActions, }); }, }); @@ -368,6 +369,7 @@ export class Plugin share: pluginsStart.share, kibanaFeatures, isServerless, + uiActions: pluginsStart.uiActions, }); }, }); @@ -417,6 +419,7 @@ export class Plugin fieldFormats: pluginsStart.fieldFormats, lens: pluginsStart.lens, fieldsMetadata: pluginsStart.fieldsMetadata, + uiActions: pluginsStart.uiActions, }); }, }); diff --git a/x-pack/solutions/observability/plugins/slo/public/pages/slo_edit/components/slo_edit_form_description_section.tsx b/x-pack/solutions/observability/plugins/slo/public/pages/slo_edit/components/slo_edit_form_description_section.tsx index 1673dc0554e66..4c23130b80b33 100644 --- a/x-pack/solutions/observability/plugins/slo/public/pages/slo_edit/components/slo_edit_form_description_section.tsx +++ b/x-pack/solutions/observability/plugins/slo/public/pages/slo_edit/components/slo_edit_form_description_section.tsx @@ -32,6 +32,7 @@ export function SloEditFormDescriptionSection() { const { suggestions } = useFetchSLOSuggestions(); const { services } = useKibana(); + const { uiActions } = services; return ( ( field.onChange(selected.map((d) => ({ id: d.value })))} diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/contexts/synthetics_shared_context.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/contexts/synthetics_shared_context.tsx index 2c85c038e3748..e26a5a05e3905 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/contexts/synthetics_shared_context.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/contexts/synthetics_shared_context.tsx @@ -56,6 +56,7 @@ export const SyntheticsSharedContext: React.FC< slo: startPlugins.slo, serverless: startPlugins.serverless, charts: startPlugins.charts, + uiActions: startPlugins.uiActions, }} > diff --git a/x-pack/solutions/observability/test/observability_functional/apps/observability/pages/rule_details_page.ts b/x-pack/solutions/observability/test/observability_functional/apps/observability/pages/rule_details_page.ts index 38bacf3c0b105..6e4243bfb786e 100644 --- a/x-pack/solutions/observability/test/observability_functional/apps/observability/pages/rule_details_page.ts +++ b/x-pack/solutions/observability/test/observability_functional/apps/observability/pages/rule_details_page.ts @@ -4,12 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ import expect from '@kbn/expect'; import type { FtrProviderContext } from '../../../ftr_provider_context'; @@ -239,5 +233,66 @@ export default ({ getService }: FtrProviderContext) => { await testSubjects.missingOrFail('actions'); }); }); + + describe('Related dashboards', function () { + const comboBox = getService('comboBox'); + const kibanaServer = getService('kibanaServer'); + let testDashboardId: string; + const testDashboardTitle = `Test Dashboard for Rule Details ${Date.now()}`; + + before(async () => { + const dashboardResponse = await kibanaServer.savedObjects.create({ + type: 'dashboard', + overwrite: false, + attributes: { + title: testDashboardTitle, + description: 'Test dashboard for rule details functional test', + panelsJSON: '[]', + kibanaSavedObjectMeta: { + searchSourceJSON: '{}', + }, + }, + }); + + testDashboardId = dashboardResponse.id; + }); + + after(async () => { + if (testDashboardId) { + await kibanaServer.savedObjects.delete({ + type: 'dashboard', + id: testDashboardId, + }); + } + }); + + it('should display dashboard options in "Related dashboards" dropdown when editing rule', async () => { + await observability.alerts.common.navigateToRuleDetailsByRuleId(logThresholdRuleId); + await retry.waitFor( + 'Rule details to be visible', + async () => await testSubjects.exists('ruleDetails') + ); + + await testSubjects.click('actions'); + await testSubjects.click('editRuleButton'); + + await retry.waitFor( + 'Rule form to be visible', + async () => await testSubjects.exists('ruleDetailsNameInput') + ); + + await retry.waitFor( + 'Dashboard selector to be visible', + async () => await testSubjects.exists('dashboardsSelector') + ); + + const optionsText = await comboBox.getOptionsList('dashboardsSelector'); + + expect(optionsText.length).to.be.greaterThan(0); + expect(optionsText.trim()).to.not.be.empty(); + + expect(optionsText).to.contain(testDashboardTitle); + }); + }); }); };