diff --git a/src/plugins/dashboard/public/application/actions/filters_notification_badge.test.tsx b/src/plugins/dashboard/public/application/actions/filters_notification_action.test.tsx similarity index 74% rename from src/plugins/dashboard/public/application/actions/filters_notification_badge.test.tsx rename to src/plugins/dashboard/public/application/actions/filters_notification_action.test.tsx index 3b3fb5dde0497..be9dc25f69fb9 100644 --- a/src/plugins/dashboard/public/application/actions/filters_notification_badge.test.tsx +++ b/src/plugins/dashboard/public/application/actions/filters_notification_action.test.tsx @@ -6,12 +6,7 @@ * Side Public License, v 1. */ -import { - IContainer, - ErrorEmbeddable, - isErrorEmbeddable, - FilterableEmbeddable, -} from '@kbn/embeddable-plugin/public'; +import { ErrorEmbeddable, isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public'; import { ContactCardEmbeddable, CONTACT_CARD_EMBEDDABLE, @@ -25,16 +20,13 @@ import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; import { getSampleDashboardInput } from '../test_helpers'; import { pluginServices } from '../../services/plugin_services'; import { DashboardContainer } from '../embeddable/dashboard_container'; -import { FiltersNotificationBadge } from './filters_notification_badge'; +import { FiltersNotificationAction } from './filters_notification_action'; const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); pluginServices.getServices().embeddable.getEmbeddableFactory = jest .fn() .mockReturnValue(mockEmbeddableFactory); -let action: FiltersNotificationBadge; -let container: DashboardContainer; -let embeddable: ContactCardEmbeddable & FilterableEmbeddable; const mockGetFilters = jest.fn(async () => [] as Filter[]); const mockGetQuery = jest.fn(async () => undefined as Query | AggregateQuery | undefined); @@ -58,46 +50,54 @@ const getMockPhraseFilter = (key: string, value: string) => { }; }; -beforeEach(async () => { - container = new DashboardContainer(getSampleDashboardInput()); - +const buildEmbeddable = async (input?: Partial) => { + const container = new DashboardContainer(getSampleDashboardInput()); const contactCardEmbeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, ContactCardEmbeddable >(CONTACT_CARD_EMBEDDABLE, { firstName: 'Kibanana', + viewMode: ViewMode.EDIT, + ...input, }); if (isErrorEmbeddable(contactCardEmbeddable)) { throw new Error('Failed to create embeddable'); } - action = new FiltersNotificationBadge(); - embeddable = embeddablePluginMock.mockFilterableEmbeddable(contactCardEmbeddable, { + const embeddable = embeddablePluginMock.mockFilterableEmbeddable(contactCardEmbeddable, { getFilters: () => mockGetFilters(), getQuery: () => mockGetQuery(), }); -}); + + return embeddable; +}; + +const action = new FiltersNotificationAction(); test('Badge is incompatible with Error Embeddables', async () => { - const errorEmbeddable = new ErrorEmbeddable( - 'Wow what an awful error', - { id: ' 404' }, - embeddable.getRoot() as IContainer - ); + const errorEmbeddable = new ErrorEmbeddable('Wow what an awful error', { id: ' 404' }); expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false); }); test('Badge is not shown when panel has no app-level filters or queries', async () => { + const embeddable = await buildEmbeddable(); expect(await action.isCompatible({ embeddable })).toBe(false); }); test('Badge is shown when panel has at least one app-level filter', async () => { + const embeddable = await buildEmbeddable(); mockGetFilters.mockResolvedValue([getMockPhraseFilter('fieldName', 'someValue')] as Filter[]); expect(await action.isCompatible({ embeddable })).toBe(true); }); test('Badge is shown when panel has at least one app-level query', async () => { + const embeddable = await buildEmbeddable(); mockGetQuery.mockResolvedValue({ sql: 'SELECT * FROM test_dataview' } as AggregateQuery); expect(await action.isCompatible({ embeddable })).toBe(true); }); + +test('Badge is not shown in view mode', async () => { + const embeddable = await buildEmbeddable({ viewMode: ViewMode.VIEW }); + expect(await action.isCompatible({ embeddable })).toBe(false); +}); diff --git a/src/plugins/dashboard/public/application/actions/filters_notification_badge.tsx b/src/plugins/dashboard/public/application/actions/filters_notification_action.tsx similarity index 64% rename from src/plugins/dashboard/public/application/actions/filters_notification_badge.tsx rename to src/plugins/dashboard/public/application/actions/filters_notification_action.tsx index 6dbe7d5dbe3c9..b7ee2311ebd18 100644 --- a/src/plugins/dashboard/public/application/actions/filters_notification_badge.tsx +++ b/src/plugins/dashboard/public/application/actions/filters_notification_action.tsx @@ -8,15 +8,17 @@ import React from 'react'; -import { EditPanelAction, isFilterableEmbeddable } from '@kbn/embeddable-plugin/public'; -import { type AggregateQuery } from '@kbn/es-query'; -import { toMountPoint } from '@kbn/kibana-react-plugin/public'; -import type { ApplicationStart } from '@kbn/core/public'; -import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; -import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; +import { EditPanelAction, isFilterableEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public'; import { type IEmbeddable, isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; +import { KibanaThemeProvider, reactToUiComponent } from '@kbn/kibana-react-plugin/public'; +import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; +import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; +import type { ApplicationStart } from '@kbn/core/public'; +import { type AggregateQuery } from '@kbn/es-query'; +import { I18nProvider } from '@kbn/i18n-react'; -import { dashboardFilterNotificationBadge } from '../../dashboard_strings'; +import { FiltersNotificationPopover } from './filters_notification_popover'; +import { dashboardFilterNotificationAction } from '../../dashboard_strings'; import { pluginServices } from '../../services/plugin_services'; export const BADGE_FILTERS_NOTIFICATION = 'ACTION_FILTERS_NOTIFICATION'; @@ -25,27 +27,57 @@ export interface FiltersNotificationActionContext { embeddable: IEmbeddable; } -export class FiltersNotificationBadge implements Action { +export class FiltersNotificationAction implements Action { public readonly id = BADGE_FILTERS_NOTIFICATION; public readonly type = BADGE_FILTERS_NOTIFICATION; public readonly order = 2; - private displayName = dashboardFilterNotificationBadge.getDisplayName(); + private displayName = dashboardFilterNotificationAction.getDisplayName(); private icon = 'filter'; private applicationService; private embeddableService; private settingsService; - private openModal; constructor() { ({ application: this.applicationService, embeddable: this.embeddableService, - overlays: { openModal: this.openModal }, settings: this.settingsService, } = pluginServices.getServices()); } + private FilterIconButton = ({ context }: { context: FiltersNotificationActionContext }) => { + const { embeddable } = context; + + const editPanelAction = new EditPanelAction( + this.embeddableService.getEmbeddableFactory, + this.applicationService as unknown as ApplicationStart, + this.embeddableService.getStateTransfer() + ); + + const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ + uiSettings: this.settingsService.uiSettings, + }); + + return ( + + + + + + + + ); + }; + + public readonly MenuItem = reactToUiComponent(this.FilterIconButton); + public getDisplayName({ embeddable }: FiltersNotificationActionContext) { if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { throw new IncompatibleActionError(); @@ -65,6 +97,7 @@ export class FiltersNotificationBadge implements Action { - const { embeddable } = context; - - const isCompatible = await this.isCompatible({ embeddable }); - if (!isCompatible || !isFilterableEmbeddable(embeddable)) { - throw new IncompatibleActionError(); - } - - const { - uiSettings, - theme: { theme$ }, - } = this.settingsService; - const { getEmbeddableFactory, getStateTransfer } = this.embeddableService; - - const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ - uiSettings, - }); - const editPanelAction = new EditPanelAction( - getEmbeddableFactory, - this.applicationService as unknown as ApplicationStart, - getStateTransfer() - ); - const FiltersNotificationModal = await import('./filters_notification_modal').then( - (m) => m.FiltersNotificationModal - ); - - const session = this.openModal( - toMountPoint( - - session.close()} - /> - , - { theme$ } - ), - { - 'data-test-subj': 'filtersNotificationModal', - } - ); - }; + public execute = async () => {}; } diff --git a/src/plugins/dashboard/public/application/actions/filters_notification_modal.tsx b/src/plugins/dashboard/public/application/actions/filters_notification_modal.tsx deleted file mode 100644 index b188674a85b69..0000000000000 --- a/src/plugins/dashboard/public/application/actions/filters_notification_modal.tsx +++ /dev/null @@ -1,162 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { useState } from 'react'; -import useMount from 'react-use/lib/useMount'; - -import { - EuiButton, - EuiButtonEmpty, - EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, - EuiForm, - EuiFormRow, - EuiLoadingContent, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, -} from '@elastic/eui'; -import { css } from '@emotion/react'; -import { DataView } from '@kbn/data-views-plugin/public'; -import { - EditPanelAction, - FilterableEmbeddable, - IEmbeddable, - ViewMode, -} from '@kbn/embeddable-plugin/public'; -import { - type AggregateQuery, - type Filter, - getAggregateQueryMode, - isOfQueryType, -} from '@kbn/es-query'; -import { FilterItems } from '@kbn/unified-search-plugin/public'; - -import { FiltersNotificationActionContext } from './filters_notification_badge'; -import { DashboardContainer } from '../embeddable'; -import { dashboardFilterNotificationBadge } from '../../dashboard_strings'; - -export interface FiltersNotificationProps { - context: FiltersNotificationActionContext; - displayName: string; - id: string; - editPanelAction: EditPanelAction; - onClose: () => void; -} - -export function FiltersNotificationModal({ - context, - displayName, - id, - editPanelAction, - onClose, -}: FiltersNotificationProps) { - const { embeddable } = context; - const [isLoading, setIsLoading] = useState(true); - const [filters, setFilters] = useState([]); - const [queryString, setQueryString] = useState(''); - const [queryLanguage, setQueryLanguage] = useState<'sql' | 'esql' | undefined>(); - - useMount(() => { - Promise.all([ - (embeddable as IEmbeddable & FilterableEmbeddable).getFilters(), - (embeddable as IEmbeddable & FilterableEmbeddable).getQuery(), - ]).then(([embeddableFilters, embeddableQuery]) => { - setFilters(embeddableFilters); - if (embeddableQuery) { - if (isOfQueryType(embeddableQuery)) { - setQueryString(embeddableQuery.query as string); - } else { - const language = getAggregateQueryMode(embeddableQuery); - setQueryLanguage(language); - setQueryString(embeddableQuery[language as keyof AggregateQuery]); - } - } - setIsLoading(false); - }); - }); - - const dataViewList: DataView[] = (embeddable.getRoot() as DashboardContainer)?.getAllDataViews(); - const viewMode = embeddable.getInput().viewMode; - - return ( - <> - - -

{displayName}

-
-
- - - {isLoading ? ( - - ) : ( - - {queryString !== '' && ( - - - {queryString} - - - )} - {filters && filters.length > 0 && ( - - - - - - )} - - )} - - - {viewMode !== ViewMode.VIEW && ( - - - - - {dashboardFilterNotificationBadge.getCloseButtonTitle()} - - - - { - onClose(); - editPanelAction.execute(context); - }} - fill - > - {dashboardFilterNotificationBadge.getEditButtonTitle()} - - - - - )} - - ); -} diff --git a/src/plugins/dashboard/public/application/actions/filters_notification_modal.test.tsx b/src/plugins/dashboard/public/application/actions/filters_notification_popover.test.tsx similarity index 63% rename from src/plugins/dashboard/public/application/actions/filters_notification_modal.test.tsx rename to src/plugins/dashboard/public/application/actions/filters_notification_popover.test.tsx index 9a83bf52e1092..92608264125ef 100644 --- a/src/plugins/dashboard/public/application/actions/filters_notification_modal.test.tsx +++ b/src/plugins/dashboard/public/application/actions/filters_notification_popover.test.tsx @@ -7,14 +7,18 @@ */ import React from 'react'; -import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { findTestSubject } from '@elastic/eui/lib/test'; import { FilterableEmbeddable, isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public'; import { DashboardContainer } from '../embeddable/dashboard_container'; import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; import { getSampleDashboardInput } from '../test_helpers'; -import { EuiModalFooter } from '@elastic/eui'; -import { FiltersNotificationModal, FiltersNotificationProps } from './filters_notification_modal'; +import { EuiPopover } from '@elastic/eui'; +import { + FiltersNotificationPopover, + FiltersNotificationProps, +} from './filters_notification_popover'; import { ContactCardEmbeddable, ContactCardEmbeddableFactory, @@ -53,55 +57,36 @@ describe('LibraryNotificationPopover', () => { }); defaultProps = { + icon: 'test', context: { embeddable: contactCardEmbeddable }, displayName: 'test display', id: 'testId', editPanelAction: { execute: jest.fn(), } as unknown as FiltersNotificationProps['editPanelAction'], - onClose: jest.fn(), }; }); function mountComponent(props?: Partial) { - return mountWithIntl(); + return mountWithIntl(); } - test('show modal footer in edit mode', async () => { + test('clicking edit button executes edit panel action', async () => { embeddable.updateInput({ viewMode: ViewMode.EDIT }); - await act(async () => { - const component = mountComponent(); - const footer = component.find(EuiModalFooter); - expect(footer.exists()).toBe(true); - }); - }); + const component = mountComponent(); - test('hide modal footer in view mode', async () => { - embeddable.updateInput({ viewMode: ViewMode.VIEW }); await act(async () => { - const component = mountComponent(); - const footer = component.find(EuiModalFooter); - expect(footer.exists()).toBe(false); + findTestSubject(component, `embeddablePanelNotification-${defaultProps.id}`).simulate( + 'click' + ); }); - }); - - test('clicking edit button executes edit panel action', async () => { - embeddable.updateInput({ viewMode: ViewMode.EDIT }); await act(async () => { - const component = mountComponent(); - const editButton = findTestSubject(component, 'filtersNotificationModal__editButton'); - editButton.simulate('click'); - expect(defaultProps.editPanelAction.execute).toHaveBeenCalled(); + component.update(); }); - }); - test('clicking close button calls onClose', async () => { - embeddable.updateInput({ viewMode: ViewMode.EDIT }); - await act(async () => { - const component = mountComponent(); - const editButton = findTestSubject(component, 'filtersNotificationModal__closeButton'); - editButton.simulate('click'); - expect(defaultProps.onClose).toHaveBeenCalled(); - }); + const popover = component.find(EuiPopover); + const editButton = findTestSubject(popover, 'filtersNotificationModal__editButton'); + editButton.simulate('click'); + expect(defaultProps.editPanelAction.execute).toHaveBeenCalled(); }); }); diff --git a/src/plugins/dashboard/public/application/actions/filters_notification_popover.tsx b/src/plugins/dashboard/public/application/actions/filters_notification_popover.tsx new file mode 100644 index 0000000000000..974c7280f8968 --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/filters_notification_popover.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; + +import { + EuiButton, + EuiPopover, + EuiFlexItem, + EuiFlexGroup, + EuiButtonIcon, + EuiPopoverTitle, + EuiPopoverFooter, +} from '@elastic/eui'; +import { EditPanelAction } from '@kbn/embeddable-plugin/public'; + +import { dashboardFilterNotificationAction } from '../../dashboard_strings'; +import { FiltersNotificationActionContext } from './filters_notification_action'; +import { FiltersNotificationPopoverContents } from './filters_notification_popover_contents'; + +export interface FiltersNotificationProps { + context: FiltersNotificationActionContext; + editPanelAction: EditPanelAction; + displayName: string; + icon: string; + id: string; +} + +export function FiltersNotificationPopover({ + editPanelAction, + displayName, + context, + icon, + id, +}: FiltersNotificationProps) { + const { embeddable } = context; + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + setIsPopoverOpen(!isPopoverOpen)} + data-test-subj={`embeddablePanelNotification-${id}`} + aria-label={displayName} + /> + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + anchorPosition="upCenter" + > + {displayName} + + + + + editPanelAction.execute({ embeddable })} + > + {dashboardFilterNotificationAction.getEditButtonTitle()} + + + + + + ); +} diff --git a/src/plugins/dashboard/public/application/actions/filters_notification_popover_contents.tsx b/src/plugins/dashboard/public/application/actions/filters_notification_popover_contents.tsx new file mode 100644 index 0000000000000..b3c37f40d6c6c --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/filters_notification_popover_contents.tsx @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo, useState } from 'react'; +import useMount from 'react-use/lib/useMount'; + +import { EuiCodeBlock, EuiFlexGroup, EuiForm, EuiFormRow, EuiLoadingContent } from '@elastic/eui'; +import { FilterableEmbeddable, IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { FilterItems } from '@kbn/unified-search-plugin/public'; +import { css } from '@emotion/react'; +import { + type AggregateQuery, + type Filter, + getAggregateQueryMode, + isOfQueryType, +} from '@kbn/es-query'; + +import { FiltersNotificationActionContext } from './filters_notification_action'; +import { dashboardFilterNotificationAction } from '../../dashboard_strings'; +import { DashboardContainer } from '../embeddable'; + +export interface FiltersNotificationProps { + context: FiltersNotificationActionContext; +} + +export function FiltersNotificationPopoverContents({ context }: FiltersNotificationProps) { + const { embeddable } = context; + const [isLoading, setIsLoading] = useState(true); + const [filters, setFilters] = useState([]); + const [queryString, setQueryString] = useState(''); + const [queryLanguage, setQueryLanguage] = useState<'sql' | 'esql' | undefined>(); + + const dataViews = useMemo( + () => (embeddable.getRoot() as DashboardContainer)?.getAllDataViews(), + [embeddable] + ); + + useMount(() => { + Promise.all([ + (embeddable as IEmbeddable & FilterableEmbeddable).getFilters(), + (embeddable as IEmbeddable & FilterableEmbeddable).getQuery(), + ]).then(([embeddableFilters, embeddableQuery]) => { + setFilters(embeddableFilters); + if (embeddableQuery) { + if (isOfQueryType(embeddableQuery)) { + setQueryString(embeddableQuery.query as string); + } else { + const language = getAggregateQueryMode(embeddableQuery); + setQueryLanguage(language); + setQueryString(embeddableQuery[language as keyof AggregateQuery]); + } + } + setIsLoading(false); + }); + }); + + return ( + <> + {isLoading ? ( + + ) : ( + + {queryString !== '' && ( + + + {queryString} + + + )} + {filters && filters.length > 0 && ( + + + + + + )} + + )} + + ); +} diff --git a/src/plugins/dashboard/public/application/actions/index.ts b/src/plugins/dashboard/public/application/actions/index.ts index a238ce05e1017..7793c28037544 100644 --- a/src/plugins/dashboard/public/application/actions/index.ts +++ b/src/plugins/dashboard/public/application/actions/index.ts @@ -6,11 +6,7 @@ * Side Public License, v 1. */ -import { - CONTEXT_MENU_TRIGGER, - PANEL_BADGE_TRIGGER, - PANEL_NOTIFICATION_TRIGGER, -} from '@kbn/embeddable-plugin/public'; +import { CONTEXT_MENU_TRIGGER, PANEL_NOTIFICATION_TRIGGER } from '@kbn/embeddable-plugin/public'; import { CoreStart } from '@kbn/core/public'; import { getSavedObjectFinder } from '@kbn/saved-objects-plugin/public'; @@ -22,7 +18,7 @@ import { ReplacePanelAction } from './replace_panel_action'; import { AddToLibraryAction } from './add_to_library_action'; import { CopyToDashboardAction } from './copy_to_dashboard_action'; import { UnlinkFromLibraryAction } from './unlink_from_library_action'; -import { FiltersNotificationBadge } from './filters_notification_badge'; +import { FiltersNotificationAction } from './filters_notification_action'; import { LibraryNotificationAction } from './library_notification_action'; interface BuildAllDashboardActionsProps { @@ -48,14 +44,14 @@ export const buildAllDashboardActions = async ({ uiActions.registerAction(changeViewAction); uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction.id); - const panelLevelFiltersNotification = new FiltersNotificationBadge(); - uiActions.registerAction(panelLevelFiltersNotification); - uiActions.attachAction(PANEL_BADGE_TRIGGER, panelLevelFiltersNotification.id); - const expandPanelAction = new ExpandPanelAction(); uiActions.registerAction(expandPanelAction); uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); + const panelLevelFiltersNotificationAction = new FiltersNotificationAction(); + uiActions.registerAction(panelLevelFiltersNotificationAction); + uiActions.attachAction(PANEL_NOTIFICATION_TRIGGER, panelLevelFiltersNotificationAction.id); + if (share) { const ExportCSVPlugin = new ExportCSVAction(); uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, ExportCSVPlugin); diff --git a/src/plugins/dashboard/public/dashboard_strings.ts b/src/plugins/dashboard/public/dashboard_strings.ts index fcb7f3cb2a7c1..73c1969ae1996 100644 --- a/src/plugins/dashboard/public/dashboard_strings.ts +++ b/src/plugins/dashboard/public/dashboard_strings.ts @@ -191,7 +191,7 @@ export const dashboardReplacePanelAction = { }), }; -export const dashboardFilterNotificationBadge = { +export const dashboardFilterNotificationAction = { getDisplayName: () => i18n.translate('dashboard.panel.filters', { defaultMessage: 'Panel filters',