From 5eb8cfb2439cbb79bb5bf6468d470709820b8b37 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 14 Aug 2019 15:20:59 -0400 Subject: [PATCH] Per panel time range (#43153) (#43297) * Per panel time range * Added tests and fixed lack of await check for incompatibility * Remove a couple more unneccessary `anys` --- .../public/tests/dashboard_container.test.tsx | 2 +- .../public/np_ready/public/bootstrap.ts | 14 +- .../public/np_ready/public/index.ts | 1 + .../public/lib/containers/container.ts | 4 +- .../embeddable_child_panel.test.tsx | 4 +- .../public/lib/containers/i_container.ts | 3 +- .../lib/panel/embeddable_panel.test.tsx | 6 +- .../public/lib/panel/embeddable_panel.tsx | 22 +- .../panel_actions/remove_panel_action.ts | 6 +- .../lib/panel/panel_header/panel_header.tsx | 29 +- .../np_ready/public/lib/triggers/index.ts | 1 + .../embeddable/visualize_embeddable.ts | 2 + x-pack/.i18nrc.json | 1 + x-pack/index.js | 2 + .../plugins/advanced_ui_actions/index.ts | 16 + .../public/np_ready/kibana.json | 9 + .../public/can_inherit_time_range.test.ts | 42 +++ .../np_ready/public/can_inherit_time_range.ts | 27 ++ .../public/custom_time_range_action.test.ts | 345 ++++++++++++++++++ .../public/custom_time_range_action.tsx | 112 ++++++ .../public/custom_time_range_badge.test.ts | 186 ++++++++++ .../public/custom_time_range_badge.tsx | 93 +++++ .../public/customize_time_range_modal.tsx | 185 ++++++++++ .../public/does_inherit_time_range.ts | 27 ++ .../public/np_ready/public/index.ts | 14 + .../public/np_ready/public/legacy.ts | 24 ++ .../public/np_ready/public/plugin.ts | 56 +++ .../np_ready/public/test_helpers/index.ts | 8 + .../test_helpers/time_range_container.ts | 53 +++ .../test_helpers/time_range_embeddable.ts | 31 ++ .../time_range_embeddable_factory.ts | 33 ++ .../public/np_ready/public/types.ts | 21 ++ x-pack/test_utils/enzyme_helpers.tsx | 2 + 33 files changed, 1364 insertions(+), 17 deletions(-) create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/index.ts create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/kibana.json create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.test.ts create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.ts create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.test.ts create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.tsx create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.test.ts create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.tsx create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/customize_time_range_modal.tsx create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/does_inherit_time_range.ts create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/index.ts create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/legacy.ts create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/plugin.ts create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/index.ts create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_container.ts create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable.ts create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable_factory.ts create mode 100644 x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/types.ts diff --git a/src/legacy/core_plugins/dashboard_embeddable_container/public/np_ready/public/tests/dashboard_container.test.tsx b/src/legacy/core_plugins/dashboard_embeddable_container/public/np_ready/public/tests/dashboard_container.test.tsx index b70da90b046c4..3c33b0bde401c 100644 --- a/src/legacy/core_plugins/dashboard_embeddable_container/public/np_ready/public/tests/dashboard_container.test.tsx +++ b/src/legacy/core_plugins/dashboard_embeddable_container/public/np_ready/public/tests/dashboard_container.test.tsx @@ -70,7 +70,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { []) as any} + getActions={() => Promise.resolve([])} getAllEmbeddableFactories={(() => []) as any} getEmbeddableFactory={(() => null) as any} notifications={{} as any} diff --git a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/bootstrap.ts b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/bootstrap.ts index 5249c169798d8..2ccc0bdb5f7af 100644 --- a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/bootstrap.ts +++ b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/bootstrap.ts @@ -18,7 +18,12 @@ */ import { EmbeddableApi } from './api/types'; -import { CONTEXT_MENU_TRIGGER, APPLY_FILTER_TRIGGER, ApplyFilterAction } from './lib'; +import { + CONTEXT_MENU_TRIGGER, + APPLY_FILTER_TRIGGER, + ApplyFilterAction, + PANEL_BADGE_TRIGGER, +} from './lib'; /** * This method initializes Embeddable plugin with initial set of @@ -39,10 +44,17 @@ export const bootstrap = (api: EmbeddableApi) => { description: 'Triggered when user applies filter to an embeddable.', actionIds: [], }; + const triggerBadge = { + id: PANEL_BADGE_TRIGGER, + title: 'Panel badges', + description: 'Actions appear in title bar when an embeddable loads in a panel', + actionIds: [], + }; const actionApplyFilter = new ApplyFilterAction(); api.registerTrigger(triggerContext); api.registerTrigger(triggerFilter); api.registerAction(actionApplyFilter); + api.registerTrigger(triggerBadge); api.attachAction(triggerFilter.id, actionApplyFilter.id); }; diff --git a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/index.ts b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/index.ts index 31c0cddb011a0..ff264f72cacca 100644 --- a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/index.ts +++ b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/index.ts @@ -24,6 +24,7 @@ export { ADD_PANEL_ACTION_ID, APPLY_FILTER_ACTION, APPLY_FILTER_TRIGGER, + PANEL_BADGE_TRIGGER, Action, ActionContext, Adapters, diff --git a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/containers/container.ts b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/containers/container.ts index 71749fd00d8dd..f4fca4bbd8d6e 100644 --- a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/containers/container.ts +++ b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/containers/container.ts @@ -35,10 +35,10 @@ const getKeys = (o: T): Array => Object.keys(o) as Array< export abstract class Container< TChildInput extends Partial = {}, - TContainerInput extends ContainerInput = ContainerInput, + TContainerInput extends ContainerInput = ContainerInput, TContainerOutput extends ContainerOutput = ContainerOutput > extends Embeddable - implements IContainer { + implements IContainer { public readonly isContainer: boolean = true; protected readonly children: { [key: string]: IEmbeddable | ErrorEmbeddable; diff --git a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/containers/embeddable_child_panel.test.tsx b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/containers/embeddable_child_panel.test.tsx index 307699d35f5d7..2335173c4ef34 100644 --- a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/containers/embeddable_child_panel.test.tsx +++ b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/containers/embeddable_child_panel.test.tsx @@ -63,7 +63,7 @@ test('EmbeddableChildPanel renders an embeddable when it is done loading', async intl={null as any} container={container} embeddableId={newEmbeddable.id} - getActions={(() => undefined) as any} + getActions={() => Promise.resolve([])} getAllEmbeddableFactories={(() => []) as any} getEmbeddableFactory={(() => undefined) as any} notifications={{} as any} @@ -102,7 +102,7 @@ test(`EmbeddableChildPanel renders an error message if the factory doesn't exist intl={null as any} container={container} embeddableId={'1'} - getActions={(() => undefined) as any} + getActions={() => Promise.resolve([])} getAllEmbeddableFactories={(() => []) as any} getEmbeddableFactory={(() => undefined) as any} notifications={{} as any} diff --git a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/containers/i_container.ts b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/containers/i_container.ts index 02c1401ceb462..8f094447064f0 100644 --- a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/containers/i_container.ts +++ b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/containers/i_container.ts @@ -50,7 +50,8 @@ export interface ContainerInput extends EmbeddableInput } export interface IContainer< - I extends ContainerInput = ContainerInput, + Inherited extends {} = {}, + I extends ContainerInput = ContainerInput, O extends ContainerOutput = ContainerOutput > extends IEmbeddable { /** diff --git a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/embeddable_panel.test.tsx b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/embeddable_panel.test.tsx index 0e3cfeb2acbbb..72e26434df095 100644 --- a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/embeddable_panel.test.tsx +++ b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/embeddable_panel.test.tsx @@ -156,7 +156,7 @@ test('HelloWorldContainer in view mode hides edit mode actions', async () => { undefined) as any} + getActions={() => Promise.resolve([])} getAllEmbeddableFactories={(() => []) as any} getEmbeddableFactory={(() => undefined) as any} notifications={{} as any} @@ -193,7 +193,7 @@ test('HelloWorldContainer in edit mode shows edit mode actions', async () => { undefined) as any} + getActions={() => Promise.resolve([])} getAllEmbeddableFactories={(() => []) as any} getEmbeddableFactory={(() => undefined) as any} notifications={{} as any} @@ -255,7 +255,7 @@ test('Updates when hidePanelTitles is toggled', async () => { undefined) as any} + getActions={() => Promise.resolve([])} getAllEmbeddableFactories={(() => []) as any} getEmbeddableFactory={(() => undefined) as any} notifications={{} as any} diff --git a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/embeddable_panel.tsx b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/embeddable_panel.tsx index 0a3d0a2bdf7c2..c06f4ed99e8fd 100644 --- a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/embeddable_panel.tsx +++ b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/embeddable_panel.tsx @@ -23,7 +23,7 @@ import { Subscription } from 'rxjs'; import { CoreStart } from '../../../../../../../../core/public'; import { buildContextMenuForActions } from '../context_menu_actions'; -import { CONTEXT_MENU_TRIGGER } from '../triggers'; +import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER } from '../triggers'; import { IEmbeddable } from '../embeddables/i_embeddable'; import { ViewMode, @@ -58,6 +58,7 @@ interface State { viewMode: ViewMode; hidePanelTitles: boolean; closeContextMenu: boolean; + badges: Action[]; } export class EmbeddablePanel extends React.Component { @@ -80,11 +81,24 @@ export class EmbeddablePanel extends React.Component { viewMode, hidePanelTitles, closeContextMenu: false, + badges: [], }; this.embeddableRoot = React.createRef(); } + private async refreshBadges() { + const badges = await this.props.getActions(PANEL_BADGE_TRIGGER, { + embeddable: this.props.embeddable, + }); + + if (this.mounted) { + this.setState({ + badges, + }); + } + } + public componentWillMount() { this.mounted = true; const { embeddable } = this.props; @@ -95,6 +109,8 @@ export class EmbeddablePanel extends React.Component { this.setState({ viewMode: embeddable.getInput().viewMode ? embeddable.getInput().viewMode : ViewMode.EDIT, }); + + this.refreshBadges(); } }); @@ -104,6 +120,8 @@ export class EmbeddablePanel extends React.Component { this.setState({ hidePanelTitles: Boolean(parent.getInput().hidePanelTitles), }); + + this.refreshBadges(); } }); } @@ -144,6 +162,8 @@ export class EmbeddablePanel extends React.Component { isViewMode={viewOnlyMode} closeContextMenu={this.state.closeContextMenu} title={title} + badges={this.state.badges} + embeddable={this.props.embeddable} />
diff --git a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts index fbea36e35f53d..f8fc9d02992d4 100644 --- a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts +++ b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts @@ -29,9 +29,9 @@ interface ExpandedPanelInput extends ContainerInput { } function hasExpandedPanelInput( - container: IContainer | IContainer -): container is IContainer { - return (container as IContainer).getInput().expandedPanelId !== undefined; + container: IContainer +): container is IContainer<{}, ExpandedPanelInput> { + return (container as IContainer<{}, ExpandedPanelInput>).getInput().expandedPanelId !== undefined; } export class RemovePanelAction extends Action { diff --git a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/panel_header/panel_header.tsx b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/panel_header/panel_header.tsx index 3d6f82b94693d..92597e3a8b0e2 100644 --- a/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/panel_header/panel_header.tsx +++ b/src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/panel/panel_header/panel_header.tsx @@ -17,11 +17,13 @@ * under the License. */ -import { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { EuiContextMenuPanelDescriptor, EuiBadge } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import classNames from 'classnames'; import React from 'react'; import { PanelOptionsMenu } from './panel_options_menu'; +import { Action } from '../../actions'; +import { IEmbeddable } from '../../embeddables'; export interface PanelHeaderProps { title?: string; @@ -29,12 +31,27 @@ export interface PanelHeaderProps { hidePanelTitles: boolean; getActionContextMenuPanel: () => Promise; closeContextMenu: boolean; + badges: Action[]; + embeddable: IEmbeddable; } interface PanelHeaderUiProps extends PanelHeaderProps { intl: InjectedIntl; } +function renderBadges(badges: Action[], embeddable: IEmbeddable) { + return badges.map(badge => ( + badge.execute({ embeddable })} + onClickAriaLabel={badge.getDisplayName({ embeddable })} + > + {badge.getDisplayName({ embeddable })} + + )); +} + function PanelHeaderUi({ title, isViewMode, @@ -42,12 +59,17 @@ function PanelHeaderUi({ getActionContextMenuPanel, intl, closeContextMenu, + badges, + embeddable, }: PanelHeaderUiProps) { const classes = classNames('embPanel__header', { 'embPanel__header--floater': !title || hidePanelTitles, }); - if (isViewMode && (!title || hidePanelTitles)) { + const showTitle = !isViewMode || (title && !hidePanelTitles); + const showPanelBar = badges.length > 0 || showTitle; + + if (!showPanelBar) { return (
- {hidePanelTitles ? '' : title} + {showTitle ? `${title} ` : ''} + {renderBadges(badges, embeddable)}
{ @@ -99,6 +100,7 @@ export class VisualizeEmbeddable extends Embeddable + new kibana.Plugin({ + id: 'advanced_ui_actions', + publicDir: resolve(__dirname, 'public'), + uiExports: { + hacks: 'plugins/advanced_ui_actions/np_ready/public/legacy', + }, + }); diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/kibana.json b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/kibana.json new file mode 100644 index 0000000000000..f0303e7ad6a7a --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "advanced_ui_actions", + "version": "kibana", + "requiredPlugins": [ + "embeddable" + ], + "server": false, + "ui": true +} diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.test.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.test.ts new file mode 100644 index 0000000000000..fe6a37c92f84b --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { canInheritTimeRange } from './can_inherit_time_range'; +import { + HelloWorldEmbeddable, + HelloWorldContainer, +} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/test_samples'; +import { TimeRangeEmbeddable, TimeRangeContainer } from './test_helpers'; + +jest.mock('ui/new_platform'); + +test('canInheritTimeRange returns false if embeddable is inside container without a time range', () => { + const embeddable = new TimeRangeEmbeddable( + { id: '1234', timeRange: { from: 'noxw-15m', to: 'now' } }, + new HelloWorldContainer({ id: '123', panels: {} }, (() => null) as any) + ); + + expect(canInheritTimeRange(embeddable)).toBe(false); +}); + +test('canInheritTimeRange returns false if embeddable is without a time range', () => { + const embeddable = new HelloWorldEmbeddable( + { id: '1234' }, + new HelloWorldContainer({ id: '123', panels: {} }, (() => null) as any) + ); + // @ts-ignore + expect(canInheritTimeRange(embeddable)).toBe(false); +}); + +test('canInheritTimeRange returns true if embeddable is inside a container with a time range', () => { + const embeddable = new TimeRangeEmbeddable( + { id: '1234', timeRange: { from: 'noxw-15m', to: 'now' } }, + new TimeRangeContainer( + { id: '123', panels: {}, timeRange: { from: 'noxw-15m', to: 'now' } }, + (() => null) as any + ) + ); + expect(canInheritTimeRange(embeddable)).toBe(true); +}); diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.ts new file mode 100644 index 0000000000000..221fdc314f8c8 --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/can_inherit_time_range.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TimeRange } from '../../../../../../../src/plugins/data/public'; +import { + Embeddable, + IContainer, + ContainerInput, +} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { TimeRangeInput } from './custom_time_range_action'; + +interface ContainerTimeRangeInput extends ContainerInput { + timeRange: TimeRange; +} + +export function canInheritTimeRange(embeddable: Embeddable) { + if (!embeddable.parent) { + return false; + } + + const parent = embeddable.parent as IContainer; + + return parent.getInput().timeRange !== undefined; +} diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.test.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.test.ts new file mode 100644 index 0000000000000..f553ac1c0b2d7 --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.test.ts @@ -0,0 +1,345 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// @ts-ignore +import { findTestSubject } from '@elastic/eui/lib/test'; +import { skip } from 'rxjs/operators'; +import * as Rx from 'rxjs'; +import { mount } from 'enzyme'; + +import { EmbeddableFactory } from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { TimeRangeEmbeddable, TimeRangeContainer, TIME_RANGE_EMBEDDABLE } from './test_helpers'; +import { TimeRangeEmbeddableFactory } from './test_helpers/time_range_embeddable_factory'; +import { CustomTimeRangeAction } from './custom_time_range_action'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { + HelloWorldEmbeddableFactory, + HELLO_WORLD_EMBEDDABLE_TYPE, + HelloWorldEmbeddable, + HelloWorldContainer, +} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/test_samples'; + +import { nextTick } from 'test_utils/enzyme_helpers'; +import { ReactElement } from 'react'; + +jest.mock('ui/new_platform'); + +test('Custom time range action prevents embeddable from using container time', async done => { + const embeddableFactories = new Map(); + embeddableFactories.set(TIME_RANGE_EMBEDDABLE, new TimeRangeEmbeddableFactory()); + + const container = new TimeRangeContainer( + { + timeRange: { from: 'now-15m', to: 'now' }, + panels: { + '1': { + type: TIME_RANGE_EMBEDDABLE, + explicitInput: { + id: '1', + }, + }, + '2': { + type: TIME_RANGE_EMBEDDABLE, + explicitInput: { + id: '2', + }, + }, + }, + id: '123', + }, + (() => {}) as any + ); + + await container.untilEmbeddableLoaded('1'); + await container.untilEmbeddableLoaded('2'); + + const child1 = container.getChild('1'); + expect(child1).toBeDefined(); + expect(child1.getInput().timeRange).toEqual({ from: 'now-15m', to: 'now' }); + + const child2 = container.getChild('2'); + expect(child2).toBeDefined(); + expect(child2.getInput().timeRange).toEqual({ from: 'now-15m', to: 'now' }); + + const start = coreMock.createStart(); + const overlayMock = start.overlays; + overlayMock.openModal.mockClear(); + new CustomTimeRangeAction({ + openModal: start.overlays.openModal, + commonlyUsedRanges: [], + dateFormat: 'MM YYY', + }).execute({ + embeddable: child1, + }); + + await nextTick(); + const openModal = overlayMock.openModal.mock.calls[0][0] as ReactElement; + + const wrapper = mount(openModal); + wrapper.setState({ timeRange: { from: 'now-30days', to: 'now-29days' } }); + + findTestSubject(wrapper, 'addPerPanelTimeRangeButton').simulate('click'); + + const subscription = Rx.merge(container.getOutput$(), container.getInput$()) + .pipe(skip(2)) + .subscribe(() => { + expect(child1.getInput().timeRange).toEqual({ from: 'now-30days', to: 'now-29days' }); + expect(child2.getInput().timeRange).toEqual({ from: 'now-30m', to: 'now-1m' }); + subscription.unsubscribe(); + done(); + }); + + container.updateInput({ timeRange: { from: 'now-30m', to: 'now-1m' } }); +}); + +test('Removing custom time range action resets embeddable back to container time', async done => { + const embeddableFactories = new Map(); + embeddableFactories.set(TIME_RANGE_EMBEDDABLE, new TimeRangeEmbeddableFactory()); + + const container = new TimeRangeContainer( + { + timeRange: { from: 'now-15m', to: 'now' }, + panels: { + '1': { + type: TIME_RANGE_EMBEDDABLE, + explicitInput: { + id: '1', + }, + }, + '2': { + type: TIME_RANGE_EMBEDDABLE, + explicitInput: { + id: '2', + }, + }, + }, + id: '123', + }, + (() => {}) as any + ); + + await container.untilEmbeddableLoaded('1'); + await container.untilEmbeddableLoaded('2'); + + const child1 = container.getChild('1'); + const child2 = container.getChild('2'); + + const start = coreMock.createStart(); + const overlayMock = start.overlays; + overlayMock.openModal.mockClear(); + new CustomTimeRangeAction({ + openModal: start.overlays.openModal, + commonlyUsedRanges: [], + dateFormat: 'MM YYY', + }).execute({ + embeddable: child1, + }); + + await nextTick(); + const openModal = overlayMock.openModal.mock.calls[0][0] as ReactElement; + + const wrapper = mount(openModal); + wrapper.setState({ timeRange: { from: 'now-30days', to: 'now-29days' } }); + + findTestSubject(wrapper, 'addPerPanelTimeRangeButton').simulate('click'); + + container.updateInput({ timeRange: { from: 'now-30m', to: 'now-1m' } }); + + new CustomTimeRangeAction({ + openModal: start.overlays.openModal, + commonlyUsedRanges: [], + dateFormat: 'MM YYY', + }).execute({ + embeddable: child1, + }); + + await nextTick(); + const openModal2 = (overlayMock.openModal as any).mock.calls[1][0]; + + const wrapper2 = mount(openModal2); + findTestSubject(wrapper2, 'removePerPanelTimeRangeButton').simulate('click'); + + const subscription = Rx.merge(container.getOutput$(), container.getInput$()) + .pipe(skip(2)) + .subscribe(() => { + expect(child1.getInput().timeRange).toEqual({ from: 'now-10m', to: 'now-5m' }); + expect(child2.getInput().timeRange).toEqual({ from: 'now-10m', to: 'now-5m' }); + subscription.unsubscribe(); + done(); + }); + + container.updateInput({ timeRange: { from: 'now-10m', to: 'now-5m' } }); +}); + +test('Cancelling custom time range action leaves state alone', async done => { + const embeddableFactories = new Map(); + embeddableFactories.set(TIME_RANGE_EMBEDDABLE, new TimeRangeEmbeddableFactory()); + + const container = new TimeRangeContainer( + { + timeRange: { from: 'now-15m', to: 'now' }, + panels: { + '1': { + type: TIME_RANGE_EMBEDDABLE, + explicitInput: { + id: '1', + timeRange: { to: '2', from: '1' }, + }, + }, + '2': { + type: TIME_RANGE_EMBEDDABLE, + explicitInput: { + id: '2', + }, + }, + }, + id: '123', + }, + (() => {}) as any + ); + + await container.untilEmbeddableLoaded('1'); + await container.untilEmbeddableLoaded('2'); + + const child1 = container.getChild('1'); + const child2 = container.getChild('2'); + + const start = coreMock.createStart(); + const overlayMock = start.overlays; + overlayMock.openModal.mockClear(); + new CustomTimeRangeAction({ + openModal: start.overlays.openModal, + commonlyUsedRanges: [], + dateFormat: 'MM YYY', + }).execute({ + embeddable: child1, + }); + + await nextTick(); + const openModal = overlayMock.openModal.mock.calls[0][0] as ReactElement; + + const wrapper = mount(openModal); + wrapper.setState({ timeRange: { from: 'now-300m', to: 'now-400m' } }); + + findTestSubject(wrapper, 'cancelPerPanelTimeRangeButton').simulate('click'); + + const subscription = Rx.merge(container.getOutput$(), container.getInput$()) + .pipe(skip(2)) + .subscribe(() => { + expect(child1.getInput().timeRange).toEqual({ from: '1', to: '2' }); + expect(child2.getInput().timeRange).toEqual({ from: 'now-30m', to: 'now-1m' }); + subscription.unsubscribe(); + done(); + }); + + container.updateInput({ timeRange: { from: 'now-30m', to: 'now-1m' } }); +}); + +test(`badge is compatible with embeddable that inherits from parent`, async () => { + const embeddableFactories = new Map(); + embeddableFactories.set(TIME_RANGE_EMBEDDABLE, new TimeRangeEmbeddableFactory()); + const container = new TimeRangeContainer( + { + timeRange: { from: 'now-15m', to: 'now' }, + panels: { + '1': { + type: TIME_RANGE_EMBEDDABLE, + explicitInput: { + id: '1', + }, + }, + }, + id: '123', + }, + (() => {}) as any + ); + + await container.untilEmbeddableLoaded('1'); + + const child = container.getChild('1'); + + const start = coreMock.createStart(); + const compatible = await new CustomTimeRangeAction({ + openModal: start.overlays.openModal, + commonlyUsedRanges: [], + dateFormat: 'MM YYY', + }).isCompatible({ + embeddable: child, + }); + expect(compatible).toBe(true); +}); + +// TODO: uncomment when https://github.com/elastic/kibana/issues/43271 is fixed. +// test('Embeddable that does not use time range in a container that has time range is incompatible', async () => { +// const embeddableFactories = new Map(); +// embeddableFactories.set(HELLO_WORLD_EMBEDDABLE_TYPE, new HelloWorldEmbeddableFactory()); +// const container = new TimeRangeContainer( +// { +// timeRange: { from: 'now-15m', to: 'now' }, +// panels: { +// '1': { +// type: HELLO_WORLD_EMBEDDABLE_TYPE, +// explicitInput: { +// id: '1', +// }, +// }, +// }, +// id: '123', +// }, +// (() => null) as any +// ); + +// await container.untilEmbeddableLoaded('1'); + +// const child = container.getChild('1'); + +// const start = coreMock.createStart(); +// const action = await new CustomTimeRangeAction({ +// openModal: start.overlays.openModal, +// dateFormat: 'MM YYYY', +// commonlyUsedRanges: [], +// }); + +// async function check() { +// await action.execute({ embeddable: child }); +// } +// await expect(check()).rejects.toThrow(Error); +// }); + +test('Attempting to execute on incompatible embeddable throws an error', async () => { + const embeddableFactories = new Map(); + embeddableFactories.set(HELLO_WORLD_EMBEDDABLE_TYPE, new HelloWorldEmbeddableFactory()); + const container = new HelloWorldContainer( + { + panels: { + '1': { + type: HELLO_WORLD_EMBEDDABLE_TYPE, + explicitInput: { + id: '1', + }, + }, + }, + id: '123', + }, + (() => null) as any + ); + + await container.untilEmbeddableLoaded('1'); + + const child = container.getChild('1'); + + const start = coreMock.createStart(); + const action = await new CustomTimeRangeAction({ + openModal: start.overlays.openModal, + dateFormat: 'MM YYYY', + commonlyUsedRanges: [], + }); + + async function check() { + await action.execute({ embeddable: child }); + } + await expect(check()).rejects.toThrow(Error); +}); diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.tsx b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.tsx new file mode 100644 index 0000000000000..ae3cbbc059119 --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_action.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { TimeRange } from '../../../../../../../src/plugins/data/public'; +import { SEARCH_EMBEDDABLE_TYPE } from '../../../../../../../src/legacy/core_plugins/kibana/public/discover/embeddable/search_embeddable'; +import { VisualizeEmbeddable } from '../../../../../../../src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable'; +import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../../../../src/legacy/core_plugins/kibana/public/visualize/embeddable/constants'; + +import { + Action, + IEmbeddable, + ActionContext, + IncompatibleActionError, + Embeddable, + EmbeddableInput, +} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; + +import { CustomizeTimeRangeModal } from './customize_time_range_modal'; +import { OpenModal, CommonlyUsedRange } from './types'; + +const CUSTOM_TIME_RANGE = 'CUSTOM_TIME_RANGE'; + +export interface TimeRangeInput extends EmbeddableInput { + timeRange: TimeRange; +} + +function hasTimeRange( + embeddable: IEmbeddable | Embeddable +): embeddable is Embeddable { + return (embeddable as Embeddable).getInput().timeRange !== undefined; +} + +function isVisualizeEmbeddable( + embeddable: IEmbeddable | VisualizeEmbeddable +): embeddable is VisualizeEmbeddable { + return embeddable.type === VISUALIZE_EMBEDDABLE_TYPE; +} + +export class CustomTimeRangeAction extends Action { + public readonly type = CUSTOM_TIME_RANGE; + private openModal: OpenModal; + private dateFormat?: string; + private commonlyUsedRanges: CommonlyUsedRange[]; + + constructor({ + openModal, + dateFormat, + commonlyUsedRanges, + }: { + openModal: OpenModal; + dateFormat: string; + commonlyUsedRanges: CommonlyUsedRange[]; + }) { + super(CUSTOM_TIME_RANGE); + this.order = 7; + this.openModal = openModal; + this.dateFormat = dateFormat; + this.commonlyUsedRanges = commonlyUsedRanges; + } + + public getDisplayName() { + return i18n.translate('xpack.advancedUiActions.customizeTimeRangeMenuItem.displayName', { + defaultMessage: 'Customize time range', + }); + } + + public getIconType() { + return 'calendar'; + } + + public async isCompatible({ embeddable }: ActionContext) { + const isInputControl = + isVisualizeEmbeddable(embeddable) && + embeddable.getOutput().visTypeName === 'input_control_vis'; + + const isMarkdown = + isVisualizeEmbeddable(embeddable) && embeddable.getOutput().visTypeName === 'markdown'; + return Boolean( + embeddable && + hasTimeRange(embeddable) && + // Saved searches don't listen to the time range from the container that is passed down to them so it + // won't work without a fix. For now, just leave them out. + embeddable.type !== SEARCH_EMBEDDABLE_TYPE && + !isInputControl && + !isMarkdown + ); + } + + public async execute({ embeddable }: ActionContext) { + const isCompatible = await this.isCompatible({ embeddable }); + if (!isCompatible) { + throw new IncompatibleActionError(); + } + + // Only here for typescript + if (hasTimeRange(embeddable)) { + const modalSession = this.openModal( + modalSession.close()} + embeddable={embeddable} + dateFormat={this.dateFormat} + commonlyUsedRanges={this.commonlyUsedRanges} + /> + ); + } + } +} diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.test.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.test.ts new file mode 100644 index 0000000000000..33a6adb61775a --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.test.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// @ts-ignore +import { findTestSubject } from '@elastic/eui/lib/test'; +import { skip } from 'rxjs/operators'; +import * as Rx from 'rxjs'; +import { mount } from 'enzyme'; + +import { EmbeddableFactory } from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { TimeRangeEmbeddable, TimeRangeContainer, TIME_RANGE_EMBEDDABLE } from './test_helpers'; +import { TimeRangeEmbeddableFactory } from './test_helpers/time_range_embeddable_factory'; +import { CustomTimeRangeBadge } from './custom_time_range_badge'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { ReactElement } from 'react'; +import { nextTick } from 'test_utils/enzyme_helpers'; + +test('Removing custom time range from badge resets embeddable back to container time', async done => { + const embeddableFactories = new Map(); + embeddableFactories.set(TIME_RANGE_EMBEDDABLE, new TimeRangeEmbeddableFactory()); + + const container = new TimeRangeContainer( + { + timeRange: { from: 'now-15m', to: 'now' }, + panels: { + '1': { + type: TIME_RANGE_EMBEDDABLE, + explicitInput: { + id: '1', + timeRange: { from: '1', to: '2' }, + }, + }, + '2': { + type: TIME_RANGE_EMBEDDABLE, + explicitInput: { + id: '2', + }, + }, + }, + id: '123', + }, + (() => null) as any + ); + + await container.untilEmbeddableLoaded('1'); + await container.untilEmbeddableLoaded('2'); + + const child1 = container.getChild('1'); + const child2 = container.getChild('2'); + + const start = coreMock.createStart(); + const overlayMock = start.overlays; + overlayMock.openModal.mockClear(); + new CustomTimeRangeBadge({ + openModal: start.overlays.openModal, + dateFormat: 'MM YYYY', + commonlyUsedRanges: [], + }).execute({ + embeddable: child1, + }); + + await nextTick(); + const openModal = overlayMock.openModal.mock.calls[0][0] as ReactElement; + + const wrapper = mount(openModal); + findTestSubject(wrapper, 'removePerPanelTimeRangeButton').simulate('click'); + + const subscription = Rx.merge(child1.getInput$(), container.getOutput$(), container.getInput$()) + .pipe(skip(4)) + .subscribe(() => { + expect(child1.getInput().timeRange).toEqual({ from: 'now-10m', to: 'now-5m' }); + expect(child2.getInput().timeRange).toEqual({ from: 'now-10m', to: 'now-5m' }); + subscription.unsubscribe(); + done(); + }); + + container.updateInput({ timeRange: { from: 'now-10m', to: 'now-5m' } }); +}); + +test(`badge is not compatible with embeddable that inherits from parent`, async () => { + const embeddableFactories = new Map(); + embeddableFactories.set(TIME_RANGE_EMBEDDABLE, new TimeRangeEmbeddableFactory()); + const container = new TimeRangeContainer( + { + timeRange: { from: 'now-15m', to: 'now' }, + panels: { + '1': { + type: TIME_RANGE_EMBEDDABLE, + explicitInput: { + id: '1', + }, + }, + }, + id: '123', + }, + (() => null) as any + ); + + await container.untilEmbeddableLoaded('1'); + + const child = container.getChild('1'); + + const start = coreMock.createStart(); + const compatible = await new CustomTimeRangeBadge({ + openModal: start.overlays.openModal, + dateFormat: 'MM YYYY', + commonlyUsedRanges: [], + }).isCompatible({ + embeddable: child, + }); + expect(compatible).toBe(false); +}); + +test(`badge is compatible with embeddable that has custom time range`, async () => { + const embeddableFactories = new Map(); + embeddableFactories.set(TIME_RANGE_EMBEDDABLE, new TimeRangeEmbeddableFactory()); + const container = new TimeRangeContainer( + { + timeRange: { from: 'now-15m', to: 'now' }, + panels: { + '1': { + type: TIME_RANGE_EMBEDDABLE, + explicitInput: { + id: '1', + timeRange: { to: '123', from: '456' }, + }, + }, + }, + id: '123', + }, + (() => null) as any + ); + + await container.untilEmbeddableLoaded('1'); + + const child = container.getChild('1'); + + const start = coreMock.createStart(); + const compatible = await new CustomTimeRangeBadge({ + openModal: start.overlays.openModal, + dateFormat: 'MM YYYY', + commonlyUsedRanges: [], + }).isCompatible({ + embeddable: child, + }); + expect(compatible).toBe(true); +}); + +test('Attempting to execute on incompatible embeddable throws an error', async () => { + const embeddableFactories = new Map(); + embeddableFactories.set(TIME_RANGE_EMBEDDABLE, new TimeRangeEmbeddableFactory()); + const container = new TimeRangeContainer( + { + timeRange: { from: 'now-15m', to: 'now' }, + panels: { + '1': { + type: TIME_RANGE_EMBEDDABLE, + explicitInput: { + id: '1', + }, + }, + }, + id: '123', + }, + (() => null) as any + ); + + await container.untilEmbeddableLoaded('1'); + + const child = container.getChild('1'); + + const start = coreMock.createStart(); + const badge = await new CustomTimeRangeBadge({ + openModal: start.overlays.openModal, + dateFormat: 'MM YYYY', + commonlyUsedRanges: [], + }); + + async function check() { + await badge.execute({ embeddable: child }); + } + await expect(check()).rejects.toThrow(Error); +}); diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.tsx b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.tsx new file mode 100644 index 0000000000000..537ac498a7dab --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/custom_time_range_badge.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { prettyDuration, commonDurationRanges } from '@elastic/eui'; + +import { TimeRange } from '../../../../../../../src/plugins/data/public'; +import { + Action, + IEmbeddable, + ActionContext, + IncompatibleActionError, + Embeddable, + EmbeddableInput, +} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; + +import { CustomizeTimeRangeModal } from './customize_time_range_modal'; +import { doesInheritTimeRange } from './does_inherit_time_range'; +import { OpenModal, CommonlyUsedRange } from './types'; + +const CUSTOM_TIME_RANGE_BADGE = 'CUSTOM_TIME_RANGE_BADGE'; + +export interface TimeRangeInput extends EmbeddableInput { + timeRange: TimeRange; +} + +function hasTimeRange( + embeddable: IEmbeddable | Embeddable +): embeddable is Embeddable { + return (embeddable as Embeddable).getInput().timeRange !== undefined; +} + +export class CustomTimeRangeBadge extends Action { + public readonly type = CUSTOM_TIME_RANGE_BADGE; + private openModal: OpenModal; + private dateFormat: string; + private commonlyUsedRanges: CommonlyUsedRange[]; + + constructor({ + openModal, + dateFormat, + commonlyUsedRanges, + }: { + openModal: OpenModal; + dateFormat: string; + commonlyUsedRanges: CommonlyUsedRange[]; + }) { + super(CUSTOM_TIME_RANGE_BADGE); + this.order = 7; + this.openModal = openModal; + this.dateFormat = dateFormat; + this.commonlyUsedRanges = commonlyUsedRanges; + } + + public getDisplayName({ embeddable }: ActionContext>) { + return prettyDuration( + embeddable.getInput().timeRange.from, + embeddable.getInput().timeRange.to, + commonDurationRanges, + this.dateFormat + ); + } + + public getIconType() { + return 'calendar'; + } + + public async isCompatible({ embeddable }: ActionContext) { + return Boolean(embeddable && hasTimeRange(embeddable) && !doesInheritTimeRange(embeddable)); + } + + public async execute({ embeddable }: ActionContext) { + const isCompatible = await this.isCompatible({ embeddable }); + if (!isCompatible) { + throw new IncompatibleActionError(); + } + + // Only here for typescript + if (hasTimeRange(embeddable)) { + const modalSession = this.openModal( + modalSession.close()} + embeddable={embeddable} + dateFormat={this.dateFormat} + commonlyUsedRanges={this.commonlyUsedRanges} + /> + ); + } + } +} diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/customize_time_range_modal.tsx b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/customize_time_range_modal.tsx new file mode 100644 index 0000000000000..dfa5768a4f859 --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/customize_time_range_modal.tsx @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component } from 'react'; + +import { + EuiFormRow, + EuiButton, + EuiButtonEmpty, + EuiModalHeader, + EuiModalFooter, + EuiModalBody, + EuiModalHeaderTitle, + EuiSuperDatePicker, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { TimeRange } from '../../../../../../../src/plugins/data/public'; +import { + Embeddable, + IContainer, + ContainerInput, +} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { TimeRangeInput } from './custom_time_range_action'; +import { doesInheritTimeRange } from './does_inherit_time_range'; +import { CommonlyUsedRange } from './types'; + +interface CustomizeTimeRangeProps { + embeddable: Embeddable; + onClose: () => void; + dateFormat?: string; + commonlyUsedRanges: CommonlyUsedRange[]; +} + +interface State { + timeRange?: TimeRange; + inheritTimeRange: boolean; +} + +export class CustomizeTimeRangeModal extends Component { + constructor(props: CustomizeTimeRangeProps) { + super(props); + this.state = { + timeRange: props.embeddable.getInput().timeRange, + inheritTimeRange: doesInheritTimeRange(props.embeddable), + }; + } + + onTimeChange = ({ start, end }: { start: string; end: string }) => { + this.setState({ timeRange: { from: start, to: end } }); + }; + + cancel = () => { + this.props.onClose(); + }; + + onInheritToggle = () => { + this.setState(prevState => ({ + inheritTimeRange: !prevState.inheritTimeRange, + })); + }; + + addToPanel = () => { + const { embeddable } = this.props; + + embeddable.updateInput({ timeRange: this.state.timeRange }); + + this.props.onClose(); + }; + + inheritFromParent = () => { + const { embeddable } = this.props; + const parent = embeddable.parent as IContainer<{}, ContainerInput>; + const parentPanels = parent!.getInput().panels; + + // Remove any explicit input to this child from the parent. + parent!.updateInput({ + panels: { + ...parentPanels, + [embeddable.id]: { + ...parentPanels[embeddable.id], + explicitInput: { + ...parentPanels[embeddable.id].explicitInput, + timeRange: undefined, + }, + }, + }, + }); + + this.props.onClose(); + }; + + public render() { + return ( + + + + {i18n.translate('xpack.advancedUiActions.customizeTimeRange.modal.headerTitle', { + defaultMessage: 'Customize panel time range', + })} + + + + + + { + return { + start: from, + end: to, + label: display, + }; + } + )} + /> + + + + + + + {i18n.translate( + 'xpack.advancedUiActions.customizePanelTimeRange.modal.removeButtonTitle', + { + defaultMessage: 'Remove', + } + )} + + + + + {i18n.translate( + 'xpack.advancedUiActions.customizePanelTimeRange.modal.cancelButtonTitle', + { + defaultMessage: 'Cancel', + } + )} + + + + + {this.state.inheritTimeRange + ? i18n.translate( + 'xpack.advancedUiActions.customizePanelTimeRange.modal.addToPanelButtonTitle', + { + defaultMessage: 'Add to panel', + } + ) + : i18n.translate( + 'xpack.advancedUiActions.customizePanelTimeRange.modal.updatePanelTimeRangeButtonTitle', + { + defaultMessage: 'Update', + } + )} + + + + + + ); + } +} diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/does_inherit_time_range.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/does_inherit_time_range.ts new file mode 100644 index 0000000000000..aa960c45e20d6 --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/does_inherit_time_range.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + Embeddable, + IContainer, + ContainerInput, +} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { TimeRangeInput } from './custom_time_range_action'; + +export function doesInheritTimeRange(embeddable: Embeddable) { + if (!embeddable.parent) { + return false; + } + + const parent = embeddable.parent as IContainer<{}, ContainerInput>; + + // Note: this logic might not work in a container nested world... the explicit input + // may be on the root... or any of the interim parents. + + // If there is no explicit input defined on the parent then this embeddable inherits the + // time range from whatever the time range of the parent is. + return parent.getInput().panels[embeddable.id].explicitInput.timeRange === undefined; +} diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/index.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/index.ts new file mode 100644 index 0000000000000..5dd807ba2442d --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { AdvancedUiActionsPublicPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new AdvancedUiActionsPublicPlugin(initializerContext); +} + +export { AdvancedUiActionsPublicPlugin as Plugin }; diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/legacy.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/legacy.ts new file mode 100644 index 0000000000000..18a2a4181040f --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/legacy.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable @kbn/eslint/no-restricted-paths */ +import { npSetup, npStart } from 'ui/new_platform'; +/* eslint-enable @kbn/eslint/no-restricted-paths */ + +import { plugin } from '.'; + +import { + setup as embeddableSetup, + start as embeddableStart, +} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy'; + +const pluginInstance = plugin({} as any); +export const setup = pluginInstance.setup(npSetup.core, { + embeddable: embeddableSetup, +}); +export const start = pluginInstance.start(npStart.core, { + embeddable: embeddableStart, +}); diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/plugin.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/plugin.ts new file mode 100644 index 0000000000000..afd3ccc93f21c --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/plugin.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { + Plugin as EmbeddablePlugin, + CONTEXT_MENU_TRIGGER, + PANEL_BADGE_TRIGGER, +} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { CustomTimeRangeAction } from './custom_time_range_action'; + +import { CustomTimeRangeBadge } from './custom_time_range_badge'; +import { CommonlyUsedRange } from './types'; + +interface SetupDependencies { + embeddable: ReturnType; +} + +interface StartDependencies { + embeddable: ReturnType; +} + +export type Setup = void; +export type Start = void; + +export class AdvancedUiActionsPublicPlugin + implements Plugin { + constructor(initializerContext: PluginInitializerContext) {} + + public setup(core: CoreSetup, { embeddable }: SetupDependencies): Setup {} + + public start(core: CoreStart, { embeddable }: StartDependencies): Start { + const dateFormat = core.uiSettings.get('dateFormat') as string; + const commonlyUsedRanges = core.uiSettings.get('timepicker:quickRanges') as CommonlyUsedRange[]; + const timeRangeAction = new CustomTimeRangeAction({ + openModal: core.overlays.openModal, + dateFormat, + commonlyUsedRanges, + }); + embeddable.registerAction(timeRangeAction); + embeddable.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction.id); + + const timeRangeBadge = new CustomTimeRangeBadge({ + openModal: core.overlays.openModal, + dateFormat, + commonlyUsedRanges, + }); + embeddable.registerAction(timeRangeBadge); + embeddable.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge.id); + } + + public stop() {} +} diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/index.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/index.ts new file mode 100644 index 0000000000000..8d33b8344ff56 --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TimeRangeEmbeddable, TIME_RANGE_EMBEDDABLE } from './time_range_embeddable'; +export { TimeRangeContainer } from './time_range_container'; diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_container.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_container.ts new file mode 100644 index 0000000000000..0227e97fe3537 --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_container.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TimeRange } from '../../../../../../../../src/plugins/data/public'; +import { + ContainerInput, + Container, + ContainerOutput, + GetEmbeddableFactory, +} from '../../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; + +/** + * interfaces are not allowed to specify a sub-set of the required types until + * https://github.com/microsoft/TypeScript/issues/15300 is fixed so we use a type + * here instead + */ +// eslint-disable-next-line @typescript-eslint/prefer-interface +export type InheritedChildrenInput = { + timeRange: TimeRange; + id?: string; +}; + +interface ContainerTimeRangeInput extends ContainerInput { + timeRange: TimeRange; +} + +const TIME_RANGE_CONTAINER = 'TIME_RANGE_CONTAINER'; + +export class TimeRangeContainer extends Container< + InheritedChildrenInput, + ContainerTimeRangeInput, + ContainerOutput +> { + public readonly type = TIME_RANGE_CONTAINER; + constructor( + initialInput: ContainerTimeRangeInput, + getFactory: GetEmbeddableFactory, + parent?: Container + ) { + super(initialInput, { embeddableLoaded: {} }, getFactory, parent); + } + + public getInheritedInput() { + return { timeRange: this.input.timeRange }; + } + + public render() {} + + public reload() {} +} diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable.ts new file mode 100644 index 0000000000000..7fb8c390f720c --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TimeRange } from '../../../../../../../../src/plugins/data/public'; +import { + EmbeddableOutput, + Embeddable, + EmbeddableInput, + IContainer, +} from '../../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; + +interface EmbeddableTimeRangeInput extends EmbeddableInput { + timeRange: TimeRange; +} + +export const TIME_RANGE_EMBEDDABLE = 'TIME_RANGE_EMBEDDABLE'; + +export class TimeRangeEmbeddable extends Embeddable { + public readonly type = TIME_RANGE_EMBEDDABLE; + + constructor(initialInput: EmbeddableTimeRangeInput, parent?: IContainer) { + super(initialInput, {}, parent); + } + + public render() {} + + public reload() {} +} diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable_factory.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable_factory.ts new file mode 100644 index 0000000000000..f4dcaa1e935ad --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/test_helpers/time_range_embeddable_factory.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TimeRange } from '../../../../../../../../src/plugins/data/public'; +import { + EmbeddableInput, + IContainer, + EmbeddableFactory, +} from '../../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { TIME_RANGE_EMBEDDABLE, TimeRangeEmbeddable } from './time_range_embeddable'; + +interface EmbeddableTimeRangeInput extends EmbeddableInput { + timeRange: TimeRange; +} + +export class TimeRangeEmbeddableFactory extends EmbeddableFactory { + public readonly type = TIME_RANGE_EMBEDDABLE; + + public isEditable() { + return true; + } + + public async create(initialInput: EmbeddableTimeRangeInput, parent?: IContainer) { + return new TimeRangeEmbeddable(initialInput, parent); + } + + public getDisplayName() { + return 'time range'; + } +} diff --git a/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/types.ts b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/types.ts new file mode 100644 index 0000000000000..626782ba372ce --- /dev/null +++ b/x-pack/legacy/plugins/advanced_ui_actions/public/np_ready/public/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { OverlayRef } from 'src/core/public'; + +export interface CommonlyUsedRange { + from: string; + to: string; + display: string; +} + +export type OpenModal = ( + modalChildren: React.ReactNode, + modalProps?: { + closeButtonAriaLabel?: string; + 'data-test-subj'?: string; + } +) => OverlayRef; diff --git a/x-pack/test_utils/enzyme_helpers.tsx b/x-pack/test_utils/enzyme_helpers.tsx index d905ce433041b..64bb3b07686fc 100644 --- a/x-pack/test_utils/enzyme_helpers.tsx +++ b/x-pack/test_utils/enzyme_helpers.tsx @@ -191,3 +191,5 @@ export const mountHook = ( hookValueCallback, }; }; + +export const nextTick = () => new Promise(res => process.nextTick(res));