Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Embeddable API V2: Infrastructure changes necessary to support actions #35622

Closed
Closed
Changes from 8 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
8029518
Embeddable API Plugin
stacey-gammon Apr 25, 2019
a6a10d2
Fix issue with errorembeddable children not receiving updates from pa…
stacey-gammon May 22, 2019
56f30a2
Type changes in response to review comments
stacey-gammon May 22, 2019
603f451
Merge branch 'master' of github.com:elastic/kibana into 2019-04-25-em…
stacey-gammon May 22, 2019
1dcfe7e
Get rid of ExecuteActionContext, just use ActionContext
stacey-gammon May 22, 2019
4c7b29f
Merge branch 'master' of github.com:elastic/kibana into 2019-04-25-em…
stacey-gammon May 22, 2019
6d2b773
Go back to default export because of #32149
stacey-gammon May 22, 2019
c37a16c
Fix es import path error… again
stacey-gammon May 23, 2019
1be7bbc
Use new registry interface and do some functional refactoring
stacey-gammon May 23, 2019
ca8792b
Fix bug with output state losing instance data and add jest test
stacey-gammon May 23, 2019
14cd8b2
remove unnecessary changes for a table row click trigger
stacey-gammon May 23, 2019
d6610b2
Merge branch 'master' of github.com:elastic/kibana into 2019-04-25-em…
stacey-gammon May 23, 2019
842aebb
Remove unnecessary changes to a file
stacey-gammon May 23, 2019
808d5dd
Remove trackUiMetric calls from PR 1
stacey-gammon May 23, 2019
f98c591
Remove unused translations
stacey-gammon May 23, 2019
ec6f778
dashboard mode improvements
stacey-gammon May 23, 2019
3e646ea
Merge branch 'master' of github.com:elastic/kibana into 2019-04-25-em…
stacey-gammon May 23, 2019
7a01480
Fix a bad merge, these changes were lost
stacey-gammon May 23, 2019
01fee75
Fix bug with refresh timer not working
stacey-gammon May 24, 2019
80d8b46
Fix saved searches not refreshing with auto interval on.
stacey-gammon May 28, 2019
0fff4b3
Fix map embeddable not updating with auto refresh interval on.
stacey-gammon May 28, 2019
1490a4d
Type fix
stacey-gammon May 28, 2019
8beb278
Merge branch 'master' of github.com:elastic/kibana into 2019-04-25-em…
stacey-gammon May 28, 2019
7e2bc5f
Adjust bad import path in code merged from master, and update yarn
stacey-gammon May 28, 2019
7a91eb8
Get rid of what I think is an unnecessary hack, since the courier.fet…
stacey-gammon May 28, 2019
2e58f39
Track jest coverage for all core plugins
stacey-gammon May 28, 2019
256c553
Update sass based on review comments
stacey-gammon May 29, 2019
a52db94
Merge branch 'master' of github.com:elastic/kibana into 2019-04-25-em…
stacey-gammon May 29, 2019
3dc4eff
Use async fn instead of Promise.resolve
stacey-gammon May 29, 2019
d9b4ae2
clean up and add comments to embeddable_child_panel
stacey-gammon May 29, 2019
65426e1
Fix issue with panels not resizing. Fixes reporting
stacey-gammon May 29, 2019
6a7e4b4
Merge branch 'master' of github.com:elastic/kibana into 2019-04-25-em…
stacey-gammon May 29, 2019
b4af4b5
Adjust import paths, remove need for custom tsconfig rule
stacey-gammon May 29, 2019
6624128
Migrate export csv action
stacey-gammon May 29, 2019
cb28fb0
use relative paths in all test files, the tsconfig path doesn’t work …
stacey-gammon May 29, 2019
76372e7
Can’t use relative paths into oss, breaks yarn build
stacey-gammon May 29, 2019
7c2703b
Update module name mapper so jest tests work with imports like `plugi…
stacey-gammon May 30, 2019
f02e22a
Put back sass that made expanded panel mode work.
stacey-gammon May 30, 2019
e2ba898
Fix build issues with static imports from xpack into oss
stacey-gammon May 30, 2019
815bfd8
Reorder moduleNameMapper resolutions to get jest tests to pass
stacey-gammon May 30, 2019
f069b06
Small clean up
stacey-gammon May 31, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/dev/jest/config.js
Original file line number Diff line number Diff line change
@@ -37,15 +37,23 @@ export default {
'<rootDir>/src/test_utils',
'<rootDir>/test/functional/services/remote',
],

collectCoverageFrom: [
'packages/kbn-ui-framework/src/components/**/*.js',
'!packages/kbn-ui-framework/src/components/index.js',
'!packages/kbn-ui-framework/src/components/**/*/index.js',
'packages/kbn-ui-framework/src/services/**/*.js',
'!packages/kbn-ui-framework/src/services/index.js',
'!packages/kbn-ui-framework/src/services/**/*/index.js',
'src/legacy/core_plugins/metrics/**/*.js'
'src/legacy/core_plugins/metrics/**/*.js',
stacey-gammon marked this conversation as resolved.
Show resolved Hide resolved
'src/legacy/core_plugins/embeddable_api/public/**/*',
'!src/legacy/core_plugins/embeddable_api/public/__test__/**/*',
'!src/legacy/core_plugins/embeddable_api/public/**/__snapshots__/**/*',
'src/legacy/core_plugins/dashboard_embeddable/public/**/*',
'!src/legacy/core_plugins/dashboard_embeddable/public/__test__/**/*',
'!src/legacy/core_plugins/dashboard_embeddable/public/**/__snapshots__/**/*',
],

moduleNameMapper: {
'^plugins/([^\/.]*)/(.*)': '<rootDir>/src/legacy/core_plugins/$1/public/$2',
'^ui/(.*)': '<rootDir>/src/legacy/ui/public/$1',
Original file line number Diff line number Diff line change
@@ -17,18 +17,15 @@
* under the License.
*/

import ngMock from 'ng_mock';
import expect from '@kbn/expect';
import { resolve } from 'path';

describe('SavedDashboards Service', function () {
let savedDashboardLoader;

beforeEach(ngMock.module('kibana'));
beforeEach(ngMock.inject(function (savedDashboards) {
savedDashboardLoader = savedDashboards;
}));

it('delete returns a native promise', function () {
expect(savedDashboardLoader.delete(['1', '2'])).to.be.a(Promise);
// eslint-disable-next-line import/no-default-export
export default function(kibana: any) {
return new kibana.Plugin({
uiExports: {
embeddableActions: ['plugins/dashboard_embeddable/actions/expand_panel_action'],
embeddableFactories: ['plugins/dashboard_embeddable/embeddable/dashboard_container_factory'],
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
},
});
});
}
4 changes: 4 additions & 0 deletions src/legacy/core_plugins/dashboard_embeddable/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "dashboard_embeddable",
"version": "kibana"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { QueryLanguageType, ViewMode, EmbeddableInput } from 'plugins/embeddable_api/index';
import { DashboardContainerInput, DashboardPanelState } from '../embeddable';

export function getSampleDashboardInput(
overrides?: Partial<DashboardContainerInput>
): DashboardContainerInput {
return {
id: '123',
filters: [],
useMargins: false,
isFullScreenMode: false,
title: 'My Dashboard',
query: {
language: QueryLanguageType.KUERY,
query: 'hi',
},
timeRange: {
to: 'now',
from: 'now-15m',
},
viewMode: ViewMode.VIEW,
panels: {},
...overrides,
};
}

export function getSampleDashboardPanel<TEmbeddableInput extends EmbeddableInput = EmbeddableInput>(
overrides: Partial<DashboardPanelState<TEmbeddableInput>> & { embeddableId: string; type: string }
): DashboardPanelState {
return {
gridData: {
h: 15,
w: 15,
x: 0,
y: 0,
i: overrides.embeddableId,
},
embeddableId: overrides.embeddableId,
type: overrides.type,
explicitInput: overrides.explicitInput || {},
...overrides,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

export { getSampleDashboardInput, getSampleDashboardPanel } from './get_sample_dashboard_input';
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

jest.mock('ui/metadata', () => ({
metadata: {
branch: 'my-metadata-branch',
version: 'my-metadata-version',
},
}));

jest.mock('ui/capabilities', () => ({
uiCapabilities: {
visualize: {
save: true,
},
},
}));

import { EmbeddableFactoryRegistry, isErrorEmbeddable } from 'plugins/embeddable_api/index';
import { ExpandPanelAction } from './expand_panel_action';
import {
ContactCardEmbeddable,
CONTACT_CARD_EMBEDDABLE,
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddableFactory,
} from 'plugins/embeddable_api/__test__/index';
import { DashboardContainer } from '../embeddable';
import { getSampleDashboardInput, getSampleDashboardPanel } from '../__test__';

const embeddableFactories = new EmbeddableFactoryRegistry();
embeddableFactories.registerFactory(new ContactCardEmbeddableFactory());

let container: DashboardContainer;
let embeddable: ContactCardEmbeddable;

beforeEach(async () => {
container = new DashboardContainer(
getSampleDashboardInput({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
embeddableId: '123',
explicitInput: { firstName: 'Sam' },
type: CONTACT_CARD_EMBEDDABLE,
}),
},
}),
embeddableFactories
);

const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Kibana',
});

if (isErrorEmbeddable(contactCardEmbeddable)) {
throw new Error('Failed to create embeddable');
} else {
embeddable = contactCardEmbeddable;
}
});

test('Sets the embeddable expanded panel id on the parent', async () => {
const expandPanelAction = new ExpandPanelAction();

expect(container.getInput().expandedPanelId).toBeUndefined();

expandPanelAction.execute({ embeddable });

expect(container.getInput().expandedPanelId).toBe(embeddable.id);
});

test('Is not compatible when embeddable is not in a dashboard container', async () => {
const action = new ExpandPanelAction();
expect(
await action.isCompatible({
embeddable: new ContactCardEmbeddable({ firstName: 'sue', id: '123' }),
})
).toBe(false);
});

test('Execute throws an error when called with an embeddable not in a parent', async () => {
const action = new ExpandPanelAction();
async function check() {
await action.execute({ embeddable: container });
}
await expect(check()).rejects.toThrow(Error);
});

test('Returns title', async () => {
const action = new ExpandPanelAction();
expect(action.getDisplayName({ embeddable })).toBeDefined();
});

test('Returns an icon', async () => {
const action = new ExpandPanelAction();
expect(action.getIcon({ embeddable })).toBeDefined();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
Action,
actionRegistry,
IEmbeddable,
CONTEXT_MENU_TRIGGER,
triggerRegistry,
ActionContext,
} from 'plugins/embeddable_api/index';
import React from 'react';
import { IncompatibleActionError } from 'plugins/embeddable_api/actions';
import { DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '../embeddable';

export const EXPAND_PANEL_ACTION = 'togglePanel';

function isDashboard(
embeddable: IEmbeddable | DashboardContainer
): embeddable is DashboardContainer {
return (embeddable as DashboardContainer).type === DASHBOARD_CONTAINER_TYPE;
}

function isExpanded(embeddable: IEmbeddable) {
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
throw new IncompatibleActionError();
}

return embeddable.id === embeddable.parent.getInput().expandedPanelId;
}

export class ExpandPanelAction extends Action {
public readonly type = EXPAND_PANEL_ACTION;

constructor() {
super(EXPAND_PANEL_ACTION);
this.order = 7;
}

public getDisplayName({ embeddable }: ActionContext) {
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
throw new IncompatibleActionError();
}

return isExpanded(embeddable)
? i18n.translate('kbn.embeddable.actions.toggleExpandPanel.expandedDisplayName', {
defaultMessage: 'Minimize',
})
: i18n.translate('kbn.embeddable.actions.toggleExpandPanel.notExpandedDisplayName', {
defaultMessage: 'Full screen',
});
}

public getIcon({ embeddable }: ActionContext) {
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
throw new IncompatibleActionError();
}
// TODO: use 'minimize' when an eui-icon of such is available.
return <EuiIcon type={isExpanded(embeddable) ? 'expand' : 'expand'} />;
stacey-gammon marked this conversation as resolved.
Show resolved Hide resolved
}

public isCompatible({ embeddable }: ActionContext) {
return Promise.resolve(Boolean(embeddable.parent && isDashboard(embeddable.parent)));
}

public execute({ embeddable }: ActionContext) {
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
throw new IncompatibleActionError();
}
const newValue = isExpanded(embeddable) ? undefined : embeddable.id;
embeddable.parent.updateInput({
expandedPanelId: newValue,
});
}
}

actionRegistry.addAction(new ExpandPanelAction());

triggerRegistry.attachAction({
triggerId: CONTEXT_MENU_TRIGGER,
actionId: EXPAND_PANEL_ACTION,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

@import './variables';

@import './viewport/index';
@import './panel/index';
@import './grid/index';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
$dshEditingModeHoverColor: transparentize($euiColorWarning, lightOrDarkTheme(.9, .7));
stacey-gammon marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

export const DASHBOARD_GRID_COLUMN_COUNT = 48;
export const DASHBOARD_GRID_HEIGHT = 20;
export const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2;
export const DEFAULT_PANEL_HEIGHT = 15;
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
jest.mock('ui/metadata', () => ({
metadata: {
branch: 'my-metadata-branch',
version: 'my-metadata-version',
},
}));

import {
EmbeddableFactoryRegistry,
isErrorEmbeddable,
ViewMode,
actionRegistry,
triggerRegistry,
CONTEXT_MENU_TRIGGER,
} from 'plugins/embeddable_api/index';
import { DashboardContainer } from './dashboard_container';
import { getSampleDashboardInput, getSampleDashboardPanel } from '../__test__';
import { mount } from 'enzyme';
import { nextTick } from 'test_utils/enzyme_helpers';

// @ts-ignore
import { findTestSubject } from '@elastic/eui/lib/test';
import { EmbeddablePanel } from 'plugins/embeddable_api/panel';
import { I18nProvider } from '@kbn/i18n/react';
import {
ContactCardEmbeddableOutput,
EditModeAction,
ContactCardEmbeddable,
ContactCardEmbeddableInput,
CONTACT_CARD_EMBEDDABLE,
ContactCardEmbeddableFactory,
} from 'plugins/embeddable_api/__test__';

test('DashboardContainer initializes embeddables', async done => {
const embeddableFactories = new EmbeddableFactoryRegistry();
embeddableFactories.registerFactory(new ContactCardEmbeddableFactory());
const container = new DashboardContainer(
getSampleDashboardInput({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
embeddableId: '123',
explicitInput: { firstName: 'Sam' },
type: CONTACT_CARD_EMBEDDABLE,
}),
},
}),
embeddableFactories
);

const subscription = container.getOutput$().subscribe(output => {
if (container.getOutput().embeddableLoaded['123']) {
const embeddable = container.getChild<ContactCardEmbeddable>('123');
expect(embeddable).toBeDefined();
expect(embeddable.id).toBe('123');
done();
}
});

if (container.getOutput().embeddableLoaded['123']) {
const embeddable = container.getChild<ContactCardEmbeddable>('123');
expect(embeddable).toBeDefined();
expect(embeddable.id).toBe('123');
subscription.unsubscribe();
done();
}
});

test('DashboardContainer.addNewEmbeddable', async () => {
const embeddableFactories = new EmbeddableFactoryRegistry();
embeddableFactories.registerFactory(new ContactCardEmbeddableFactory());
const container = new DashboardContainer(getSampleDashboardInput(), embeddableFactories);
const embeddable = await container.addNewEmbeddable<ContactCardEmbeddableInput>(
CONTACT_CARD_EMBEDDABLE,
{
firstName: 'Kibana',
}
);
expect(embeddable).toBeDefined();

if (!isErrorEmbeddable(embeddable)) {
expect(embeddable.getInput().firstName).toBe('Kibana');
} else {
expect(false).toBe(true);
}

const embeddableInContainer = container.getChild<ContactCardEmbeddable>(embeddable.id);
expect(embeddableInContainer).toBeDefined();
expect(embeddableInContainer.id).toBe(embeddable.id);
});

test('Container view mode change propagates to existing children', async () => {
const embeddableFactories = new EmbeddableFactoryRegistry();
embeddableFactories.registerFactory(new ContactCardEmbeddableFactory());
const container = new DashboardContainer(
getSampleDashboardInput({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
embeddableId: '123',
explicitInput: { firstName: 'Sam' },
type: CONTACT_CARD_EMBEDDABLE,
}),
},
}),
embeddableFactories
);
await nextTick();

const embeddable = await container.getChild('123');
expect(embeddable.getInput().viewMode).toBe(ViewMode.VIEW);
container.updateInput({ viewMode: ViewMode.EDIT });
expect(embeddable.getInput().viewMode).toBe(ViewMode.EDIT);
});

test('Container view mode change propagates to new children', async () => {
const embeddableFactories = new EmbeddableFactoryRegistry();
embeddableFactories.registerFactory(new ContactCardEmbeddableFactory());
const container = new DashboardContainer(getSampleDashboardInput(), embeddableFactories);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Bob',
});

expect(embeddable.getInput().viewMode).toBe(ViewMode.VIEW);

container.updateInput({ viewMode: ViewMode.EDIT });

expect(embeddable.getInput().viewMode).toBe(ViewMode.EDIT);
});

test('DashboardContainer in edit mode shows edit mode actions', async () => {
const editModeAction = new EditModeAction();
actionRegistry.addAction(editModeAction);
triggerRegistry.attachAction({
triggerId: CONTEXT_MENU_TRIGGER,
actionId: editModeAction.id,
});

const embeddableFactories = new EmbeddableFactoryRegistry();
embeddableFactories.registerFactory(new ContactCardEmbeddableFactory());
const container = new DashboardContainer(
getSampleDashboardInput({ viewMode: ViewMode.VIEW }),
embeddableFactories
);

const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Bob',
});

const component = mount(
<I18nProvider>
<EmbeddablePanel embeddable={embeddable} />
</I18nProvider>
);

const button = findTestSubject(component, 'embeddablePanelToggleMenuIcon');

expect(button.length).toBe(1);
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');

expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1);

const editAction = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`);

