mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
Per panel time range (#43153)
* Per panel time range * Added tests and fixed lack of await check for incompatibility * Remove a couple more unneccessary `anys`
This commit is contained in:
parent
4a1f41ece6
commit
a9b479a667
33 changed files with 1364 additions and 17 deletions
|
@ -70,7 +70,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => {
|
|||
<I18nProvider>
|
||||
<EmbeddablePanel
|
||||
embeddable={embeddable}
|
||||
getActions={(() => []) as any}
|
||||
getActions={() => Promise.resolve([])}
|
||||
getAllEmbeddableFactories={(() => []) as any}
|
||||
getEmbeddableFactory={(() => null) as any}
|
||||
notifications={{} as any}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -24,6 +24,7 @@ export {
|
|||
ADD_PANEL_ACTION_ID,
|
||||
APPLY_FILTER_ACTION,
|
||||
APPLY_FILTER_TRIGGER,
|
||||
PANEL_BADGE_TRIGGER,
|
||||
Action,
|
||||
ActionContext,
|
||||
Adapters,
|
||||
|
|
|
@ -35,10 +35,10 @@ const getKeys = <T extends {}>(o: T): Array<keyof T> => Object.keys(o) as Array<
|
|||
|
||||
export abstract class Container<
|
||||
TChildInput extends Partial<EmbeddableInput> = {},
|
||||
TContainerInput extends ContainerInput = ContainerInput,
|
||||
TContainerInput extends ContainerInput<TChildInput> = ContainerInput<TChildInput>,
|
||||
TContainerOutput extends ContainerOutput = ContainerOutput
|
||||
> extends Embeddable<TContainerInput, TContainerOutput>
|
||||
implements IContainer<TContainerInput, TContainerOutput> {
|
||||
implements IContainer<TChildInput, TContainerInput, TContainerOutput> {
|
||||
public readonly isContainer: boolean = true;
|
||||
protected readonly children: {
|
||||
[key: string]: IEmbeddable<any, any> | ErrorEmbeddable;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -50,7 +50,8 @@ export interface ContainerInput<PanelExplicitInput = {}> extends EmbeddableInput
|
|||
}
|
||||
|
||||
export interface IContainer<
|
||||
I extends ContainerInput = ContainerInput,
|
||||
Inherited extends {} = {},
|
||||
I extends ContainerInput<Inherited> = ContainerInput<Inherited>,
|
||||
O extends ContainerOutput = ContainerOutput
|
||||
> extends IEmbeddable<I, O> {
|
||||
/**
|
||||
|
|
|
@ -156,7 +156,7 @@ test('HelloWorldContainer in view mode hides edit mode actions', async () => {
|
|||
<I18nProvider>
|
||||
<EmbeddablePanel
|
||||
embeddable={embeddable}
|
||||
getActions={(() => 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 () => {
|
|||
<I18nProvider>
|
||||
<EmbeddablePanel
|
||||
embeddable={embeddable}
|
||||
getActions={(() => 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 () => {
|
|||
<I18nProvider>
|
||||
<EmbeddablePanel
|
||||
embeddable={embeddable}
|
||||
getActions={(() => undefined) as any}
|
||||
getActions={() => Promise.resolve([])}
|
||||
getAllEmbeddableFactories={(() => []) as any}
|
||||
getEmbeddableFactory={(() => undefined) as any}
|
||||
notifications={{} as any}
|
||||
|
|
|
@ -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<Props, State> {
|
||||
|
@ -80,11 +81,24 @@ export class EmbeddablePanel extends React.Component<Props, State> {
|
|||
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<Props, State> {
|
|||
this.setState({
|
||||
viewMode: embeddable.getInput().viewMode ? embeddable.getInput().viewMode : ViewMode.EDIT,
|
||||
});
|
||||
|
||||
this.refreshBadges();
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -104,6 +120,8 @@ export class EmbeddablePanel extends React.Component<Props, State> {
|
|||
this.setState({
|
||||
hidePanelTitles: Boolean(parent.getInput().hidePanelTitles),
|
||||
});
|
||||
|
||||
this.refreshBadges();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -144,6 +162,8 @@ export class EmbeddablePanel extends React.Component<Props, State> {
|
|||
isViewMode={viewOnlyMode}
|
||||
closeContextMenu={this.state.closeContextMenu}
|
||||
title={title}
|
||||
badges={this.state.badges}
|
||||
embeddable={this.props.embeddable}
|
||||
/>
|
||||
<div className="embPanel__content" ref={this.embeddableRoot} />
|
||||
</EuiPanel>
|
||||
|
|
|
@ -29,9 +29,9 @@ interface ExpandedPanelInput extends ContainerInput {
|
|||
}
|
||||
|
||||
function hasExpandedPanelInput(
|
||||
container: IContainer | IContainer<ExpandedPanelInput>
|
||||
): container is IContainer<ExpandedPanelInput> {
|
||||
return (container as IContainer<ExpandedPanelInput>).getInput().expandedPanelId !== undefined;
|
||||
container: IContainer
|
||||
): container is IContainer<{}, ExpandedPanelInput> {
|
||||
return (container as IContainer<{}, ExpandedPanelInput>).getInput().expandedPanelId !== undefined;
|
||||
}
|
||||
|
||||
export class RemovePanelAction extends Action {
|
||||
|
|
|
@ -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<EuiContextMenuPanelDescriptor>;
|
||||
closeContextMenu: boolean;
|
||||
badges: Action[];
|
||||
embeddable: IEmbeddable;
|
||||
}
|
||||
|
||||
interface PanelHeaderUiProps extends PanelHeaderProps {
|
||||
intl: InjectedIntl;
|
||||
}
|
||||
|
||||
function renderBadges(badges: Action[], embeddable: IEmbeddable) {
|
||||
return badges.map(badge => (
|
||||
<EuiBadge
|
||||
key={badge.id}
|
||||
iconType={badge.getIconType({ embeddable })}
|
||||
onClick={() => badge.execute({ embeddable })}
|
||||
onClickAriaLabel={badge.getDisplayName({ embeddable })}
|
||||
>
|
||||
{badge.getDisplayName({ embeddable })}
|
||||
</EuiBadge>
|
||||
));
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={classes}>
|
||||
<PanelOptionsMenu
|
||||
|
@ -78,7 +100,8 @@ function PanelHeaderUi({
|
|||
}
|
||||
)}
|
||||
>
|
||||
{hidePanelTitles ? '' : title}
|
||||
{showTitle ? `${title} ` : ''}
|
||||
{renderBadges(badges, embeddable)}
|
||||
</div>
|
||||
|
||||
<PanelOptionsMenu
|
||||
|
|
|
@ -19,3 +19,4 @@
|
|||
|
||||
export const CONTEXT_MENU_TRIGGER = 'CONTEXT_MENU_TRIGGER';
|
||||
export const APPLY_FILTER_TRIGGER = 'FILTER_TRIGGER';
|
||||
export const PANEL_BADGE_TRIGGER = 'PANEL_BADGE_TRIGGER';
|
||||
|
|
|
@ -65,6 +65,7 @@ export interface VisualizeOutput extends EmbeddableOutput {
|
|||
editUrl: string;
|
||||
indexPatterns?: StaticIndexPattern[];
|
||||
savedObjectId: string;
|
||||
visTypeName: string;
|
||||
}
|
||||
|
||||
export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOutput> {
|
||||
|
@ -99,6 +100,7 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut
|
|||
indexPatterns,
|
||||
editable,
|
||||
savedObjectId: savedVisualization.id!,
|
||||
visTypeName: savedVisualization.vis.type.name,
|
||||
},
|
||||
parent
|
||||
);
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
"prefix": "xpack",
|
||||
"paths": {
|
||||
"xpack.actions": "legacy/plugins/actions",
|
||||
"xpack.advancedUiActions": "legacy/plugins/advanced_ui_actions",
|
||||
"xpack.alerting": "legacy/plugins/alerting",
|
||||
"xpack.apm": "legacy/plugins/apm",
|
||||
"xpack.beatsManagement": "legacy/plugins/beats_management",
|
||||
|
|
|
@ -43,6 +43,7 @@ import { encryptedSavedObjects } from './legacy/plugins/encrypted_saved_objects'
|
|||
import { snapshotRestore } from './legacy/plugins/snapshot_restore';
|
||||
import { actions } from './legacy/plugins/actions';
|
||||
import { alerting } from './legacy/plugins/alerting';
|
||||
import { advancedUiActions } from './legacy/plugins/advanced_ui_actions';
|
||||
|
||||
module.exports = function (kibana) {
|
||||
return [
|
||||
|
@ -85,5 +86,6 @@ module.exports = function (kibana) {
|
|||
snapshotRestore(kibana),
|
||||
actions(kibana),
|
||||
alerting(kibana),
|
||||
advancedUiActions(kibana),
|
||||
];
|
||||
};
|
||||
|
|
16
x-pack/legacy/plugins/advanced_ui_actions/index.ts
Normal file
16
x-pack/legacy/plugins/advanced_ui_actions/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 { resolve } from 'path';
|
||||
|
||||
export const advancedUiActions = (kibana: any) =>
|
||||
new kibana.Plugin({
|
||||
id: 'advanced_ui_actions',
|
||||
publicDir: resolve(__dirname, 'public'),
|
||||
uiExports: {
|
||||
hacks: 'plugins/advanced_ui_actions/np_ready/public/legacy',
|
||||
},
|
||||
});
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "advanced_ui_actions",
|
||||
"version": "kibana",
|
||||
"requiredPlugins": [
|
||||
"embeddable"
|
||||
],
|
||||
"server": false,
|
||||
"ui": true
|
||||
}
|
|
@ -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);
|
||||
});
|
|
@ -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<TimeRangeInput> {
|
||||
timeRange: TimeRange;
|
||||
}
|
||||
|
||||
export function canInheritTimeRange(embeddable: Embeddable<TimeRangeInput>) {
|
||||
if (!embeddable.parent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parent = embeddable.parent as IContainer<TimeRangeInput, ContainerTimeRangeInput>;
|
||||
|
||||
return parent.getInput().timeRange !== undefined;
|
||||
}
|
|
@ -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<string, EmbeddableFactory>();
|
||||
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<TimeRangeEmbeddable>('1');
|
||||
expect(child1).toBeDefined();
|
||||
expect(child1.getInput().timeRange).toEqual({ from: 'now-15m', to: 'now' });
|
||||
|
||||
const child2 = container.getChild<TimeRangeEmbeddable>('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<string, EmbeddableFactory>();
|
||||
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<TimeRangeEmbeddable>('1');
|
||||
const child2 = container.getChild<TimeRangeEmbeddable>('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<string, EmbeddableFactory>();
|
||||
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<TimeRangeEmbeddable>('1');
|
||||
const child2 = container.getChild<TimeRangeEmbeddable>('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<string, EmbeddableFactory>();
|
||||
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<TimeRangeEmbeddable>('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<string, EmbeddableFactory>();
|
||||
// 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<HelloWorldEmbeddable>('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<string, EmbeddableFactory>();
|
||||
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<HelloWorldEmbeddable>('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);
|
||||
});
|
|
@ -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<TimeRangeInput>
|
||||
): embeddable is Embeddable<TimeRangeInput> {
|
||||
return (embeddable as Embeddable<TimeRangeInput>).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(
|
||||
<CustomizeTimeRangeModal
|
||||
onClose={() => modalSession.close()}
|
||||
embeddable={embeddable}
|
||||
dateFormat={this.dateFormat}
|
||||
commonlyUsedRanges={this.commonlyUsedRanges}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<string, EmbeddableFactory>();
|
||||
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<TimeRangeEmbeddable>('1');
|
||||
const child2 = container.getChild<TimeRangeEmbeddable>('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<string, EmbeddableFactory>();
|
||||
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<TimeRangeEmbeddable>('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<string, EmbeddableFactory>();
|
||||
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<TimeRangeEmbeddable>('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<string, EmbeddableFactory>();
|
||||
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<TimeRangeEmbeddable>('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);
|
||||
});
|
|
@ -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<TimeRangeInput>
|
||||
): embeddable is Embeddable<TimeRangeInput> {
|
||||
return (embeddable as Embeddable<TimeRangeInput>).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<Embeddable<TimeRangeInput>>) {
|
||||
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(
|
||||
<CustomizeTimeRangeModal
|
||||
onClose={() => modalSession.close()}
|
||||
embeddable={embeddable}
|
||||
dateFormat={this.dateFormat}
|
||||
commonlyUsedRanges={this.commonlyUsedRanges}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<TimeRangeInput>;
|
||||
onClose: () => void;
|
||||
dateFormat?: string;
|
||||
commonlyUsedRanges: CommonlyUsedRange[];
|
||||
}
|
||||
|
||||
interface State {
|
||||
timeRange?: TimeRange;
|
||||
inheritTimeRange: boolean;
|
||||
}
|
||||
|
||||
export class CustomizeTimeRangeModal extends Component<CustomizeTimeRangeProps, State> {
|
||||
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<TimeRangeInput>>;
|
||||
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 (
|
||||
<React.Fragment>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle data-test-subj="customizePanelTitle">
|
||||
{i18n.translate('xpack.advancedUiActions.customizeTimeRange.modal.headerTitle', {
|
||||
defaultMessage: 'Customize panel time range',
|
||||
})}
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
|
||||
<EuiModalBody>
|
||||
<EuiFormRow
|
||||
label={i18n.translate(
|
||||
'xpack.advancedUiActions.customizePanelTimeRange.modal.optionsMenuForm.panelTitleFormRowLabel',
|
||||
{
|
||||
defaultMessage: 'Time range',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<EuiSuperDatePicker
|
||||
start={this.state.timeRange ? this.state.timeRange.from : undefined}
|
||||
end={this.state.timeRange ? this.state.timeRange.to : undefined}
|
||||
isPaused={false}
|
||||
onTimeChange={this.onTimeChange}
|
||||
showUpdateButton={false}
|
||||
dateFormat={this.props.dateFormat}
|
||||
commonlyUsedRanges={this.props.commonlyUsedRanges.map(
|
||||
({ from, to, display }: { from: string; to: string; display: string }) => {
|
||||
return {
|
||||
start: from,
|
||||
end: to,
|
||||
label: display,
|
||||
};
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiModalBody>
|
||||
<EuiModalFooter>
|
||||
<EuiFlexGroup gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem>
|
||||
<EuiButtonEmpty
|
||||
onClick={this.inheritFromParent}
|
||||
color="danger"
|
||||
data-test-subj="removePerPanelTimeRangeButton"
|
||||
disabled={this.state.inheritTimeRange}
|
||||
>
|
||||
{i18n.translate(
|
||||
'xpack.advancedUiActions.customizePanelTimeRange.modal.removeButtonTitle',
|
||||
{
|
||||
defaultMessage: 'Remove',
|
||||
}
|
||||
)}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiButtonEmpty onClick={this.cancel} data-test-subj="cancelPerPanelTimeRangeButton">
|
||||
{i18n.translate(
|
||||
'xpack.advancedUiActions.customizePanelTimeRange.modal.cancelButtonTitle',
|
||||
{
|
||||
defaultMessage: 'Cancel',
|
||||
}
|
||||
)}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiButton data-test-subj="addPerPanelTimeRangeButton" onClick={this.addToPanel} fill>
|
||||
{this.state.inheritTimeRange
|
||||
? i18n.translate(
|
||||
'xpack.advancedUiActions.customizePanelTimeRange.modal.addToPanelButtonTitle',
|
||||
{
|
||||
defaultMessage: 'Add to panel',
|
||||
}
|
||||
)
|
||||
: i18n.translate(
|
||||
'xpack.advancedUiActions.customizePanelTimeRange.modal.updatePanelTimeRangeButtonTitle',
|
||||
{
|
||||
defaultMessage: 'Update',
|
||||
}
|
||||
)}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiModalFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<TimeRangeInput>) {
|
||||
if (!embeddable.parent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parent = embeddable.parent as IContainer<{}, ContainerInput<TimeRangeInput>>;
|
||||
|
||||
// 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;
|
||||
}
|
|
@ -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 };
|
|
@ -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,
|
||||
});
|
|
@ -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<EmbeddablePlugin['setup']>;
|
||||
}
|
||||
|
||||
interface StartDependencies {
|
||||
embeddable: ReturnType<EmbeddablePlugin['start']>;
|
||||
}
|
||||
|
||||
export type Setup = void;
|
||||
export type Start = void;
|
||||
|
||||
export class AdvancedUiActionsPublicPlugin
|
||||
implements Plugin<Setup, Start, SetupDependencies, StartDependencies> {
|
||||
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() {}
|
||||
}
|
|
@ -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';
|
|
@ -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<InheritedChildrenInput> {
|
||||
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() {}
|
||||
}
|
|
@ -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<EmbeddableTimeRangeInput, EmbeddableOutput> {
|
||||
public readonly type = TIME_RANGE_EMBEDDABLE;
|
||||
|
||||
constructor(initialInput: EmbeddableTimeRangeInput, parent?: IContainer) {
|
||||
super(initialInput, {}, parent);
|
||||
}
|
||||
|
||||
public render() {}
|
||||
|
||||
public reload() {}
|
||||
}
|
|
@ -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<EmbeddableTimeRangeInput> {
|
||||
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';
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -191,3 +191,5 @@ export const mountHook = <Args extends {}, HookValue extends any>(
|
|||
hookValueCallback,
|
||||
};
|
||||
};
|
||||
|
||||
export const nextTick = () => new Promise(res => process.nextTick(res));
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue