diff --git a/api_docs/kbn_dashboard_agent_common.devdocs.json b/api_docs/kbn_dashboard_agent_common.devdocs.json index d39a6646419fd..ef2b6ebd889ab 100644 --- a/api_docs/kbn_dashboard_agent_common.devdocs.json +++ b/api_docs/kbn_dashboard_agent_common.devdocs.json @@ -21,21 +21,21 @@ "functions": [ { "parentPluginId": "@kbn/dashboard-agent-common", - "id": "def-common.attachmentToDashboardState", + "id": "def-common.attachmentDataToDashboardState", "type": "Function", "tags": [], - "label": "attachmentToDashboardState", + "label": "attachmentDataToDashboardState", "description": [ - "\nConverts a DashboardAttachment to a DashboardState.\nUses provided values from the attachment, falling back to defaults for missing fields." + "\nConverts dashboard attachment data to a DashboardState.\nUses provided values from the attachment data, falling back to defaults for missing fields." ], "signature": [ - "({ data: { panels, filters, pinned_panels, access_control, options, ...rest }, }: ", + "({ panels = [], filters, pinned_panels, access_control, options, ...rest }: ", { "pluginId": "@kbn/dashboard-agent-common", "scope": "common", "docId": "kibKbnDashboardAgentCommonPluginApi", - "section": "def-common.DashboardAttachment", - "text": "DashboardAttachment" + "section": "def-common.DashboardAttachmentData", + "text": "DashboardAttachmentData" }, ") => ", { @@ -63,18 +63,18 @@ "children": [ { "parentPluginId": "@kbn/dashboard-agent-common", - "id": "def-common.attachmentToDashboardState.$1", + "id": "def-common.attachmentDataToDashboardState.$1", "type": "Object", "tags": [], - "label": "{\n data: { panels = [], filters, pinned_panels, access_control, options, ...rest },\n}", + "label": "{\n panels = [],\n filters,\n pinned_panels,\n access_control,\n options,\n ...rest\n}", "description": [], "signature": [ { "pluginId": "@kbn/dashboard-agent-common", "scope": "common", "docId": "kibKbnDashboardAgentCommonPluginApi", - "section": "def-common.DashboardAttachment", - "text": "DashboardAttachment" + "section": "def-common.DashboardAttachmentData", + "text": "DashboardAttachmentData" } ], "path": "x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/converters/from_attachment.ts", @@ -90,10 +90,10 @@ }, { "parentPluginId": "@kbn/dashboard-agent-common", - "id": "def-common.dashboardStateToAttachment", + "id": "def-common.dashboardStateToAttachmentData", "type": "Function", "tags": [], - "label": "dashboardStateToAttachment", + "label": "dashboardStateToAttachmentData", "description": [ "\nConverts a DashboardState to DashboardAttachmentData.\nPreserves all dashboard state fields for full round-trip support." ], @@ -124,7 +124,7 @@ "children": [ { "parentPluginId": "@kbn/dashboard-agent-common", - "id": "def-common.dashboardStateToAttachment.$1", + "id": "def-common.dashboardStateToAttachmentData.$1", "type": "Object", "tags": [], "label": "state", diff --git a/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/converters/from_attachment.ts b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/converters/from_attachment.ts index a23386cf840c7..d5eee7ace814e 100644 --- a/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/converters/from_attachment.ts +++ b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/converters/from_attachment.ts @@ -15,7 +15,7 @@ import { isLensAPIFormat } from '@kbn/lens-embeddable-utils/config_builder/utils import type { AttachmentPanel, DashboardSection as AgentDashboardSection, - DashboardAttachment, + DashboardAttachmentData, } from '../types'; import { isSection } from '../types'; @@ -97,9 +97,15 @@ const EMPTY_DASHBOARD_STATE: Readonly, 'project_ro * Converts a DashboardAttachment to a DashboardState. * Uses provided values from the attachment, falling back to defaults for missing fields. */ -export const attachmentToDashboardState = ({ - data: { panels = [], filters, query, pinned_panels, access_control, options, ...rest }, -}: DashboardAttachment): DashboardState => ({ +export const attachmentDataToDashboardState = ({ + panels = [], + filters, + query, + pinned_panels, + access_control, + options, + ...rest +}: DashboardAttachmentData): DashboardState => ({ ...EMPTY_DASHBOARD_STATE, ...rest, options: { diff --git a/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/converters/index.ts b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/converters/index.ts index 6dc1d46bd5a6e..afde354c2eee1 100644 --- a/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/converters/index.ts +++ b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/converters/index.ts @@ -5,9 +5,9 @@ * 2.0. */ -export { isLensAttributes, dashboardStateToAttachment } from './to_attachment'; +export { isLensAttributes, dashboardStateToAttachmentData } from './to_attachment'; -export { attachmentToDashboardState, DEFAULT_TIME_RANGE } from './from_attachment'; +export { attachmentDataToDashboardState, DEFAULT_TIME_RANGE } from './from_attachment'; export { toEmbeddablePanel, diff --git a/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/converters/to_attachment.ts b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/converters/to_attachment.ts index 6267b261b890f..0e589bd27d2a1 100644 --- a/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/converters/to_attachment.ts +++ b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/converters/to_attachment.ts @@ -99,7 +99,7 @@ export const toAttachmentWidget = ( * Converts a DashboardState to DashboardAttachmentData. * Preserves all dashboard state fields for full round-trip support. */ -export const dashboardStateToAttachment = (state: DashboardState): DashboardAttachmentData => { +export const dashboardStateToAttachmentData = (state: DashboardState): DashboardAttachmentData => { return { ...state, panels: state.panels diff --git a/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/dashboard_schema_types.ts b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/dashboard_schema_types.ts new file mode 100644 index 0000000000000..00eed3b6008cd --- /dev/null +++ b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/dashboard_schema_types.ts @@ -0,0 +1,157 @@ +/* + * 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 { z } from '@kbn/zod/v4'; + +// ============================================================================ +// Panel Grid Schema - ideally we should import the schema from dashboard schemas, but they use config-schema library +// which is not compatible with Zod, so we duplicate the relevant parts here for validation of attachment data. +// ============================================================================ + +/** + * Grid dimensions (in dashboard grid units) for layout. + * Dashboard grid is 48 columns wide; height is in same units. + */ +export const panelGridSchema = z.object({ + x: z.number(), + y: z.number(), + w: z.number().int().min(1).max(48), + h: z.number().int().min(1), +}); + +// ============================================================================ +// Panel Schema +// ============================================================================ + +/** + * Zod schema for dashboard panel entries. + * The `type` field contains the actual embeddable type. + */ +const attachmentPanelSchema = z.object({ + type: z.string(), + uid: z.string(), + config: z.record(z.string(), z.unknown()), + grid: panelGridSchema, +}); + +export type AttachmentPanel = z.infer; + +// ============================================================================ +// Section Schema +// ============================================================================ + +export const sectionGridSchema = z.object({ + y: z.number().int().min(0).describe('Section position in outer dashboard grid coordinates.'), +}); + +const dashboardSectionSchema = z.object({ + uid: z.string(), + title: z.string(), + collapsed: z.boolean(), + grid: sectionGridSchema, + panels: z.array(attachmentPanelSchema), +}); + +export type DashboardSection = z.infer; + +export const isSection = ( + widget: AttachmentPanel | DashboardSection +): widget is DashboardSection => { + return 'panels' in widget; +}; + +// ============================================================================ +// Query Schema +// ============================================================================ + +const querySchema = z.object({ + expression: z.union([z.string(), z.record(z.string(), z.unknown())]), + language: z.string(), +}); + +// ============================================================================ +// Time Range Schema +// ============================================================================ + +const timeRangeSchema = z.object({ + from: z.string(), + to: z.string(), + mode: z.union([z.literal('absolute'), z.literal('relative')]).optional(), +}); + +// ============================================================================ +// Refresh Interval Schema +// ============================================================================ + +const refreshIntervalSchema = z.object({ + pause: z.boolean(), + value: z.number(), +}); + +// ============================================================================ +// Dashboard Options Schema +// ============================================================================ + +const optionsSchema = z.object({ + auto_apply_filters: z.boolean().optional(), + hide_panel_titles: z.boolean().optional(), + hide_panel_borders: z.boolean().optional(), + use_margins: z.boolean().optional(), + sync_colors: z.boolean().optional(), + sync_tooltips: z.boolean().optional(), + sync_cursor: z.boolean().optional(), +}); + +// ============================================================================ +// Access Control Schema +// ============================================================================ + +const accessControlSchema = z + .object({ + access_mode: z.union([z.literal('write_restricted'), z.literal('default')]).optional(), + }) + .optional(); + +// ============================================================================ +// Filter Schema +// ============================================================================ + +// Filters have complex union types that are difficult to express precisely in Zod. +const filterSchema = z.record(z.string(), z.unknown()); + +// ============================================================================ +// Pinned Panels (Controls) Schema +// ============================================================================ + +// Controls have complex union types. We use a permissive schema here. +const pinnedControlSchema = z.record(z.string(), z.unknown()); +const pinnedPanelsSchema = z.array(pinnedControlSchema); + +// ============================================================================ +// Dashboard Attachment Data Schema (matches DashboardState) +// ============================================================================ + +/** + * Zod schema for dashboard attachment data. + * This schema matches the structure of DashboardState from @kbn/dashboard-plugin. + */ +export const dashboardAttachmentDataSchema = z.object({ + title: z.string(), + description: z.string().optional(), + panels: z.array(z.union([attachmentPanelSchema, dashboardSectionSchema])), + query: querySchema.optional(), + time_range: timeRangeSchema.optional(), + refresh_interval: refreshIntervalSchema.optional(), + filters: z.array(filterSchema).optional(), + options: optionsSchema.optional(), + tags: z.array(z.string()).optional(), + pinned_panels: pinnedPanelsSchema.optional(), + access_control: accessControlSchema.optional(), + project_routing: z.string().optional(), +}); + +export type DashboardAttachmentData = z.infer; diff --git a/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/index.ts b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/index.ts index fd37c4db2d7cf..0620ef7936dad 100644 --- a/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/index.ts +++ b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/index.ts @@ -10,8 +10,6 @@ export { DASHBOARD_ATTACHMENT_TYPE } from './constants'; export { panelGridSchema, sectionGridSchema, - attachmentPanelSchema, - dashboardSectionSchema, dashboardAttachmentDataSchema, isSection, } from './types'; @@ -21,14 +19,17 @@ export type { DashboardSection, DashboardAttachmentData, DashboardAttachment, + PendingDashboardAttachment, } from './types'; export { - dashboardStateToAttachment, - attachmentToDashboardState, + dashboardStateToAttachmentData, + attachmentDataToDashboardState, toEmbeddablePanel, fromEmbeddablePanel, DEFAULT_TIME_RANGE, type VisualizationContent, type DashboardPanelInput, } from './converters'; + +export { isDashboardAttachment } from './is_dashboard_attachment'; diff --git a/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/is_dashboard_attachment.ts b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/is_dashboard_attachment.ts new file mode 100644 index 0000000000000..18d8640a6b10e --- /dev/null +++ b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/is_dashboard_attachment.ts @@ -0,0 +1,15 @@ +/* + * 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 type { VersionedAttachment } from '@kbn/agent-builder-common/attachments'; +import { DASHBOARD_ATTACHMENT_TYPE } from './constants'; +import type { DashboardAttachmentData } from './types'; + +export const isDashboardAttachment = ( + attachment: VersionedAttachment +): attachment is VersionedAttachment => + attachment.type === DASHBOARD_ATTACHMENT_TYPE; diff --git a/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/types.ts b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/types.ts index 4d0deed51acbf..85598a2d723bc 100644 --- a/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/types.ts +++ b/x-pack/platform/packages/shared/dashboard-agent/dashboard-agent-common/types.ts @@ -5,164 +5,40 @@ * 2.0. */ -import { z } from '@kbn/zod/v4'; -import type { Attachment } from '@kbn/agent-builder-common/attachments'; +import type { Attachment, AttachmentInput } from '@kbn/agent-builder-common/attachments'; import type { DASHBOARD_ATTACHMENT_TYPE } from './constants'; +import type { + DashboardAttachmentData, + AttachmentPanel, + DashboardSection, +} from './dashboard_schema_types'; +import { + dashboardAttachmentDataSchema, + panelGridSchema, + sectionGridSchema, + isSection, +} from './dashboard_schema_types'; + +// Re-export dashboard schema types for convenience +// NOTE: These types are temporary and should be replaced with types from @kbn/dashboard-plugin +// once the dashboard plugin exports its schema types. +export type { DashboardAttachmentData, AttachmentPanel, DashboardSection }; +export { dashboardAttachmentDataSchema, panelGridSchema, sectionGridSchema, isSection }; // ============================================================================ -// Panel Grid Schema - ideally we should import the schema from dashboard schemas, but they use config-schema library -// which is not compatible with Zod, so we duplicate the relevant parts here for validation of attachment data. -// ============================================================================ - -/** - * Grid dimensions (in dashboard grid units) for layout. - * Dashboard grid is 48 columns wide; height is in same units. - */ -export const panelGridSchema = z.object({ - x: z.number(), - y: z.number(), - w: z.number().int().min(1).max(48), - h: z.number().int().min(1), -}); - -// ============================================================================ -// Panel Schema -// ============================================================================ - -/** - * Zod schema for dashboard panel entries. - * The `type` field contains the actual embeddable type. - */ -export const attachmentPanelSchema = z.object({ - type: z.string(), - uid: z.string(), - config: z.record(z.string(), z.unknown()), - grid: panelGridSchema, -}); - -export type AttachmentPanel = z.infer; - -// ============================================================================ -// Section Schema -// ============================================================================ - -export const sectionGridSchema = z.object({ - y: z.number(), -}); - -export const dashboardSectionSchema = z.object({ - uid: z.string(), - title: z.string(), - collapsed: z.boolean(), - grid: sectionGridSchema, - panels: z.array(attachmentPanelSchema), -}); - -export type DashboardSection = z.infer; - -export const isSection = ( - widget: AttachmentPanel | DashboardSection -): widget is DashboardSection => { - return 'panels' in widget; -}; - -// ============================================================================ -// Query Schema -// ============================================================================ - -const querySchema = z.object({ - expression: z.union([z.string(), z.record(z.string(), z.unknown())]), - language: z.string(), -}); - -// ============================================================================ -// Time Range Schema -// ============================================================================ - -const timeRangeSchema = z.object({ - from: z.string(), - to: z.string(), - mode: z.union([z.literal('absolute'), z.literal('relative')]).optional(), -}); - -// ============================================================================ -// Refresh Interval Schema -// ============================================================================ - -const refreshIntervalSchema = z.object({ - pause: z.boolean(), - value: z.number(), -}); - -// ============================================================================ -// Dashboard Options Schema -// ============================================================================ - -const optionsSchema = z.object({ - auto_apply_filters: z.boolean().optional(), - hide_panel_titles: z.boolean().optional(), - hide_panel_borders: z.boolean().optional(), - use_margins: z.boolean().optional(), - sync_colors: z.boolean().optional(), - sync_tooltips: z.boolean().optional(), - sync_cursor: z.boolean().optional(), -}); - -// ============================================================================ -// Access Control Schema -// ============================================================================ - -const accessControlSchema = z - .object({ - access_mode: z.union([z.literal('write_restricted'), z.literal('default')]).optional(), - }) - .optional(); - -// ============================================================================ -// Filter Schema -// ============================================================================ - -// Filters have complex union types that are difficult to express precisely in Zod. -const filterSchema = z.record(z.string(), z.unknown()); - -// ============================================================================ -// Pinned Panels (Controls) Schema +// Attachment Type // ============================================================================ -// Controls have complex union types. We use a permissive schema here. -const pinnedControlSchema = z.record(z.string(), z.unknown()); -const pinnedPanelsSchema = z.array(pinnedControlSchema); - -// ============================================================================ -// Dashboard Attachment Data Schema (matches DashboardState) -// ============================================================================ +export type DashboardAttachment = Attachment< + typeof DASHBOARD_ATTACHMENT_TYPE, + DashboardAttachmentData +>; /** - * Zod schema for dashboard attachment data. - * This schema matches the structure of DashboardState from @kbn/dashboard-plugin. + * Represents a pending dashboard attachment input. + * Used when creating attachments before they're persisted to a conversation. */ -export const dashboardAttachmentDataSchema = z.object({ - title: z.string(), - description: z.string().optional(), - panels: z.array(z.union([attachmentPanelSchema, dashboardSectionSchema])), - query: querySchema.optional(), - time_range: timeRangeSchema.optional(), - refresh_interval: refreshIntervalSchema.optional(), - filters: z.array(filterSchema).optional(), - options: optionsSchema.optional(), - tags: z.array(z.string()).optional(), - pinned_panels: pinnedPanelsSchema.optional(), - access_control: accessControlSchema.optional(), - project_routing: z.string().optional(), -}); - -export type DashboardAttachmentData = z.infer; - -// ============================================================================ -// Attachment Type -// ============================================================================ - -export type DashboardAttachment = Attachment< +export type PendingDashboardAttachment = AttachmentInput< typeof DASHBOARD_ATTACHMENT_TYPE, DashboardAttachmentData >; diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/canvas_integration/dashboard_canvas_content.test.tsx b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/canvas_integration/dashboard_canvas_content.test.tsx index 9ffc8f834e3b7..2a6254d9ce89a 100644 --- a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/canvas_integration/dashboard_canvas_content.test.tsx +++ b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/canvas_integration/dashboard_canvas_content.test.tsx @@ -111,7 +111,7 @@ describe('DashboardCanvasContent', () => { registerActionButtons, updateOrigin, closeCanvas, - checkSavedDashboardExist, + checkSavedDashboardExist: props.checkSavedDashboardExist, openSidebarConversation, }; }; @@ -191,18 +191,18 @@ describe('DashboardCanvasContent', () => { }); it('should include existing dashboard ID when saved object exists', async () => { - const checkSavedDashboardExist = jest.fn().mockResolvedValue(true); - const attachmentWithOrigin: DashboardAttachment = { ...mockAttachment, origin: 'existing-dashboard-id', }; - const { registerActionButtons, mockApi } = await renderDashboardCanvasContent({ - attachment: attachmentWithOrigin, - checkSavedDashboardExist, - }); + const { registerActionButtons, mockApi, checkSavedDashboardExist } = + await renderDashboardCanvasContent({ + attachment: attachmentWithOrigin, + checkSavedDashboardExist: jest.fn().mockResolvedValue(true), + }); + expect(checkSavedDashboardExist).toHaveBeenCalledWith('existing-dashboard-id'); const buttons: ActionButton[] = registerActionButtons.mock.calls.at(-1)?.[0] ?? []; const editButton = buttons.find((b) => b.label === 'Edit in Dashboards'); @@ -210,27 +210,51 @@ describe('DashboardCanvasContent', () => { await editButton?.handler(); }); - expect(checkSavedDashboardExist).toHaveBeenCalledWith('existing-dashboard-id'); expect(mockApi.locator?.navigate).toHaveBeenCalledWith( expect.objectContaining({ dashboardId: 'existing-dashboard-id', }) ); }); + + it('should not include dashboard ID when the linked saved object does not exist', async () => { + const attachmentWithOrigin: DashboardAttachment = { + ...mockAttachment, + origin: 'deleted-dashboard-id', + }; + + const { registerActionButtons, mockApi, checkSavedDashboardExist } = + await renderDashboardCanvasContent({ + attachment: attachmentWithOrigin, + checkSavedDashboardExist: jest.fn().mockResolvedValue(false), + }); + + expect(checkSavedDashboardExist).toHaveBeenCalledWith('deleted-dashboard-id'); + const buttons: ActionButton[] = registerActionButtons.mock.calls.at(-1)?.[0] ?? []; + const editButton = buttons.find((b) => b.label === 'Edit in Dashboards'); + + await act(async () => { + await editButton?.handler(); + }); + + expect(mockApi.locator?.navigate).toHaveBeenCalledWith( + expect.objectContaining({ + dashboardId: undefined, + }) + ); + }); }); describe('Save button', () => { it('should run quick save when linked saved object exists', async () => { - const checkSavedDashboardExist = jest.fn().mockResolvedValue(true); - const attachmentWithOrigin: DashboardAttachment = { ...mockAttachment, origin: 'existing-dashboard-id', }; - const { registerActionButtons, mockApi } = await renderDashboardCanvasContent({ + const { registerActionButtons, mockApi, updateOrigin } = await renderDashboardCanvasContent({ attachment: attachmentWithOrigin, - checkSavedDashboardExist, + checkSavedDashboardExist: jest.fn().mockResolvedValue(true), }); const buttons: ActionButton[] = registerActionButtons.mock.calls.at(-1)?.[0] ?? []; @@ -241,6 +265,33 @@ describe('DashboardCanvasContent', () => { }); expect(mockApi.runQuickSave).toHaveBeenCalled(); + expect(updateOrigin).toHaveBeenCalledWith('existing-dashboard-id'); + expect(mockApi.runInteractiveSave).not.toHaveBeenCalled(); + }); + + it('should run interactive save when linked saved object no longer exists', async () => { + const attachmentWithOrigin: DashboardAttachment = { + ...mockAttachment, + origin: 'deleted-dashboard-id', + }; + + const updateOrigin = jest.fn().mockResolvedValue(undefined); + const { registerActionButtons, mockApi } = await renderDashboardCanvasContent({ + attachment: attachmentWithOrigin, + updateOrigin, + checkSavedDashboardExist: jest.fn().mockResolvedValue(false), + }); + + const buttons: ActionButton[] = registerActionButtons.mock.calls.at(-1)?.[0] ?? []; + const saveButton = buttons.find((b) => b.label === 'Save'); + + await act(async () => { + await saveButton?.handler(); + }); + + expect(mockApi.runQuickSave).not.toHaveBeenCalled(); + expect(mockApi.runInteractiveSave).toHaveBeenCalled(); + expect(updateOrigin).toHaveBeenCalledWith('new-dashboard-id'); }); it('should run interactive save and update origin for new dashboard', async () => { @@ -260,4 +311,44 @@ describe('DashboardCanvasContent', () => { expect(updateOrigin).toHaveBeenCalledWith('new-dashboard-id'); }); }); + + describe('DashboardRenderer', () => { + it('passes the existing saved object id when the linked dashboard exists', async () => { + const attachmentWithOrigin: DashboardAttachment = { + ...mockAttachment, + origin: 'existing-dashboard-id', + }; + + await renderDashboardCanvasContent({ + attachment: attachmentWithOrigin, + checkSavedDashboardExist: jest.fn().mockResolvedValue(true), + }); + + expect(DashboardRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + savedObjectId: 'existing-dashboard-id', + }), + {} + ); + }); + + it('renders by value when the linked dashboard does not exist', async () => { + const attachmentWithOrigin: DashboardAttachment = { + ...mockAttachment, + origin: 'deleted-dashboard-id', + }; + + await renderDashboardCanvasContent({ + attachment: attachmentWithOrigin, + checkSavedDashboardExist: jest.fn().mockResolvedValue(false), + }); + + expect(DashboardRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + savedObjectId: undefined, + }), + {} + ); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/canvas_integration/dashboard_canvas_content.tsx b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/canvas_integration/dashboard_canvas_content.tsx index 2ae5d1537d488..0429a63dbed6a 100644 --- a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/canvas_integration/dashboard_canvas_content.tsx +++ b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/canvas_integration/dashboard_canvas_content.tsx @@ -14,7 +14,7 @@ import type { UseEuiTheme } from '@elastic/eui'; import { DashboardRenderer } from '@kbn/dashboard-plugin/public'; import { useMemoCss } from '@kbn/css-utils/public/use_memo_css'; import type { DashboardAttachment } from '@kbn/dashboard-agent-common/types'; -import { DEFAULT_TIME_RANGE, attachmentToDashboardState } from '@kbn/dashboard-agent-common'; +import { DEFAULT_TIME_RANGE, attachmentDataToDashboardState } from '@kbn/dashboard-agent-common'; import type { SavedObjectStatus } from './use_register_canvas_action_buttons'; import { useRegisterCanvasActionButtons } from './use_register_canvas_action_buttons'; @@ -112,7 +112,10 @@ export const DashboardCanvasContent = ({ [attachmentOrigin, checkSavedDashboardExist] ); - const dashboardState = useMemo(() => attachmentToDashboardState(attachment), [attachment]); + const dashboardState = useMemo( + () => attachmentDataToDashboardState(attachment.data), + [attachment.data] + ); const [timeRange, setTimeRange] = useState<{ from: string; to: string }>( dashboardState.time_range ?? DEFAULT_TIME_RANGE @@ -135,7 +138,7 @@ export const DashboardCanvasContent = ({ timeRange, dashboardState, attachmentOrigin, - checkSavedDashboardExist, + savedObjectStatus, isSidebar, }); diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/canvas_integration/use_register_canvas_action_buttons.ts b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/canvas_integration/use_register_canvas_action_buttons.ts index 531e5519eae62..9d174d4312cf9 100644 --- a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/canvas_integration/use_register_canvas_action_buttons.ts +++ b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/canvas_integration/use_register_canvas_action_buttons.ts @@ -18,26 +18,6 @@ export type SavedObjectStatus = | { status: 'loading' } | { status: 'resolved'; exists: boolean }; -// TODO: this feels very iffy, but it fixes the following flow as in edit in Dashboard, maybe we should rethink the flow? -// 1. Open a canvas dshboard view and save the dashboard -// 2. Go to Dashboard listing page and remove the dashboard you’ve created -// 3. Open the Canvas again -> Edit in Dashboards -// 4. You’re taken to the new dashboard page, but the origin is still set on the id of the removed dashboard - that’s ok, you can still edit -// 5. Click on save in dashboard app — it will not work because we check on save if origin === previousId to not override other dashboard - this fixes it -const getValidAttachmentOrigin = async ( - origin: string | undefined, - checkSavedDashboardExist: (dashboardId: string) => Promise, - updateOrigin: (origin: string) => Promise -) => { - if (!origin) return undefined; - const exists = await checkSavedDashboardExist(origin); - if (!exists) { - await updateOrigin(''); - return undefined; - } - return origin; -}; - interface UseRegisterCanvasActionButtonsParams { dashboardApi: DashboardApi | undefined; registerActionButtons: (buttons: ActionButton[]) => void; @@ -45,10 +25,10 @@ interface UseRegisterCanvasActionButtonsParams { timeRange: { from: string; to: string }; dashboardState: Pick; attachmentOrigin: string | undefined; - checkSavedDashboardExist: (dashboardId: string) => Promise; isSidebar: boolean; closeCanvas: () => void; openSidebarConversation?: () => void; + savedObjectStatus: SavedObjectStatus; } export const useRegisterCanvasActionButtons = ({ @@ -60,13 +40,14 @@ export const useRegisterCanvasActionButtons = ({ timeRange, dashboardState, attachmentOrigin, - checkSavedDashboardExist, isSidebar, + savedObjectStatus, }: UseRegisterCanvasActionButtonsParams) => { const timeRangeRef = useLatest(timeRange); const attachmentOriginRef = useLatest(attachmentOrigin); const dashboardStateRef = useLatest(dashboardState); const openSidebarConversationRef = useLatest(openSidebarConversation); + const savedObjectStatusRef = useLatest(savedObjectStatus); useEffect(() => { if (!dashboardApi) { @@ -84,11 +65,11 @@ export const useRegisterCanvasActionButtons = ({ }), type: ActionButtonType.PRIMARY, handler: async () => { - const existingAttachmentOrigin = await getValidAttachmentOrigin( - attachmentOriginRef.current, - checkSavedDashboardExist, - updateOrigin - ); + const existingAttachmentOrigin = + savedObjectStatusRef.current.status === 'resolved' && + savedObjectStatusRef.current.exists + ? attachmentOriginRef.current + : undefined; await locator.navigate({ ...dashboardStateRef.current, dashboardId: existingAttachmentOrigin, @@ -109,20 +90,18 @@ export const useRegisterCanvasActionButtons = ({ icon: 'save', type: ActionButtonType.PRIMARY, handler: async () => { - const existingAttachmentOrigin = await getValidAttachmentOrigin( - attachmentOriginRef.current, - checkSavedDashboardExist, - updateOrigin - ); + const existingAttachmentOrigin = + savedObjectStatusRef.current.status === 'resolved' && savedObjectStatusRef.current.exists + ? attachmentOriginRef.current + : undefined; if (existingAttachmentOrigin) { await dashboardApi.runQuickSave(); await updateOrigin(existingAttachmentOrigin); return; } const result = await dashboardApi.runInteractiveSave(); - const nextSavedObjectId = result?.id ?? dashboardApi.savedObjectId$.value; - if (nextSavedObjectId && nextSavedObjectId !== existingAttachmentOrigin) { - await updateOrigin(nextSavedObjectId); + if (result?.id) { + await updateOrigin(result.id); } }, }); @@ -131,12 +110,12 @@ export const useRegisterCanvasActionButtons = ({ dashboardApi, registerActionButtons, updateOrigin, - checkSavedDashboardExist, closeCanvas, openSidebarConversationRef, timeRangeRef, attachmentOriginRef, dashboardStateRef, + savedObjectStatusRef, isSidebar, ]); }; diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/agent_live_updates_subscription.ts b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/agent_live_updates_subscription.ts new file mode 100644 index 0000000000000..9bc5c3da3a3dc --- /dev/null +++ b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/agent_live_updates_subscription.ts @@ -0,0 +1,65 @@ +/* + * 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 { filter, type Subscription } from 'rxjs'; +import { isRoundCompleteEvent } from '@kbn/agent-builder-common'; +import { ATTACHMENT_REF_OPERATION, getLatestVersion } from '@kbn/agent-builder-common/attachments'; +import type { AgentBuilderPluginStart } from '@kbn/agent-builder-plugin/public'; +import { attachmentDataToDashboardState, isDashboardAttachment } from '@kbn/dashboard-agent-common'; +import type { DashboardApi } from '@kbn/dashboard-plugin/public'; + +export interface AgentLiveUpdatesSubscriptionParams { + agentBuilder: AgentBuilderPluginStart; + api: DashboardApi; +} + +/** + * Creates a subscription that applies LLM-driven dashboard attachment updates + * to the dashboard currently open in the app. + */ +export const createAgentLiveUpdatesSubscription = ({ + agentBuilder, + api, +}: AgentLiveUpdatesSubscriptionParams): Subscription => + agentBuilder.events.chat$.pipe(filter(isRoundCompleteEvent)).subscribe((event) => { + const incomingAttachments = event.data.attachments + ?.filter(isDashboardAttachment) + .filter((attachment) => { + return ( + event.data.round.input.attachment_refs?.some( + (ref) => + ref.attachment_id === attachment.id && + (ref.operation === ATTACHMENT_REF_OPERATION.updated || + ref.operation === ATTACHMENT_REF_OPERATION.created) + ) === true + ); + }); + + if (!incomingAttachments) { + return; + } + // TODO: handle multiple attachments in the future + const incomingAttachment = incomingAttachments.at(0); + if (!incomingAttachment) { + return; + } + + const currentSavedObjectId = api.savedObjectId$.getValue(); + + // Skip if viewing a saved dashboard that differs from the attachment's linked dashboard + if (currentSavedObjectId && incomingAttachment.origin !== currentSavedObjectId) { + return; + } + + const latestVersionData = getLatestVersion(incomingAttachment)?.data; + + if (!latestVersionData) { + return; + } + + api.setState(attachmentDataToDashboardState(latestVersionData)); + }); diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/create_dashboard_attachment_mount_sync.ts b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/create_dashboard_attachment_mount_sync.ts deleted file mode 100644 index efbc35cf9f899..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/create_dashboard_attachment_mount_sync.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { filter, ignoreElements, merge, tap, type Observable } from 'rxjs'; -import type { ChatEvent } from '@kbn/agent-builder-common'; -import { isRoundCompleteEvent } from '@kbn/agent-builder-common'; -import type { AttachmentInput, VersionedAttachment } from '@kbn/agent-builder-common/attachments'; -import { ATTACHMENT_REF_OPERATION, getLatestVersion } from '@kbn/agent-builder-common/attachments'; -import { DASHBOARD_ATTACHMENT_TYPE, attachmentToDashboardState } from '@kbn/dashboard-agent-common'; -import type { DashboardAttachment } from '@kbn/dashboard-agent-common/types'; -import type { DashboardApi } from '@kbn/dashboard-plugin/public'; -import { createManualChanges$ } from './manual_changes_tracker'; - -export interface DashboardAttachmentMountSyncParams { - api: DashboardApi; - chat$: Observable; - getAttachment: () => DashboardAttachment; - updateOrigin: (origin: string) => Promise; - addAttachment: (attachment: AttachmentInput) => void; -} - -export const createDashboardAttachmentMountSync$ = ({ - api, - chat$, - getAttachment, - updateOrigin, - addAttachment, -}: DashboardAttachmentMountSyncParams): Observable => { - // Sync attachment origin when dashboard is saved. - const savedDashboardOriginSync$ = api.onSave$.pipe( - tap(({ previousDashboardId, dashboardId }) => { - if (!dashboardId) { - return; - } - const currentAttachment = getAttachment(); - - // Only update origin if: - // a. The attachment has no origin yet (first save of unsaved dashboard) - // b. The attachment already points to this dashboard (second+ save) - // c. The saved dashboard was previously the attachment's origin (save / save as) - const shouldUpdate = - !currentAttachment.origin || - dashboardId === currentAttachment.origin || - previousDashboardId === currentAttachment.origin; - - if (shouldUpdate) { - void updateOrigin(dashboardId); - } - }), - ignoreElements() - ); - - const agentLiveUpdates$ = chat$.pipe( - filter(isRoundCompleteEvent), - tap((event) => { - const updatedVersionedAttachment = event.data.attachments?.find( - (attachment): attachment is VersionedAttachment => - attachment.type === DASHBOARD_ATTACHMENT_TYPE && - event.data.round.input.attachment_refs?.some( - (ref) => - ref.attachment_id === attachment.id && - (ref.operation === ATTACHMENT_REF_OPERATION.updated || - ref.operation === ATTACHMENT_REF_OPERATION.created) - ) === true - ); - - if (!updatedVersionedAttachment) { - return; - } - - const currentSavedObjectId = api.savedObjectId$.getValue(); - const attachmentLinkedSavedObjectId = updatedVersionedAttachment.origin; - - // Skip if viewing a saved dashboard that differs from the attachment's linked dashboard - if (currentSavedObjectId && attachmentLinkedSavedObjectId !== currentSavedObjectId) { - return; - } - - const latestVersion = getLatestVersion(updatedVersionedAttachment); - if (!latestVersion) { - return; - } - - const attachment: DashboardAttachment = { - id: updatedVersionedAttachment.id, - type: DASHBOARD_ATTACHMENT_TYPE, - data: latestVersion.data as DashboardAttachment['data'], // TODO: fix type - origin: updatedVersionedAttachment.origin, - }; - api.setState(attachmentToDashboardState(attachment)); - }), - ignoreElements() - ); - - const manualChanges$ = createManualChanges$({ - api, - getAttachment, - }).pipe( - tap((attachment) => { - addAttachment(attachment); - }), - ignoreElements() - ); - - return merge(savedDashboardOriginSync$, agentLiveUpdates$, manualChanges$); -}; diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/dashboard_app_integration.test.ts b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/dashboard_app_integration.test.ts new file mode 100644 index 0000000000000..21ee5f2aa6dbc --- /dev/null +++ b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/dashboard_app_integration.test.ts @@ -0,0 +1,344 @@ +/* + * 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 { BehaviorSubject, Subject } from 'rxjs'; +import type { ChatEvent } from '@kbn/agent-builder-common'; +import type { AgentBuilderPluginStart } from '@kbn/agent-builder-plugin/public'; +import type { DashboardAttachment } from '@kbn/dashboard-agent-common/types'; +import { DASHBOARD_ATTACHMENT_TYPE } from '@kbn/dashboard-agent-common'; +import type { DashboardApi, DashboardSaveEvent } from '@kbn/dashboard-plugin/public'; +import { registerDashboardAppIntegration } from './dashboard_app_integration'; + +interface MockDashboardApi { + savedObjectId$: BehaviorSubject; + onSave$: Subject; + layout$: BehaviorSubject; + children$: BehaviorSubject>; + title$: BehaviorSubject; + description$: BehaviorSubject; + filters$: BehaviorSubject; + query$: BehaviorSubject; + timeRange$: BehaviorSubject; + projectRouting$: BehaviorSubject; + hideTitle$: BehaviorSubject; + hideBorder$: BehaviorSubject; + settings?: { + autoApplyFilters$?: BehaviorSubject; + syncColors$?: BehaviorSubject; + syncCursor$?: BehaviorSubject; + syncTooltips$?: BehaviorSubject; + useMargins$?: BehaviorSubject; + }; + getSerializedState: jest.Mock; +} + +interface MockChildApi { + uuid: string; + hasUnsavedChanges$: BehaviorSubject; + resetUnsavedChanges: jest.Mock; + serializeState: jest.Mock; + applySerializedState: jest.Mock; +} + +const mockSavedDashboardState = { + title: 'Saved Dashboard', + description: '', + panels: [], +} as unknown as DashboardSaveEvent['dashboardState']; + +const createMockDashboardApi = (): MockDashboardApi => ({ + savedObjectId$: new BehaviorSubject(undefined), + onSave$: new Subject(), + layout$: new BehaviorSubject([]), + children$: new BehaviorSubject>({ + 'panel-1': { + uuid: 'panel-1', + hasUnsavedChanges$: new BehaviorSubject(false), + resetUnsavedChanges: jest.fn(), + serializeState: jest.fn().mockReturnValue({}), + applySerializedState: jest.fn(), + }, + }), + title$: new BehaviorSubject('Test Dashboard'), + description$: new BehaviorSubject('Test Description'), + filters$: new BehaviorSubject([]), + query$: new BehaviorSubject({ query: '', language: 'kuery' }), + timeRange$: new BehaviorSubject({ from: 'now-15m', to: 'now' }), + projectRouting$: new BehaviorSubject(undefined), + hideTitle$: new BehaviorSubject(false), + hideBorder$: new BehaviorSubject(false), + settings: { + autoApplyFilters$: new BehaviorSubject(true), + syncColors$: new BehaviorSubject(false), + syncCursor$: new BehaviorSubject(true), + syncTooltips$: new BehaviorSubject(true), + useMargins$: new BehaviorSubject(true), + }, + getSerializedState: jest.fn().mockReturnValue({ + attributes: { + title: 'Test Dashboard', + description: 'Test Description', + panels: [], + }, + }), +}); + +const createMockAttachment = (overrides?: Partial): DashboardAttachment => ({ + id: 'test-attachment-id', + type: DASHBOARD_ATTACHMENT_TYPE, + data: { + title: 'Test Dashboard', + description: 'Test Description', + panels: [], + }, + origin: undefined, + ...overrides, +}); + +describe('registerDashboardAppIntegration', () => { + let mockApi: MockDashboardApi; + let getAttachment: jest.Mock; + let getSyncAttachment: jest.Mock; + let checkSavedDashboardExist: jest.Mock; + let updateOrigin: jest.Mock; + let addAttachment: jest.Mock; + let chat$: Subject; + let cleanup: () => void; + + beforeEach(() => { + jest.useFakeTimers(); + mockApi = createMockDashboardApi(); + getAttachment = jest.fn().mockReturnValue(createMockAttachment()); + getSyncAttachment = jest + .fn() + .mockImplementation((_savedObjectId: string | undefined) => getAttachment()); + checkSavedDashboardExist = jest.fn().mockResolvedValue(true); + updateOrigin = jest.fn().mockResolvedValue(undefined); + addAttachment = jest.fn(); + chat$ = new Subject(); + }); + + afterEach(() => { + cleanup?.(); + jest.useRealTimers(); + }); + + const register = () => { + const agentBuilder = { + addAttachment, + events: { chat$ }, + } as unknown as AgentBuilderPluginStart; + + cleanup = registerDashboardAppIntegration({ + agentBuilder, + api: mockApi as unknown as DashboardApi, + getAttachment, + getSyncAttachment, + checkSavedDashboardExist, + updateOrigin, + }); + }; + + it('syncs manual dashboard changes back to the attachment', () => { + register(); + + mockApi.title$.next('New Title'); + jest.advanceTimersByTime(200); + + expect(addAttachment).toHaveBeenCalledTimes(1); + expect(addAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test-attachment-id', + type: DASHBOARD_ATTACHMENT_TYPE, + data: expect.any(Object), + }) + ); + }); + + it('skips syncing when viewing a different saved dashboard', () => { + getAttachment.mockReturnValue(createMockAttachment({ origin: 'attachment-dashboard-id' })); + getSyncAttachment.mockReturnValue(undefined); + mockApi.savedObjectId$.next('different-dashboard-id'); + + register(); + + mockApi.title$.next('New Title'); + jest.advanceTimersByTime(200); + + expect(addAttachment).not.toHaveBeenCalled(); + }); + + it('skips syncing when another attachment owns the current dashboard', () => { + const currentAttachment = createMockAttachment({ + id: 'current-attachment-id', + origin: 'dashboard-a', + }); + getAttachment.mockReturnValue(currentAttachment); + getSyncAttachment.mockReturnValue( + createMockAttachment({ + id: 'other-attachment-id', + origin: 'dashboard-a', + }) + ); + mockApi.savedObjectId$.next('dashboard-a'); + + register(); + + mockApi.title$.next('New Title'); + jest.advanceTimersByTime(200); + + expect(addAttachment).not.toHaveBeenCalled(); + }); + + it('does not sync when serialized attributes are missing', () => { + mockApi.getSerializedState.mockReturnValue({ attributes: undefined }); + + register(); + + mockApi.title$.next('New Title'); + jest.advanceTimersByTime(200); + + expect(addAttachment).not.toHaveBeenCalled(); + }); + + it('handles missing settings observables', () => { + mockApi = { + ...mockApi, + settings: { + useMargins$: new BehaviorSubject(true), + }, + }; + + register(); + + mockApi.title$.next('New Title'); + jest.advanceTimersByTime(200); + + expect(addAttachment).toHaveBeenCalledTimes(1); + }); + + it('updates origin on first save of an unsaved dashboard', async () => { + getAttachment.mockReturnValue(createMockAttachment({ origin: undefined })); + + register(); + + mockApi.onSave$.next({ + previousDashboardId: undefined, + dashboardId: 'new-dashboard-id', + dashboardState: mockSavedDashboardState, + }); + await Promise.resolve(); + + expect(updateOrigin).toHaveBeenCalledWith('new-dashboard-id'); + }); + + it('updates origin on save as when linked to the previous dashboard', async () => { + getAttachment.mockReturnValue(createMockAttachment({ origin: 'dashboard-a' })); + + register(); + + mockApi.onSave$.next({ + previousDashboardId: 'dashboard-a', + dashboardId: 'dashboard-b', + dashboardState: mockSavedDashboardState, + }); + await Promise.resolve(); + + expect(updateOrigin).toHaveBeenCalledWith('dashboard-b'); + }); + + it('avoids checking existence when saving the dashboard already linked by origin', async () => { + getAttachment.mockReturnValue(createMockAttachment({ origin: 'dashboard-a' })); + + register(); + + mockApi.onSave$.next({ + previousDashboardId: 'some-other-dashboard', + dashboardId: 'dashboard-a', + dashboardState: mockSavedDashboardState, + }); + await Promise.resolve(); + + expect(checkSavedDashboardExist).not.toHaveBeenCalled(); + expect(updateOrigin).toHaveBeenCalledWith('dashboard-a'); + }); + + it('does not relink after navigating to a different saved dashboard', async () => { + getAttachment.mockReturnValue(createMockAttachment({ origin: 'dashboard-a' })); + mockApi.savedObjectId$.next('dashboard-b'); + + register(); + + mockApi.onSave$.next({ + previousDashboardId: 'dashboard-b', + dashboardId: 'dashboard-b', + dashboardState: mockSavedDashboardState, + }); + await Promise.resolve(); + + expect(updateOrigin).not.toHaveBeenCalled(); + }); + + it('relinks to the current dashboard when the stored origin no longer exists', async () => { + getAttachment.mockReturnValue(createMockAttachment({ origin: 'deleted-dashboard' })); + checkSavedDashboardExist.mockResolvedValue(false); + + register(); + + mockApi.onSave$.next({ + previousDashboardId: undefined, + dashboardId: 'current-dashboard', + dashboardState: mockSavedDashboardState, + }); + await Promise.resolve(); + + expect(checkSavedDashboardExist).toHaveBeenCalledWith('deleted-dashboard'); + expect(updateOrigin).toHaveBeenCalledWith('current-dashboard'); + }); + + it('does not relink when another attachment owns the current dashboard', async () => { + const currentAttachment = createMockAttachment({ + id: 'current-attachment-id', + origin: undefined, + }); + getAttachment.mockReturnValue(currentAttachment); + getSyncAttachment.mockReturnValue( + createMockAttachment({ + id: 'other-attachment-id', + origin: undefined, + }) + ); + + register(); + + mockApi.onSave$.next({ + previousDashboardId: undefined, + dashboardId: 'new-dashboard-id', + dashboardState: mockSavedDashboardState, + }); + await Promise.resolve(); + + expect(updateOrigin).not.toHaveBeenCalled(); + }); + + it('unsubscribes from manual and origin subscriptions on cleanup', async () => { + register(); + cleanup(); + + mockApi.title$.next('New Title'); + jest.advanceTimersByTime(200); + mockApi.onSave$.next({ + previousDashboardId: undefined, + dashboardId: 'new-dashboard-id', + dashboardState: mockSavedDashboardState, + }); + await Promise.resolve(); + + expect(addAttachment).not.toHaveBeenCalled(); + expect(updateOrigin).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/dashboard_app_integration.ts b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/dashboard_app_integration.ts new file mode 100644 index 0000000000000..334300d820b98 --- /dev/null +++ b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/dashboard_app_integration.ts @@ -0,0 +1,61 @@ +/* + * 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 { Observable } from 'rxjs'; +import type { AgentBuilderPluginStart } from '@kbn/agent-builder-plugin/public'; +import type { DashboardAttachment } from '@kbn/dashboard-agent-common/types'; +import type { DashboardApi } from '@kbn/dashboard-plugin/public'; +import { createAgentLiveUpdatesSubscription } from './agent_live_updates_subscription'; +import { createManualChangesSubscription } from './manual_changes_subscription'; +import { createOriginSyncSubscription } from './origin_sync_subscription'; + +export interface DashboardAppIntegrationParams { + agentBuilder: AgentBuilderPluginStart; + api: DashboardApi; + getAttachment: () => DashboardAttachment; + getSyncAttachment: (currentSavedObjectId: string | undefined) => DashboardAttachment | undefined; + checkSavedDashboardExist: (dashboardId: string) => Promise; + updateOrigin: (origin: string) => Promise; +} + +export const registerDashboardAppIntegration = ({ + agentBuilder, + api, + getAttachment, + getSyncAttachment, + checkSavedDashboardExist, + updateOrigin, +}: DashboardAppIntegrationParams): (() => void) => { + const originSyncSubscription = createOriginSyncSubscription({ + api, + getAttachment, + getSyncAttachment, + checkSavedDashboardExist, + updateOrigin, + }); + const agentLiveUpdatesSubscription = createAgentLiveUpdatesSubscription({ + agentBuilder, + api, + }); + const manualChangesSubscription = createManualChangesSubscription({ + agentBuilder, + api, + getAttachment, + getSyncAttachment, + }); + + return () => { + originSyncSubscription.unsubscribe(); + agentLiveUpdatesSubscription.unsubscribe(); + manualChangesSubscription.unsubscribe(); + }; +}; + +export const createDashboardAppIntegration$ = ( + params: DashboardAppIntegrationParams + // this stream is meant to be subscribed to for the side effect of registering the integration, it doesn't emit any values and completes when the integration is unregistered +): Observable => new Observable(() => registerDashboardAppIntegration(params)); diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/manual_changes_subscription.ts b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/manual_changes_subscription.ts new file mode 100644 index 0000000000000..2c216e9cc6072 --- /dev/null +++ b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/manual_changes_subscription.ts @@ -0,0 +1,107 @@ +/* + * 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 { + debounceTime, + filter, + ignoreElements, + map, + merge, + skip, + tap, + type Observable, + type Subscription, +} from 'rxjs'; +import type { AttachmentInput } from '@kbn/agent-builder-common/attachments'; +import { + DASHBOARD_ATTACHMENT_TYPE, + dashboardStateToAttachmentData, +} from '@kbn/dashboard-agent-common'; +import type { AgentBuilderPluginStart } from '@kbn/agent-builder-plugin/public'; +import type { DashboardAttachment } from '@kbn/dashboard-agent-common/types'; +import type { DashboardApi } from '@kbn/dashboard-plugin/public'; +import { childrenUnsavedChanges$ } from '@kbn/presentation-publishing'; + +export interface ManualChangesSubscriptionParams { + agentBuilder: AgentBuilderPluginStart; + api: DashboardApi; + getAttachment: () => DashboardAttachment | undefined; + getSyncAttachment: (currentSavedObjectId: string | undefined) => DashboardAttachment | undefined; +} + +/** + * Creates a subscription that tracks manual changes to the dashboard + * and syncs them back to the attachment. + */ +export const createManualChangesSubscription = ({ + agentBuilder, + api, + getAttachment, + getSyncAttachment, +}: ManualChangesSubscriptionParams): Subscription => { + // TODO: we should get it directly from the dashboard plugin + // Collect observables for all trackable dashboard state + const observables: Array> = [ + api.layout$, + api.title$, + api.description$, + api.filters$, + api.query$, + api.timeRange$, + api.projectRouting$, + api.settings?.autoApplyFilters$, + api.settings?.syncColors$, + api.settings?.syncCursor$, + api.settings?.syncTooltips$, + api.settings?.useMargins$, + api.hideTitle$, + api.hideBorder$, + ].filter((o): o is NonNullable => Boolean(o)); + const childrenChanges$ = childrenUnsavedChanges$(api.children$).pipe(skip(1)); + + return merge(...observables, childrenChanges$) + .pipe( + skip(observables.length), // Skip initial emissions from all BehaviorSubjects + debounceTime(150), + map((): AttachmentInput | undefined => { + const currentAttachment = getAttachment(); + if (!currentAttachment) { + return undefined; + } + + const currentSavedObjectId = api.savedObjectId$.getValue(); + const syncAttachment = getSyncAttachment(currentSavedObjectId); + + // Only the attachment selected for the current dashboard should own sync. + if (!syncAttachment || syncAttachment.id !== currentAttachment.id) { + return undefined; + } + const currentDashboardState = api.getSerializedState().attributes; + if (!currentDashboardState) { + return undefined; + } + + const data = dashboardStateToAttachmentData(currentDashboardState); + if (!data) { + return undefined; + } + + return { + data, + id: currentAttachment.id, + origin: currentAttachment.origin, + type: DASHBOARD_ATTACHMENT_TYPE, + }; + }), + filter((attachment): attachment is AttachmentInput => attachment !== undefined), + tap((attachment) => { + agentBuilder.addAttachment(attachment); + }), + ignoreElements() + ) + .subscribe(); +}; diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/manual_changes_tracker.ts b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/manual_changes_tracker.ts deleted file mode 100644 index c13ae5007324b..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/manual_changes_tracker.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { debounceTime, filter, map, merge, skip, type Observable } from 'rxjs'; -import type { AttachmentInput } from '@kbn/agent-builder-common/attachments'; -import type { DashboardAttachment } from '@kbn/dashboard-agent-common/types'; -import type { DashboardApi } from '@kbn/dashboard-plugin/public'; -import { DASHBOARD_ATTACHMENT_TYPE, dashboardStateToAttachment } from '@kbn/dashboard-agent-common'; -import { childrenUnsavedChanges$ } from '@kbn/presentation-publishing'; - -export interface ManualChangesTrackerParams { - api: DashboardApi; - getAttachment: () => DashboardAttachment | undefined; - addAttachment: (attachment: AttachmentInput) => void; -} - -type ManualChangesSourceParams = Omit; - -/** - * Creates an observable that tracks manual changes to the dashboard - * and syncs them back to the attachment. - */ -export const createManualChanges$ = ({ - api, - getAttachment, -}: ManualChangesSourceParams): Observable => { - // TODO: we should get it directly from the dashboard plugin - // Collect observables for all trackable dashboard state - const observables: Array> = [ - api.layout$, - api.title$, - api.description$, - api.filters$, - api.query$, - api.timeRange$, - api.projectRouting$, - api.settings?.autoApplyFilters$, - api.settings?.syncColors$, - api.settings?.syncCursor$, - api.settings?.syncTooltips$, - api.settings?.useMargins$, - api.hideTitle$, - api.hideBorder$, - ].filter((o): o is NonNullable => Boolean(o)); - const childrenChanges$ = childrenUnsavedChanges$(api.children$).pipe(skip(1)); - - return merge(...observables, childrenChanges$).pipe( - skip(observables.length), // Skip initial emissions from all BehaviorSubjects - debounceTime(150), - map((): AttachmentInput | undefined => { - const currentAttachment = getAttachment(); - if (!currentAttachment) { - return undefined; - } - - const currentSavedObjectId = api.savedObjectId$.getValue(); - - // Only sync if: no saved dashboard OR saved dashboard matches attachment's origin - if (currentSavedObjectId && currentSavedObjectId !== currentAttachment.origin) { - return undefined; - } - - const currentDashboardState = api.getSerializedState().attributes; - if (!currentDashboardState) { - return undefined; - } - - return { - id: currentAttachment.id, - type: DASHBOARD_ATTACHMENT_TYPE, - data: dashboardStateToAttachment(currentDashboardState), - origin: currentAttachment.origin, - }; - }), - filter((attachment): attachment is AttachmentInput => attachment !== undefined) - ); -}; diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/origin_sync_subscription.ts b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/origin_sync_subscription.ts new file mode 100644 index 0000000000000..270fe87a3681e --- /dev/null +++ b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/origin_sync_subscription.ts @@ -0,0 +1,60 @@ +/* + * 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 type { Subscription } from 'rxjs'; +import type { DashboardAttachment } from '@kbn/dashboard-agent-common/types'; +import type { DashboardApi } from '@kbn/dashboard-plugin/public'; + +export const createOriginSyncSubscription = ({ + api, + getAttachment, + getSyncAttachment, + checkSavedDashboardExist, + updateOrigin, +}: { + api: DashboardApi; + getAttachment: () => DashboardAttachment; + getSyncAttachment: (currentSavedObjectId: string | undefined) => DashboardAttachment | undefined; + checkSavedDashboardExist: (dashboardId: string) => Promise; + updateOrigin: (origin: string) => void; +}): Subscription => { + let origin = getAttachment().origin; + + return api.onSave$.subscribe(async ({ previousDashboardId, dashboardId }) => { + if (!dashboardId) { + return; + } + + const currentSavedObjectId = api.savedObjectId$.getValue(); + const currentAttachment = getAttachment(); + const syncAttachment = getSyncAttachment(currentSavedObjectId); + + if (!syncAttachment || syncAttachment.id !== currentAttachment.id) { + return; + } + + // Only update origin if: + // - the attachment has no origin yet (first save of unsaved dashboard) + // - the attachment already points to this dashboard (subsequent saves) - we need to update the origin for the staleness check to match origin_snapshot_at + // - the saved dashboard was previously the attachment origin (save as) + if (!origin || dashboardId === origin || previousDashboardId === origin) { + updateOrigin(dashboardId); + origin = dashboardId; + return; + } + + // If we're saving some other dashboard, only relink when the stored origin no longer exists. + const linkedDashboardExists = await checkSavedDashboardExist(origin); + + if (linkedDashboardExists) { + return; + } + + updateOrigin(dashboardId); + origin = dashboardId; + }); +}; diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/preview_attachment_in_dashboard.ts b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/preview_attachment.ts similarity index 59% rename from x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/preview_attachment_in_dashboard.ts rename to x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/preview_attachment.ts index b33324abfd4cf..5e0eeb79f489f 100644 --- a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/preview_attachment_in_dashboard.ts +++ b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/preview_attachment.ts @@ -6,7 +6,7 @@ */ import type { DashboardAttachment } from '@kbn/dashboard-agent-common/types'; -import { attachmentToDashboardState } from '@kbn/dashboard-agent-common'; +import { attachmentDataToDashboardState } from '@kbn/dashboard-agent-common'; import type { DashboardApi } from '@kbn/dashboard-plugin/public'; interface PreviewAttachmentInDashboardParams { @@ -22,41 +22,34 @@ export const previewAttachmentInDashboard = async ({ checkSavedDashboardExist, updateOrigin, }: PreviewAttachmentInDashboardParams) => { - const dashboardState = attachmentToDashboardState(attachment); - let attachmentLinkedSavedObjectId = attachment.origin; + const dashboardState = attachmentDataToDashboardState(attachment.data); const currentSavedObjectId = dashboardApi.savedObjectId$.getValue(); // a) Viewing saved dashboard + attachment linked to same dashboard -> apply state - if ( - attachmentLinkedSavedObjectId === currentSavedObjectId || - (!attachmentLinkedSavedObjectId && !currentSavedObjectId) - ) { + // Also handles both being undefined (unsaved dashboard, unlinked attachment) + if (attachment.origin === currentSavedObjectId) { dashboardApi.setState(dashboardState); return; } - const dashboardHasBeenDeleted = - attachmentLinkedSavedObjectId && - (await checkSavedDashboardExist(attachmentLinkedSavedObjectId)) === false; + // Check if the attachment's linked dashboard still exists + const linkedDashboardExists = attachment.origin + ? await checkSavedDashboardExist(attachment.origin) + : false; - if (dashboardHasBeenDeleted) { - await updateOrigin(''); - attachmentLinkedSavedObjectId = undefined; - } - - // b) Viewing saved dashboard + attachment not linked -> navigate to new unsaved dashboard - if (!attachmentLinkedSavedObjectId && !currentSavedObjectId) { + if (!currentSavedObjectId && !linkedDashboardExists) { + // b) Viewing unsaved dashboard + attachment linked to deleted dashboard dashboardApi.setState(dashboardState); return; } - // c) Viewing saved dashboard + attachment linked to different dashboard -> navigate to linked dashboard + // c) Attachment linked to different existing dashboard -> navigate to linked dashboard or a new unsaved dashboard with the attachment's state dashboardApi.locator?.navigate({ title: dashboardState.title, description: dashboardState.description, panels: dashboardState.panels, time_range: dashboardState.time_range, - dashboardId: attachmentLinkedSavedObjectId, + dashboardId: linkedDashboardExists ? attachment.origin : undefined, viewMode: 'edit', }); }; diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/select_dashboard_attachment_for_sync.test.ts b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/select_dashboard_attachment_for_sync.test.ts new file mode 100644 index 0000000000000..7c7d08f03ab5b --- /dev/null +++ b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/select_dashboard_attachment_for_sync.test.ts @@ -0,0 +1,100 @@ +/* + * 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 type { DashboardAttachment } from '@kbn/dashboard-agent-common/types'; +import { DASHBOARD_ATTACHMENT_TYPE } from '@kbn/dashboard-agent-common'; +import { selectDashboardAttachmentForSync } from './select_dashboard_attachment_for_sync'; + +const createAttachment = ({ + id, + origin, +}: { + id: string; + origin: string | undefined; +}): DashboardAttachment => ({ + id, + origin, + type: DASHBOARD_ATTACHMENT_TYPE, + data: { + title: `Dashboard ${id}`, + description: '', + panels: [], + }, +}); + +describe('selectDashboardAttachmentForSync', () => { + describe('when viewing an existing dashboard', () => { + it('selects the attachment linked to the current saved dashboard', () => { + const selectedAttachment = selectDashboardAttachmentForSync({ + attachments: [ + createAttachment({ id: 'attachment-a', origin: 'dashboard-a' }), + createAttachment({ id: 'attachment-b', origin: 'dashboard-b' }), + ], + currentSavedObjectId: 'dashboard-b', + }); + + expect(selectedAttachment?.id).toBe('attachment-b'); + }); + + it('does not select an unlinked attachment when no origins match', () => { + const selectedAttachment = selectDashboardAttachmentForSync({ + attachments: [ + createAttachment({ id: 'attachment-a', origin: 'dashboard-a' }), + createAttachment({ id: 'attachment-b', origin: undefined }), + ], + currentSavedObjectId: 'dashboard-c', + }); + + expect(selectedAttachment).toBeUndefined(); + }); + + it('falls back to the only attachment when there is a single stale-origin attachment', () => { + const selectedAttachment = selectDashboardAttachmentForSync({ + attachments: [createAttachment({ id: 'attachment-a', origin: 'deleted-dashboard' })], + currentSavedObjectId: 'current-dashboard', + }); + + expect(selectedAttachment?.id).toBe('attachment-a'); + }); + }); + + describe('when viewing a new unsaved dashboard', () => { + it('selects the first unlinked attachment', () => { + const selectedAttachment = selectDashboardAttachmentForSync({ + attachments: [ + createAttachment({ id: 'attachment-a', origin: 'dashboard-a' }), + createAttachment({ id: 'attachment-b', origin: undefined }), + createAttachment({ id: 'attachment-c', origin: undefined }), + ], + currentSavedObjectId: undefined, + }); + + expect(selectedAttachment?.id).toBe('attachment-b'); + }); + + it('falls back to the first attachment when none are unlinked', () => { + const selectedAttachment = selectDashboardAttachmentForSync({ + attachments: [ + createAttachment({ id: 'attachment-a', origin: 'dashboard-a' }), + createAttachment({ id: 'attachment-b', origin: 'dashboard-b' }), + ], + currentSavedObjectId: undefined, + }); + + expect(selectedAttachment?.id).toBe('attachment-a'); + }); + + it('returns undefined when there are no attachments', () => { + const selectedAttachment = selectDashboardAttachmentForSync({ + attachments: [], + currentSavedObjectId: undefined, + }); + + expect(selectedAttachment).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/select_dashboard_attachment_for_sync.ts b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/select_dashboard_attachment_for_sync.ts new file mode 100644 index 0000000000000..3d2203377f4cc --- /dev/null +++ b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/dashboard_integration/select_dashboard_attachment_for_sync.ts @@ -0,0 +1,25 @@ +/* + * 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 type { DashboardAttachment } from '@kbn/dashboard-agent-common/types'; + +export const selectDashboardAttachmentForSync = ({ + attachments, + currentSavedObjectId, +}: { + attachments: readonly DashboardAttachment[]; + currentSavedObjectId: string | undefined; +}): DashboardAttachment | undefined => { + if (currentSavedObjectId) { + return ( + attachments.find(({ origin }) => origin === currentSavedObjectId) ?? + (attachments.length === 1 ? attachments[0] : undefined) + ); + } + + return attachments.find(({ origin }) => origin === undefined) ?? attachments.at(0); +}; diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/index.test.tsx b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/index.test.tsx index c9fb8a46dac97..118494949cfe0 100644 --- a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/index.test.tsx +++ b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/index.test.tsx @@ -181,12 +181,14 @@ describe('registerDashboardAttachmentUiDefinition', () => { return { agentBuilder, + addAttachment: mockAddAttachment, dashboardPlugin, unifiedSearch, dashboardLocator: undefined, dashboardAppClientApi$, addAttachmentType, updateAttachmentOrigin, + findDashboardsService, chat$, }; }; @@ -216,7 +218,7 @@ describe('registerDashboardAttachmentUiDefinition', () => { }); describe('onAttachmentMount - origin sync', () => { - it('updates origin when new dashboard is saved', () => { + it('updates origin when new dashboard is saved', async () => { const { getAttachment } = createMockAttachment('attachment-1'); const mockApi = createMockDashboardApi(); @@ -232,6 +234,7 @@ describe('registerDashboardAttachmentUiDefinition', () => { dashboardId: 'new-dashboard-id', dashboardState: mockSavedDashboardState, }); + await Promise.resolve(); expect(updateOrigin).toHaveBeenCalledWith('new-dashboard-id'); // Undefined doesn't trigger @@ -241,12 +244,13 @@ describe('registerDashboardAttachmentUiDefinition', () => { dashboardId: undefined, dashboardState: mockSavedDashboardState, }); + await Promise.resolve(); expect(updateOrigin).not.toHaveBeenCalled(); cleanup?.(); }); - it('does not update origin when attachment is linked to a different dashboard', () => { + it('does not update origin when attachment is linked to a different dashboard', async () => { const { getAttachment } = createMockAttachment('attachment-1', 'original-dashboard-id'); const mockApi = createMockDashboardApi('different-dashboard-id'); @@ -260,11 +264,42 @@ describe('registerDashboardAttachmentUiDefinition', () => { dashboardId: 'newly-saved-id', dashboardState: mockSavedDashboardState, }); + await Promise.resolve(); expect(updateOrigin).not.toHaveBeenCalled(); cleanup?.(); }); + it('relinks to the current dashboard when the attachment origin points to a deleted dashboard', async () => { + const { getAttachment } = createMockAttachment('attachment-1', 'deleted-dashboard-id'); + const mockApi = createMockDashboardApi('current-dashboard-id'); + + deps.findDashboardsService.mockResolvedValue({ + findById: jest.fn().mockResolvedValue({ status: 'error' }), + }); + unregister(); + unregister = registerDashboardAttachmentUiDefinition(deps); + uiDefinition = deps.addAttachmentType.mock.calls.at(-1)?.[1]; + + const cleanup = uiDefinition.onAttachmentMount!({ + getAttachment, + updateOrigin, + }); + deps.dashboardAppClientApi$.next(mockApi as unknown as DashboardApi); + + mockApi.emitSave({ + previousDashboardId: 'current-dashboard-id', + dashboardId: 'current-dashboard-id', + dashboardState: mockSavedDashboardState, + }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(updateOrigin).toHaveBeenCalledWith('current-dashboard-id'); + cleanup?.(); + }); + it('cleans up subscriptions properly', () => { const { getAttachment } = createMockAttachment('attachment-1'); const mockApi = createMockDashboardApi(); diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/index.tsx b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/index.tsx index 79ccdaac38ddd..a0333a5d11369 100644 --- a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/index.tsx +++ b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/index.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { EMPTY, switchMap } from 'rxjs'; import { i18n } from '@kbn/i18n'; import type { AttachmentLifecycleParams } from '@kbn/agent-builder-browser/attachments'; import { ActionButtonType } from '@kbn/agent-builder-browser/attachments'; @@ -19,15 +20,12 @@ import type { import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { AgentBuilderPluginStart } from '@kbn/agent-builder-plugin/public'; import { DashboardCanvasContent } from './canvas_integration/dashboard_canvas_content'; -import { previewAttachmentInDashboard } from './dashboard_integration/preview_attachment_in_dashboard'; -import { onAttachmentMount } from './on_attachment_mount'; +import { createDashboardAppIntegration$ } from './dashboard_integration/dashboard_app_integration'; +import { previewAttachmentInDashboard } from './dashboard_integration/preview_attachment'; +import { selectDashboardAttachmentForSync } from './dashboard_integration/select_dashboard_attachment_for_sync'; export const registerDashboardAttachmentUiDefinition = ({ - agentBuilder: { - attachments, - addAttachment, - events: { chat$ }, - }, + agentBuilder, dashboardLocator, unifiedSearch, dashboardPlugin, @@ -37,7 +35,10 @@ export const registerDashboardAttachmentUiDefinition = ({ unifiedSearch: UnifiedSearchPublicPluginStart; dashboardPlugin: DashboardStart; }): (() => void) => { + const { attachments } = agentBuilder; let dashboardApi: DashboardApi | undefined; + let nextMountedAttachmentId = 0; + const mountedDashboardAttachments = new Map DashboardAttachment>(); // maintains a dashboardApi reference for access in getActionButtons const dashboardAppApiSubscription = dashboardPlugin.dashboardAppClientApi$.subscribe((api) => { dashboardApi = api; @@ -49,6 +50,14 @@ export const registerDashboardAttachmentUiDefinition = ({ const result = await findDashboardsService.findById(dashboardId); return result.status === 'success'; }; + const getSyncAttachment = (currentSavedObjectId: string | undefined) => + selectDashboardAttachmentForSync({ + attachments: Array.from(mountedDashboardAttachments.values(), (getAttachment) => + getAttachment() + ), + currentSavedObjectId, + }); + attachments.addAttachmentType(DASHBOARD_ATTACHMENT_TYPE, { getLabel: (attachment) => { return ( @@ -59,8 +68,30 @@ export const registerDashboardAttachmentUiDefinition = ({ ); }, getIcon: () => 'productDashboard', - onAttachmentMount: (params: AttachmentLifecycleParams) => - onAttachmentMount({ ...params, dashboardPlugin, chat$, addAttachment }), + onAttachmentMount: (params: AttachmentLifecycleParams) => { + const mountedAttachmentId = nextMountedAttachmentId++; + mountedDashboardAttachments.set(mountedAttachmentId, params.getAttachment); + const apiSubscription = dashboardPlugin.dashboardAppClientApi$ + .pipe( + switchMap((api) => + api + ? createDashboardAppIntegration$({ + ...params, + agentBuilder, + api, + checkSavedDashboardExist, + getSyncAttachment, + }) + : EMPTY + ) + ) + .subscribe(); + + return () => { + apiSubscription.unsubscribe(); + mountedDashboardAttachments.delete(mountedAttachmentId); + }; + }, renderCanvasContent: (props, callbacks) => ( ; - onSave$: Subject; - layout$: BehaviorSubject; - children$: BehaviorSubject>; - title$: BehaviorSubject; - description$: BehaviorSubject; - filters$: BehaviorSubject; - query$: BehaviorSubject; - timeRange$: BehaviorSubject; - projectRouting$: BehaviorSubject; - hideTitle$: BehaviorSubject; - hideBorder$: BehaviorSubject; - settings: { - autoApplyFilters$: BehaviorSubject; - syncColors$: BehaviorSubject; - syncCursor$: BehaviorSubject; - syncTooltips$: BehaviorSubject; - useMargins$: BehaviorSubject; - }; - setState: jest.Mock; - getSerializedState: jest.Mock; -} - -interface MockChildApi { - uuid: string; - hasUnsavedChanges$: BehaviorSubject; - resetUnsavedChanges: jest.Mock; - serializeState: jest.Mock; - applySerializedState: jest.Mock; -} - -const mockSavedDashboardState = { - title: 'Saved Dashboard', - description: '', - panels: [], -} as unknown as DashboardSaveEvent['dashboardState']; - -const createMockDashboardApi = (): MockDashboardApi => ({ - savedObjectId$: new BehaviorSubject(undefined), - onSave$: new Subject(), - layout$: new BehaviorSubject([]), - children$: new BehaviorSubject>({ - 'panel-1': { - uuid: 'panel-1', - hasUnsavedChanges$: new BehaviorSubject(false), - resetUnsavedChanges: jest.fn(), - serializeState: jest.fn().mockReturnValue({}), - applySerializedState: jest.fn(), - }, - }), - title$: new BehaviorSubject('Test Dashboard'), - description$: new BehaviorSubject('Test Description'), - filters$: new BehaviorSubject([]), - query$: new BehaviorSubject({ query: '', language: 'kuery' }), - timeRange$: new BehaviorSubject({ from: 'now-15m', to: 'now' }), - projectRouting$: new BehaviorSubject(undefined), - hideTitle$: new BehaviorSubject(false), - hideBorder$: new BehaviorSubject(false), - settings: { - autoApplyFilters$: new BehaviorSubject(true), - syncColors$: new BehaviorSubject(false), - syncCursor$: new BehaviorSubject(true), - syncTooltips$: new BehaviorSubject(true), - useMargins$: new BehaviorSubject(true), - }, - setState: jest.fn(), - getSerializedState: jest.fn().mockReturnValue({ - attributes: { - title: 'Test Dashboard', - description: 'Test Description', - panels: [], - }, - }), -}); - -const createMockAttachment = (overrides?: Partial): DashboardAttachment => ({ - id: 'test-attachment-id', - type: DASHBOARD_ATTACHMENT_TYPE, - data: { - title: 'Test Dashboard', - description: 'Test Description', - panels: [], - }, - origin: undefined, - ...overrides, -}); - -describe('onAttachmentMount - manual changes sync', () => { - let mockApi: MockDashboardApi; - let dashboardAppClientApi$: BehaviorSubject; - let chat$: Subject; - let getAttachment: jest.Mock; - let updateOrigin: jest.Mock; - let addAttachment: jest.Mock; - let cleanup: () => void; - - beforeEach(() => { - jest.useFakeTimers(); - mockApi = createMockDashboardApi(); - dashboardAppClientApi$ = new BehaviorSubject(undefined); - chat$ = new Subject(); - getAttachment = jest.fn().mockReturnValue(createMockAttachment()); - updateOrigin = jest.fn().mockResolvedValue(undefined); - addAttachment = jest.fn(); - }); - - afterEach(() => { - cleanup?.(); - jest.useRealTimers(); - }); - - const mountHandler = () => { - cleanup = onAttachmentMount({ - dashboardPlugin: { - dashboardAppClientApi$, - }, - chat$, - getAttachment, - updateOrigin, - addAttachment, - } as unknown as OnAttachmentMountParams); - }; - - describe('subscription setup', () => { - it('sets up manual changes subscription when api becomes available', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - // Verify subscription is set up by checking that observables can emit - expect(() => mockApi.title$.next('New Title')).not.toThrow(); - }); - - it('cleans up manual changes subscription when api becomes undefined', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - dashboardAppClientApi$.next(undefined); - - // After cleanup, emitting should not trigger addAttachment - mockApi.title$.next('New Title'); - jest.advanceTimersByTime(200); - - expect(addAttachment).not.toHaveBeenCalled(); - }); - - it('resubscribes when api changes', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - const newMockApi = createMockDashboardApi(); - dashboardAppClientApi$.next(newMockApi); - - // Old api emissions should not trigger addAttachment - mockApi.title$.next('Old API Title'); - jest.advanceTimersByTime(200); - expect(addAttachment).not.toHaveBeenCalled(); - - // New api emissions should trigger addAttachment - newMockApi.title$.next('New API Title'); - jest.advanceTimersByTime(200); - expect(addAttachment).toHaveBeenCalled(); - }); - }); - - describe('debouncing', () => { - it('debounces rapid changes with 150ms delay', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - // Emit multiple changes rapidly - mockApi.title$.next('Title 1'); - mockApi.title$.next('Title 2'); - mockApi.title$.next('Title 3'); - - // Before debounce time, addAttachment should not be called - jest.advanceTimersByTime(100); - expect(addAttachment).not.toHaveBeenCalled(); - - // After debounce time, addAttachment should be called once - jest.advanceTimersByTime(100); - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - - it('resets debounce timer on each emission', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.title$.next('Title 1'); - jest.advanceTimersByTime(100); - - mockApi.title$.next('Title 2'); - jest.advanceTimersByTime(100); - - // Still not called because timer was reset - expect(addAttachment).not.toHaveBeenCalled(); - - jest.advanceTimersByTime(100); - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - }); - - describe('observable merging', () => { - it('triggers sync when title changes', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.title$.next('New Title'); - jest.advanceTimersByTime(200); - - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - - it('triggers sync when description changes', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.description$.next('New Description'); - jest.advanceTimersByTime(200); - - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - - it('triggers sync when filters change', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.filters$.next([{ meta: {}, query: {} }]); - jest.advanceTimersByTime(200); - - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - - it('triggers sync when query changes', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.query$.next({ query: 'new query', language: 'kuery' }); - jest.advanceTimersByTime(200); - - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - - it('triggers sync when timeRange changes', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.timeRange$.next({ from: 'now-1h', to: 'now' }); - jest.advanceTimersByTime(200); - - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - - it('triggers sync when layout changes', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.layout$.next([{ id: 'panel-1', row: 0, column: 0 }]); - jest.advanceTimersByTime(200); - - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - - it('triggers sync when child state changes', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - jest.advanceTimersByTime(150); - mockApi.children$.value['panel-1'].hasUnsavedChanges$.next(true); - jest.advanceTimersByTime(300); - - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - - it('triggers sync when settings change', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.settings.useMargins$.next(false); - jest.advanceTimersByTime(200); - - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - - it('batches multiple different observable emissions', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.title$.next('New Title'); - mockApi.description$.next('New Description'); - mockApi.filters$.next([]); - jest.advanceTimersByTime(200); - - // All changes batched into single addAttachment call - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - }); - - describe('savedObjectId filtering', () => { - it('does not sync when viewing a saved dashboard that differs from attachment origin', () => { - getAttachment.mockReturnValue(createMockAttachment({ origin: 'attachment-dashboard-id' })); - mockApi.savedObjectId$.next('different-dashboard-id'); - - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.title$.next('New Title'); - jest.advanceTimersByTime(200); - - expect(addAttachment).not.toHaveBeenCalled(); - }); - - it('syncs when viewing the same saved dashboard as attachment origin', () => { - getAttachment.mockReturnValue(createMockAttachment({ origin: 'same-dashboard-id' })); - mockApi.savedObjectId$.next('same-dashboard-id'); - - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.title$.next('New Title'); - jest.advanceTimersByTime(200); - - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - - it('syncs when there is no saved dashboard (unsaved state)', () => { - getAttachment.mockReturnValue(createMockAttachment({ origin: undefined })); - mockApi.savedObjectId$.next(undefined); - - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.title$.next('New Title'); - jest.advanceTimersByTime(200); - - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - }); - - describe('save origin sync', () => { - it('updates origin on the first save of an unsaved dashboard', () => { - getAttachment.mockReturnValue(createMockAttachment({ origin: undefined })); - - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.onSave$.next({ - previousDashboardId: undefined, - dashboardId: 'new-dashboard-id', - dashboardState: mockSavedDashboardState, - }); - - expect(updateOrigin).toHaveBeenCalledWith('new-dashboard-id'); - }); - - it('updates origin on save as when the attachment is linked to the previous dashboard', () => { - getAttachment.mockReturnValue(createMockAttachment({ origin: 'dashboard-a' })); - - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.onSave$.next({ - previousDashboardId: 'dashboard-a', - dashboardId: 'dashboard-b', - dashboardState: mockSavedDashboardState, - }); - - expect(updateOrigin).toHaveBeenCalledWith('dashboard-b'); - }); - - it('does not relink the attachment after navigating to a different saved dashboard', () => { - getAttachment.mockReturnValue(createMockAttachment({ origin: 'dashboard-a' })); - mockApi.savedObjectId$.next('dashboard-b'); - - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.onSave$.next({ - previousDashboardId: 'dashboard-b', - dashboardId: 'dashboard-b', - dashboardState: mockSavedDashboardState, - }); - - expect(updateOrigin).not.toHaveBeenCalled(); - }); - }); - - describe('attachment data', () => { - it('calls addAttachment with correct attachment structure', () => { - const attachment = createMockAttachment({ id: 'my-attachment-id' }); - getAttachment.mockReturnValue(attachment); - mockApi.getSerializedState.mockReturnValue({ - attributes: { - title: 'Serialized Title', - description: 'Serialized Description', - panels: [{ id: 'panel-1' }], - }, - }); - - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.title$.next('New Title'); - jest.advanceTimersByTime(200); - - expect(addAttachment).toHaveBeenCalledWith({ - id: 'my-attachment-id', - type: DASHBOARD_ATTACHMENT_TYPE, - data: expect.any(Object), - }); - }); - - it('does not sync when getSerializedState returns no attributes', () => { - mockApi.getSerializedState.mockReturnValue({ attributes: undefined }); - - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - mockApi.title$.next('New Title'); - jest.advanceTimersByTime(200); - - expect(addAttachment).not.toHaveBeenCalled(); - }); - }); - - describe('skipping initial emissions', () => { - it('skips initial BehaviorSubject emissions and only reacts to changes', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - // After mounting, initial emissions are skipped synchronously - jest.advanceTimersByTime(200); - - // Initial emissions should not trigger addAttachment - expect(addAttachment).not.toHaveBeenCalled(); - - // Actual change should trigger addAttachment - mockApi.title$.next('Changed Title'); - jest.advanceTimersByTime(200); - - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - }); - - describe('cleanup', () => { - it('unsubscribes from all observables on cleanup', () => { - mountHandler(); - dashboardAppClientApi$.next(mockApi); - - cleanup(); - - // After cleanup, changes should not trigger addAttachment - mockApi.title$.next('New Title'); - jest.advanceTimersByTime(200); - - expect(addAttachment).not.toHaveBeenCalled(); - }); - }); - - describe('handles missing settings observables', () => { - it('works when some settings observables are undefined', () => { - const apiWithMissingSettings = { - ...mockApi, - settings: { - autoApplyFilters$: undefined, - syncColors$: undefined, - syncCursor$: undefined, - syncTooltips$: undefined, - useMargins$: new BehaviorSubject(true), - }, - } as unknown as MockDashboardApi; - - mountHandler(); - dashboardAppClientApi$.next(apiWithMissingSettings); - - // Should not throw and should still work with available observables - apiWithMissingSettings.title$.next('New Title'); - jest.advanceTimersByTime(200); - - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - - it('works when settings object is undefined', () => { - const apiWithoutSettings = { - ...mockApi, - settings: undefined, - } as unknown as MockDashboardApi; - - mountHandler(); - dashboardAppClientApi$.next(apiWithoutSettings); - - // Should not throw and should still work with available observables - apiWithoutSettings.title$.next('New Title'); - jest.advanceTimersByTime(200); - - expect(addAttachment).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/on_attachment_mount.ts b/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/on_attachment_mount.ts deleted file mode 100644 index 01ce5aa2c96d1..0000000000000 --- a/x-pack/platform/plugins/shared/dashboard_agent/public/attachment_types/on_attachment_mount.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EMPTY, switchMap, type Observable } from 'rxjs'; -import type { AttachmentInput } from '@kbn/agent-builder-common/attachments'; -import type { AttachmentLifecycleParams } from '@kbn/agent-builder-browser/attachments'; -import type { DashboardAttachment } from '@kbn/dashboard-agent-common/types'; -import type { DashboardStart } from '@kbn/dashboard-plugin/public'; -import type { ChatEvent } from '@kbn/agent-builder-common'; -import { createDashboardAttachmentMountSync$ } from './dashboard_integration/create_dashboard_attachment_mount_sync'; - -export interface OnAttachmentMountParams extends AttachmentLifecycleParams { - dashboardPlugin: DashboardStart; - chat$: Observable; - addAttachment: (attachment: AttachmentInput) => void; -} - -/** - * Subscribes dashboard attachment sync when a dashboard app API is available - * and cleans it up when the attachment unmounts. - */ -export const onAttachmentMount = ({ - dashboardPlugin, - chat$, - getAttachment, - updateOrigin, - addAttachment, -}: OnAttachmentMountParams) => { - const apiSubscription = dashboardPlugin.dashboardAppClientApi$ - .pipe( - switchMap((api) => - api - ? createDashboardAttachmentMountSync$({ - api, - chat$, - getAttachment, - updateOrigin, - addAttachment, - }) - : EMPTY - ) - ) - .subscribe(); - - return () => { - apiSubscription.unsubscribe(); - }; -}; diff --git a/x-pack/platform/plugins/shared/dashboard_agent/server/attachment_types/dashboard.test.ts b/x-pack/platform/plugins/shared/dashboard_agent/server/attachment_types/dashboard.test.ts index 8a75e07c9b68e..ce0f0f0e0ba3b 100644 --- a/x-pack/platform/plugins/shared/dashboard_agent/server/attachment_types/dashboard.test.ts +++ b/x-pack/platform/plugins/shared/dashboard_agent/server/attachment_types/dashboard.test.ts @@ -12,7 +12,7 @@ import { } from '@kbn/agent-builder-common/attachments'; import { DASHBOARD_ATTACHMENT_TYPE, - attachmentToDashboardState, + attachmentDataToDashboardState, type DashboardAttachmentData, } from '@kbn/dashboard-agent-common'; import type { DashboardPluginStart } from '@kbn/dashboard-plugin/server'; @@ -52,11 +52,7 @@ const createDashboardClient = ({ ({ read: jest.fn().mockResolvedValue({ id: 'dashboard-1', - data: attachmentToDashboardState({ - id: 'dashboard-1', - type: DASHBOARD_ATTACHMENT_TYPE, - data: dashboardAttachmentData, - }), + data: attachmentDataToDashboardState(dashboardAttachmentData), meta: { outcome: 'exactMatch', updated_at: updatedAt, diff --git a/x-pack/platform/plugins/shared/dashboard_agent/server/attachment_types/dashboard.ts b/x-pack/platform/plugins/shared/dashboard_agent/server/attachment_types/dashboard.ts index 10f2481423648..2f6a1f349c64d 100644 --- a/x-pack/platform/plugins/shared/dashboard_agent/server/attachment_types/dashboard.ts +++ b/x-pack/platform/plugins/shared/dashboard_agent/server/attachment_types/dashboard.ts @@ -14,7 +14,7 @@ import deepEqual from 'fast-deep-equal'; import { DASHBOARD_ATTACHMENT_TYPE, dashboardAttachmentDataSchema, - dashboardStateToAttachment, + dashboardStateToAttachmentData, isSection, type DashboardAttachmentData, } from '@kbn/dashboard-agent-common'; @@ -70,7 +70,7 @@ export const createDashboardAttachmentType = ({ return undefined; } - return dashboardStateToAttachment(dashboard.data); + return dashboardStateToAttachmentData(dashboard.data); } catch (error) { logger.warn(`Failed to resolve dashboard attachment for origin "${origin}": ${error}`); return undefined; @@ -99,7 +99,7 @@ export const createDashboardAttachmentType = ({ return false; } // if the content is equal, we don't consider it stale - return !deepEqual(dashboardStateToAttachment(dashboard.data), latestVersion.data); + return !deepEqual(dashboardStateToAttachmentData(dashboard.data), latestVersion.data); } return false; } catch (error) { diff --git a/x-pack/platform/plugins/shared/dashboard_agent/server/sml_types/dashboard.test.ts b/x-pack/platform/plugins/shared/dashboard_agent/server/sml_types/dashboard.test.ts index dc4b9f0e9f992..9e08fa5b22808 100644 --- a/x-pack/platform/plugins/shared/dashboard_agent/server/sml_types/dashboard.test.ts +++ b/x-pack/platform/plugins/shared/dashboard_agent/server/sml_types/dashboard.test.ts @@ -7,8 +7,11 @@ import type { Logger } from '@kbn/logging'; import type { DashboardPluginStart, DashboardState } from '@kbn/dashboard-plugin/server'; -import type { DashboardAttachment, DashboardAttachmentData } from '@kbn/dashboard-agent-common'; -import { DASHBOARD_ATTACHMENT_TYPE, attachmentToDashboardState } from '@kbn/dashboard-agent-common'; +import type { DashboardAttachmentData } from '@kbn/dashboard-agent-common'; +import { + DASHBOARD_ATTACHMENT_TYPE, + attachmentDataToDashboardState, +} from '@kbn/dashboard-agent-common'; import { createDashboardSmlType } from './dashboard'; const dashboardAttachmentData: DashboardAttachmentData = { @@ -83,13 +86,7 @@ const createDashboardClient = ({ ({ read: jest.fn().mockResolvedValue({ id, - data: - data ?? - attachmentToDashboardState({ - id, - type: DASHBOARD_ATTACHMENT_TYPE, - data: attachmentData, - } as DashboardAttachment), + data: data ?? attachmentDataToDashboardState(attachmentData), meta: { outcome: 'exactMatch', version: 'v1', diff --git a/x-pack/platform/plugins/shared/dashboard_agent/server/sml_types/dashboard.ts b/x-pack/platform/plugins/shared/dashboard_agent/server/sml_types/dashboard.ts index d7224d2f14763..9304d1f3f7d69 100644 --- a/x-pack/platform/plugins/shared/dashboard_agent/server/sml_types/dashboard.ts +++ b/x-pack/platform/plugins/shared/dashboard_agent/server/sml_types/dashboard.ts @@ -6,7 +6,10 @@ */ import type { SmlTypeDefinition } from '@kbn/agent-builder-plugin/server'; -import { DASHBOARD_ATTACHMENT_TYPE, dashboardStateToAttachment } from '@kbn/dashboard-agent-common'; +import { + DASHBOARD_ATTACHMENT_TYPE, + dashboardStateToAttachmentData, +} from '@kbn/dashboard-agent-common'; import type { DashboardPanel, DashboardPluginStart, @@ -121,7 +124,7 @@ export const createDashboardSmlType = ({ return { type: DASHBOARD_ATTACHMENT_TYPE, - data: dashboardStateToAttachment(dashboard.data), + data: dashboardStateToAttachmentData(dashboard.data), origin: dashboard.id, }; } catch (error) { diff --git a/x-pack/platform/plugins/shared/dashboard_agent/server/tools/manage_dashboard/operations.ts b/x-pack/platform/plugins/shared/dashboard_agent/server/tools/manage_dashboard/operations.ts index 7b731dbc2a4cb..0a54b3d1e841b 100644 --- a/x-pack/platform/plugins/shared/dashboard_agent/server/tools/manage_dashboard/operations.ts +++ b/x-pack/platform/plugins/shared/dashboard_agent/server/tools/manage_dashboard/operations.ts @@ -13,7 +13,7 @@ import type { DashboardAttachmentData, DashboardSection, } from '@kbn/dashboard-agent-common'; -import { panelGridSchema } from '@kbn/dashboard-agent-common'; +import { panelGridSchema, sectionGridSchema } from '@kbn/dashboard-agent-common'; import type { Logger } from '@kbn/core/server'; import { MARKDOWN_EMBEDDABLE_TYPE } from '@kbn/dashboard-markdown/server'; import { toEmbeddablePanel } from '@kbn/dashboard-agent-common'; @@ -56,10 +56,6 @@ const attachmentWithGridSchema = z.object({ ), }); -const sectionGridSchema = z.object({ - y: z.number().int().min(0).describe('Section position in outer dashboard grid coordinates.'), -}); - export const addPanelsFromAttachmentsOperationSchema = z.object({ operation: z.literal('add_panels_from_attachments'), items: z diff --git a/x-pack/platform/plugins/shared/dashboard_agent/server/tools/manage_dashboard/utils.ts b/x-pack/platform/plugins/shared/dashboard_agent/server/tools/manage_dashboard/utils.ts index a6a5b1e5126f1..28c1cf119d0dd 100644 --- a/x-pack/platform/plugins/shared/dashboard_agent/server/tools/manage_dashboard/utils.ts +++ b/x-pack/platform/plugins/shared/dashboard_agent/server/tools/manage_dashboard/utils.ts @@ -8,7 +8,7 @@ import type { AttachmentStateManager } from '@kbn/agent-builder-server/attachments'; import { AttachmentType } from '@kbn/agent-builder-common/attachments'; import type { AttachmentPanel, DashboardAttachmentData } from '@kbn/dashboard-agent-common'; -import { DASHBOARD_ATTACHMENT_TYPE } from '@kbn/dashboard-agent-common'; +import { DASHBOARD_ATTACHMENT_TYPE, isDashboardAttachment } from '@kbn/dashboard-agent-common'; import type { Logger } from '@kbn/core/server'; import { type AttachmentVersion, getLatestVersion } from '@kbn/agent-builder-common/attachments'; import type { LensApiSchemaType } from '@kbn/lens-embeddable-utils'; @@ -161,15 +161,13 @@ export const retrieveLatestVersion = ( throw new Error(`Dashboard attachment "${attachmentId}" not found.`); } - if (attachment.type !== DASHBOARD_ATTACHMENT_TYPE) { + if (!isDashboardAttachment(attachment)) { throw new Error( `Attachment "${attachmentId}" is not a ${DASHBOARD_ATTACHMENT_TYPE} attachment.` ); } - const latestVersion = getLatestVersion( - attachment - ) as unknown as AttachmentVersion; + const latestVersion = getLatestVersion(attachment); if (!latestVersion) { throw new Error(`Could not retrieve latest version of dashboard attachment "${attachmentId}".`); }