expect(editAction.length).toBe(0);

container.updateInput({ viewMode: ViewMode.EDIT });
await nextTick();
component.update();
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
await nextTick();
component.update();
expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(0);
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
await nextTick();
component.update();
expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(1);

await nextTick();
component.update();

const action = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`);
expect(action.length).toBe(1);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import React from 'react';
import ReactDOM from 'react-dom';

import { I18nProvider } from '@kbn/i18n/react';
import { IndexPattern } from 'ui/index_patterns';

import {
Container,
ContainerInput,
EmbeddableFactoryRegistry,
EmbeddableInput,
Filter,
Query,
RefreshConfig,
ViewMode,
isErrorEmbeddable,
EmbeddableFactory,
IEmbeddable,
TimeRange,
} from '../../../embeddable_api/public/index';

import { DASHBOARD_CONTAINER_TYPE } from './dashboard_container_factory';
import { createPanelState } from './panel';
import { DashboardPanelState } from './types';
import { DashboardViewport } from './viewport/dashboard_viewport';

export interface DashboardContainerInput extends ContainerInput {
viewMode: ViewMode;
filters: Filter[];
query: Query;
timeRange: TimeRange;
refreshConfig?: RefreshConfig;
expandedPanelId?: string;
useMargins: boolean;
title: string;
description?: string;
isFullScreenMode: boolean;
panels: { [panelId: string]: DashboardPanelState<any> };

// Used to force a refresh of embeddables even if there were no other input state
// changes.
lastReloadRequestTime?: number;
}

export interface InheritedChildInput {
filters: Filter[];
query: Query;
timeRange: TimeRange;
refreshConfig?: RefreshConfig;
viewMode: ViewMode;
hidePanelTitles?: boolean;
id: string;
}

export class DashboardContainer extends Container<InheritedChildInput, DashboardContainerInput> {
public readonly type = DASHBOARD_CONTAINER_TYPE;

constructor(
initialInput: DashboardContainerInput,
embeddableFactories: EmbeddableFactoryRegistry,
parent?: Container
) {
super(
{
panels: {},
isFullScreenMode: false,
filters: [],
useMargins: true,
...initialInput,
},
{ embeddableLoaded: {} },
embeddableFactories,
parent
);
}

protected createNewPanelState<
TEmbeddableInput extends EmbeddableInput,
TEmbeddable extends IEmbeddable<TEmbeddableInput, any>
>(
factory: EmbeddableFactory<TEmbeddableInput, any, TEmbeddable>,
partial: Partial<TEmbeddableInput> = {}
): DashboardPanelState<TEmbeddableInput> {
const panelState = super.createNewPanelState(factory, partial);
return createPanelState(panelState, Object.values(this.input.panels));
}

public onPanelsUpdated = (panels: { [panelId: string]: DashboardPanelState }) => {
this.updateInput({
panels: {
...panels,
},
});
};

public onExitFullScreenMode = () => {
this.updateInput({
isFullScreenMode: false,
});
};

public render(dom: React.ReactNode) {
stacey-gammon marked this conversation as resolved.
Show resolved Hide resolved
ReactDOM.render(
// @ts-ignore - hitting https://github.com/DefinitelyTyped/DefinitelyTyped/issues/27805
<I18nProvider>
<DashboardViewport container={this} />
</I18nProvider>,
dom
);
}

public getPanelIndexPatterns() {
const indexPatterns: IndexPattern[] = [];
Object.values(this.children).forEach(embeddable => {
if (!isErrorEmbeddable(embeddable)) {
const embeddableIndexPatterns = embeddable.getOutput().indexPatterns;
if (embeddableIndexPatterns) {
indexPatterns.push(...embeddableIndexPatterns);
}
}
});
return indexPatterns;
}

protected getInheritedInput(id: string): InheritedChildInput {
const { viewMode, refreshConfig, timeRange, query, hidePanelTitles, filters } = this.input;
return {
filters,
hidePanelTitles,
query,
timeRange: {
// Somewhere along the way this is modified directly, we need to make a copy so everything
// updates correctly.
...timeRange,
},
refreshConfig,
viewMode,
id,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import {
ContainerOutput,
embeddableFactories,
EmbeddableFactory,
ErrorEmbeddable,
Container,
} from 'plugins/embeddable_api/index';
import { capabilities } from 'ui/capabilities';
import { i18n } from '@kbn/i18n';
import { DashboardContainer, DashboardContainerInput } from './dashboard_container';

export const DASHBOARD_CONTAINER_TYPE = 'dashboard';

export class DashboardContainerFactory extends EmbeddableFactory<
DashboardContainerInput,
ContainerOutput
> {
public readonly isContainerType = true;
public readonly type = DASHBOARD_CONTAINER_TYPE;

public isEditable() {
return capabilities.get().dashboard.save as boolean;
}

public getDisplayName() {
return i18n.translate('kbn.embeddable.dashboard.displayName', {
defaultMessage: 'dashboard',
});
}

public getDefaultInput(): Partial<DashboardContainerInput> {
return {
panels: {},
isFullScreenMode: false,
useMargins: true,
};
}

public async create(
initialInput: DashboardContainerInput,
parent?: Container
): Promise<DashboardContainer | ErrorEmbeddable> {
return Promise.resolve(new DashboardContainer(initialInput, embeddableFactories, parent));
}
}

embeddableFactories.registerFactory(new DashboardContainerFactory());
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import React from 'react';
import { shallowWithIntl, nextTick, mountWithIntl } from 'test_utils/enzyme_helpers';
// @ts-ignore
import sizeMe from 'react-sizeme';

import { EmbeddableFactoryRegistry } from 'plugins/embeddable_api/index';

import { DashboardGrid, DashboardGridProps } from './dashboard_grid';
import {
ContactCardEmbeddableFactory,
CONTACT_CARD_EMBEDDABLE,
} from 'plugins/embeddable_api/__test__';
import { DashboardContainer } from '../dashboard_container';
import { getSampleDashboardInput } from 'plugins/dashboard_embeddable/__test__';
import { skip } from 'rxjs/operators';

jest.mock('ui/chrome', () => ({ getKibanaVersion: () => '6.0.0' }), { virtual: true });

jest.mock(
'ui/notify',
() => ({
toastNotifications: {
addDanger: () => {},
},
}),
{ virtual: true }
);

let dashboardContainer: DashboardContainer | undefined;

function getProps(props?: Partial<DashboardGridProps>): DashboardGridProps {
const embeddableFactories = new EmbeddableFactoryRegistry();
embeddableFactories.registerFactory(new ContactCardEmbeddableFactory());
dashboardContainer = new DashboardContainer(
getSampleDashboardInput({
panels: {
'1': {
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
embeddableId: '1',
type: CONTACT_CARD_EMBEDDABLE,
explicitInput: { firstName: 'Bob' },
},
'2': {
gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' },
type: CONTACT_CARD_EMBEDDABLE,
embeddableId: '2',
explicitInput: { firstName: 'Stacey' },
},
},
}),
embeddableFactories
);
const defaultTestProps: DashboardGridProps = {
container: dashboardContainer,
intl: null as any,
};
return Object.assign(defaultTestProps, props);
}

beforeAll(() => {
// sizeme detects the width to be 0 in our test environment. noPlaceholder will mean that the grid contents will
// get rendered even when width is 0, which will improve our tests.
sizeMe.noPlaceholders = true;
});

afterAll(() => {
sizeMe.noPlaceholders = false;
});

test('renders DashboardGrid', () => {
const component = shallowWithIntl(<DashboardGrid.WrappedComponent {...getProps()} />);
const panelElements = component.find('InjectIntl(EmbeddableChildPanelUi)');
expect(panelElements.length).toBe(2);
});

test('renders DashboardGrid with no visualizations', async () => {
const props = getProps();
const component = shallowWithIntl(<DashboardGrid.WrappedComponent {...props} />);
props.container.updateInput({ panels: {} });
await nextTick();
component.update();
expect(component.find('InjectIntl(EmbeddableChildPanelUi)').length).toBe(0);
});

test('DashboardGrid removes panel when removed from container', async () => {
const props = getProps();
const component = shallowWithIntl(<DashboardGrid.WrappedComponent {...props} />);
const originalPanels = props.container.getInput().panels;
const filteredPanels = { ...originalPanels };
delete filteredPanels['1'];
props.container.updateInput({ panels: filteredPanels });
await nextTick();
component.update();
const panelElements = component.find('InjectIntl(EmbeddableChildPanelUi)');
expect(panelElements.length).toBe(1);
});

test('DashboardGrid renders expanded panel', async () => {
const props = getProps();
const component = shallowWithIntl(<DashboardGrid.WrappedComponent {...props} />);
props.container.updateInput({ expandedPanelId: '1' });
await nextTick();
component.update();
// Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized.
expect(component.find('InjectIntl(EmbeddableChildPanelUi)').length).toBe(2);

expect((component.state() as { expandedPanelId?: string }).expandedPanelId).toBe('1');

props.container.updateInput({ expandedPanelId: undefined });
await nextTick();
component.update();
expect(component.find('InjectIntl(EmbeddableChildPanelUi)').length).toBe(2);

expect((component.state() as { expandedPanelId?: string }).expandedPanelId).toBeUndefined();
});

test('DashboardGrid unmount unsubscribes', async done => {
const props = getProps();
const component = mountWithIntl(<DashboardGrid.WrappedComponent {...props} />);
component.unmount();

props.container
.getInput$()
.pipe(skip(1))
.subscribe(() => {
done();
});

props.container.updateInput({ expandedPanelId: '1' });
});
Original file line number Diff line number Diff line change
@@ -27,18 +27,14 @@ import 'react-resizable/css/styles.css';

// @ts-ignore
import sizeMe from 'react-sizeme';
import { EmbeddableFactory } from 'ui/embeddable';
import { toastNotifications } from 'ui/notify';
import {
DASHBOARD_GRID_COLUMN_COUNT,
DASHBOARD_GRID_HEIGHT,
DashboardConstants,
} from '../dashboard_constants';
import { DashboardViewMode } from '../dashboard_view_mode';
import { DashboardPanel } from '../panel';
import { PanelUtils } from '../panel/panel_utils';
import { PanelState, PanelStateMap, Pre61PanelState } from '../selectors/types';
import { GridData } from '../types';
import { Subscription } from 'rxjs';
import { EmbeddableChildPanel } from 'plugins/embeddable_api/containers/embeddable_child_panel';
import { DashboardConstants } from '../../../../kibana/public/dashboard/dashboard_constants';
import { ViewMode } from '../../../../embeddable_api/public/index';
import { DASHBOARD_GRID_COLUMN_COUNT, DASHBOARD_GRID_HEIGHT } from '../dashboard_constants';
import { DashboardContainer } from '../dashboard_container';
import { DashboardPanelState, GridData } from '../types';

let lastValidGridSize = 0;

@@ -101,7 +97,7 @@ function ResponsiveGrid({
rowHeight={DASHBOARD_GRID_HEIGHT}
// Pass the named classes of what should get the dragging handle
// (.doesnt-exist literally doesnt exist)
draggableHandle={isViewMode ? '.doesnt-exist' : '.dshPanel__dragger'}
draggableHandle={isViewMode ? '.doesnt-exist' : '.embPanel__dragger'}
layout={layout}
onLayoutChange={onLayoutChange}
onResize={({}, {}, {}, {}, event) => ensureWindowScrollsToBottom(event)}
@@ -116,113 +112,111 @@ function ResponsiveGrid({
const config = { monitorWidth: true };
const ResponsiveSizedGrid = sizeMe(config)(ResponsiveGrid);

interface Props extends ReactIntl.InjectedIntlProps {
panels: PanelStateMap;
getEmbeddableFactory: (panelType: string) => EmbeddableFactory;
dashboardViewMode: DashboardViewMode.EDIT | DashboardViewMode.VIEW;
onPanelsUpdated: (updatedPanels: PanelStateMap) => void;
maximizedPanelId?: string;
useMargins: boolean;
export interface DashboardGridProps extends ReactIntl.InjectedIntlProps {
container: DashboardContainer;
}

interface State {
focusedPanelIndex?: string;
isLayoutInvalid: boolean;
layout?: GridData[];
panels: { [key: string]: DashboardPanelState };
viewMode: ViewMode;
useMargins: boolean;
expandedPanelId?: string;
}

interface PanelLayout extends Layout {
i: string;
}

class DashboardGridUi extends React.Component<Props, State> {
class DashboardGridUi extends React.Component<DashboardGridProps, State> {
private subscription?: Subscription;
private mounted: boolean = false;
// A mapping of panelIndexes to grid items so we can set the zIndex appropriately on the last focused
// item.
private gridItems = {} as { [key: string]: HTMLDivElement | null };

// A mapping of panel type to embeddable handlers. Because this function reaches out of react and into angular,
// if done in the render method, it appears to be triggering a scope.apply, which appears to be trigging a setState
// call inside TSVB visualizations. Moving the function out of render appears to fix the issue. See
// https://github.com/elastic/kibana/issues/14802 for more info.
// This is probably a better implementation anyway so the handlers are cached.
// @type {Object.<string, EmbeddableFactory>}
private embeddableFactoryMap: { [s: string]: EmbeddableFactory } = {};

constructor(props: Props) {
constructor(props: DashboardGridProps) {
super(props);

this.state = {
layout: [],
isLayoutInvalid: false,
focusedPanelIndex: undefined,
panels: this.props.container.getInput().panels,
viewMode: this.props.container.getInput().viewMode,
useMargins: this.props.container.getInput().useMargins,
expandedPanelId: this.props.container.getInput().expandedPanelId,
};
}

public componentDidMount() {
this.mounted = true;
let isLayoutInvalid = false;
let layout;
try {
layout = this.buildLayoutFromPanels();
} catch (error) {
console.error(error); // eslint-disable-line no-console

isLayoutInvalid = true;
toastNotifications.addDanger({
title: props.intl.formatMessage({
title: this.props.intl.formatMessage({
id: 'kbn.dashboard.dashboardGrid.unableToLoadDashboardDangerMessage',
defaultMessage: 'Unable to load dashboard.',
}),
text: error.message,
});
window.location.hash = DashboardConstants.LANDING_PAGE_PATH;
}
this.state = {
focusedPanelIndex: undefined,
this.setState({
layout,
isLayoutInvalid,
};
}

public buildLayoutFromPanels(): GridData[] {
return _.map(this.props.panels, panel => {
// panel version numbers added in 6.1. Any panel without version number is assumed to be 6.0.0
const panelVersion =
'version' in panel
? PanelUtils.parseVersion(panel.version)
: PanelUtils.parseVersion('6.0.0');

if (panelVersion.major < 6 || (panelVersion.major === 6 && panelVersion.minor < 1)) {
panel = PanelUtils.convertPanelDataPre_6_1(panel as Pre61PanelState);
}

if (panelVersion.major < 6 || (panelVersion.major === 6 && panelVersion.minor < 3)) {
PanelUtils.convertPanelDataPre_6_3(panel as PanelState, this.props.useMargins);
}

return (panel as PanelState).gridData;
});
}

public createEmbeddableFactoriesMap(panels: PanelStateMap) {
Object.values(panels).map(panel => {
if (!this.embeddableFactoryMap[panel.type]) {
this.embeddableFactoryMap[panel.type] = this.props.getEmbeddableFactory(panel.type);
this.subscription = this.props.container.getInput$().subscribe(input => {
if (this.mounted) {
this.setState({
panels: input.panels,
viewMode: input.viewMode,
useMargins: input.useMargins,
expandedPanelId: input.expandedPanelId,
});
}
});
}

public componentWillMount() {
this.createEmbeddableFactoriesMap(this.props.panels);
public componentWillUnmount() {
this.mounted = false;
if (this.subscription) {
this.subscription.unsubscribe();
}
}

public componentWillReceiveProps(nextProps: Props) {
this.createEmbeddableFactoriesMap(nextProps.panels);
}
public buildLayoutFromPanels = (): GridData[] => {
return _.map(this.state.panels, panel => {
return panel.gridData;
});
};

public onLayoutChange = (layout: PanelLayout[]) => {
const { onPanelsUpdated, panels } = this.props;
const updatedPanels = layout.reduce(
const panels = this.state.panels;
const updatedPanels: { [key: string]: DashboardPanelState } = layout.reduce(
(updatedPanelsAcc, panelLayout) => {
updatedPanelsAcc[panelLayout.i] = {
...panels[panelLayout.i],
panelIndex: panelLayout.i,
gridData: _.pick(panelLayout, ['x', 'y', 'w', 'h', 'i']),
};
return updatedPanelsAcc;
},
{} as PanelStateMap
{} as { [key: string]: DashboardPanelState }
);
onPanelsUpdated(updatedPanels);
this.onPanelsUpdated(updatedPanels);
};

public onPanelsUpdated = (panels: { [key: string]: DashboardPanelState }) => {
this.props.container.onPanelsUpdated(panels);
};

public onPanelFocused = (focusedPanelIndex: string): void => {
@@ -236,11 +230,12 @@ class DashboardGridUi extends React.Component<Props, State> {
};

public renderDOM() {
const { panels, maximizedPanelId } = this.props;
const { focusedPanelIndex } = this.state;
const { focusedPanelIndex, panels, expandedPanelId } = this.state;

// Part of our unofficial API - need to render in a consistent order for plugins.
const panelsInOrder = Object.keys(panels).map((key: string) => panels[key] as PanelState);
const panelsInOrder = Object.keys(panels).map(
(key: string) => panels[key] as DashboardPanelState
);
panelsInOrder.sort((panelA, panelB) => {
if (panelA.gridData.y === panelB.gridData.y) {
return panelA.gridData.x - panelB.gridData.x;
@@ -250,26 +245,24 @@ class DashboardGridUi extends React.Component<Props, State> {
});

return _.map(panelsInOrder, panel => {
const expandPanel = maximizedPanelId !== undefined && maximizedPanelId === panel.panelIndex;
const hidePanel = maximizedPanelId !== undefined && maximizedPanelId !== panel.panelIndex;
const expandPanel = expandedPanelId !== undefined && expandedPanelId === panel.embeddableId;
const hidePanel = expandedPanelId !== undefined && expandedPanelId !== panel.embeddableId;
const classes = classNames({
'dshDashboardGrid__item--expanded': expandPanel,
'dshDashboardGrid__item--hidden': hidePanel,
});
return (
<div
style={{ zIndex: focusedPanelIndex === panel.panelIndex ? 2 : 'auto' }}
style={{ zIndex: focusedPanelIndex === panel.embeddableId ? 2 : 'auto' }}
className={classes}
key={panel.panelIndex}
key={panel.embeddableId}
ref={reactGridItem => {
this.gridItems[panel.panelIndex] = reactGridItem;
this.gridItems[panel.embeddableId] = reactGridItem;
}}
>
<DashboardPanel
panelId={panel.panelIndex}
embeddableFactory={this.embeddableFactoryMap[panel.type]}
onPanelFocused={this.onPanelFocused}
onPanelBlurred={this.onPanelBlurred}
<EmbeddableChildPanel
embeddableId={panel.embeddableId}
container={this.props.container}
/>
</div>
);
@@ -281,15 +274,15 @@ class DashboardGridUi extends React.Component<Props, State> {
return null;
}

const { dashboardViewMode, maximizedPanelId, useMargins } = this.props;
const isViewMode = dashboardViewMode === DashboardViewMode.VIEW;
const { viewMode } = this.state;
const isViewMode = viewMode === ViewMode.VIEW;
return (
<ResponsiveSizedGrid
isViewMode={isViewMode}
layout={this.buildLayoutFromPanels()}
onLayoutChange={this.onLayoutChange}
maximizedPanelId={maximizedPanelId}
useMargins={useMargins}
maximizedPanelId={this.state.expandedPanelId}
useMargins={this.state.useMargins}
>
{this.renderDOM()}
</ResponsiveSizedGrid>
Original file line number Diff line number Diff line change
@@ -17,6 +17,4 @@
* under the License.
*/

export * from './types';

export * from './dashboard';
export { DashboardGrid } from './dashboard_grid';
Original file line number Diff line number Diff line change
@@ -17,18 +17,14 @@
* under the License.
*/

import { combineReducers } from 'redux';
import { embeddablesReducer } from './embeddables';
export { DASHBOARD_CONTAINER_TYPE, DashboardContainerFactory } from './dashboard_container_factory';
export { DashboardContainer, DashboardContainerInput } from './dashboard_container';
export { createPanelState } from './panel';

import { panelsReducer } from './panels';
export { DashboardPanelState } from './types';

import { viewReducer } from './view';

import { metadataReducer } from './metadata';

export const dashboard = combineReducers({
embeddables: embeddablesReducer,
metadata: metadataReducer,
panels: panelsReducer,
view: viewReducer,
});
export {
DASHBOARD_GRID_COLUMN_COUNT,
DEFAULT_PANEL_HEIGHT,
DEFAULT_PANEL_WIDTH,
} from './dashboard_constants';
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* EDITING MODE
* Use .dshLayout--editing to target editing state because
* .dshPanel--editing doesn't get updating without a hard refresh
*/

.dshPanel {
z-index: auto;
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
position: relative;

// SASSTODO: The inheritence factor stemming from embeddables makes this class hard to change
.panel-content {
display: flex;
flex: 1 1 100%;
height: auto;
z-index: 1;
min-height: 0; // Absolute must for Firefox to scroll contents
}

// SASSTODO: Pretty sure this doesn't do anything since the flex-basis 100%,
// but it MIGHT be fixing IE
.panel-content--fullWidth {
width: 100%;
}

.panel-content-isLoading {
// completely center the loading indicator
justify-content: center;
align-items: center;
}

/**
* 1. We want the kbnDocTable__container to scroll only when embedded in a dashboard panel
* 2. Fix overflow of vis's specifically for inside dashboard panels, lets the panel decide the overflow
* 3. Force a better looking scrollbar
*/
.kbnDocTable__container {
@include euiScrollBar; /* 3 */
flex: 1 1 0; /* 1 */
overflow: auto; /* 1 */
}

.visualization {
@include euiScrollBar; /* 3 */
}

.visualization .visChart__container {
overflow: visible; /* 2 */
}

.visLegend__toggle {
border-bottom-right-radius: 0;
border-top-left-radius: 0;
}
}

.dshLayout--editing .dshPanel {
border-style: dashed;
border-color: $euiColorMediumShade;
transition: all $euiAnimSpeedFast $euiAnimSlightResistance;

&:hover,
&:focus {
@include euiSlightShadowHover;
}
}

// LAYOUT MODES

// Adjust borders/etc... for non-spaced out and expanded panels
.dshLayout-withoutMargins,
.dshDashboardGrid__item--expanded {
.dshPanel {
box-shadow: none;
border-radius: 0;
}
}

// Remove border color unless in editing mode
.dshLayout-withoutMargins:not(.dshLayout--editing),
.dshDashboardGrid__item--expanded {
.dshPanel {
border-color: transparent;
}
}

// HEADER

.dshPanel__header {
flex: 0 0 auto;
display: flex;
// ensure menu button is on the right even if the title doesn't exist
justify-content: flex-end;
}

.dshPanel__title {
@include euiTextTruncate;
@include euiTitle('xxxs');
line-height: 1.5;
flex-grow: 1;

&:not(:empty) {
padding: ($euiSizeXS * 1.5) $euiSizeS 0;
}
}

.dshLayout--editing {
.dshPanel__dragger {
transition: background-color $euiAnimSpeedFast $euiAnimSlightResistance;
}

.dshPanel__dragger:hover {
background-color: $dshEditingModeHoverColor;
cursor: move;
}
}

.dshPanel__dragger:not(.dshPanel__title) {
flex-grow: 1;
}

.dshPanel__header--floater {
position: absolute;
right: 0;
top: 0;
left: 0;
z-index: $euiZLevel1;
}

// OPTIONS MENU

/**
* 1. Use opacity to make this element accessible to screen readers and keyboard.
* 2. Show on focus to enable keyboard accessibility.
* 3. Always show in editing mode
*/

.dshPanel_optionsMenuButton {
background-color: transparentize($euiColorDarkestShade, .9);
border-bottom-right-radius: 0;
border-top-left-radius: 0;

&:focus {
background-color: $euiFocusBackgroundColor;
}
}

.dshPanel .visLegend__toggle,
.dshPanel_optionsMenuButton {
opacity: 0; /* 1 */

&:focus {
opacity: 1; /* 2 */
}
}

.dshPanel_optionsMenuPopover[class*="-isOpen"],
.dshPanel:hover {
.dshPanel_optionsMenuButton,
.visLegend__toggle {
opacity: 1;
}
}

.dshLayout--editing {
.dshPanel_optionsMenuButton,
.dshPanel .visLegend__toggle {
opacity: 1; /* 3 */
}
}


// ERROR

.dshPanel__error {
text-align: center;
justify-content: center;
flex-direction: column;
overflow: auto;
text-align: center;

.fa-exclamation-triangle {
font-size: $euiFontSizeXL;
color: $euiColorDanger;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import "./dashboard_panel";
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
jest.mock('ui/metadata', () => ({
metadata: {
branch: 'my-metadata-branch',
version: 'my-metadata-version',
},
}));

import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../dashboard_constants';
import { DashboardPanelState } from '../types';
import { createPanelState } from './create_panel_state';
import { CONTACT_CARD_EMBEDDABLE } from 'plugins/embeddable_api/__test__';
import { EmbeddableInput } from 'plugins/embeddable_api/index';

interface TestInput extends EmbeddableInput {
test: string;
}
const panels: DashboardPanelState[] = [];

test('createPanelState adds a new panel state in 0,0 position', () => {
const panelState = createPanelState<TestInput>(
{ embeddableId: '123', type: CONTACT_CARD_EMBEDDABLE, explicitInput: { test: 'hi' } },
[]
);
expect(panelState.explicitInput.test).toBe('hi');
expect(panelState.type).toBe(CONTACT_CARD_EMBEDDABLE);
expect(panelState.embeddableId).toBeDefined();
expect(panelState.gridData.x).toBe(0);
expect(panelState.gridData.y).toBe(0);
expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT);
expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH);

panels.push(panelState);
});

test('createPanelState adds a second new panel state', () => {
const panelState = createPanelState<TestInput>(
{ embeddableId: '456', type: CONTACT_CARD_EMBEDDABLE, explicitInput: { test: 'bye' } },
panels
);
expect(panelState.gridData.x).toBe(DEFAULT_PANEL_WIDTH);
expect(panelState.gridData.y).toBe(0);
expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT);
expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH);

panels.push(panelState);
});

test('createPanelState adds a third new panel state', () => {
const panelState = createPanelState<TestInput>(
{ embeddableId: '789', type: CONTACT_CARD_EMBEDDABLE, explicitInput: { test: 'bye' } },
panels
);
expect(panelState.gridData.x).toBe(0);
expect(panelState.gridData.y).toBe(DEFAULT_PANEL_HEIGHT);
expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT);
expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH);

panels.push(panelState);
});

test('createPanelState adds a new panel state in the top most position', () => {
const panelsWithEmptySpace = panels.filter(panel => panel.gridData.x === 0);
const panelState = createPanelState<TestInput>(
{ embeddableId: '987', type: CONTACT_CARD_EMBEDDABLE, explicitInput: { test: 'bye' } },
panelsWithEmptySpace
);
expect(panelState.gridData.x).toBe(DEFAULT_PANEL_WIDTH);
expect(panelState.gridData.y).toBe(0);
expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT);
expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH);
});
Original file line number Diff line number Diff line change
@@ -17,35 +17,22 @@
* under the License.
*/

import chrome from 'ui/chrome';
import _ from 'lodash';

import { PanelState, EmbeddableInput } from 'plugins/embeddable_api/index';
import {
DASHBOARD_GRID_COLUMN_COUNT,
DEFAULT_PANEL_HEIGHT,
DEFAULT_PANEL_WIDTH,
} from '../dashboard_constants';
import { PanelState } from '../selectors';

/**
* Represents a panel on a grid. Keeps track of position in the grid and what visualization it
* contains.
*
* @typedef {Object} PanelState
* @property {number} id - Id of the visualization contained in the panel.
* @property {string} version - Version of Kibana this panel was created in.
* @property {string} type - Type of the visualization in the panel.
* @property {number} panelIndex - Unique id to represent this panel in the grid. Note that this is
* NOT the index in the panels array. While it may initially represent that, it is not
* updated with changes in a dashboard, and is simply used as a unique identifier. The name
* remains as panelIndex for backward compatibility reasons - changing it can break reporting.
* @property {Object} gridData
* @property {number} gridData.w - Width of the panel.
* @property {number} gridData.h - Height of the panel.
* @property {number} gridData.x - Column position of the panel.
* @property {number} gridData.y - Row position of the panel.
*/
import { DashboardPanelState } from '../types';

// Look for the smallest y and x value where the default panel will fit.
function findTopLeftMostOpenSpace(width: number, height: number, currentPanels: PanelState[]) {
function findTopLeftMostOpenSpace(
width: number,
height: number,
currentPanels: DashboardPanelState[]
) {
let maxY = -1;

currentPanels.forEach(panel => {
@@ -65,6 +52,14 @@ function findTopLeftMostOpenSpace(width: number, height: number, currentPanels:
currentPanels.forEach(panel => {
for (let x = panel.gridData.x; x < panel.gridData.x + panel.gridData.w; x++) {
for (let y = panel.gridData.y; y < panel.gridData.y + panel.gridData.h; y++) {
const row = grid[y];
if (row === undefined) {
throw new Error(
`Attempted to access a row that doesn't exist at ${y} for panel ${JSON.stringify(
panel
)}`
);
}
grid[y][x] = 1;
}
}
@@ -96,23 +91,16 @@ function findTopLeftMostOpenSpace(width: number, height: number, currentPanels:
}
}
}
return { x: 0, y: Infinity };
return { x: 0, y: maxY };
}

/**
* Creates and initializes a basic panel state.
* @param {number} id
* @param {string} type
* @param {number} panelIndex
* @param {Array} currentPanels
* @return {PanelState}
*/
export function createPanelState(
id: string,
type: string,
panelIndex: string,
currentPanels: PanelState[]
) {
export function createPanelState<TEmbeddableInput extends EmbeddableInput>(
panelState: PanelState<TEmbeddableInput>,
currentPanels: DashboardPanelState[]
): DashboardPanelState<TEmbeddableInput> {
const { x, y } = findTopLeftMostOpenSpace(
DEFAULT_PANEL_WIDTH,
DEFAULT_PANEL_HEIGHT,
@@ -124,12 +112,8 @@ export function createPanelState(
h: DEFAULT_PANEL_HEIGHT,
x,
y,
i: panelIndex.toString(),
i: panelState.embeddableId,
},
version: chrome.getKibanaVersion(),
panelIndex: panelIndex.toString(),
type,
id,
embeddableConfig: {},
...panelState,
};
}
Original file line number Diff line number Diff line change
@@ -17,7 +17,4 @@
* under the License.
*/

export enum DashboardViewMode {
EDIT = 'edit',
VIEW = 'view',
}
export { createPanelState } from './create_panel_state';
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { PanelState, EmbeddableInput } from '../../../embeddable_api/public/index';
export type PanelId = string;
export type SavedObjectId = string;

export interface GridData {
w: number;
h: number;
x: number;
y: number;
i: string;
}

export interface DashboardPanelState<TEmbeddableInput extends EmbeddableInput = EmbeddableInput>
extends PanelState<TEmbeddableInput> {
readonly gridData: GridData;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.dshDashboardViewport {
width: 100%;
background-color: $euiColorEmptyShade;
flex: 1;
display: flex;
}

.dshDashboardViewport-withMargins {
width: 100%;
flex: 1;
display: flex;
}

#dashboardViewport {
flex: 1;
display: flex;
[data-reactroot] {
flex: 1;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import React from 'react';

import { EmbeddableFactoryRegistry } from 'plugins/embeddable_api/index';
import { DashboardViewport, DashboardViewportProps } from './dashboard_viewport';
import {
ContactCardEmbeddableFactory,
CONTACT_CARD_EMBEDDABLE,
} from 'plugins/embeddable_api/__test__';
import { DashboardContainer } from '../dashboard_container';
import { getSampleDashboardInput } from 'plugins/dashboard_embeddable/__test__';
import { skip } from 'rxjs/operators';
import { mount } from 'enzyme';
import { I18nProvider } from '@kbn/i18n/react';
// @ts-ignore
import { findTestSubject } from '@elastic/eui/lib/test';
import { nextTick } from 'test_utils/enzyme_helpers';

jest.mock('ui/chrome', () => ({ getKibanaVersion: () => '6.0.0', setVisible: () => {} }), {
virtual: true,
});

jest.mock(
'ui/notify',
() => ({
toastNotifications: {
addDanger: () => {},
},
}),
{ virtual: true }
);

let dashboardContainer: DashboardContainer | undefined;

function getProps(props?: Partial<DashboardViewportProps>): DashboardViewportProps {
const embeddableFactories = new EmbeddableFactoryRegistry();
embeddableFactories.registerFactory(new ContactCardEmbeddableFactory());
dashboardContainer = new DashboardContainer(
getSampleDashboardInput({
panels: {
'1': {
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
embeddableId: '1',
type: CONTACT_CARD_EMBEDDABLE,
explicitInput: { firstName: 'Bob' },
},
'2': {
gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' },
type: CONTACT_CARD_EMBEDDABLE,
embeddableId: '2',
explicitInput: { firstName: 'Stacey' },
},
},
}),
embeddableFactories
);
const defaultTestProps: DashboardViewportProps = {
container: dashboardContainer,
};
return Object.assign(defaultTestProps, props);
}

test('renders DashboardViewport', () => {
const props = getProps();
const component = mount(
<I18nProvider>
<DashboardViewport {...props} />
</I18nProvider>
);
const panels = findTestSubject(component, 'dashboardPanel');
expect(panels.length).toBe(2);
});

test('renders DashboardViewport with no visualizations', () => {
const props = getProps();
props.container.updateInput({ panels: {} });
const component = mount(
<I18nProvider>
<DashboardViewport {...props} />
</I18nProvider>
);
const panels = findTestSubject(component, 'dashboardPanel');
expect(panels.length).toBe(0);

component.unmount();
});

test('renders exit full screen button when in full screen mode', async () => {
const props = getProps();
props.container.updateInput({ isFullScreenMode: true });
const component = mount(
<I18nProvider>
<DashboardViewport {...props} />
</I18nProvider>
);
let exitButton = findTestSubject(component, 'exitFullScreenModeText');
expect(exitButton.length).toBe(1);

props.container.updateInput({ isFullScreenMode: false });

await nextTick();
component.update();

exitButton = findTestSubject(component, 'exitFullScreenModeText');
expect(exitButton.length).toBe(0);

component.unmount();
});

test('DashboardViewport unmount unsubscribes', async done => {
const props = getProps();
const component = mount(
<I18nProvider>
<DashboardViewport {...props} />
</I18nProvider>
);
component.unmount();

props.container
.getInput$()
.pipe(skip(1))
.subscribe(() => {
done();
});

props.container.updateInput({ panels: {} });
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { PanelState } from 'plugins/embeddable_api/index';
import React from 'react';
// @ts-ignore
import { ExitFullScreenButton } from 'ui/exit_full_screen';
import { Subscription } from 'rxjs';
import { DashboardContainer } from '../dashboard_container';
import { DashboardGrid } from '../grid';

export interface DashboardViewportProps {
container: DashboardContainer;
}

interface State {
isFullScreenMode: boolean;
useMargins: boolean;
title: string;
description?: string;
panels: { [key: string]: PanelState };
}

export class DashboardViewport extends React.Component<DashboardViewportProps, State> {
private subscription?: Subscription;
private mounted: boolean = false;
constructor(props: DashboardViewportProps) {
super(props);
const { isFullScreenMode, panels, useMargins, title } = this.props.container.getInput();

this.state = {
isFullScreenMode,
panels,
useMargins,
title,
};
}

public componentDidMount() {
this.mounted = true;
this.subscription = this.props.container.getInput$().subscribe(() => {
const { isFullScreenMode, useMargins, title, description } = this.props.container.getInput();
if (this.mounted) {
this.setState({
isFullScreenMode,
description,
useMargins,
title,
});
}
});
}

public componentWillUnmount() {
this.mounted = false;
if (this.subscription) {
this.subscription.unsubscribe();
}
}

public onExitFullScreenMode = () => {
this.props.container.onExitFullScreenMode();
};

public render() {
const { container } = this.props;
return (
<div
data-shared-items-count={Object.values(this.state.panels).length}
data-shared-items-container
data-title={this.state.title}
data-description={this.state.description}
className={
this.state.useMargins ? 'dshDashboardViewport-withMargins' : 'dshDashboardViewport'
}
>
{this.state.isFullScreenMode && (
<ExitFullScreenButton onExitFullScreenMode={this.onExitFullScreenMode} />
)}
<DashboardGrid container={container} />
</div>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

@import 'src/legacy/ui/public/styles/styling_constants';

// MUST STAY AT THE BOTTOM BECAUSE OF DARK THEME IMPORTS
@import './embeddable/index';
31 changes: 31 additions & 0 deletions src/legacy/core_plugins/dashboard_embeddable/public/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import 'uiExports/embeddableActions';
import 'uiExports/embeddableFactories';

export {
DASHBOARD_GRID_COLUMN_COUNT,
DEFAULT_PANEL_HEIGHT,
DEFAULT_PANEL_WIDTH,
DashboardContainer,
DashboardContainerInput,
DASHBOARD_CONTAINER_TYPE,
DashboardContainerFactory,
DashboardPanelState,
} from './embeddable';
31 changes: 31 additions & 0 deletions src/legacy/core_plugins/embeddable_api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# The Embeddable API V2
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to add to README a small code example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't want to do that because then it can quickly get out of date and it's very easy to forget to update it. By pointing to real code samples that are being tested, we guarantee that won't happen.


The Embeddable API's main goal is to have documented and standardized ways to share and exchange information and functionality across applications and plugins.

There are three main pieces of this infrastructure:
- Embeddables & Containers
- Actions
- Triggers

## Embeddables & Containers

Embeddables are isolated, serialiable, renderable widgets. A developer can hard code an embeddable inside their
application, or they can use some built in actions to allow users to dynamically add them to *containers*.

Containers are a special type of embeddable that can contain nested embeddables.

## Actions

Actions are pluggable pieces of functionality exposed to the userthat take an embeddable as context, plus an optional action context.

## Triggers

Triggers are the way actions are connected to a user action. We ship with two default triggers, `SHOW_CONTEXT_MENU` and `APPLY_FILTER`.

Actions attached to the `SHOW_CONTEXT_MENU` will be displayed in supported embeddables context menu to the user. Actions attached to the `APPLY_FILTER` trigger will show up when any embeddable emits this trigger.

A developer can register new triggers that their embeddables, or external components, can emit (as long as they have an embeddable to pass along as context).

## Examples

Many examples can be viewed in the functionally tested `kbn_tp_embeddable_explorer` plugin, as well as the jest tested classes inside the `embeddable_api/public/__test__` folder.
Original file line number Diff line number Diff line change
@@ -17,22 +17,15 @@
* under the License.
*/

import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import { DashboardState } from '../dashboard/selectors';
import { resolve } from 'path';
import { LegacyPluginApi, LegacyPluginSpec, ArrayOrItem } from 'src/legacy/plugin_discovery/types';

export interface CoreKibanaState {
readonly dashboard: DashboardState;
// eslint-disable-next-line import/no-default-export
export default function(kibana: LegacyPluginApi): ArrayOrItem<LegacyPluginSpec> {
return new kibana.Plugin({
uiExports: {
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
embeddableActions: ['plugins/embeddable_api/actions/apply_filter_action'],
},
});
}

export interface KibanaAction<T, P> extends Action {
readonly type: T;
readonly payload: P;
}

export type KibanaThunk<
R = Action | Promise<Action> | void,
S = CoreKibanaState,
E = any,
A extends Action = Action
> = ThunkAction<R, S, E, A>;
4 changes: 4 additions & 0 deletions src/legacy/core_plugins/embeddable_api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "embeddable_api",
"version": "kibana"
}
Original file line number Diff line number Diff line change
@@ -17,18 +17,27 @@
* under the License.
*/

/**
* Creates a new instance of AppState based of the saved dashboard.
*
* @param appState {AppState} AppState class to instantiate
*/
export function migrateAppState(appState) {
// For BWC in pre 6.1 versions where uiState was stored at the dashboard level, not at the panel level.
if (appState.uiState) {
appState.panels.forEach(panel => {
panel.embeddableConfig = appState.uiState[`P-${panel.panelIndex}`];
});
delete appState.uiState;
appState.save();
import { ViewMode } from 'plugins/embeddable_api/types';
import { Action, ActionContext } from '../../actions';

export const EDIT_MODE_ACTION = 'EDIT_MODE_ACTION';

export class EditModeAction extends Action {
public readonly type = EDIT_MODE_ACTION;

constructor() {
super(EDIT_MODE_ACTION);
}

getDisplayName() {
return `I should only show up in edit mode`;
}

isCompatible(context: ActionContext) {
return Promise.resolve(context.embeddable.getInput().viewMode === ViewMode.EDIT);
}

execute() {
return;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import {
EuiForm,
EuiFormRow,
EuiFieldText,
EuiModalHeader,
EuiModalHeaderTitle,
EuiModalBody,
EuiButton,
EuiModalFooter,
EuiButtonEmpty,
} from '@elastic/eui';
import React, { Component } from 'react';

interface Props {
onDone: (message: string) => void;
onCancel: () => void;
}

interface State {
message?: string;
}

export class GetMessageModal extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {};
}

render() {
return (
<React.Fragment>
<EuiModalHeader>
<EuiModalHeaderTitle>Enter your message</EuiModalHeaderTitle>
</EuiModalHeader>

<EuiModalBody>
<EuiForm>
<EuiFormRow label="Message">
<EuiFieldText
name="popfirst"
value={this.state.message}
onChange={e => this.setState({ message: e.target.value })}
/>
</EuiFormRow>
</EuiForm>
</EuiModalBody>

<EuiModalFooter>
<EuiButtonEmpty onClick={this.props.onCancel}>Cancel</EuiButtonEmpty>

<EuiButton
isDisabled={!this.state.message}
onClick={() => {
if (this.state.message) {
this.props.onDone(this.state.message);
}
}}
fill
>
Done
</EuiButton>
</EuiModalFooter>
</React.Fragment>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import React from 'react';

import { getNewPlatform } from 'ui/new_platform';
import { Action, actionRegistry, triggerRegistry } from 'plugins/embeddable_api/index';

import { EuiFlyout } from '@elastic/eui';
import { CONTEXT_MENU_TRIGGER } from 'plugins/embeddable_api/index';

export const HELLO_WORLD_ACTION_ID = 'HELLO_WORLD_ACTION_ID';

export class HelloWorldAction extends Action {
public readonly type = HELLO_WORLD_ACTION_ID;
constructor() {
super(HELLO_WORLD_ACTION_ID);
}

public getDisplayName() {
return 'Hello World Action!';
}

public execute() {
const flyoutSession = getNewPlatform().start.core.overlays.openFlyout(
<EuiFlyout ownFocus onClose={() => flyoutSession && flyoutSession.close()}>
Hello World, I am a hello world action!
</EuiFlyout>,
{
'data-test-subj': 'helloWorldAction',
}
);
}
}

actionRegistry.addAction(new HelloWorldAction());

// Attaching to CONTEXT_MENU_TRIGGER makes this action appear in the context menu for
// all embeddables.
triggerRegistry.attachAction({
triggerId: CONTEXT_MENU_TRIGGER,
actionId: HELLO_WORLD_ACTION_ID,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

export { HelloWorldAction, HELLO_WORLD_ACTION_ID } from './hello_world_action';
export { SayHelloAction } from './say_hello_action';
export { EditModeAction } from './edit_mode_action';
export { RestrictedAction } from './restricted_action';
export { SendMessageAction, SEND_MESSAGE_ACTION } from './send_message_action';
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { Action, ActionContext } from '../../actions';

export const RESTRICTED_ACTION = 'RESTRICTED_ACTION';

export class RestrictedAction extends Action {
public readonly type = RESTRICTED_ACTION;

private isCompatibleFn: (context: ActionContext) => boolean;
constructor(isCompatible: (context: ActionContext) => boolean) {
super(RESTRICTED_ACTION);
this.isCompatibleFn = isCompatible;
}

getDisplayName() {
return `I am only sometimes compatible`;
}

isCompatible(context: ActionContext) {
return Promise.resolve(this.isCompatibleFn(context));
}

execute() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { getNewPlatform } from 'ui/new_platform';
import { EuiFlyoutBody } from '@elastic/eui';
import { triggerRegistry, CONTEXT_MENU_TRIGGER } from '../../triggers';
import { Action, ActionContext, actionRegistry, IncompatibleActionError } from '../../actions';
import { EmbeddableInput, Embeddable, EmbeddableOutput, IEmbeddable } from '../../embeddables';

export const SAY_HELLO_ACTION = 'SAY_HELLO_ACTION';

export interface FullNameEmbeddableOutput extends EmbeddableOutput {
fullName: string;
}

export function hasFullNameOutput(
embeddable: IEmbeddable | Embeddable<EmbeddableInput, FullNameEmbeddableOutput>
) {
return (
(embeddable as Embeddable<EmbeddableInput, FullNameEmbeddableOutput>).getOutput().fullName !==
undefined
);
}

function openSayHelloFlyout(hello: string) {
getNewPlatform().start.core.overlays.openFlyout(<EuiFlyoutBody>{hello}</EuiFlyoutBody>);
}

export class SayHelloAction extends Action {
public readonly type = SAY_HELLO_ACTION;
private sayHello: (name: string) => void;

// Taking in a function, instead of always directly interacting with the dom,
// can make testing the execute part of the action easier.
constructor(sayHello: (name: string) => void = openSayHelloFlyout) {
super(SAY_HELLO_ACTION);
this.sayHello = sayHello;
}

getDisplayName() {
return 'Say hello';
}

// Can use typescript generics to get compiler time warnings for immediate feedback if
// the context is not compatible.
async isCompatible(
context: ActionContext<Embeddable<EmbeddableInput, FullNameEmbeddableOutput>>
) {
// Option 1: only compatible with Greeting Embeddables.
// return Promise.resolve(context.embeddable.type === CONTACT_CARD_EMBEDDABLE);

// Option 2: require an embeddable with a specific input or output shape
return hasFullNameOutput(context.embeddable);
}

async execute(
context: ActionContext<
Embeddable<EmbeddableInput, FullNameEmbeddableOutput>,
{ message?: string }
>
) {
if (!(await this.isCompatible(context))) {
throw new IncompatibleActionError();
}

const greeting = `Hello, ${context.embeddable.getOutput().fullName}`;

if (context.triggerContext && context.triggerContext.message) {
this.sayHello(`${greeting}. ${context.triggerContext.message}`);
} else {
this.sayHello(greeting);
}
}
}

actionRegistry.addAction(new SayHelloAction());
triggerRegistry.attachAction({ triggerId: CONTEXT_MENU_TRIGGER, actionId: SAY_HELLO_ACTION });
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { getNewPlatform } from 'ui/new_platform';
import { EuiFlyoutBody } from '@elastic/eui';
import {
Action,
ActionContext,
actionRegistry,
IncompatibleActionError,
triggerRegistry,
CONTEXT_MENU_TRIGGER,
} from 'plugins/embeddable_api/index';
import { Embeddable, EmbeddableInput } from 'plugins/embeddable_api/embeddables';
import { GetMessageModal } from './get_message_modal';
import { FullNameEmbeddableOutput, hasFullNameOutput } from './say_hello_action';

export const SEND_MESSAGE_ACTION = 'SEND_MESSAGE_ACTION';

export class SendMessageAction extends Action {
public readonly type = SEND_MESSAGE_ACTION;
constructor() {
super(SEND_MESSAGE_ACTION);
}

getDisplayName() {
return 'Send message';
}

async isCompatible(
context: ActionContext<Embeddable<EmbeddableInput, FullNameEmbeddableOutput>>
) {
return hasFullNameOutput(context.embeddable);
}

async sendMessage(
context: ActionContext<Embeddable<EmbeddableInput, FullNameEmbeddableOutput>>,
message: string
) {
const greeting = `Hello, ${context.embeddable.getOutput().fullName}`;

const content = message ? `${greeting}. ${message}` : greeting;
getNewPlatform().start.core.overlays.openFlyout(<EuiFlyoutBody>{content}</EuiFlyoutBody>);
}

async execute(
context: ActionContext<
Embeddable<EmbeddableInput, FullNameEmbeddableOutput>,
{ message?: string }
>
) {
if (!(await this.isCompatible(context))) {
throw new IncompatibleActionError();
}

const modal = getNewPlatform().start.core.overlays.openModal(
<GetMessageModal
onCancel={() => modal.close()}
onDone={message => {
modal.close();
this.sendMessage(context, message);
}}
/>
);
}
}

actionRegistry.addAction(new SendMessageAction());
triggerRegistry.attachAction({ triggerId: CONTEXT_MENU_TRIGGER, actionId: SEND_MESSAGE_ACTION });
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import {
// @ts-ignore
EuiCard,
EuiFlexItem,
EuiFlexGroup,
EuiFormRow,
} from '@elastic/eui';

import { Subscription } from 'rxjs';
import { EuiButton } from '@elastic/eui';
import { executeTriggerActions } from 'plugins/embeddable_api/triggers';
import * as Rx from 'rxjs';
import { ContactCardEmbeddable, CONTACT_USER_TRIGGER } from './contact_card_embeddable';

interface Props {
embeddable: ContactCardEmbeddable;
}

interface State {
fullName: string;
firstName: string;
}

export class ContactCardEmbeddableComponent extends React.Component<Props, State> {
private subscription?: Subscription;
private mounted: boolean = false;

constructor(props: Props) {
super(props);
this.state = {
fullName: this.props.embeddable.getOutput().fullName,
firstName: this.props.embeddable.getInput().firstName,
};
}

componentDidMount() {
this.mounted = true;
this.subscription = Rx.merge(
this.props.embeddable.getOutput$(),
this.props.embeddable.getInput$()
).subscribe(() => {
if (this.mounted) {
this.setState({
fullName: this.props.embeddable.getOutput().fullName,
firstName: this.props.embeddable.getInput().firstName,
});
}
});
}

componentWillUnmount() {
if (this.subscription) {
this.subscription.unsubscribe();
}
this.mounted = false;
}

emitContactTrigger = () => {
executeTriggerActions(CONTACT_USER_TRIGGER, {
embeddable: this.props.embeddable,
triggerContext: {},
});
};

getCardFooterContent = () => (
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiFormRow label="">
<EuiButton onClick={this.emitContactTrigger}>{`Contact ${
this.state.firstName
}`}</EuiButton>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
);

render() {
return (
<EuiCard textAlign="left" title={this.state.fullName} footer={this.getCardFooterContent()} />
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Embeddable, EmbeddableInput } from 'plugins/embeddable_api/index';
import React from 'react';
import ReactDom from 'react-dom';
import { EmbeddableOutput } from 'plugins/embeddable_api/embeddables';
import { Container } from 'plugins/embeddable_api/containers';
import { Subscription } from 'rxjs';
import { triggerRegistry } from 'plugins/embeddable_api/triggers';
import { CONTACT_CARD_EMBEDDABLE } from './contact_card_embeddable_factory';
import { ContactCardEmbeddableComponent } from './contact_card';
import { SEND_MESSAGE_ACTION } from '../../actions/send_message_action';

export interface ContactCardEmbeddableInput extends EmbeddableInput {
firstName: string;
lastName?: string;
nameTitle?: string;
}

export interface ContactCardEmbeddableOutput extends EmbeddableOutput {
fullName: string;
originalLastName?: string;
}

function getFullName(input: ContactCardEmbeddableInput) {
const { nameTitle, firstName, lastName } = input;
const nameParts = [nameTitle, firstName, lastName].filter(name => name !== undefined);
return nameParts.join(' ');
}

export class ContactCardEmbeddable extends Embeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput
> {
private subscription: Subscription;
private node?: Element;
public readonly type: string = CONTACT_CARD_EMBEDDABLE;

constructor(initialInput: ContactCardEmbeddableInput, parent?: Container) {
super(
initialInput,
{
fullName: getFullName(initialInput),
originalLastName: initialInput.lastName,
defaultTitle: `Hello ${getFullName(initialInput)}`,
},
parent
);

this.subscription = this.getInput$().subscribe(() => {
const fullName = getFullName(this.input);
this.updateOutput({
fullName,
defaultTitle: `Hello ${fullName}`,
});
});
}

public render(node: HTMLElement) {
this.node = node;
ReactDom.render(<ContactCardEmbeddableComponent embeddable={this} />, node);
}

public destroy() {
super.destroy();
this.subscription.unsubscribe();
if (this.node) {
ReactDom.unmountComponentAtNode(this.node);
}
}

public reload() {}
}

export const CONTACT_USER_TRIGGER = 'CONTACT_USER_TRIGGER';

triggerRegistry.registerTrigger({
id: CONTACT_USER_TRIGGER,
actionIds: [SEND_MESSAGE_ACTION],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import React from 'react';
import { embeddableFactories, EmbeddableFactory } from 'plugins/embeddable_api/index';
import { Container } from 'plugins/embeddable_api/containers';
import { i18n } from '@kbn/i18n';
import { getNewPlatform } from 'ui/new_platform';
import { ContactCardEmbeddable, ContactCardEmbeddableInput } from './contact_card_embeddable';
import { ContactCardInitializer } from './contact_card_initializer';

export const CONTACT_CARD_EMBEDDABLE = 'CONTACT_CARD_EMBEDDABLE';

export class ContactCardEmbeddableFactory extends EmbeddableFactory<ContactCardEmbeddableInput> {
public readonly type = CONTACT_CARD_EMBEDDABLE;

public isEditable() {
return true;
}

public getDisplayName() {
return i18n.translate('kbn.embeddable.samples.contactCard.displayName', {
defaultMessage: 'contact card',
});
}

public getExplicitInput(): Promise<Partial<ContactCardEmbeddableInput>> {
return new Promise(resolve => {
const modalSession = getNewPlatform().start.core.overlays.openModal(
<ContactCardInitializer
onCancel={() => {
modalSession.close();
resolve(undefined);
}}
onCreate={(input: { firstName: string; lastName: string }) => {
modalSession.close();
resolve(input);
}}
/>,
{
'data-test-subj': 'createContactCardEmbeddable',
}
);
});
}

public async create(initialInput: ContactCardEmbeddableInput, parent?: Container) {
return new ContactCardEmbeddable(initialInput, parent);
}
}

embeddableFactories.registerFactory(new ContactCardEmbeddableFactory());
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import {
EuiForm,
EuiFormRow,
EuiFieldText,
EuiModalHeader,
EuiModalHeaderTitle,
EuiModalBody,
EuiButton,
EuiModalFooter,
EuiButtonEmpty,
} from '@elastic/eui';
import React, { Component } from 'react';

export interface ContactCardInitializerProps {
onCreate: (name: { lastName: string; firstName: string }) => void;
onCancel: () => void;
}

interface State {
firstName?: string;
lastName?: string;
}

export class ContactCardInitializer extends Component<ContactCardInitializerProps, State> {
constructor(props: ContactCardInitializerProps) {
super(props);
this.state = {};
}

render() {
return (
<div>
<EuiModalHeader>
<EuiModalHeaderTitle>Create a new greeting card</EuiModalHeaderTitle>
</EuiModalHeader>

<EuiModalBody>
<EuiForm>
<EuiFormRow label="First name">
<EuiFieldText
name="popfirst"
value={this.state.firstName}
onChange={e => this.setState({ firstName: e.target.value })}
/>
</EuiFormRow>

<EuiFormRow label="Last name">
<EuiFieldText
name="popfirst"
value={this.state.lastName}
onChange={e => this.setState({ lastName: e.target.value })}
/>
</EuiFormRow>
</EuiForm>
</EuiModalBody>

<EuiModalFooter>
<EuiButtonEmpty onClick={this.props.onCancel}>Cancel</EuiButtonEmpty>

<EuiButton
isDisabled={!this.state.lastName || !this.state.firstName}
onClick={() => {
if (this.state.lastName && this.state.firstName) {
this.props.onCreate({
firstName: this.state.firstName,
lastName: this.state.lastName,
});
}
}}
fill
>
Create
</EuiButton>
</EuiModalFooter>
</div>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

export {
ContactCardEmbeddable,
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
} from './contact_card_embeddable';

export {
ContactCardEmbeddableFactory,
CONTACT_CARD_EMBEDDABLE,
} from './contact_card_embeddable_factory';
export { SlowContactCardEmbeddableFactory } from './slow_contact_card_embeddable_factory';
export { ContactCardInitializerProps } from './contact_card_initializer';
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { EmbeddableFactory } from 'plugins/embeddable_api/index';
import { Container } from 'plugins/embeddable_api/containers';
import { ContactCardEmbeddable, ContactCardEmbeddableInput } from './contact_card_embeddable';

export const CONTACT_CARD_EMBEDDABLE = 'CONTACT_CARD_EMBEDDABLE';

export class SlowContactCardEmbeddableFactory extends EmbeddableFactory<
ContactCardEmbeddableInput
> {
private loadTickCount = 0;
public readonly type = CONTACT_CARD_EMBEDDABLE;

constructor(options: { loadTickCount?: number } = {}) {
super();
if (options.loadTickCount) {
this.loadTickCount = options.loadTickCount;
}
}

public isEditable() {
return true;
}

public getDisplayName() {
return 'slow to load contact card';
}

public async create(initialInput: ContactCardEmbeddableInput, parent?: Container) {
for (let i = 0; i < this.loadTickCount; i++) {
await Promise.resolve();
}
return Promise.resolve(new ContactCardEmbeddable(initialInput, parent));
}
}
Original file line number Diff line number Diff line change
@@ -16,21 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Embeddable, EmbeddableInput } from 'plugins/embeddable_api/index';
import { EmbeddableOutput } from 'plugins/embeddable_api/embeddables';

import { ContextMenuAction } from 'ui/embeddable';
export const EMPTY_EMBEDDABLE = 'EMPTY_EMBEDDABLE';

class PanelActionsStore {
public actions: ContextMenuAction[] = [];

/**
*
* @type {IndexedArray} panelActionsRegistry
*/
public initializeFromRegistry(panelActionsRegistry: ContextMenuAction[]) {
panelActionsRegistry.forEach(panelAction => {
this.actions.push(panelAction);
});
export class EmptyEmbeddable extends Embeddable<EmbeddableInput, EmbeddableOutput> {
public readonly type = EMPTY_EMBEDDABLE;
constructor(initialInput: EmbeddableInput) {
super(initialInput, {});
}
public render() {}
public reload() {}
}

export const panelActionsStore = new PanelActionsStore();
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { EmbeddableFactoryRegistry } from '../../embeddables';
import { Filter } from '../../types';
import { Container, ContainerInput } from '../../containers';

export const FILTERABLE_CONTAINER = 'FILTERABLE_CONTAINER';

export interface FilterableContainerInput extends ContainerInput {
filters: Filter[];
}

export interface InheritedChildrenInput {
filters: Filter[];
id?: string;
}

export class FilterableContainer extends Container<
InheritedChildrenInput,
FilterableContainerInput
> {
public readonly type = FILTERABLE_CONTAINER;

constructor(
initialInput: FilterableContainerInput,
embeddableFactories: EmbeddableFactoryRegistry,
parent?: Container
) {
super(initialInput, { embeddableLoaded: {} }, embeddableFactories, parent);
}

public getInheritedInput() {
return {
filters: this.input.filters,
};
}

public render() {}
}
Original file line number Diff line number Diff line change
@@ -17,34 +17,30 @@
* under the License.
*/

import { EuiIcon } from '@elastic/eui';
import { Container, embeddableFactories, EmbeddableFactory } from 'plugins/embeddable_api/index';
import { i18n } from '@kbn/i18n';
import React from 'react';
import {
FilterableContainer,
FilterableContainerInput,
FILTERABLE_CONTAINER,
} from './filterable_container';

import { ContextMenuAction } from 'ui/embeddable';
import { DashboardViewMode } from '../../../dashboard_view_mode';
export class FilterableContainerFactory extends EmbeddableFactory<FilterableContainerInput> {
public readonly type = FILTERABLE_CONTAINER;

/**
*
* @param {function} onDeletePanel
* @return {ContextMenuAction}
*/
export function getRemovePanelAction(onDeletePanel: () => void) {
return new ContextMenuAction(
{
id: 'deletePanel',
parentPanelId: 'mainMenu',
},
{
getDisplayName: () => {
return i18n.translate('kbn.dashboard.panel.removePanel.displayName', {
defaultMessage: 'Delete from dashboard',
});
},
icon: <EuiIcon type="trash" />,
isVisible: ({ containerState }) =>
containerState.viewMode === DashboardViewMode.EDIT && !containerState.isPanelExpanded,
onClick: onDeletePanel,
}
);
public getDisplayName() {
return i18n.translate('kbn.embeddable.samples.filterable.displayName', {
defaultMessage: 'filterable dashboard',
});
}

public isEditable() {
return true;
}

public create(initialInput: FilterableContainerInput, parent?: Container) {
return Promise.resolve(new FilterableContainer(initialInput, embeddableFactories, parent));
}
}

embeddableFactories.registerFactory(new FilterableContainerFactory());
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Adapters } from 'ui/inspector';
import { IContainer } from '../../containers';
import { EmbeddableOutput, EmbeddableInput, Embeddable } from '../../embeddables';
import { Filter } from '../../types';

export const FILTERABLE_EMBEDDABLE = 'FILTERABLE_EMBEDDABLE';

export interface FilterableEmbeddableInput extends EmbeddableInput {
filters: Filter[];
}

export class FilterableEmbeddable extends Embeddable<FilterableEmbeddableInput, EmbeddableOutput> {
public readonly type = FILTERABLE_EMBEDDABLE;
constructor(initialInput: FilterableEmbeddableInput, parent?: IContainer) {
super(initialInput, {}, parent);
}

public getInspectorAdapters() {
const inspectorAdapters: Adapters = {
filters: `My filters are ${JSON.stringify(this.input.filters)}`,
};
return inspectorAdapters;
}

public render() {}

public reload() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { i18n } from '@kbn/i18n';
import {
FilterableEmbeddable,
FilterableEmbeddableInput,
FILTERABLE_EMBEDDABLE,
} from './filterable_embeddable';
import { embeddableFactories, EmbeddableFactory } from '../../embeddables';
import { IContainer } from '../../containers';

export class FilterableEmbeddableFactory extends EmbeddableFactory<FilterableEmbeddableInput> {
public readonly type = FILTERABLE_EMBEDDABLE;

public isEditable() {
return true;
}

public getDisplayName() {
return i18n.translate('kbn.embeddable.samples.filterable.displayName', {
defaultMessage: 'filterable',
});
}

public create(initialInput: FilterableEmbeddableInput, parent?: IContainer) {
return Promise.resolve(new FilterableEmbeddable(initialInput, parent));
}
}

embeddableFactories.registerFactory(new FilterableEmbeddableFactory());
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Embeddable, EmbeddableInput, IContainer } from 'plugins/embeddable_api/index';

export const HELLO_WORLD_EMBEDDABLE_TYPE = 'HELLO_WORLD_EMBEDDABLE_TYPE';

export class HelloWorldEmbeddable extends Embeddable {
// The type of this embeddable. This will be used to find the appropriate factory
// to instantiate this kind of embeddable.
public readonly type = HELLO_WORLD_EMBEDDABLE_TYPE;

constructor(initialInput: EmbeddableInput, parent?: IContainer) {
super(
// Input state is irrelevant to this embeddable, just pass it along.
initialInput,
// Initial output state - this embeddable does not do anything with output, so just
// pass along an empty object.
{},
// Optional parent component, this embeddable can optionally be rendered inside a container.
parent
);
}

/**
* Render yourself at the dom node using whatever framework you like, angular, react, or just plain
* vanilla js.
* @param node
*/
public render(node: HTMLElement) {
node.innerHTML = '<div data-test-subj="helloWorldEmbeddable">HELLO WORLD!</div>';
}

/**
* This is mostly relevant for time based embeddables which need to update data
* even if EmbeddableInput has not changed at all.
*/
public reload() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { embeddableFactories, EmbeddableFactory } from 'plugins/embeddable_api/index';
import { IContainer } from 'plugins/embeddable_api/containers';
import { EmbeddableInput } from 'plugins/embeddable_api/embeddables';
import { i18n } from '@kbn/i18n';
import { HelloWorldEmbeddable, HELLO_WORLD_EMBEDDABLE_TYPE } from './hello_world_embeddable';

export class HelloWorldEmbeddableFactory extends EmbeddableFactory {
public readonly type = HELLO_WORLD_EMBEDDABLE_TYPE;

/**
* In our simple example, we let everyone have permissions to edit this. Most
* embeddables should check the UI Capabilities service to be sure of
* the right permissions.
*/
public isEditable() {
return true;
}

public async create(initialInput: EmbeddableInput, parent?: IContainer) {
return new HelloWorldEmbeddable(initialInput, parent);
}

public getDisplayName() {
return i18n.translate('kbn.embeddable.samples.helloworld.displayName', {
defaultMessage: 'hello world',
});
}
}

embeddableFactories.registerFactory(new HelloWorldEmbeddableFactory());
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

export { HelloWorldEmbeddableFactory } from './hello_world_embeddable_factory';
export { HelloWorldEmbeddable, HELLO_WORLD_EMBEDDABLE_TYPE } from './hello_world_embeddable';
Loading