diff --git a/src/platform/packages/shared/presentation/presentation_publishing/index.ts b/src/platform/packages/shared/presentation/presentation_publishing/index.ts index 0fd431032c908..389747bcc67b7 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/index.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/index.ts @@ -49,6 +49,8 @@ export { type AppliesFilters, apiAppliesTimeslice, type AppliesTimeslice, + apiHasUseGlobalFiltersSetting, + type HasUseGlobalFiltersSetting, } from './interfaces/fetch/applies_filters'; export { apiPublishesFilters, diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/applies_filters.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/applies_filters.ts index 6b7dd335a4151..6a3b065fecdbf 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/applies_filters.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/applies_filters.ts @@ -26,3 +26,15 @@ export interface AppliesTimeslice { export const apiAppliesTimeslice = (unknownApi: unknown): unknownApi is AppliesTimeslice => { return Boolean(unknownApi && (unknownApi as AppliesTimeslice)?.appliedTimeslice$ !== undefined); }; + +export interface HasUseGlobalFiltersSetting { + useGlobalFilters$: PublishingSubject; +} + +export const apiHasUseGlobalFiltersSetting = ( + unknownApi: unknown +): unknownApi is HasUseGlobalFiltersSetting => { + return Boolean( + unknownApi && (unknownApi as HasUseGlobalFiltersSetting)?.useGlobalFilters$ !== undefined + ); +}; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts index 369fe9eeea6bc..f8a556a467caa 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts @@ -43,6 +43,7 @@ import { initializeUnsavedChangesManager } from './unsaved_changes_manager'; import { initializeViewModeManager } from './view_mode_manager'; import type { DashboardReadResponseBody } from '../../server'; import { initializePauseFetchManager } from './pause_fetch_manager'; +import { initializeRelatedPanelsManager } from './related_panels_manager'; export function getDashboardApi({ creationOptions, @@ -169,6 +170,8 @@ export function getDashboardApi({ const pauseFetchManager = initializePauseFetchManager(filtersManager); + const relatedPanelsManager = initializeRelatedPanelsManager(trackPanel, layoutManager); + const dashboardApi = { ...viewModeManager.api, ...dataLoadingManager.api, @@ -290,6 +293,7 @@ export function getDashboardApi({ ...layoutManager.internalApi, ...unifiedSearchManager.internalApi, ...esqlVariablesManager.api, + ...relatedPanelsManager.api, dashboardContainerRef$, setDashboardContainerRef: (ref: HTMLElement | null) => dashboardContainerRef$.next(ref), }; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/related_panels_manager.test.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/related_panels_manager.test.ts new file mode 100644 index 0000000000000..879a7f5da8692 --- /dev/null +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/related_panels_manager.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Subscription } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; +import type { initializeLayoutManager } from './layout_manager'; +import { initializeRelatedPanelsManager } from './related_panels_manager'; +import type { initializeTrackPanel } from './track_panel'; + +const mockChildren = { + panel: { uuid: 'panel' }, + panel2: { uuid: 'panel2' }, + control: { + uuid: 'control', + appliedFilters$: new BehaviorSubject({}), + useGlobalFilters$: new BehaviorSubject(true), + }, + control2: { + uuid: 'control2', + appliedFilters$: new BehaviorSubject({}), + useGlobalFilters$: new BehaviorSubject(true), + }, + controlWithoutGlobalFilters: { + uuid: 'controlWithoutGlobalFilters', + appliedFilters$: new BehaviorSubject({}), + useGlobalFilters$: new BehaviorSubject(false), + }, + esqlControl: { + uuid: 'esqlControl', + esqlVariable$: new BehaviorSubject({ key: 'variable' }), + useGlobalFilters$: new BehaviorSubject(true), + }, + esqlControl2: { + uuid: 'esqlControl2', + esqlVariable$: new BehaviorSubject({ key: 'variable2' }), + useGlobalFilters$: new BehaviorSubject(true), + }, + esqlPanel: { + uuid: 'esqlPanel', + query$: new BehaviorSubject({ esql: 'FROM index | WHERE field == ?variable' }), + }, + panelInSection: { uuid: 'panelInSection', sectionId: 'a' }, + controlInSection: { + uuid: 'controlInSection', + appliedFilters$: new BehaviorSubject({}), + useGlobalFilters$: new BehaviorSubject(true), + sectionId: 'a', + }, +}; + +const mockSections: Record = { + panelInSection: 'a', + controlInSection: 'a', +}; + +const getMockedDeps = () => { + const mockTrackPanel = { + focusedPanelId$: new BehaviorSubject(undefined), + } as unknown as ReturnType; + const mockLayoutManager = { + api: { + getDashboardPanelFromId: jest + .fn() + .mockImplementation((uuid) => ({ grid: { sectionId: mockSections[uuid] ?? undefined } })), + layout$: new BehaviorSubject(undefined), + children$: new BehaviorSubject(mockChildren), + }, + } as unknown as ReturnType; + return { mockTrackPanel, mockLayoutManager }; +}; + +describe('initializeRelatedPanelsManager', () => { + describe('with no focused panel', () => { + let subscription: Subscription; + afterEach(() => { + subscription.unsubscribe(); + }); + const { mockLayoutManager, mockTrackPanel } = getMockedDeps(); + const relatedPanelsManager = initializeRelatedPanelsManager(mockTrackPanel, mockLayoutManager); + test('should not compute', (done) => { + subscription = relatedPanelsManager.api.arePanelsRelated$.subscribe(() => { + expect(mockLayoutManager.api.getDashboardPanelFromId).not.toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('with focused panel', () => { + const { mockLayoutManager, mockTrackPanel } = getMockedDeps(); + mockTrackPanel.focusedPanelId$.next('panel'); + + const relatedPanelsManager = initializeRelatedPanelsManager(mockTrackPanel, mockLayoutManager); + + test('should compute', (done) => { + relatedPanelsManager.api.arePanelsRelated$.subscribe(() => { + expect(mockLayoutManager.api.getDashboardPanelFromId).toHaveBeenCalled(); + done(); + }); + }); + + const arePanelsRelated = relatedPanelsManager.api.arePanelsRelated$.value; + + test('should mark a panel without a section ID as related to all filter controls without section IDs', () => { + expect(arePanelsRelated('control', 'panel')).toBe(true); + expect(arePanelsRelated('control2', 'panel')).toBe(true); + expect(arePanelsRelated('control', 'panel2')).toBe(true); + expect(arePanelsRelated('control2', 'panel2')).toBe(true); + }); + + test('should not mark a panel as related to other panels', () => { + expect(arePanelsRelated('panel', 'panel2')).toBe(false); + expect(arePanelsRelated('panel', 'panelInSection')).toBe(false); + }); + + test('should not mark a panel without a section ID as related to controls in a section', () => { + expect(arePanelsRelated('panel', 'controlInSection')).toBe(false); + }); + + test('should mark panels in sections as related to global filter controls, and controls in their section', () => { + expect(arePanelsRelated('panelInSection', 'controlInSection')).toBe(true); + expect(arePanelsRelated('panelInSection', 'control')).toBe(true); + expect(arePanelsRelated('panelInSection', 'control2')).toBe(true); + }); + + test('should not mark panels as related to themselves', () => { + expect(arePanelsRelated('panel', 'panel')).toBe(false); + }); + + test('should mark ES|QL panels as related to the ES|QL controls whose variables they include', () => { + expect(arePanelsRelated('esqlPanel', 'esqlControl')).toBe(true); + expect(arePanelsRelated('esqlPanel', 'esqlControl2')).toBe(false); + }); + + test('should mark controls as related to each other only if they use global filters', () => { + expect(arePanelsRelated('control', 'control2')).toBe(true); + expect(arePanelsRelated('control', 'controlWithoutGlobalFilters')).toBe(false); + }); + + test('should compute relations both ways', () => { + expect(arePanelsRelated('control', 'panel')).toBe(true); + expect(arePanelsRelated('controlInSection', 'panel')).toBe(false); + expect(arePanelsRelated('controlInSection', 'panelInSection')).toBe(true); + expect(arePanelsRelated('esqlControl', 'esqlPanel')).toBe(true); + }); + }); +}); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/related_panels_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/related_panels_manager.ts new file mode 100644 index 0000000000000..b2f3df3094453 --- /dev/null +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/related_panels_manager.ts @@ -0,0 +1,219 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import type { PublishingSubject } from '@kbn/presentation-publishing'; +import { apiAppliesFilters, apiHasUseGlobalFiltersSetting } from '@kbn/presentation-publishing'; +import type { Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, filter, map, of, switchMap } from 'rxjs'; +import type { ESQLControlVariable } from '@kbn/esql-types'; +import { apiPublishesESQLVariable } from '@kbn/esql-types'; +import { getESQLQueryVariables } from '@kbn/esql-utils'; +import type { AggregateQuery } from '@kbn/es-query'; +import type { initializeLayoutManager } from './layout_manager'; +import type { initializeTrackPanel } from './track_panel'; + +const GLOBAL = Symbol('GLOBAL'); + +interface SectionFilterEntry { + appliesFilters: Set; + doesNotApplyFilters: Set; +} +const getBlankSectionFilterEntry = () => + ({ + appliesFilters: new Set(), + doesNotApplyFilters: new Set(), + } as SectionFilterEntry); + +interface PublishesESQLQuery { + query$: PublishingSubject; +} +const apiPublishesESQLQuery = (api: unknown): api is PublishesESQLQuery => + Boolean((api as PublishesESQLQuery).query$) && + 'esql' in (api as PublishesESQLQuery).query$?.value; + +export const initializeRelatedPanelsManager = ( + trackPanel: ReturnType, + layoutManager: ReturnType +) => { + const { focusedPanelId$ } = trackPanel; + const { children$, layout$, getDashboardPanelFromId } = layoutManager.api; + const arePanelsRelated$ = new BehaviorSubject<(a: string, b: string) => boolean>(() => false); + + /** + * Iterate through the current children$ and pipe them to a stream of: + * - Which child UUIDs apply filters and which are only affected by filters, grouped by sectionID + * - Which child UUIDs publish ES|QL variables, and which ones publish ES|QL queries that may be affected + * by these variables + */ + const childUUIDsIndexed$ = combineLatest([ + children$, + focusedPanelId$, + layout$, // Update on layout change to get the most recent sectionIds + ]).pipe( + // Skip calculations if there is no focused panel + filter(([_, focusedPanelId]) => Boolean(focusedPanelId)), + switchMap(([children]) => { + const childrenBySectionAndFilterApplication = new Map(); + const uuidsUsingGlobalFilters = new Set(); + + const esqlVariableChildrenByUUID: Array< + Observable<{ uuid: string; variable: ESQLControlVariable }> + > = []; + const esqlQueryChildrenByUUID: Array> = []; + + for (const child of Object.values(children)) { + // Group all panels by section ID based on whether or not they apply filters + const layoutPanel = getDashboardPanelFromId(child.uuid); + + const sectionId = layoutPanel.grid.sectionId ?? GLOBAL; + const nextSectionEntry = + childrenBySectionAndFilterApplication.get(sectionId) ?? getBlankSectionFilterEntry(); + + if (apiAppliesFilters(child)) { + nextSectionEntry.appliesFilters.add(child.uuid); + if (apiHasUseGlobalFiltersSetting(child) && child.useGlobalFilters$.value) + uuidsUsingGlobalFilters.add(child.uuid); + } else { + nextSectionEntry.doesNotApplyFilters.add(child.uuid); + } + childrenBySectionAndFilterApplication.set(sectionId, nextSectionEntry); + + // Determine which panels publish ES|QL variables or queries + if (apiPublishesESQLQuery(child)) { + esqlQueryChildrenByUUID.push( + child.query$.pipe(map(({ esql }) => ({ esql, uuid: child.uuid }))) + ); + } else if (apiPublishesESQLVariable(child)) { + esqlVariableChildrenByUUID.push( + child.esqlVariable$.pipe(map((variable) => ({ variable, uuid: child.uuid }))) + ); + } + } + + return combineLatest([ + of( + getRelatedPanelsByAppliedFilters({ + childrenBySectionAndFilterApplication, + uuidsUsingGlobalFilters, + }) + ), + esqlVariableChildrenByUUID.length + ? combineLatest(esqlVariableChildrenByUUID) + : of(undefined), + esqlQueryChildrenByUUID.length ? combineLatest(esqlQueryChildrenByUUID) : of(undefined), + ]); + }) + ); + + const relatedPanelSubscription = childUUIDsIndexed$.subscribe( + ([filterRelatedPanels, esqlVariablesWithUUIDs, esqlQueriesWithUUIDs]) => { + const esqlRelatedPanels = getRelatedPanelsByESQL({ + esqlVariablesWithUUIDs, + esqlQueriesWithUUIDs, + }); + arePanelsRelated$.next((a: string, b: string) => { + const relatedPanelUUIDs = new Set([ + ...(filterRelatedPanels.get(b) ?? []), + ...(esqlRelatedPanels.get(b) ?? []), + ]); + return relatedPanelUUIDs.has(a); + }); + } + ); + + return { + api: { + arePanelsRelated$, + }, + cleanup: () => { + relatedPanelSubscription.unsubscribe(); + }, + }; +}; + +function getRelatedPanelsByAppliedFilters({ + childrenBySectionAndFilterApplication, + uuidsUsingGlobalFilters, +}: { + childrenBySectionAndFilterApplication: Map; + uuidsUsingGlobalFilters: Set; +}) { + const filterRelatedPanels = new Map(); + + const { appliesFilters: globalAppliesFilters } = + childrenBySectionAndFilterApplication.get(GLOBAL) ?? getBlankSectionFilterEntry(); + + for (const [ + sectionId, + { appliesFilters, doesNotApplyFilters }, + ] of childrenBySectionAndFilterApplication.entries()) { + for (const uuid of appliesFilters) { + const relatedPanels = [ + // Apply global panels to everything; apply others only to panels in the same section + ...(sectionId === GLOBAL + ? childrenBySectionAndFilterApplication + .values() + .flatMap((section) => section.doesNotApplyFilters) + : [...doesNotApplyFilters]), + // If this panel uses global filters, other filter-applying panels should be `related` + // to this panel as well + ...(uuidsUsingGlobalFilters.has(uuid) + ? [ + ...Array.from(appliesFilters).filter((id) => id !== uuid), + ...(sectionId === GLOBAL ? [] : [...globalAppliesFilters]), + ] + : []), + ]; + filterRelatedPanels.set(uuid, relatedPanels); + } + + for (const uuid of doesNotApplyFilters) { + const relatedPanels = [ + ...Array.from(appliesFilters).filter((id) => id !== uuid), + ...(sectionId === GLOBAL ? [] : [...globalAppliesFilters]), + ]; + filterRelatedPanels.set(uuid, relatedPanels); + } + } + + return filterRelatedPanels; +} + +function getRelatedPanelsByESQL({ + esqlVariablesWithUUIDs, + esqlQueriesWithUUIDs, +}: { + esqlVariablesWithUUIDs?: Array<{ uuid: string; variable: ESQLControlVariable }>; + esqlQueriesWithUUIDs?: Array<{ uuid: string; esql: string }>; +}) { + const nextESQLRelatedPanels: Map = new Map(); + if (!esqlVariablesWithUUIDs || !esqlQueriesWithUUIDs) return nextESQLRelatedPanels; + + // For each panel with an ES|QL query, check if it has any variables, then create a map of which + // panels publish these corresponding variables + for (const { esql, uuid } of esqlQueriesWithUUIDs) { + const variables = getESQLQueryVariables(esql); + if (variables.length > 0) { + const relatedPanelUUIDs = variables + .map( + (variableName) => + esqlVariablesWithUUIDs.find(({ variable: { key } }) => key === variableName)?.uuid + ) + .filter(Boolean) as string[]; + nextESQLRelatedPanels.set(uuid, relatedPanelUUIDs); + + for (const relatedUUID of relatedPanelUUIDs) { + nextESQLRelatedPanels.set(relatedUUID, [ + ...(nextESQLRelatedPanels.get(relatedUUID) ?? []), + uuid, + ]); + } + } + } + return nextESQLRelatedPanels; +} diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts index 4234363c6afec..651d6fd5b651b 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts @@ -190,6 +190,7 @@ export interface DashboardInternalApi { publishedEsqlVariables$: PublishingSubject; unpublishedEsqlVariables$: PublishingSubject; publishVariables: () => void; + arePanelsRelated$: BehaviorSubject<(a: string, b: string) => boolean>; } export interface DashboardUser { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid_item.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid_item.tsx index 90264448c3f1f..f2c7a335f0b1e 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid_item.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid_item.tsx @@ -62,6 +62,7 @@ export const Item = React.forwardRef( useMargins, viewMode, dashboardContainerRef, + arePanelsRelated, ] = useBatchedPublishingSubjects( dashboardApi.highlightPanelId$, dashboardApi.scrollToPanelId$, @@ -69,13 +70,17 @@ export const Item = React.forwardRef( dashboardApi.focusedPanelId$, dashboardApi.settings.useMargins$, dashboardApi.viewMode$, - dashboardInternalApi.dashboardContainerRef$ + dashboardInternalApi.dashboardContainerRef$, + dashboardInternalApi.arePanelsRelated$ ); const expandPanel = expandedPanelId !== undefined && expandedPanelId === id; const hidePanel = expandedPanelId !== undefined && expandedPanelId !== id; const focusPanel = focusedPanelId !== undefined && focusedPanelId === id; - const blurPanel = focusedPanelId !== undefined && focusedPanelId !== id; + const blurPanel = + focusedPanelId !== undefined && + focusedPanelId !== id && + !arePanelsRelated(id, focusedPanelId); const classes = classNames('dshDashboardGrid__item', { 'dshDashboardGrid__item--expanded': expandPanel, 'dshDashboardGrid__item--hidden': hidePanel,