mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Dashboard Navigation] Make links panel available under technical preview (#166896)
## Summary This PR wraps up the work the @elastic/kibana-presentation team has done to finish the MVP of [Phase 1](https://github.com/elastic/kibana/issues/154354) of the `Link` embeddable, which enables users to add panels to their dashboard that contain links to other dashboards + external links - with respect to dashboard links, we give the author control over which pieces of context should be kept across dashboards so that things like filter pills, queries, and time ranges are not lost. This marks a huge improvement in dashboard navigation overall, which was previously only available via a variety of different workarounds including (but not limited to): - Creating (essentially) a `noop` dashboard-to-dashboard drilldown - Using markdown panels with hard Dashboard links, which are prone to break across updates - Avoiding navigation all together, which resulted in large, slow-to-load dashboards. As an added benefit, because these panels contain **references** to each dashboard rather than hard links, (1) unlike markdown links, they should not break after updates and (2) if a links panel is exported and imported into another space or instance, all of the dashboards it links to will also be imported.1a86b713
-47e7-4db9-8a04-29d41b13681a > **Note** > 🔉 The above video has audio! Turn on your sound for the best experience. ### Note about this PR - A majority of this work was done on a feature branch, with thorough reviews from @andreadelrio on behalf of @elastic/kibana-design along the way. Therefore, while feedback on the design is encouraged, any large concerns brought up in this PR should be filed as separate issues and addressed in follow-up PRs. - This PR contains work for giving embeddables control over their own panel size / default positioning on the dashboard. This was especially important for the links panel, since we assume that (a) most links panels would be located somewhere near the top of the dashboard and (b) the horizontal links panel should have a different default "shape" (longer than it is tall) than the vertical panel (taller than it is long). - This PR also contains work for caching dashboard saved objects, which makes navigation much more seamless. ### Flaky Test Runner - https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/3251  ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] ~[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials~ This will be addressed in a follow up: https://github.com/elastic/kibana/issues/166750 - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - ~Units tests are added, functional tests are forthcoming~ Edit: All tests are in. - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Nick Peihl <nick.peihl@elastic.co> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Andrea Del Rio <delrio.andre@gmail.com> Co-authored-by: Devon Thomson <devon.thomson@elastic.co> Co-authored-by: Nick Peihl <nickpeihl@gmail.com> Co-authored-by: Gerard Soldevila <gerard.soldevila@elastic.co>
This commit is contained in:
parent
9d3213e137
commit
9e8312f2e4
187 changed files with 7203 additions and 758 deletions
|
@ -100,6 +100,7 @@ enabled:
|
|||
- test/functional/apps/dashboard_elements/controls/options_list/config.ts
|
||||
- test/functional/apps/dashboard_elements/image_embeddable/config.ts
|
||||
- test/functional/apps/dashboard_elements/input_control_vis/config.ts
|
||||
- test/functional/apps/dashboard_elements/links/config.ts
|
||||
- test/functional/apps/dashboard_elements/markdown/config.ts
|
||||
- test/functional/apps/dashboard/group1/config.ts
|
||||
- test/functional/apps/dashboard/group2/config.ts
|
||||
|
|
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -470,6 +470,7 @@ x-pack/plugins/lens @elastic/kibana-visualizations
|
|||
x-pack/plugins/license_api_guard @elastic/platform-deployment-management
|
||||
x-pack/plugins/license_management @elastic/platform-deployment-management
|
||||
x-pack/plugins/licensing @elastic/kibana-core
|
||||
src/plugins/links @elastic/kibana-presentation
|
||||
packages/kbn-lint-packages-cli @elastic/kibana-operations
|
||||
packages/kbn-lint-ts-projects-cli @elastic/kibana-operations
|
||||
x-pack/plugins/lists @elastic/security-detection-engine
|
||||
|
|
|
@ -83,6 +83,7 @@
|
|||
],
|
||||
"monaco": "packages/kbn-monaco/src",
|
||||
"navigation": "src/plugins/navigation",
|
||||
"links": "src/plugins/links",
|
||||
"newsfeed": "src/plugins/newsfeed",
|
||||
"presentationUtil": "src/plugins/presentation_util",
|
||||
"randomSampling": "x-pack/packages/kbn-random-sampling",
|
||||
|
|
|
@ -238,6 +238,10 @@ in Kibana, e.g. visualizations. It has the form of a flyout panel.
|
|||
|Utilities for building Kibana plugins.
|
||||
|
||||
|
||||
|{kib-repo}blob/{branch}/src/plugins/links/README.md[links]
|
||||
|This plugin adds the Links panel which allows authors to create hard links to navigate on click and bring all context from the source dashboard to the destination dashboard.
|
||||
|
||||
|
||||
|{kib-repo}blob/{branch}/src/plugins/management/README.md[management]
|
||||
|This plugins contains the "Stack Management" page framework. It offers navigation and an API
|
||||
to link individual management section into it. This plugin does not contain any individual
|
||||
|
|
|
@ -496,6 +496,7 @@
|
|||
"@kbn/license-api-guard-plugin": "link:x-pack/plugins/license_api_guard",
|
||||
"@kbn/license-management-plugin": "link:x-pack/plugins/license_management",
|
||||
"@kbn/licensing-plugin": "link:x-pack/plugins/licensing",
|
||||
"@kbn/links-plugin": "link:src/plugins/links",
|
||||
"@kbn/lists-plugin": "link:x-pack/plugins/lists",
|
||||
"@kbn/locator-examples-plugin": "link:examples/locator_examples",
|
||||
"@kbn/locator-explorer-plugin": "link:examples/locator_explorer",
|
||||
|
|
|
@ -1062,6 +1062,24 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"dynamic": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "text"
|
||||
},
|
||||
"title": {
|
||||
"type": "text"
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"links": {
|
||||
"dynamic": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"lens": {
|
||||
"properties": {
|
||||
"title": {
|
||||
|
|
|
@ -87,6 +87,7 @@ pageLoadAssetSize:
|
|||
lens: 38000
|
||||
licenseManagement: 41817
|
||||
licensing: 29004
|
||||
links: 44490
|
||||
lists: 22900
|
||||
logExplorer: 39045
|
||||
logsShared: 281060
|
||||
|
|
|
@ -96,6 +96,7 @@ const STANDARD_LIST_TYPES = [
|
|||
'dashboard',
|
||||
'search',
|
||||
'lens',
|
||||
'links',
|
||||
'map',
|
||||
'cases',
|
||||
// synthetics based objects
|
||||
|
|
|
@ -113,6 +113,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
|
|||
"legacy-url-alias": "9b8cca3fbb2da46fd12823d3cd38fdf1c9f24bc8",
|
||||
"lens": "5cfa2c52b979b4f8df56dd13c477e152183468b9",
|
||||
"lens-ui-telemetry": "8c47a9e393861f76e268345ecbadfc8a5fb1e0bd",
|
||||
"links": "39117a08966e9082d0f47b0b2e7e508499fc1e6d",
|
||||
"maintenance-window": "d893544460abad56ff7a0e25b78f78776dfe10d1",
|
||||
"map": "76c71023bd198fb6b1163b31bafd926fe2ceb9da",
|
||||
"metrics-data-source": "81b69dc9830699d9ead5ac8dcb9264612e2a3c89",
|
||||
|
|
|
@ -81,6 +81,7 @@ const previouslyRegisteredTypes = [
|
|||
'legacy-url-alias',
|
||||
'lens',
|
||||
'lens-ui-telemetry',
|
||||
'links',
|
||||
'maintenance-window',
|
||||
'map',
|
||||
'maps-telemetry',
|
||||
|
|
|
@ -438,6 +438,7 @@ describe('split .kibana index into multiple system indices', () => {
|
|||
"legacy-url-alias",
|
||||
"lens",
|
||||
"lens-ui-telemetry",
|
||||
"links",
|
||||
"maintenance-window",
|
||||
"map",
|
||||
"metrics-data-source",
|
||||
|
|
|
@ -92,7 +92,11 @@ export const EditControlFlyout = ({
|
|||
}
|
||||
|
||||
closeFlyout();
|
||||
await controlGroup.replaceEmbeddable(embeddable.id, inputToReturn, type);
|
||||
if (panel.type === type) {
|
||||
controlGroup.updateInputForChild(embeddable.id, inputToReturn);
|
||||
} else {
|
||||
await controlGroup.replaceEmbeddable(embeddable.id, inputToReturn, type);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -222,22 +222,22 @@ export class ControlGroupContainer extends Container<
|
|||
|
||||
public async addDataControlFromField(controlProps: AddDataControlProps) {
|
||||
const panelState = await getDataControlPanelState(this.getInput(), controlProps);
|
||||
return this.createAndSaveEmbeddable(panelState.type, panelState);
|
||||
return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels);
|
||||
}
|
||||
|
||||
public addOptionsListControl(controlProps: AddOptionsListControlProps) {
|
||||
const panelState = getOptionsListPanelState(this.getInput(), controlProps);
|
||||
return this.createAndSaveEmbeddable(panelState.type, panelState);
|
||||
return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels);
|
||||
}
|
||||
|
||||
public addRangeSliderControl(controlProps: AddRangeSliderControlProps) {
|
||||
const panelState = getRangeSliderPanelState(this.getInput(), controlProps);
|
||||
return this.createAndSaveEmbeddable(panelState.type, panelState);
|
||||
return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels);
|
||||
}
|
||||
|
||||
public addTimeSliderControl() {
|
||||
const panelState = getTimeSliderPanelState(this.getInput());
|
||||
return this.createAndSaveEmbeddable(panelState.type, panelState);
|
||||
return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels);
|
||||
}
|
||||
|
||||
public openAddDataControlFlyout = openAddDataControlFlyout;
|
||||
|
@ -283,15 +283,19 @@ export class ControlGroupContainer extends Container<
|
|||
|
||||
protected createNewPanelState<TEmbeddableInput extends ControlInput = ControlInput>(
|
||||
factory: EmbeddableFactory<ControlInput, ControlOutput, ControlEmbeddable>,
|
||||
partial: Partial<TEmbeddableInput> = {}
|
||||
): ControlPanelState<TEmbeddableInput> {
|
||||
const panelState = super.createNewPanelState(factory, partial);
|
||||
partial: Partial<TEmbeddableInput> = {},
|
||||
otherPanels: ControlGroupInput['panels']
|
||||
) {
|
||||
const { newPanel } = super.createNewPanelState(factory, partial);
|
||||
return {
|
||||
order: getNextPanelOrder(this.getInput().panels),
|
||||
width: this.getInput().defaultControlWidth,
|
||||
grow: this.getInput().defaultControlGrow,
|
||||
...panelState,
|
||||
} as ControlPanelState<TEmbeddableInput>;
|
||||
newPanel: {
|
||||
order: getNextPanelOrder(this.getInput().panels),
|
||||
width: this.getInput().defaultControlWidth,
|
||||
grow: this.getInput().defaultControlGrow,
|
||||
...newPanel,
|
||||
} as ControlPanelState<TEmbeddableInput>,
|
||||
otherPanels,
|
||||
};
|
||||
}
|
||||
|
||||
protected onRemoveEmbeddable(idToRemove: string) {
|
||||
|
|
|
@ -6,7 +6,13 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { pluginServices } from './public/services/plugin_services';
|
||||
import { registry } from './public/services/plugin_services.stub';
|
||||
import { setStubDashboardServices } from './public/services/mocks';
|
||||
|
||||
pluginServices.setRegistry(registry.start({}));
|
||||
/**
|
||||
* CAUTION: Be very mindful of the things you import in to this `jest_setup` file - anything that is imported
|
||||
* here (either directly or implicitly through dependencies) will be **unable** to be mocked elsewhere!
|
||||
*
|
||||
* Refer to the "Caution" section here:
|
||||
* https://jestjs.io/docs/jest-object#jestmockmodulename-factory-options
|
||||
*/
|
||||
setStubDashboardServices();
|
||||
|
|
|
@ -47,11 +47,13 @@ beforeEach(async () => {
|
|||
.fn()
|
||||
.mockReturnValue(mockEmbeddableFactory);
|
||||
container = buildMockDashboard({
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Kibanana', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
}),
|
||||
overrides: {
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Kibanana', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -22,10 +22,9 @@ import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
|||
|
||||
import { type DashboardPanelState } from '../../common';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { createPanelState } from '../dashboard_container/component/panel';
|
||||
import { dashboardClonePanelActionStrings } from './_dashboard_actions_strings';
|
||||
import { placeClonePanel } from '../dashboard_container/component/panel_placement';
|
||||
import { DASHBOARD_CONTAINER_TYPE, type DashboardContainer } from '../dashboard_container';
|
||||
import { placePanelBeside } from '../dashboard_container/component/panel/dashboard_panel_placement';
|
||||
|
||||
export const ACTION_CLONE_PANEL = 'clonePanel';
|
||||
|
||||
|
@ -82,6 +81,7 @@ export class ClonePanelAction implements Action<ClonePanelActionContext> {
|
|||
throw new PanelNotFoundError();
|
||||
}
|
||||
|
||||
// Clone panel input
|
||||
const clonedPanelState: PanelState<EmbeddableInput> = await (async () => {
|
||||
const newTitle = await this.getCloneTitle(embeddable, embeddable.getTitle() || '');
|
||||
const id = uuidv4();
|
||||
|
@ -110,18 +110,20 @@ export class ClonePanelAction implements Action<ClonePanelActionContext> {
|
|||
'data-test-subj': 'addObjectToContainerSuccess',
|
||||
});
|
||||
|
||||
const { otherPanels, newPanel } = createPanelState(
|
||||
clonedPanelState,
|
||||
dashboard.getInput().panels,
|
||||
placePanelBeside,
|
||||
{
|
||||
width: panelToClone.gridData.w,
|
||||
height: panelToClone.gridData.h,
|
||||
currentPanels: dashboard.getInput().panels,
|
||||
placeBesideId: panelToClone.explicitInput.id,
|
||||
scrollToPanel: true,
|
||||
}
|
||||
);
|
||||
const { newPanelPlacement, otherPanels } = placeClonePanel({
|
||||
width: panelToClone.gridData.w,
|
||||
height: panelToClone.gridData.h,
|
||||
currentPanels: dashboard.getInput().panels,
|
||||
placeBesideId: panelToClone.explicitInput.id,
|
||||
});
|
||||
|
||||
const newPanel = {
|
||||
...clonedPanelState,
|
||||
gridData: {
|
||||
...newPanelPlacement,
|
||||
i: clonedPanelState.explicitInput.id,
|
||||
},
|
||||
};
|
||||
|
||||
dashboard.updateInput({
|
||||
panels: {
|
||||
|
|
|
@ -31,11 +31,13 @@ pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
|||
|
||||
beforeEach(async () => {
|
||||
container = buildMockDashboard({
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Sam', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
}),
|
||||
overrides: {
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Sam', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -46,11 +46,13 @@ describe('Export CSV action', () => {
|
|||
};
|
||||
|
||||
container = buildMockDashboard({
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Kibanana', id: '123' },
|
||||
type: CONTACT_CARD_EXPORTABLE_EMBEDDABLE,
|
||||
}),
|
||||
overrides: {
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Kibanana', id: '123' },
|
||||
type: CONTACT_CARD_EXPORTABLE_EMBEDDABLE,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -29,11 +29,13 @@ let container: DashboardContainer;
|
|||
let embeddable: ContactCardEmbeddable;
|
||||
beforeEach(async () => {
|
||||
container = buildMockDashboard({
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Sam', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
}),
|
||||
overrides: {
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Sam', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -18,7 +18,6 @@ import {
|
|||
} from '@kbn/embeddable-plugin/public';
|
||||
import { Toast } from '@kbn/core/public';
|
||||
|
||||
import { DashboardPanelState } from '../../common';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { dashboardReplacePanelActionStrings } from './_dashboard_actions_strings';
|
||||
import { DashboardContainer } from '../dashboard_container';
|
||||
|
@ -58,30 +57,15 @@ export class ReplacePanelFlyout extends React.Component<Props> {
|
|||
|
||||
public onReplacePanel = async (savedObjectId: string, type: string, name: string) => {
|
||||
const { panelToRemove, container } = this.props;
|
||||
const { w, h, x, y } = (container.getInput().panels[panelToRemove.id] as DashboardPanelState)
|
||||
.gridData;
|
||||
|
||||
const { id } = await container.addNewEmbeddable<SavedObjectEmbeddableInput>(type, {
|
||||
savedObjectId,
|
||||
});
|
||||
|
||||
const { [panelToRemove.id]: omit, ...panels } = container.getInput().panels;
|
||||
|
||||
container.updateInput({
|
||||
panels: {
|
||||
...panels,
|
||||
[id]: {
|
||||
...panels[id],
|
||||
gridData: {
|
||||
...(panels[id] as DashboardPanelState).gridData,
|
||||
w,
|
||||
h,
|
||||
x,
|
||||
y,
|
||||
},
|
||||
} as DashboardPanelState,
|
||||
const id = await container.replaceEmbeddable<SavedObjectEmbeddableInput>(
|
||||
panelToRemove.id,
|
||||
{
|
||||
savedObjectId,
|
||||
},
|
||||
});
|
||||
type,
|
||||
true
|
||||
);
|
||||
|
||||
(container as DashboardContainer).setHighlightPanelId(id);
|
||||
this.showToast(name);
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { isQuery, isTimeRange } from '@kbn/data-plugin/common';
|
||||
import { Filter, isFilterPinned, Query, TimeRange } from '@kbn/es-query';
|
||||
import { EmbeddableInput, IEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import { DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public';
|
||||
|
||||
import { DashboardAppLocatorParams } from './locator';
|
||||
|
||||
interface EmbeddableQueryInput extends EmbeddableInput {
|
||||
query?: Query;
|
||||
filters?: Filter[];
|
||||
timeRange?: TimeRange;
|
||||
}
|
||||
|
||||
export const getEmbeddableParams = (
|
||||
source: IEmbeddable<EmbeddableQueryInput>,
|
||||
options: DashboardDrilldownOptions
|
||||
): Partial<DashboardAppLocatorParams> => {
|
||||
const params: DashboardAppLocatorParams = {};
|
||||
|
||||
const input = source.getInput();
|
||||
if (isQuery(input.query) && options.useCurrentFilters) {
|
||||
params.query = input.query;
|
||||
}
|
||||
|
||||
// if useCurrentDashboardDataRange is enabled, then preserve current time range
|
||||
// if undefined is passed, then destination dashboard will figure out time range itself
|
||||
// for brush event this time range would be overwritten
|
||||
if (isTimeRange(input.timeRange) && options.useCurrentDateRange) {
|
||||
params.timeRange = input.timeRange;
|
||||
}
|
||||
|
||||
// if useCurrentDashboardFilters enabled, then preserve all the filters (pinned, unpinned, and from controls)
|
||||
// otherwise preserve only pinned
|
||||
params.filters = options.useCurrentFilters
|
||||
? input.filters
|
||||
: input.filters?.filter((f) => isFilterPinned(f));
|
||||
|
||||
return params;
|
||||
};
|
|
@ -12,8 +12,9 @@ import { METRIC_TYPE } from '@kbn/analytics';
|
|||
import { useEuiTheme } from '@elastic/eui';
|
||||
|
||||
import { AddFromLibraryButton, Toolbar, ToolbarButton } from '@kbn/shared-ux-button-toolbar';
|
||||
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableFactory, EmbeddableInput } from '@kbn/embeddable-plugin/public';
|
||||
import { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public';
|
||||
import { isExplicitInputWithAttributes } from '@kbn/embeddable-plugin/public';
|
||||
|
||||
import { getCreateVisualizationButtonTitle } from '../_dashboard_app_strings';
|
||||
import { EditorMenu } from './editor_menu';
|
||||
|
@ -83,15 +84,26 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }
|
|||
trackUiMetric(METRIC_TYPE.CLICK, embeddableFactory.type);
|
||||
}
|
||||
|
||||
let explicitInput: Awaited<ReturnType<typeof embeddableFactory.getExplicitInput>>;
|
||||
let explicitInput: Partial<EmbeddableInput>;
|
||||
let attributes: unknown;
|
||||
try {
|
||||
explicitInput = await embeddableFactory.getExplicitInput();
|
||||
const explicitInputReturn = await embeddableFactory.getExplicitInput(undefined, dashboard);
|
||||
if (isExplicitInputWithAttributes(explicitInputReturn)) {
|
||||
explicitInput = explicitInputReturn.newInput;
|
||||
attributes = explicitInputReturn.attributes;
|
||||
} else {
|
||||
explicitInput = explicitInputReturn;
|
||||
}
|
||||
} catch (e) {
|
||||
// error likely means user canceled embeddable creation
|
||||
return;
|
||||
}
|
||||
|
||||
const newEmbeddable = await dashboard.addNewEmbeddable(embeddableFactory.type, explicitInput);
|
||||
const newEmbeddable = await dashboard.addNewEmbeddable(
|
||||
embeddableFactory.type,
|
||||
explicitInput,
|
||||
attributes
|
||||
);
|
||||
|
||||
if (newEmbeddable) {
|
||||
dashboard.setScrollToPanelId(newEmbeddable.id);
|
||||
|
|
|
@ -65,8 +65,14 @@ export const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2;
|
|||
|
||||
export const CHANGE_CHECK_DEBOUNCE = 100;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Content Management
|
||||
// ------------------------------------------------------------------
|
||||
export { CONTENT_ID as DASHBOARD_CONTENT_ID } from '../common/content_management/constants';
|
||||
|
||||
export const DASHBOARD_CACHE_SIZE = 20; // only store a max of 20 dashboards
|
||||
export const DASHBOARD_CACHE_TTL = 1000 * 60 * 5; // time to live = 5 minutes
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Default State
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
@import '../../../embeddable/public/variables';
|
||||
|
||||
@import './component/grid/index';
|
||||
@import './component/panel/index';
|
||||
@import './component/viewport/index';
|
||||
|
||||
.dashboardContainer, .dashboardViewport {
|
||||
|
|
|
@ -22,7 +22,7 @@ pluginServices.getServices().visualizations.getAliases = jest
|
|||
|
||||
describe('DashboardEmptyScreen', () => {
|
||||
function mountComponent(viewMode: ViewMode) {
|
||||
const dashboardContainer = buildMockDashboard({ viewMode });
|
||||
const dashboardContainer = buildMockDashboard({ overrides: { viewMode } });
|
||||
return mountWithIntl(
|
||||
<DashboardContainerContext.Provider value={dashboardContainer}>
|
||||
<DashboardEmptyScreen />
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
@import './dashboard_grid';
|
||||
@import './dashboard_panel';
|
|
@ -46,16 +46,18 @@ jest.mock('./dashboard_grid_item', () => {
|
|||
|
||||
const createAndMountDashboardGrid = () => {
|
||||
const dashboardContainer = buildMockDashboard({
|
||||
panels: {
|
||||
'1': {
|
||||
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { id: '1' },
|
||||
},
|
||||
'2': {
|
||||
gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { id: '2' },
|
||||
overrides: {
|
||||
panels: {
|
||||
'1': {
|
||||
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { id: '1' },
|
||||
},
|
||||
'2': {
|
||||
gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { id: '2' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -88,6 +88,8 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => {
|
|||
|
||||
const onLayoutChange = useCallback(
|
||||
(newLayout: Array<Layout & { i: string }>) => {
|
||||
if (viewMode !== ViewMode.EDIT) return;
|
||||
|
||||
const updatedPanels: { [key: string]: DashboardPanelState } = newLayout.reduce(
|
||||
(updatedPanelsAcc, panelLayout) => {
|
||||
updatedPanelsAcc[panelLayout.i] = {
|
||||
|
@ -102,7 +104,7 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => {
|
|||
dashboard.dispatch.setPanels(updatedPanels);
|
||||
}
|
||||
},
|
||||
[dashboard, panels]
|
||||
[dashboard, panels, viewMode]
|
||||
);
|
||||
|
||||
const classes = classNames({
|
||||
|
@ -127,8 +129,7 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => {
|
|||
className={classes}
|
||||
width={viewportWidth}
|
||||
breakpoints={breakpoints}
|
||||
onDragStop={onLayoutChange}
|
||||
onResizeStop={onLayoutChange}
|
||||
onLayoutChange={onLayoutChange}
|
||||
isResizable={!expandedPanelId && !focusedPanelId}
|
||||
isDraggable={!expandedPanelId && !focusedPanelId}
|
||||
rowHeight={DASHBOARD_GRID_HEIGHT}
|
||||
|
|
|
@ -43,7 +43,7 @@ const createAndMountDashboardGridItem = (props: DashboardGridItemProps) => {
|
|||
explicitInput: { id: '2' },
|
||||
},
|
||||
};
|
||||
const dashboardContainer = buildMockDashboard({ panels });
|
||||
const dashboardContainer = buildMockDashboard({ overrides: { panels } });
|
||||
|
||||
const component = mountWithIntl(
|
||||
<DashboardContainerContext.Provider value={dashboardContainer}>
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
@import './dashboard_panel';
|
|
@ -1,83 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DashboardPanelState } from '../../../../common';
|
||||
import { EmbeddableInput } from '@kbn/embeddable-plugin/public';
|
||||
import { CONTACT_CARD_EMBEDDABLE } from '@kbn/embeddable-plugin/public/lib/test_samples';
|
||||
import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../../dashboard_constants';
|
||||
|
||||
import { createPanelState } from './create_panel_state';
|
||||
|
||||
interface TestInput extends EmbeddableInput {
|
||||
test: string;
|
||||
}
|
||||
const panels: { [key: string]: DashboardPanelState } = {};
|
||||
|
||||
test('createPanelState adds a new panel state in 0,0 position', () => {
|
||||
const { newPanel: panelState } = createPanelState<TestInput>(
|
||||
{
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { test: 'hi', id: '123' },
|
||||
},
|
||||
panels
|
||||
);
|
||||
expect(panelState.explicitInput.test).toBe('hi');
|
||||
expect(panelState.type).toBe(CONTACT_CARD_EMBEDDABLE);
|
||||
expect(panelState.explicitInput.id).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[panelState.explicitInput.id] = panelState;
|
||||
});
|
||||
|
||||
test('createPanelState adds a second new panel state', () => {
|
||||
const { newPanel: panelState } = createPanelState<TestInput>(
|
||||
{ type: CONTACT_CARD_EMBEDDABLE, explicitInput: { test: 'bye', id: '456' } },
|
||||
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[panelState.explicitInput.id] = panelState;
|
||||
});
|
||||
|
||||
test('createPanelState adds a third new panel state', () => {
|
||||
const { newPanel: panelState } = createPanelState<TestInput>(
|
||||
{
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { test: 'bye', id: '789' },
|
||||
},
|
||||
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[panelState.explicitInput.id] = panelState;
|
||||
});
|
||||
|
||||
test('createPanelState adds a new panel state in the top most position', () => {
|
||||
delete panels['456'];
|
||||
const { newPanel: panelState } = createPanelState<TestInput>(
|
||||
{
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { test: 'bye', id: '987' },
|
||||
},
|
||||
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);
|
||||
});
|
|
@ -1,60 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { PanelState, EmbeddableInput } from '@kbn/embeddable-plugin/public';
|
||||
|
||||
import {
|
||||
IPanelPlacementArgs,
|
||||
findTopLeftMostOpenSpace,
|
||||
PanelPlacementMethod,
|
||||
} from './dashboard_panel_placement';
|
||||
import { DashboardPanelState } from '../../../../common';
|
||||
import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../../dashboard_constants';
|
||||
|
||||
/**
|
||||
* Creates and initializes a basic panel state.
|
||||
*/
|
||||
export function createPanelState<
|
||||
TEmbeddableInput extends EmbeddableInput,
|
||||
TPlacementMethodArgs extends IPanelPlacementArgs = IPanelPlacementArgs
|
||||
>(
|
||||
panelState: PanelState<TEmbeddableInput>,
|
||||
currentPanels: { [key: string]: DashboardPanelState },
|
||||
placementMethod?: PanelPlacementMethod<TPlacementMethodArgs>,
|
||||
placementArgs?: TPlacementMethodArgs
|
||||
): {
|
||||
newPanel: DashboardPanelState<TEmbeddableInput>;
|
||||
otherPanels: { [key: string]: DashboardPanelState };
|
||||
} {
|
||||
const defaultPlacementArgs = {
|
||||
width: DEFAULT_PANEL_WIDTH,
|
||||
height: DEFAULT_PANEL_HEIGHT,
|
||||
currentPanels,
|
||||
};
|
||||
const finalPlacementArgs = placementArgs
|
||||
? {
|
||||
...defaultPlacementArgs,
|
||||
...placementArgs,
|
||||
}
|
||||
: defaultPlacementArgs;
|
||||
|
||||
const { newPanelPlacement, otherPanels } = placementMethod
|
||||
? placementMethod(finalPlacementArgs as TPlacementMethodArgs)
|
||||
: findTopLeftMostOpenSpace(defaultPlacementArgs);
|
||||
|
||||
return {
|
||||
newPanel: {
|
||||
gridData: {
|
||||
...newPanelPlacement,
|
||||
i: panelState.explicitInput.id,
|
||||
},
|
||||
...panelState,
|
||||
},
|
||||
otherPanels,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { placePanel } from './place_panel';
|
||||
|
||||
export { placeClonePanel } from './place_clone_panel_strategy';
|
|
@ -6,103 +6,14 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import { cloneDeep, forOwn } from 'lodash';
|
||||
import { PanelNotFoundError } from '@kbn/embeddable-plugin/public';
|
||||
|
||||
import { DashboardPanelState } from '../../../../common';
|
||||
import { GridData } from '../../../../common/content_management';
|
||||
import { PanelPlacementProps, PanelPlacementReturn } from './types';
|
||||
import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../dashboard_constants';
|
||||
|
||||
export type PanelPlacementMethod<PlacementArgs extends IPanelPlacementArgs> = (
|
||||
args: PlacementArgs
|
||||
) => PanelPlacementMethodReturn;
|
||||
|
||||
interface PanelPlacementMethodReturn {
|
||||
newPanelPlacement: Omit<GridData, 'i'>;
|
||||
otherPanels: { [key: string]: DashboardPanelState };
|
||||
}
|
||||
|
||||
export interface IPanelPlacementArgs {
|
||||
width: number;
|
||||
height: number;
|
||||
currentPanels: { [key: string]: DashboardPanelState };
|
||||
scrollToPanel?: boolean;
|
||||
}
|
||||
|
||||
export interface IPanelPlacementBesideArgs extends IPanelPlacementArgs {
|
||||
placeBesideId: string;
|
||||
}
|
||||
|
||||
// Look for the smallest y and x value where the default panel will fit.
|
||||
export function findTopLeftMostOpenSpace({
|
||||
width,
|
||||
height,
|
||||
currentPanels,
|
||||
}: IPanelPlacementArgs): PanelPlacementMethodReturn {
|
||||
let maxY = -1;
|
||||
|
||||
const currentPanelsArray = Object.values(currentPanels);
|
||||
currentPanelsArray.forEach((panel) => {
|
||||
maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY);
|
||||
});
|
||||
|
||||
// Handle case of empty grid.
|
||||
if (maxY < 0) {
|
||||
return { newPanelPlacement: { x: 0, y: 0, w: width, h: height }, otherPanels: currentPanels };
|
||||
}
|
||||
|
||||
const grid = new Array(maxY);
|
||||
for (let y = 0; y < maxY; y++) {
|
||||
grid[y] = new Array(DASHBOARD_GRID_COLUMN_COUNT).fill(0);
|
||||
}
|
||||
|
||||
currentPanelsArray.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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (let y = 0; y < maxY; y++) {
|
||||
for (let x = 0; x < DASHBOARD_GRID_COLUMN_COUNT; x++) {
|
||||
if (grid[y][x] === 1) {
|
||||
// Space is filled
|
||||
continue;
|
||||
} else {
|
||||
for (let h = y; h < Math.min(y + height, maxY); h++) {
|
||||
for (let w = x; w < Math.min(x + width, DASHBOARD_GRID_COLUMN_COUNT); w++) {
|
||||
const spaceIsEmpty = grid[h][w] === 0;
|
||||
const fitsPanelWidth = w === x + width - 1;
|
||||
// If the panel is taller than any other panel in the current grid, it can still fit in the space, hence
|
||||
// we check the minimum of maxY and the panel height.
|
||||
const fitsPanelHeight = h === Math.min(y + height - 1, maxY - 1);
|
||||
|
||||
if (spaceIsEmpty && fitsPanelWidth && fitsPanelHeight) {
|
||||
// Found space
|
||||
return {
|
||||
newPanelPlacement: { x, y, w: width, h: height },
|
||||
otherPanels: currentPanels,
|
||||
};
|
||||
} else if (grid[h][w] === 1) {
|
||||
// x, y spot doesn't work, break.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { newPanelPlacement: { x: 0, y: maxY, w: width, h: height }, otherPanels: currentPanels };
|
||||
}
|
||||
|
||||
interface IplacementDirection {
|
||||
grid: Omit<GridData, 'i'>;
|
||||
fits: boolean;
|
||||
|
@ -128,19 +39,19 @@ function comparePanels(a: GridData, b: GridData): number {
|
|||
return 1;
|
||||
}
|
||||
|
||||
export function placePanelBeside({
|
||||
export function placeClonePanel({
|
||||
width,
|
||||
height,
|
||||
currentPanels,
|
||||
placeBesideId,
|
||||
}: IPanelPlacementBesideArgs): PanelPlacementMethodReturn {
|
||||
}: PanelPlacementProps & { placeBesideId: string }): PanelPlacementReturn {
|
||||
const panelToPlaceBeside = currentPanels[placeBesideId];
|
||||
if (!panelToPlaceBeside) {
|
||||
throw new PanelNotFoundError();
|
||||
}
|
||||
const beside = panelToPlaceBeside.gridData;
|
||||
const otherPanelGridData: GridData[] = [];
|
||||
_.forOwn(currentPanels, (panel: DashboardPanelState, key: string | undefined) => {
|
||||
forOwn(currentPanels, (panel: DashboardPanelState, key: string | undefined) => {
|
||||
otherPanelGridData.push(panel.gridData);
|
||||
});
|
||||
|
||||
|
@ -197,7 +108,7 @@ export function placePanelBeside({
|
|||
|
||||
for (let j = position + 1; j < grid.length; j++) {
|
||||
originalPositionInTheGrid = grid[j].i;
|
||||
const movedPanel = _.cloneDeep(otherPanels[originalPositionInTheGrid]);
|
||||
const movedPanel = cloneDeep(otherPanels[originalPositionInTheGrid]);
|
||||
movedPanel.gridData.y = movedPanel.gridData.y + diff;
|
||||
otherPanels[originalPositionInTheGrid] = movedPanel;
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../dashboard_constants';
|
||||
import { PanelPlacementProps, PanelPlacementReturn } from './types';
|
||||
|
||||
export const panelPlacementStrategies = {
|
||||
// Place on the very top of the Dashboard, add the height of this panel to all other panels.
|
||||
placeAtTop: ({ width, height, currentPanels }: PanelPlacementProps): PanelPlacementReturn => {
|
||||
const otherPanels = { ...currentPanels };
|
||||
for (const [id, panel] of Object.entries(currentPanels)) {
|
||||
const currentPanel = cloneDeep(panel);
|
||||
currentPanel.gridData.y = currentPanel.gridData.y + height;
|
||||
otherPanels[id] = currentPanel;
|
||||
}
|
||||
return {
|
||||
newPanelPlacement: { x: 0, y: 0, w: width, h: height },
|
||||
otherPanels,
|
||||
};
|
||||
},
|
||||
|
||||
// Look for the smallest y and x value where the default panel will fit.
|
||||
findTopLeftMostOpenSpace: ({
|
||||
width,
|
||||
height,
|
||||
currentPanels,
|
||||
}: PanelPlacementProps): PanelPlacementReturn => {
|
||||
let maxY = -1;
|
||||
|
||||
const currentPanelsArray = Object.values(currentPanels);
|
||||
currentPanelsArray.forEach((panel) => {
|
||||
maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY);
|
||||
});
|
||||
|
||||
// Handle case of empty grid.
|
||||
if (maxY < 0) {
|
||||
return {
|
||||
newPanelPlacement: { x: 0, y: 0, w: width, h: height },
|
||||
otherPanels: currentPanels,
|
||||
};
|
||||
}
|
||||
|
||||
const grid = new Array(maxY);
|
||||
for (let y = 0; y < maxY; y++) {
|
||||
grid[y] = new Array(DASHBOARD_GRID_COLUMN_COUNT).fill(0);
|
||||
}
|
||||
|
||||
currentPanelsArray.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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (let y = 0; y < maxY; y++) {
|
||||
for (let x = 0; x < DASHBOARD_GRID_COLUMN_COUNT; x++) {
|
||||
if (grid[y][x] === 1) {
|
||||
// Space is filled
|
||||
continue;
|
||||
} else {
|
||||
for (let h = y; h < Math.min(y + height, maxY); h++) {
|
||||
for (let w = x; w < Math.min(x + width, DASHBOARD_GRID_COLUMN_COUNT); w++) {
|
||||
const spaceIsEmpty = grid[h][w] === 0;
|
||||
const fitsPanelWidth = w === x + width - 1;
|
||||
// If the panel is taller than any other panel in the current grid, it can still fit in the space, hence
|
||||
// we check the minimum of maxY and the panel height.
|
||||
const fitsPanelHeight = h === Math.min(y + height - 1, maxY - 1);
|
||||
|
||||
if (spaceIsEmpty && fitsPanelWidth && fitsPanelHeight) {
|
||||
// Found space
|
||||
return {
|
||||
newPanelPlacement: { x, y, w: width, h: height },
|
||||
otherPanels: currentPanels,
|
||||
};
|
||||
} else if (grid[h][w] === 1) {
|
||||
// x, y spot doesn't work, break.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
newPanelPlacement: { x: 0, y: maxY, w: width, h: height },
|
||||
otherPanels: currentPanels,
|
||||
};
|
||||
},
|
||||
} as const;
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DashboardPanelState } from '../../../../common';
|
||||
import { EmbeddableFactory, EmbeddableInput } from '@kbn/embeddable-plugin/public';
|
||||
import { CONTACT_CARD_EMBEDDABLE } from '@kbn/embeddable-plugin/public/lib/test_samples';
|
||||
import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../../dashboard_constants';
|
||||
|
||||
import { placePanel } from './place_panel';
|
||||
import { IProvidesPanelPlacementSettings } from './types';
|
||||
|
||||
interface TestInput extends EmbeddableInput {
|
||||
test: string;
|
||||
}
|
||||
const panels: { [key: string]: DashboardPanelState } = {};
|
||||
|
||||
test('adds a new panel state in 0,0 position', () => {
|
||||
const { newPanel: panelState } = placePanel<TestInput>(
|
||||
{} as unknown as EmbeddableFactory,
|
||||
{
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { test: 'hi', id: '123' },
|
||||
},
|
||||
panels
|
||||
);
|
||||
expect(panelState.explicitInput.test).toBe('hi');
|
||||
expect(panelState.type).toBe(CONTACT_CARD_EMBEDDABLE);
|
||||
expect(panelState.explicitInput.id).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[panelState.explicitInput.id] = panelState;
|
||||
});
|
||||
|
||||
test('adds a second new panel state', () => {
|
||||
const { newPanel: panelState } = placePanel<TestInput>(
|
||||
{} as unknown as EmbeddableFactory,
|
||||
{ type: CONTACT_CARD_EMBEDDABLE, explicitInput: { test: 'bye', id: '456' } },
|
||||
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[panelState.explicitInput.id] = panelState;
|
||||
});
|
||||
|
||||
test('adds a third new panel state', () => {
|
||||
const { newPanel: panelState } = placePanel<TestInput>(
|
||||
{} as unknown as EmbeddableFactory,
|
||||
{
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { test: 'bye', id: '789' },
|
||||
},
|
||||
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[panelState.explicitInput.id] = panelState;
|
||||
});
|
||||
|
||||
test('adds a new panel state in the top most position when it is open', () => {
|
||||
// deleting panel 456 means that the top leftmost open position will be at the top of the Dashboard.
|
||||
delete panels['456'];
|
||||
const { newPanel: panelState } = placePanel<TestInput>(
|
||||
{} as unknown as EmbeddableFactory,
|
||||
{
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { test: 'bye', id: '987' },
|
||||
},
|
||||
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);
|
||||
|
||||
// replace the topmost panel.
|
||||
panels[panelState.explicitInput.id] = panelState;
|
||||
});
|
||||
|
||||
test('adds a new panel state at the very top of the Dashboard with default sizing', () => {
|
||||
const embeddableFactoryStub: IProvidesPanelPlacementSettings = {
|
||||
getPanelPlacementSettings: jest.fn().mockImplementation(() => {
|
||||
return { strategy: 'placeAtTop' };
|
||||
}),
|
||||
};
|
||||
|
||||
const { newPanel: panelState } = placePanel<TestInput>(
|
||||
embeddableFactoryStub as unknown as EmbeddableFactory,
|
||||
{
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { test: 'wowee', id: '9001' },
|
||||
},
|
||||
panels
|
||||
);
|
||||
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);
|
||||
|
||||
expect(embeddableFactoryStub.getPanelPlacementSettings).toHaveBeenCalledWith(
|
||||
{ id: '9001', test: 'wowee' },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test('adds a new panel state at the very top of the Dashboard with custom sizing', () => {
|
||||
const embeddableFactoryStub: IProvidesPanelPlacementSettings = {
|
||||
getPanelPlacementSettings: jest.fn().mockImplementation(() => {
|
||||
return { strategy: 'placeAtTop', width: 10, height: 5 };
|
||||
}),
|
||||
};
|
||||
|
||||
const { newPanel: panelState } = placePanel<TestInput>(
|
||||
embeddableFactoryStub as unknown as EmbeddableFactory,
|
||||
{
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { test: 'woweee', id: '9002' },
|
||||
},
|
||||
panels
|
||||
);
|
||||
expect(panelState.gridData.x).toBe(0);
|
||||
expect(panelState.gridData.y).toBe(0);
|
||||
expect(panelState.gridData.h).toBe(5);
|
||||
expect(panelState.gridData.w).toBe(10);
|
||||
|
||||
expect(embeddableFactoryStub.getPanelPlacementSettings).toHaveBeenCalledWith(
|
||||
{ id: '9002', test: 'woweee' },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test('passes through given attributes', () => {
|
||||
const embeddableFactoryStub: IProvidesPanelPlacementSettings = {
|
||||
getPanelPlacementSettings: jest.fn().mockImplementation(() => {
|
||||
return { strategy: 'placeAtTop', width: 10, height: 5 };
|
||||
}),
|
||||
};
|
||||
|
||||
placePanel<TestInput>(
|
||||
embeddableFactoryStub as unknown as EmbeddableFactory,
|
||||
{
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { test: 'wow', id: '9004' },
|
||||
},
|
||||
panels,
|
||||
{ testAttr: 'hello' }
|
||||
);
|
||||
|
||||
expect(embeddableFactoryStub.getPanelPlacementSettings).toHaveBeenCalledWith(
|
||||
{ id: '9004', test: 'wow' },
|
||||
{ testAttr: 'hello' }
|
||||
);
|
||||
});
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { PanelState, EmbeddableInput, EmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
|
||||
import { DashboardPanelState } from '../../../../common';
|
||||
import { panelPlacementStrategies } from './place_new_panel_strategies';
|
||||
import { IProvidesPanelPlacementSettings, PanelPlacementSettings } from './types';
|
||||
import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../../dashboard_constants';
|
||||
|
||||
export const providesPanelPlacementSettings = (
|
||||
value: unknown
|
||||
): value is IProvidesPanelPlacementSettings => {
|
||||
return Boolean((value as IProvidesPanelPlacementSettings).getPanelPlacementSettings);
|
||||
};
|
||||
|
||||
export function placePanel<TEmbeddableInput extends EmbeddableInput>(
|
||||
factory: EmbeddableFactory,
|
||||
newPanel: PanelState<TEmbeddableInput>,
|
||||
currentPanels: { [key: string]: DashboardPanelState },
|
||||
attributes?: unknown
|
||||
): {
|
||||
newPanel: DashboardPanelState<TEmbeddableInput>;
|
||||
otherPanels: { [key: string]: DashboardPanelState };
|
||||
} {
|
||||
let placementSettings: PanelPlacementSettings = {
|
||||
width: DEFAULT_PANEL_WIDTH,
|
||||
height: DEFAULT_PANEL_HEIGHT,
|
||||
strategy: 'findTopLeftMostOpenSpace',
|
||||
};
|
||||
if (providesPanelPlacementSettings(factory)) {
|
||||
placementSettings = {
|
||||
...placementSettings,
|
||||
...factory.getPanelPlacementSettings(newPanel.explicitInput, attributes),
|
||||
};
|
||||
}
|
||||
const { width, height, strategy } = placementSettings;
|
||||
|
||||
const { newPanelPlacement, otherPanels } = panelPlacementStrategies[strategy]({
|
||||
currentPanels,
|
||||
height,
|
||||
width,
|
||||
});
|
||||
|
||||
return {
|
||||
newPanel: {
|
||||
gridData: {
|
||||
...newPanelPlacement,
|
||||
i: newPanel.explicitInput.id,
|
||||
},
|
||||
...newPanel,
|
||||
},
|
||||
otherPanels,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EmbeddableInput } from '@kbn/embeddable-plugin/public';
|
||||
import { DashboardPanelState } from '../../../../common';
|
||||
import { GridData } from '../../../../common/content_management';
|
||||
import { panelPlacementStrategies } from './place_new_panel_strategies';
|
||||
|
||||
export type PanelPlacementStrategy = keyof typeof panelPlacementStrategies;
|
||||
|
||||
export interface PanelPlacementSettings {
|
||||
strategy: PanelPlacementStrategy;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface PanelPlacementReturn {
|
||||
newPanelPlacement: Omit<GridData, 'i'>;
|
||||
otherPanels: { [key: string]: DashboardPanelState };
|
||||
}
|
||||
|
||||
export interface PanelPlacementProps {
|
||||
width: number;
|
||||
height: number;
|
||||
currentPanels: { [key: string]: DashboardPanelState };
|
||||
}
|
||||
|
||||
export interface IProvidesPanelPlacementSettings<
|
||||
InputType extends EmbeddableInput = EmbeddableInput,
|
||||
AttributesType = unknown
|
||||
> {
|
||||
getPanelPlacementSettings: (
|
||||
input: InputType,
|
||||
attributes?: AttributesType
|
||||
) => Partial<PanelPlacementSettings>;
|
||||
}
|
|
@ -86,7 +86,9 @@ export const DashboardViewportComponent = () => {
|
|||
data-description={description}
|
||||
data-shared-items-count={panelCount}
|
||||
>
|
||||
<DashboardGrid viewportWidth={viewportWidth} />
|
||||
{/* Wait for `viewportWidth` to actually be set before rendering the dashboard grid -
|
||||
otherwise, there is a race condition where the panels can end up being squashed */}
|
||||
{viewportWidth !== 0 && <DashboardGrid viewportWidth={viewportWidth} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -12,7 +12,6 @@ import {
|
|||
IEmbeddable,
|
||||
PanelState,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { DashboardPanelState } from '../../../../common';
|
||||
import { DashboardContainer } from '../dashboard_container';
|
||||
|
@ -41,45 +40,14 @@ export async function replacePanel(
|
|||
newPanelState: Partial<PanelState>,
|
||||
generateNewId?: boolean
|
||||
): Promise<string> {
|
||||
let panels;
|
||||
let panelId;
|
||||
|
||||
if (generateNewId) {
|
||||
// replace panel can be called with generateNewId in order to totally destroy and recreate the embeddable
|
||||
panelId = uuidv4();
|
||||
panels = { ...this.input.panels };
|
||||
delete panels[previousPanelState.explicitInput.id];
|
||||
panels[panelId] = {
|
||||
...previousPanelState,
|
||||
...newPanelState,
|
||||
gridData: {
|
||||
...previousPanelState.gridData,
|
||||
i: panelId,
|
||||
},
|
||||
explicitInput: {
|
||||
...newPanelState.explicitInput,
|
||||
id: panelId,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// Because the embeddable type can change, we have to operate at the container level here
|
||||
panelId = previousPanelState.explicitInput.id;
|
||||
panels = {
|
||||
...this.input.panels,
|
||||
[panelId]: {
|
||||
...previousPanelState,
|
||||
...newPanelState,
|
||||
gridData: {
|
||||
...previousPanelState.gridData,
|
||||
},
|
||||
explicitInput: {
|
||||
...newPanelState.explicitInput,
|
||||
id: panelId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await this.updateInput({ panels });
|
||||
const panelId = await this.replaceEmbeddable(
|
||||
previousPanelState.explicitInput.id,
|
||||
{
|
||||
...newPanelState.explicitInput,
|
||||
id: previousPanelState.explicitInput.id,
|
||||
},
|
||||
newPanelState.type,
|
||||
generateNewId
|
||||
);
|
||||
return panelId;
|
||||
}
|
||||
|
|
|
@ -25,9 +25,9 @@ import { pluginServices } from '../../../services/plugin_services';
|
|||
import { DashboardCreationOptions } from '../dashboard_container_factory';
|
||||
import { DashboardContainerInput, DashboardPanelState } from '../../../../common';
|
||||
import { startSyncingDashboardDataViews } from './data_views/sync_dashboard_data_views';
|
||||
import { findTopLeftMostOpenSpace } from '../../component/panel/dashboard_panel_placement';
|
||||
import { LoadDashboardReturn } from '../../../services/dashboard_content_management/types';
|
||||
import { syncUnifiedSearchState } from './unified_search/sync_dashboard_unified_search_state';
|
||||
import { panelPlacementStrategies } from '../../component/panel_placement/place_new_panel_strategies';
|
||||
import {
|
||||
DEFAULT_DASHBOARD_INPUT,
|
||||
DEFAULT_PANEL_HEIGHT,
|
||||
|
@ -297,6 +297,7 @@ export const initializeDashboard = async ({
|
|||
const { width, height } = incomingEmbeddable.size;
|
||||
const currentPanels = container.getInput().panels;
|
||||
const embeddableId = incomingEmbeddable.embeddableId ?? v4();
|
||||
const { findTopLeftMostOpenSpace } = panelPlacementStrategies;
|
||||
const { newPanelPlacement } = findTopLeftMostOpenSpace({
|
||||
width: width ?? DEFAULT_PANEL_WIDTH,
|
||||
height: height ?? DEFAULT_PANEL_HEIGHT,
|
||||
|
|
|
@ -43,11 +43,13 @@ pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
|||
|
||||
test('DashboardContainer initializes embeddables', (done) => {
|
||||
const container = buildMockDashboard({
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Sam', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
}),
|
||||
overrides: {
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Sam', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -94,11 +96,13 @@ test('DashboardContainer.replacePanel', (done) => {
|
|||
const ID = '123';
|
||||
|
||||
const container = buildMockDashboard({
|
||||
panels: {
|
||||
[ID]: getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Sam', id: ID },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
}),
|
||||
overrides: {
|
||||
panels: {
|
||||
[ID]: getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Sam', id: ID },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
let counter = 0;
|
||||
|
@ -134,11 +138,13 @@ test('DashboardContainer.replacePanel', (done) => {
|
|||
|
||||
test('Container view mode change propagates to existing children', async () => {
|
||||
const container = buildMockDashboard({
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Sam', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
}),
|
||||
overrides: {
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Sam', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -197,7 +203,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => {
|
|||
uiActionsSetup.registerAction(editModeAction);
|
||||
uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction);
|
||||
|
||||
const container = buildMockDashboard({ viewMode: ViewMode.VIEW });
|
||||
const container = buildMockDashboard({ overrides: { viewMode: ViewMode.VIEW } });
|
||||
|
||||
const embeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
|
@ -273,8 +279,10 @@ describe('getInheritedInput', () => {
|
|||
|
||||
test('Should pass dashboard timeRange and timeslice to panel when panel does not have custom time range', async () => {
|
||||
const container = buildMockDashboard({
|
||||
timeRange: dashboardTimeRange,
|
||||
timeslice: dashboardTimeslice,
|
||||
overrides: {
|
||||
timeRange: dashboardTimeRange,
|
||||
timeslice: dashboardTimeslice,
|
||||
},
|
||||
});
|
||||
const embeddable = await container.addNewEmbeddable<ContactCardEmbeddableInput>(
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
|
@ -296,8 +304,10 @@ describe('getInheritedInput', () => {
|
|||
|
||||
test('Should not pass dashboard timeRange and timeslice to panel when panel has custom time range', async () => {
|
||||
const container = buildMockDashboard({
|
||||
timeRange: dashboardTimeRange,
|
||||
timeslice: dashboardTimeslice,
|
||||
overrides: {
|
||||
timeRange: dashboardTimeRange,
|
||||
timeslice: dashboardTimeslice,
|
||||
},
|
||||
});
|
||||
const embeddableTimeRange = {
|
||||
to: 'now',
|
||||
|
|
|
@ -50,7 +50,7 @@ import {
|
|||
DashboardRenderPerformanceStats,
|
||||
} from '../types';
|
||||
import { DASHBOARD_CONTAINER_TYPE } from '../..';
|
||||
import { createPanelState } from '../component/panel';
|
||||
import { placePanel } from '../component/panel_placement';
|
||||
import { pluginServices } from '../../services/plugin_services';
|
||||
import { initializeDashboard } from './create/create_dashboard';
|
||||
import { DASHBOARD_LOADED_EVENT } from '../../dashboard_constants';
|
||||
|
@ -213,11 +213,14 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
|
|||
TEmbeddable extends IEmbeddable<TEmbeddableInput, any>
|
||||
>(
|
||||
factory: EmbeddableFactory<TEmbeddableInput, any, TEmbeddable>,
|
||||
partial: Partial<TEmbeddableInput> = {}
|
||||
): DashboardPanelState<TEmbeddableInput> {
|
||||
const panelState = super.createNewPanelState(factory, partial);
|
||||
const { newPanel } = createPanelState(panelState, this.input.panels);
|
||||
return newPanel;
|
||||
partial: Partial<TEmbeddableInput> = {},
|
||||
attributes?: unknown
|
||||
): {
|
||||
newPanel: DashboardPanelState<TEmbeddableInput>;
|
||||
otherPanels: DashboardContainerInput['panels'];
|
||||
} {
|
||||
const { newPanel } = super.createNewPanelState(factory, partial, attributes);
|
||||
return placePanel(factory, newPanel, this.input.panels, attributes);
|
||||
}
|
||||
|
||||
public render(dom: HTMLElement) {
|
||||
|
|
|
@ -13,6 +13,7 @@ export {
|
|||
createDashboardEditUrl,
|
||||
DASHBOARD_APP_ID,
|
||||
LEGACY_DASHBOARD_APP_ID,
|
||||
DASHBOARD_GRID_COLUMN_COUNT,
|
||||
} from './dashboard_constants';
|
||||
export {
|
||||
type DashboardAPI,
|
||||
|
@ -30,6 +31,7 @@ export {
|
|||
type DashboardAppLocatorParams,
|
||||
cleanEmptyKeys,
|
||||
} from './dashboard_app/locator/locator';
|
||||
export { getEmbeddableParams } from './dashboard_app/locator/get_dashboard_locator_params';
|
||||
|
||||
export function plugin(initializerContext: PluginInitializerContext) {
|
||||
return new DashboardPlugin(initializerContext);
|
||||
|
|
|
@ -66,7 +66,13 @@ export function setupIntersectionObserverMock({
|
|||
});
|
||||
}
|
||||
|
||||
export function buildMockDashboard(overrides?: Partial<DashboardContainerInput>) {
|
||||
export function buildMockDashboard({
|
||||
overrides,
|
||||
savedObjectId,
|
||||
}: {
|
||||
overrides?: Partial<DashboardContainerInput>;
|
||||
savedObjectId?: string;
|
||||
} = {}) {
|
||||
const initialInput = getSampleDashboardInput(overrides);
|
||||
const dashboardContainer = new DashboardContainer(
|
||||
initialInput,
|
||||
|
@ -75,7 +81,7 @@ export function buildMockDashboard(overrides?: Partial<DashboardContainerInput>)
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ lastSavedInput: initialInput }
|
||||
{ lastSavedInput: initialInput, lastSavedId: savedObjectId }
|
||||
);
|
||||
return dashboardContainer;
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen
|
|||
hits,
|
||||
});
|
||||
}),
|
||||
findById: jest.fn(),
|
||||
findByIds: jest.fn().mockImplementation(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import LRUCache from 'lru-cache';
|
||||
import { DashboardCrudTypes } from '../../../common/content_management';
|
||||
import { DASHBOARD_CACHE_SIZE, DASHBOARD_CACHE_TTL } from '../../dashboard_constants';
|
||||
|
||||
export class DashboardContentManagementCache {
|
||||
private cache: LRUCache<string, DashboardCrudTypes['GetOut']>;
|
||||
|
||||
constructor() {
|
||||
this.cache = new LRUCache<string, DashboardCrudTypes['GetOut']>({
|
||||
max: DASHBOARD_CACHE_SIZE,
|
||||
maxAge: DASHBOARD_CACHE_TTL,
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch the dashboard with `id` from the cache */
|
||||
public fetchDashboard(id: string) {
|
||||
return this.cache.get(id);
|
||||
}
|
||||
|
||||
/** Add the fetched dashboard to the cache */
|
||||
public addDashboard({ item: dashboard, meta }: DashboardCrudTypes['GetOut']) {
|
||||
this.cache.set(dashboard.id, {
|
||||
meta,
|
||||
item: dashboard,
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete the dashboard with `id` from the cache */
|
||||
public deleteDashboard(id: string) {
|
||||
this.cache.del(id);
|
||||
}
|
||||
}
|
|
@ -13,6 +13,7 @@ import { checkForDuplicateDashboardTitle } from './lib/check_for_duplicate_dashb
|
|||
|
||||
import {
|
||||
searchDashboards,
|
||||
findDashboardById,
|
||||
findDashboardsByIds,
|
||||
findDashboardIdByTitle,
|
||||
} from './lib/find_dashboards';
|
||||
|
@ -21,9 +22,10 @@ import type {
|
|||
DashboardContentManagementRequiredServices,
|
||||
DashboardContentManagementService,
|
||||
} from './types';
|
||||
import { loadDashboardState } from './lib/load_dashboard_state';
|
||||
import { deleteDashboards } from './lib/delete_dashboards';
|
||||
import { loadDashboardState } from './lib/load_dashboard_state';
|
||||
import { updateDashboardMeta } from './lib/update_dashboard_meta';
|
||||
import { DashboardContentManagementCache } from './dashboard_content_management_cache';
|
||||
|
||||
export type DashboardContentManagementServiceFactory = KibanaPluginServiceFactory<
|
||||
DashboardContentManagementService,
|
||||
|
@ -31,6 +33,8 @@ export type DashboardContentManagementServiceFactory = KibanaPluginServiceFactor
|
|||
DashboardContentManagementRequiredServices
|
||||
>;
|
||||
|
||||
export const dashboardContentManagementCache = new DashboardContentManagementCache();
|
||||
|
||||
export const dashboardContentManagementServiceFactory: DashboardContentManagementServiceFactory = (
|
||||
{ startPlugins: { contentManagement } },
|
||||
requiredServices
|
||||
|
@ -66,14 +70,16 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen
|
|||
dashboardSessionStorage,
|
||||
}),
|
||||
findDashboards: {
|
||||
search: ({ hasReference, hasNoReference, search, size }) =>
|
||||
search: ({ hasReference, hasNoReference, search, size, options }) =>
|
||||
searchDashboards({
|
||||
contentManagement,
|
||||
hasNoReference,
|
||||
hasReference,
|
||||
options,
|
||||
search,
|
||||
size,
|
||||
}),
|
||||
findById: (id) => findDashboardById(contentManagement, id),
|
||||
findByIds: (ids) => findDashboardsByIds(contentManagement, ids),
|
||||
findByTitle: (title) => findDashboardIdByTitle(contentManagement, title),
|
||||
},
|
||||
|
|
|
@ -9,20 +9,22 @@
|
|||
import { DashboardStartDependencies } from '../../../plugin';
|
||||
import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants';
|
||||
import { DashboardCrudTypes } from '../../../../common/content_management';
|
||||
import { dashboardContentManagementCache } from '../dashboard_content_management_service';
|
||||
|
||||
export const deleteDashboards = async (
|
||||
ids: string[],
|
||||
contentManagement: DashboardStartDependencies['contentManagement']
|
||||
) => {
|
||||
const deletePromises = ids.map((id) =>
|
||||
contentManagement.client.delete<
|
||||
const deletePromises = ids.map((id) => {
|
||||
dashboardContentManagementCache.deleteDashboard(id);
|
||||
return contentManagement.client.delete<
|
||||
DashboardCrudTypes['DeleteIn'],
|
||||
DashboardCrudTypes['DeleteOut']
|
||||
>({
|
||||
contentTypeId: DASHBOARD_CONTENT_ID,
|
||||
id,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
};
|
||||
|
|
|
@ -16,9 +16,11 @@ import {
|
|||
} from '../../../../common/content_management';
|
||||
import { DashboardStartDependencies } from '../../../plugin';
|
||||
import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants';
|
||||
import { dashboardContentManagementCache } from '../dashboard_content_management_service';
|
||||
|
||||
export interface SearchDashboardsArgs {
|
||||
contentManagement: DashboardStartDependencies['contentManagement'];
|
||||
options?: DashboardCrudTypes['SearchIn']['options'];
|
||||
hasNoReference?: SavedObjectsFindOptionsReference[];
|
||||
hasReference?: SavedObjectsFindOptionsReference[];
|
||||
search: string;
|
||||
|
@ -34,6 +36,7 @@ export async function searchDashboards({
|
|||
contentManagement,
|
||||
hasNoReference,
|
||||
hasReference,
|
||||
options,
|
||||
search,
|
||||
size,
|
||||
}: SearchDashboardsArgs): Promise<SearchDashboardsResponse> {
|
||||
|
@ -53,6 +56,7 @@ export async function searchDashboards({
|
|||
excluded: (hasNoReference ?? []).map(({ id }) => id),
|
||||
},
|
||||
},
|
||||
options,
|
||||
});
|
||||
return {
|
||||
total,
|
||||
|
@ -65,23 +69,42 @@ export type FindDashboardsByIdResponse = { id: string } & (
|
|||
| { status: 'error'; error: SavedObjectError }
|
||||
);
|
||||
|
||||
export async function findDashboardById(
|
||||
contentManagement: DashboardStartDependencies['contentManagement'],
|
||||
id: string
|
||||
): Promise<FindDashboardsByIdResponse> {
|
||||
/** If the dashboard exists in the cache, then return the result from that */
|
||||
const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id);
|
||||
if (cachedDashboard) {
|
||||
return {
|
||||
id,
|
||||
status: 'success',
|
||||
attributes: cachedDashboard.item.attributes,
|
||||
references: cachedDashboard.item.references,
|
||||
};
|
||||
}
|
||||
/** Otherwise, fetch the dashboard from the content management client, add it to the cache, and return the result */
|
||||
const response = await contentManagement.client
|
||||
.get<DashboardCrudTypes['GetIn'], DashboardCrudTypes['GetOut']>({
|
||||
contentTypeId: DASHBOARD_CONTENT_ID,
|
||||
id,
|
||||
})
|
||||
.then((result) => {
|
||||
dashboardContentManagementCache.addDashboard(result);
|
||||
return { id, status: 'success', attributes: result.item.attributes };
|
||||
})
|
||||
.catch((e) => ({ status: 'error', error: e.body, id }));
|
||||
|
||||
return response as FindDashboardsByIdResponse;
|
||||
}
|
||||
|
||||
export async function findDashboardsByIds(
|
||||
contentManagement: DashboardStartDependencies['contentManagement'],
|
||||
ids: string[]
|
||||
): Promise<FindDashboardsByIdResponse[]> {
|
||||
const findPromises = ids.map((id) =>
|
||||
contentManagement.client.get<DashboardCrudTypes['GetIn'], DashboardCrudTypes['GetOut']>({
|
||||
contentTypeId: DASHBOARD_CONTENT_ID,
|
||||
id,
|
||||
})
|
||||
);
|
||||
const findPromises = ids.map((id) => findDashboardById(contentManagement, id));
|
||||
const results = await Promise.all(findPromises);
|
||||
|
||||
return results.map((result) => {
|
||||
if (result.item.error) return { status: 'error', error: result.item.error, id: result.item.id };
|
||||
const { attributes, id, references } = result.item;
|
||||
return { id, status: 'success', attributes, references };
|
||||
});
|
||||
return results as FindDashboardsByIdResponse[];
|
||||
}
|
||||
|
||||
export async function findDashboardIdByTitle(
|
||||
|
|
|
@ -21,10 +21,11 @@ import {
|
|||
convertSavedPanelsToPanelMap,
|
||||
} from '../../../../common';
|
||||
import { migrateDashboardInput } from './migrate_dashboard_input';
|
||||
import { convertNumberToDashboardVersion } from './dashboard_versioning';
|
||||
import { DashboardCrudTypes } from '../../../../common/content_management';
|
||||
import type { LoadDashboardFromSavedObjectProps, LoadDashboardReturn } from '../types';
|
||||
import { dashboardContentManagementCache } from '../dashboard_content_management_service';
|
||||
import { DASHBOARD_CONTENT_ID, DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants';
|
||||
import { convertNumberToDashboardVersion } from './dashboard_versioning';
|
||||
|
||||
export function migrateLegacyQuery(query: Query | { [key: string]: any } | string): Query {
|
||||
// Lucene was the only option before, so language-less queries are all lucene
|
||||
|
@ -60,14 +61,27 @@ export const loadDashboardState = async ({
|
|||
/**
|
||||
* Load the saved object from Content Management
|
||||
*/
|
||||
const { item: rawDashboardContent, meta: resolveMeta } = await contentManagement.client
|
||||
.get<DashboardCrudTypes['GetIn'], DashboardCrudTypes['GetOut']>({
|
||||
contentTypeId: DASHBOARD_CONTENT_ID,
|
||||
id,
|
||||
})
|
||||
.catch((e) => {
|
||||
throw new SavedObjectNotFound(DASHBOARD_CONTENT_ID, id);
|
||||
});
|
||||
let rawDashboardContent;
|
||||
let resolveMeta;
|
||||
|
||||
const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id);
|
||||
if (cachedDashboard) {
|
||||
/** If the dashboard exists in the cache, use the cached version to load the dashboard */
|
||||
({ item: rawDashboardContent, meta: resolveMeta } = cachedDashboard);
|
||||
} else {
|
||||
/** Otherwise, fetch and load the dashboard from the content management client, and add it to the cache */
|
||||
const result = await contentManagement.client
|
||||
.get<DashboardCrudTypes['GetIn'], DashboardCrudTypes['GetOut']>({
|
||||
contentTypeId: DASHBOARD_CONTENT_ID,
|
||||
id,
|
||||
})
|
||||
.catch((e) => {
|
||||
throw new SavedObjectNotFound(DASHBOARD_CONTENT_ID, id);
|
||||
});
|
||||
|
||||
dashboardContentManagementCache.addDashboard(result);
|
||||
({ item: rawDashboardContent, meta: resolveMeta } = result);
|
||||
}
|
||||
|
||||
if (!rawDashboardContent || !rawDashboardContent.version) {
|
||||
return {
|
||||
|
|
|
@ -29,10 +29,11 @@ import {
|
|||
} from '../types';
|
||||
import { DashboardStartDependencies } from '../../../plugin';
|
||||
import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants';
|
||||
import { convertDashboardVersionToNumber } from './dashboard_versioning';
|
||||
import { LATEST_DASHBOARD_CONTAINER_VERSION } from '../../../dashboard_container';
|
||||
import { dashboardContentManagementCache } from '../dashboard_content_management_service';
|
||||
import { DashboardCrudTypes, DashboardAttributes } from '../../../../common/content_management';
|
||||
import { dashboardSaveToastStrings } from '../../../dashboard_container/_dashboard_container_strings';
|
||||
import { convertDashboardVersionToNumber } from './dashboard_versioning';
|
||||
|
||||
export const serializeControlGroupInput = (
|
||||
controlGroupInput: DashboardContainerInput['controlGroupInput']
|
||||
|
@ -203,6 +204,8 @@ export const saveDashboardState = async ({
|
|||
if (newId !== lastSavedId) {
|
||||
dashboardSessionStorage.clearState(lastSavedId);
|
||||
return { redirectRequired: true, id: newId };
|
||||
} else {
|
||||
dashboardContentManagementCache.deleteDashboard(newId); // something changed in an existing dashboard, so delete it from the cache so that it can be re-fetched
|
||||
}
|
||||
}
|
||||
return { id: newId };
|
||||
|
|
|
@ -92,8 +92,12 @@ export interface SaveDashboardReturn {
|
|||
*/
|
||||
export interface FindDashboardsService {
|
||||
search: (
|
||||
props: Pick<SearchDashboardsArgs, 'hasReference' | 'hasNoReference' | 'search' | 'size'>
|
||||
props: Pick<
|
||||
SearchDashboardsArgs,
|
||||
'hasReference' | 'hasNoReference' | 'search' | 'size' | 'options'
|
||||
>
|
||||
) => Promise<SearchDashboardsResponse>;
|
||||
findById: (id: string) => Promise<FindDashboardsByIdResponse>;
|
||||
findByIds: (ids: string[]) => Promise<FindDashboardsByIdResponse[]>;
|
||||
findByTitle: (title: string) => Promise<{ id: string } | undefined>;
|
||||
}
|
||||
|
|
16
src/plugins/dashboard/public/services/mocks.ts
Normal file
16
src/plugins/dashboard/public/services/mocks.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { pluginServices } from './plugin_services';
|
||||
import { registry } from './plugin_services.stub';
|
||||
|
||||
export function setStubDashboardServices() {
|
||||
pluginServices.setRegistry(registry.start({}));
|
||||
}
|
||||
|
||||
setStubDashboardServices();
|
|
@ -103,7 +103,8 @@ export const AddPanelFlyout = ({
|
|||
|
||||
const embeddable = await container.addNewEmbeddable<SavedObjectEmbeddableInput>(
|
||||
factoryForSavedObjectType.type,
|
||||
{ savedObjectId: id }
|
||||
{ savedObjectId: id },
|
||||
savedObject.attributes
|
||||
);
|
||||
onAddPanel?.(embeddable.id);
|
||||
|
||||
|
|
|
@ -75,9 +75,15 @@ export class CustomizePanelAction implements Action<CustomizePanelActionContext>
|
|||
(embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'markdown';
|
||||
|
||||
const isImage = embeddable.type === 'image';
|
||||
const isNavigation = embeddable.type === 'navigation';
|
||||
|
||||
return Boolean(
|
||||
embeddable && hasTimeRange(embeddable) && !isInputControl && !isMarkdown && !isImage
|
||||
embeddable &&
|
||||
hasTimeRange(embeddable) &&
|
||||
!isInputControl &&
|
||||
!isMarkdown &&
|
||||
!isImage &&
|
||||
!isNavigation
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
EmbeddableInput,
|
||||
EmbeddableEditorState,
|
||||
EmbeddableStateTransfer,
|
||||
isExplicitInputWithAttributes,
|
||||
} from '../../../lib';
|
||||
import { ViewMode } from '../../../lib/types';
|
||||
import { EmbeddableStart } from '../../../plugin';
|
||||
|
@ -95,7 +96,19 @@ export class EditPanelAction implements Action<ActionContext> {
|
|||
}
|
||||
|
||||
const oldExplicitInput = embeddable.getExplicitInput();
|
||||
const newExplicitInput = await factory.getExplicitInput(oldExplicitInput);
|
||||
let newExplicitInput: Partial<EmbeddableInput>;
|
||||
try {
|
||||
const explicitInputReturn = await factory.getExplicitInput(
|
||||
oldExplicitInput,
|
||||
embeddable.parent
|
||||
);
|
||||
newExplicitInput = isExplicitInputWithAttributes(explicitInputReturn)
|
||||
? explicitInputReturn.newInput
|
||||
: explicitInputReturn;
|
||||
} catch (e) {
|
||||
// error likely means user canceled editing
|
||||
return;
|
||||
}
|
||||
embeddable.parent?.replaceEmbeddable(embeddable.id, newExplicitInput);
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -76,6 +76,7 @@ export {
|
|||
EmbeddableRenderer,
|
||||
useEmbeddableFactory,
|
||||
isFilterableEmbeddable,
|
||||
isExplicitInputWithAttributes,
|
||||
shouldFetch$,
|
||||
shouldRefreshFilterCompareOptions,
|
||||
PANEL_HOVER_TRIGGER,
|
||||
|
|
|
@ -160,23 +160,32 @@ export abstract class Container<
|
|||
EEI extends EmbeddableInput = EmbeddableInput,
|
||||
EEO extends EmbeddableOutput = EmbeddableOutput,
|
||||
E extends IEmbeddable<EEI, EEO> = IEmbeddable<EEI, EEO>
|
||||
>(type: string, explicitInput: Partial<EEI>): Promise<E | ErrorEmbeddable> {
|
||||
>(type: string, explicitInput: Partial<EEI>, attributes?: unknown): Promise<E | ErrorEmbeddable> {
|
||||
const factory = this.getFactory(type) as EmbeddableFactory<EEI, EEO, E> | undefined;
|
||||
|
||||
if (!factory) {
|
||||
throw new EmbeddableFactoryNotFoundError(type);
|
||||
}
|
||||
|
||||
const panelState = this.createNewPanelState<EEI, E>(factory, explicitInput);
|
||||
const { newPanel, otherPanels } = this.createNewPanelState<EEI, E>(
|
||||
factory,
|
||||
explicitInput,
|
||||
attributes
|
||||
);
|
||||
|
||||
return this.createAndSaveEmbeddable(type, panelState);
|
||||
return this.createAndSaveEmbeddable(type, newPanel, otherPanels);
|
||||
}
|
||||
|
||||
public async replaceEmbeddable<
|
||||
EEI extends EmbeddableInput = EmbeddableInput,
|
||||
EEO extends EmbeddableOutput = EmbeddableOutput,
|
||||
E extends IEmbeddable<EEI, EEO> = IEmbeddable<EEI, EEO>
|
||||
>(id: string, newExplicitInput: Partial<EEI>, newType?: string) {
|
||||
>(
|
||||
id: string,
|
||||
newExplicitInput: Partial<EEI>,
|
||||
newType?: string,
|
||||
generateNewId?: boolean
|
||||
): Promise<string> {
|
||||
if (!this.input.panels[id]) {
|
||||
throw new PanelNotFoundError();
|
||||
}
|
||||
|
@ -186,21 +195,28 @@ export abstract class Container<
|
|||
if (!factory) {
|
||||
throw new EmbeddableFactoryNotFoundError(newType);
|
||||
}
|
||||
this.updateInput({
|
||||
panels: {
|
||||
...this.input.panels,
|
||||
[id]: {
|
||||
...this.input.panels[id],
|
||||
explicitInput: { ...newExplicitInput, id },
|
||||
type: newType,
|
||||
},
|
||||
},
|
||||
} as Partial<TContainerInput>);
|
||||
} else {
|
||||
this.updateInputForChild(id, newExplicitInput);
|
||||
}
|
||||
|
||||
const panels = { ...this.input.panels };
|
||||
const oldPanel = panels[id];
|
||||
|
||||
if (generateNewId) {
|
||||
delete panels[id];
|
||||
id = uuidv4();
|
||||
}
|
||||
this.updateInput({
|
||||
panels: {
|
||||
...panels,
|
||||
[id]: {
|
||||
...oldPanel,
|
||||
explicitInput: { ...newExplicitInput, id },
|
||||
type: newType ?? oldPanel.type,
|
||||
},
|
||||
},
|
||||
} as Partial<TContainerInput>);
|
||||
|
||||
await this.untilEmbeddableLoaded<E>(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
public removeEmbeddable(embeddableId: string) {
|
||||
|
@ -301,7 +317,7 @@ export abstract class Container<
|
|||
|
||||
public async getExplicitInputIsEqual(lastInput: TContainerInput) {
|
||||
const { panels: lastPanels, ...restOfLastInput } = lastInput;
|
||||
const { panels: currentPanels, ...restOfCurrentInput } = this.getInput();
|
||||
const { panels: currentPanels, ...restOfCurrentInput } = this.getExplicitInput();
|
||||
const otherInputIsEqual = isEqual(restOfLastInput, restOfCurrentInput);
|
||||
if (!otherInputIsEqual) return false;
|
||||
|
||||
|
@ -330,8 +346,9 @@ export abstract class Container<
|
|||
TEmbeddable extends IEmbeddable<TEmbeddableInput, any>
|
||||
>(
|
||||
factory: EmbeddableFactory<TEmbeddableInput, any, TEmbeddable>,
|
||||
partial: Partial<TEmbeddableInput> = {}
|
||||
): PanelState<TEmbeddableInput> {
|
||||
partial: Partial<TEmbeddableInput> = {},
|
||||
attributes?: unknown
|
||||
): { newPanel: PanelState<TEmbeddableInput>; otherPanels: TContainerInput['panels'] } {
|
||||
const embeddableId = partial.id || uuidv4();
|
||||
|
||||
const explicitInput = this.createNewExplicitEmbeddableInput<TEmbeddableInput>(
|
||||
|
@ -341,12 +358,15 @@ export abstract class Container<
|
|||
);
|
||||
|
||||
return {
|
||||
type: factory.type,
|
||||
explicitInput: {
|
||||
...explicitInput,
|
||||
id: embeddableId,
|
||||
version: factory.latestVersion,
|
||||
} as TEmbeddableInput,
|
||||
newPanel: {
|
||||
type: factory.type,
|
||||
explicitInput: {
|
||||
...explicitInput,
|
||||
id: embeddableId,
|
||||
version: factory.latestVersion,
|
||||
} as TEmbeddableInput,
|
||||
},
|
||||
otherPanels: this.getInput().panels,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -372,7 +392,6 @@ export abstract class Container<
|
|||
initializeSettings?: EmbeddableContainerSettings
|
||||
) {
|
||||
let initializeOrder = Object.keys(initialInput.panels);
|
||||
|
||||
if (initializeSettings?.childIdInitializeOrder) {
|
||||
const initializeOrderSet = new Set<string>();
|
||||
|
||||
|
@ -401,10 +420,10 @@ export abstract class Container<
|
|||
protected async createAndSaveEmbeddable<
|
||||
TEmbeddableInput extends EmbeddableInput = EmbeddableInput,
|
||||
TEmbeddable extends IEmbeddable<TEmbeddableInput> = IEmbeddable<TEmbeddableInput>
|
||||
>(type: string, panelState: PanelState) {
|
||||
>(type: string, panelState: PanelState, otherPanels: TContainerInput['panels']) {
|
||||
this.updateInput({
|
||||
panels: {
|
||||
...this.input.panels,
|
||||
...otherPanels,
|
||||
[panelState.explicitInput.id]: panelState,
|
||||
},
|
||||
} as Partial<TContainerInput>);
|
||||
|
|
|
@ -96,7 +96,8 @@ export interface IContainer<
|
|||
E extends Embeddable<EEI, EEO> = Embeddable<EEI, EEO>
|
||||
>(
|
||||
type: string,
|
||||
explicitInput: Partial<EEI>
|
||||
explicitInput: Partial<EEI>,
|
||||
attributes?: unknown
|
||||
): Promise<E | ErrorEmbeddable>;
|
||||
|
||||
replaceEmbeddable<
|
||||
|
@ -106,6 +107,7 @@ export interface IContainer<
|
|||
>(
|
||||
id: string,
|
||||
newExplicitInput: Partial<EEI>,
|
||||
newType?: string
|
||||
): void;
|
||||
newType?: string,
|
||||
generateNewId?: boolean
|
||||
): Promise<string>;
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ export const defaultEmbeddableFactoryProvider = <
|
|||
}
|
||||
|
||||
const factory: EmbeddableFactory<I, O, E, T> = {
|
||||
...def,
|
||||
latestVersion: def.latestVersion,
|
||||
isContainerType: def.isContainerType ?? false,
|
||||
canCreateNew: def.canCreateNew ? def.canCreateNew.bind(def) : () => true,
|
||||
|
|
|
@ -24,6 +24,17 @@ export interface OutputSpec {
|
|||
[key: string]: PropertySpec;
|
||||
}
|
||||
|
||||
export interface ExplicitInputWithAttributes {
|
||||
newInput: Partial<EmbeddableInput>;
|
||||
attributes?: unknown;
|
||||
}
|
||||
|
||||
export const isExplicitInputWithAttributes = (
|
||||
value: ExplicitInputWithAttributes | Partial<EmbeddableInput>
|
||||
): value is ExplicitInputWithAttributes => {
|
||||
return Boolean((value as ExplicitInputWithAttributes).newInput);
|
||||
};
|
||||
|
||||
/**
|
||||
* EmbeddableFactories create and initialize an embeddable instance
|
||||
*/
|
||||
|
@ -106,8 +117,14 @@ export interface EmbeddableFactory<
|
|||
* input passed down from the parent container.
|
||||
*
|
||||
* Can be used to edit an embeddable by re-requesting explicit input. Initial input can be provided to allow the editor to show the current state.
|
||||
*
|
||||
* If saved object information is needed for creation use-cases, getExplicitInput can also return an unknown typed attributes object which will be passed
|
||||
* into the container's addNewEmbeddable function.
|
||||
*/
|
||||
getExplicitInput(initialInput?: Partial<TEmbeddableInput>): Promise<Partial<TEmbeddableInput>>;
|
||||
getExplicitInput(
|
||||
initialInput?: Partial<TEmbeddableInput>,
|
||||
parent?: IContainer
|
||||
): Promise<Partial<TEmbeddableInput> | ExplicitInputWithAttributes>;
|
||||
|
||||
/**
|
||||
* Creates a new embeddable instance based off the saved object id.
|
||||
|
|
|
@ -493,6 +493,10 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = {
|
|||
type: 'boolean',
|
||||
_meta: { description: 'Non-default value of setting.' },
|
||||
},
|
||||
'labs:dashboard:linksPanel': {
|
||||
type: 'boolean',
|
||||
_meta: { description: 'Non-default value of setting.' },
|
||||
},
|
||||
'discover:showFieldStatistics': {
|
||||
type: 'boolean',
|
||||
_meta: { description: 'Non-default value of setting.' },
|
||||
|
|
|
@ -135,6 +135,7 @@ export interface UsageStats {
|
|||
'labs:canvas:useDataService': boolean;
|
||||
'labs:presentation:timeToPresent': boolean;
|
||||
'labs:dashboard:enable_ui': boolean;
|
||||
'labs:dashboard:linksPanel': boolean;
|
||||
'labs:dashboard:deferBelowFold': boolean;
|
||||
'labs:dashboard:dashboardControls': boolean;
|
||||
'discover:rowHeightOption': number;
|
||||
|
|
3
src/plugins/links/README.md
Normal file
3
src/plugins/links/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Links panel
|
||||
|
||||
This plugin adds the Links panel which allows authors to create hard links to navigate on click and bring all context from the source dashboard to the destination dashboard.
|
19
src/plugins/links/common/constants.ts
Normal file
19
src/plugins/links/common/constants.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const LATEST_VERSION = 1;
|
||||
|
||||
export const CONTENT_ID = 'links';
|
||||
|
||||
export const APP_ICON = 'link';
|
||||
|
||||
export const APP_NAME = i18n.translate('links.visTypeAlias.title', {
|
||||
defaultMessage: 'Links',
|
||||
});
|
21
src/plugins/links/common/content_management/cm_services.ts
Normal file
21
src/plugins/links/common/content_management/cm_services.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ContentManagementServicesDefinition as ServicesDefinition,
|
||||
Version,
|
||||
} from '@kbn/object-versioning';
|
||||
|
||||
// We export the versioned service definition from this file and not the index file to avoid adding
|
||||
// the schemas in the "public" js bundle
|
||||
|
||||
import { serviceDefinition as v1 } from './v1/cm_services';
|
||||
|
||||
export const cmServicesDefinition: { [version: Version]: ServicesDefinition } = {
|
||||
1: v1,
|
||||
};
|
30
src/plugins/links/common/content_management/index.ts
Normal file
30
src/plugins/links/common/content_management/index.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { LATEST_VERSION, CONTENT_ID } from '../constants';
|
||||
|
||||
export type { LinksContentType } from '../types';
|
||||
|
||||
export type {
|
||||
LinkType,
|
||||
LinksLayoutType,
|
||||
LinkOptions,
|
||||
Link,
|
||||
LinksItem,
|
||||
LinksCrudTypes,
|
||||
LinksAttributes,
|
||||
} from './latest';
|
||||
|
||||
export {
|
||||
EXTERNAL_LINK_TYPE,
|
||||
DASHBOARD_LINK_TYPE,
|
||||
LINKS_VERTICAL_LAYOUT,
|
||||
LINKS_HORIZONTAL_LAYOUT,
|
||||
} from './latest';
|
||||
|
||||
export * as LinksV1 from './v1';
|
9
src/plugins/links/common/content_management/latest.ts
Normal file
9
src/plugins/links/common/content_management/latest.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export * from './v1';
|
139
src/plugins/links/common/content_management/v1/cm_services.ts
Normal file
139
src/plugins/links/common/content_management/v1/cm_services.ts
Normal file
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning';
|
||||
import {
|
||||
savedObjectSchema,
|
||||
createResultSchema,
|
||||
updateOptionsSchema,
|
||||
createOptionsSchemas,
|
||||
objectTypeToGetResultSchema,
|
||||
} from '@kbn/content-management-utils';
|
||||
import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from '.';
|
||||
import { LINKS_HORIZONTAL_LAYOUT, LINKS_VERTICAL_LAYOUT } from './constants';
|
||||
|
||||
const baseLinkSchema = {
|
||||
id: schema.string(),
|
||||
label: schema.maybe(schema.string()),
|
||||
order: schema.number(),
|
||||
};
|
||||
|
||||
const dashboardLinkSchema = schema.object({
|
||||
...baseLinkSchema,
|
||||
destinationRefName: schema.string(),
|
||||
type: schema.literal(DASHBOARD_LINK_TYPE),
|
||||
options: schema.maybe(
|
||||
schema.object(
|
||||
{
|
||||
openInNewTab: schema.boolean(),
|
||||
useCurrentFilters: schema.boolean(),
|
||||
useCurrentDateRange: schema.boolean(),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
const externalLinkSchema = schema.object({
|
||||
...baseLinkSchema,
|
||||
type: schema.literal(EXTERNAL_LINK_TYPE),
|
||||
destination: schema.string(),
|
||||
options: schema.maybe(
|
||||
schema.object(
|
||||
{
|
||||
openInNewTab: schema.boolean(),
|
||||
encodeUrl: schema.boolean(),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
const linksAttributesSchema = schema.object(
|
||||
{
|
||||
title: schema.string(),
|
||||
description: schema.maybe(schema.string()),
|
||||
links: schema.arrayOf(schema.oneOf([dashboardLinkSchema, externalLinkSchema])),
|
||||
layout: schema.maybe(
|
||||
schema.oneOf([schema.literal(LINKS_HORIZONTAL_LAYOUT), schema.literal(LINKS_VERTICAL_LAYOUT)])
|
||||
),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
);
|
||||
|
||||
const linksSavedObjectSchema = savedObjectSchema(linksAttributesSchema);
|
||||
|
||||
const searchOptionsSchema = schema.maybe(
|
||||
schema.object(
|
||||
{
|
||||
onlyTitle: schema.maybe(schema.boolean()),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
)
|
||||
);
|
||||
|
||||
const linksCreateOptionsSchema = schema.object({
|
||||
references: schema.maybe(createOptionsSchemas.references),
|
||||
overwrite: createOptionsSchemas.overwrite,
|
||||
});
|
||||
|
||||
const linksUpdateOptionsSchema = schema.object({
|
||||
references: updateOptionsSchema.references,
|
||||
});
|
||||
|
||||
// Content management service definition.
|
||||
// We need it for BWC support between different versions of the content
|
||||
export const serviceDefinition: ServicesDefinition = {
|
||||
get: {
|
||||
out: {
|
||||
result: {
|
||||
schema: objectTypeToGetResultSchema(linksSavedObjectSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
create: {
|
||||
in: {
|
||||
options: {
|
||||
schema: linksCreateOptionsSchema,
|
||||
},
|
||||
data: {
|
||||
schema: linksAttributesSchema,
|
||||
},
|
||||
},
|
||||
out: {
|
||||
result: {
|
||||
schema: createResultSchema(linksSavedObjectSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
update: {
|
||||
in: {
|
||||
options: {
|
||||
schema: linksUpdateOptionsSchema, // same schema as "create"
|
||||
},
|
||||
data: {
|
||||
schema: linksAttributesSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
search: {
|
||||
in: {
|
||||
options: {
|
||||
schema: searchOptionsSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
mSearch: {
|
||||
out: {
|
||||
result: {
|
||||
schema: linksSavedObjectSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
19
src/plugins/links/common/content_management/v1/constants.ts
Normal file
19
src/plugins/links/common/content_management/v1/constants.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Link types
|
||||
*/
|
||||
export const DASHBOARD_LINK_TYPE = 'dashboardLink';
|
||||
export const EXTERNAL_LINK_TYPE = 'externalLink';
|
||||
|
||||
/**
|
||||
* Layout options
|
||||
*/
|
||||
export const LINKS_HORIZONTAL_LAYOUT = 'horizontal';
|
||||
export const LINKS_VERTICAL_LAYOUT = 'vertical';
|
24
src/plugins/links/common/content_management/v1/index.ts
Normal file
24
src/plugins/links/common/content_management/v1/index.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { LinksCrudTypes } from './types';
|
||||
export type {
|
||||
LinksCrudTypes,
|
||||
LinksAttributes,
|
||||
Link,
|
||||
LinkOptions,
|
||||
LinksLayoutType,
|
||||
LinkType,
|
||||
} from './types';
|
||||
export type LinksItem = LinksCrudTypes['Item'];
|
||||
export {
|
||||
EXTERNAL_LINK_TYPE,
|
||||
DASHBOARD_LINK_TYPE,
|
||||
LINKS_VERTICAL_LAYOUT,
|
||||
LINKS_HORIZONTAL_LAYOUT,
|
||||
} from './constants';
|
67
src/plugins/links/common/content_management/v1/types.ts
Normal file
67
src/plugins/links/common/content_management/v1/types.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ContentManagementCrudTypes,
|
||||
SavedObjectCreateOptions,
|
||||
SavedObjectUpdateOptions,
|
||||
} from '@kbn/content-management-utils';
|
||||
import { type UrlDrilldownOptions } from '@kbn/ui-actions-enhanced-plugin/public';
|
||||
import { type DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public';
|
||||
|
||||
import { LinksContentType } from '../../types';
|
||||
import {
|
||||
DASHBOARD_LINK_TYPE,
|
||||
EXTERNAL_LINK_TYPE,
|
||||
LINKS_HORIZONTAL_LAYOUT,
|
||||
LINKS_VERTICAL_LAYOUT,
|
||||
} from './constants';
|
||||
|
||||
export type LinksCrudTypes = ContentManagementCrudTypes<
|
||||
LinksContentType,
|
||||
LinksAttributes,
|
||||
Pick<SavedObjectCreateOptions, 'references'>,
|
||||
Pick<SavedObjectUpdateOptions, 'references'>,
|
||||
{
|
||||
/** Flag to indicate to only search the text on the "title" field */
|
||||
onlyTitle?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
export type LinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE;
|
||||
|
||||
export type LinkOptions = DashboardDrilldownOptions | UrlDrilldownOptions;
|
||||
interface BaseLink {
|
||||
id: string;
|
||||
label?: string;
|
||||
order: number;
|
||||
options?: LinkOptions;
|
||||
destination?: string;
|
||||
}
|
||||
|
||||
interface DashboardLink extends BaseLink {
|
||||
type: typeof DASHBOARD_LINK_TYPE;
|
||||
destinationRefName?: string;
|
||||
}
|
||||
|
||||
interface ExternalLink extends BaseLink {
|
||||
type: typeof EXTERNAL_LINK_TYPE;
|
||||
destination: string;
|
||||
}
|
||||
|
||||
export type Link = DashboardLink | ExternalLink;
|
||||
|
||||
export type LinksLayoutType = typeof LINKS_HORIZONTAL_LAYOUT | typeof LINKS_VERTICAL_LAYOUT;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
export type LinksAttributes = {
|
||||
title: string;
|
||||
description?: string;
|
||||
links?: Link[];
|
||||
layout?: LinksLayoutType;
|
||||
};
|
64
src/plugins/links/common/embeddable/extract.test.ts
Normal file
64
src/plugins/links/common/embeddable/extract.test.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { extract } from './extract';
|
||||
|
||||
test('Should return original state and empty references with by-reference embeddable state', () => {
|
||||
const linksByReferenceInput = {
|
||||
id: '2192e502-0ec7-4316-82fb-c9bbf78525c4',
|
||||
type: 'links',
|
||||
};
|
||||
|
||||
expect(extract!(linksByReferenceInput)).toEqual({
|
||||
state: linksByReferenceInput,
|
||||
references: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('Should update state with refNames with by-value embeddable state', () => {
|
||||
const linksByValueInput = {
|
||||
id: '8d62c3f0-c61f-4c09-ac24-9b8ee4320e20',
|
||||
attributes: {
|
||||
links: [
|
||||
{
|
||||
type: 'dashboardLink',
|
||||
id: 'fc7b8c70-2eb9-40b2-936d-457d1721a438',
|
||||
destination: 'elastic_agent-1a4e7280-6b5e-11ed-98de-67bdecd21824',
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
layout: 'horizontal',
|
||||
},
|
||||
type: 'links',
|
||||
};
|
||||
|
||||
expect(extract!(linksByValueInput)).toEqual({
|
||||
references: [
|
||||
{
|
||||
name: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard',
|
||||
type: 'dashboard',
|
||||
id: 'elastic_agent-1a4e7280-6b5e-11ed-98de-67bdecd21824',
|
||||
},
|
||||
],
|
||||
state: {
|
||||
id: '8d62c3f0-c61f-4c09-ac24-9b8ee4320e20',
|
||||
attributes: {
|
||||
links: [
|
||||
{
|
||||
type: 'dashboardLink',
|
||||
id: 'fc7b8c70-2eb9-40b2-936d-457d1721a438',
|
||||
destinationRefName: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard',
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
layout: 'horizontal',
|
||||
},
|
||||
type: 'links',
|
||||
},
|
||||
});
|
||||
});
|
35
src/plugins/links/common/embeddable/extract.ts
Normal file
35
src/plugins/links/common/embeddable/extract.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common';
|
||||
import type { LinksAttributes } from '../content_management';
|
||||
import { extractReferences } from '../persistable_state';
|
||||
import { LinksPersistableState } from './types';
|
||||
|
||||
export const extract: EmbeddableRegistryDefinition['extract'] = (state) => {
|
||||
const typedState = state as LinksPersistableState;
|
||||
|
||||
// by-reference embeddable
|
||||
if (!('attributes' in typedState) || typedState.attributes === undefined) {
|
||||
// No references to extract for by-reference embeddable since all references are stored with by-reference saved object
|
||||
return { state, references: [] };
|
||||
}
|
||||
|
||||
// by-value embeddable
|
||||
const { attributes, references } = extractReferences({
|
||||
attributes: typedState.attributes as unknown as LinksAttributes,
|
||||
});
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
attributes,
|
||||
},
|
||||
references,
|
||||
};
|
||||
};
|
10
src/plugins/links/common/embeddable/index.ts
Normal file
10
src/plugins/links/common/embeddable/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { inject } from './inject';
|
||||
export { extract } from './extract';
|
67
src/plugins/links/common/embeddable/inject.test.ts
Normal file
67
src/plugins/links/common/embeddable/inject.test.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { inject } from './inject';
|
||||
|
||||
test('Should return original state with by-reference embeddable state', () => {
|
||||
const linksByReferenceInput = {
|
||||
id: 'ea40fd4e-216c-49a7-917f-f733c8a2c817',
|
||||
type: 'links',
|
||||
};
|
||||
|
||||
const references = [
|
||||
{
|
||||
name: 'panel_ea40fd4e-216c-49a7-917f-f733c8a2c817',
|
||||
type: 'links',
|
||||
id: '7f92d7d0-8e5f-11ec-9477-312c8a6de896',
|
||||
},
|
||||
];
|
||||
|
||||
expect(inject!(linksByReferenceInput, references)).toEqual(linksByReferenceInput);
|
||||
});
|
||||
|
||||
test('Should inject refNames with by-value embeddable state', () => {
|
||||
const linksByValueInput = {
|
||||
id: 'c3937cf9-29be-43df-a4af-a4df742d7d35',
|
||||
attributes: {
|
||||
links: [
|
||||
{
|
||||
type: 'dashboardLink',
|
||||
id: 'fc7b8c70-2eb9-40b2-936d-457d1721a438',
|
||||
destinationRefName: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard',
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
layout: 'horizontal',
|
||||
},
|
||||
type: 'links',
|
||||
};
|
||||
const references = [
|
||||
{
|
||||
name: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard',
|
||||
type: 'dashboard',
|
||||
id: 'elastic_agent-1a4e7280-6b5e-11ed-98de-67bdecd21824',
|
||||
},
|
||||
];
|
||||
|
||||
expect(inject!(linksByValueInput, references)).toEqual({
|
||||
id: 'c3937cf9-29be-43df-a4af-a4df742d7d35',
|
||||
attributes: {
|
||||
links: [
|
||||
{
|
||||
type: 'dashboardLink',
|
||||
id: 'fc7b8c70-2eb9-40b2-936d-457d1721a438',
|
||||
destination: 'elastic_agent-1a4e7280-6b5e-11ed-98de-67bdecd21824',
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
layout: 'horizontal',
|
||||
},
|
||||
type: 'links',
|
||||
});
|
||||
});
|
40
src/plugins/links/common/embeddable/inject.ts
Normal file
40
src/plugins/links/common/embeddable/inject.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common';
|
||||
import { LinksAttributes } from '../content_management';
|
||||
import { injectReferences } from '../persistable_state';
|
||||
import { LinksPersistableState } from './types';
|
||||
|
||||
export const inject: EmbeddableRegistryDefinition['inject'] = (state, references) => {
|
||||
const typedState = state as LinksPersistableState;
|
||||
|
||||
// by-reference embeddable
|
||||
if (!('attributes' in typedState) || typedState.attributes === undefined) {
|
||||
return typedState;
|
||||
}
|
||||
|
||||
// by-value embeddable
|
||||
try {
|
||||
const { attributes: attributesWithInjectedIds } = injectReferences({
|
||||
attributes: typedState.attributes as unknown as LinksAttributes,
|
||||
references,
|
||||
});
|
||||
|
||||
return {
|
||||
...typedState,
|
||||
attributes: attributesWithInjectedIds,
|
||||
};
|
||||
} catch (error) {
|
||||
// inject exception prevents entire dashboard from display
|
||||
// Instead of throwing, swallow error and let dashboard display
|
||||
// Errors will surface in links panel.
|
||||
// Users can then manually edit links to resolve any problems.
|
||||
return typedState;
|
||||
}
|
||||
};
|
14
src/plugins/links/common/embeddable/types.ts
Normal file
14
src/plugins/links/common/embeddable/types.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common';
|
||||
import { SerializableRecord } from '@kbn/utility-types';
|
||||
|
||||
export type LinksPersistableState = EmbeddableStateWithType & {
|
||||
attributes: SerializableRecord;
|
||||
};
|
9
src/plugins/links/common/index.ts
Normal file
9
src/plugins/links/common/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { APP_ICON, APP_NAME, CONTENT_ID, LATEST_VERSION } from './constants';
|
53
src/plugins/links/common/mocks.tsx
Normal file
53
src/plugins/links/common/mocks.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { buildMockDashboard } from '@kbn/dashboard-plugin/public/mocks';
|
||||
import { DashboardContainerInput } from '@kbn/dashboard-plugin/common';
|
||||
import { LinksByValueInput } from '../public/embeddable/types';
|
||||
import { LinksFactoryDefinition } from '../public';
|
||||
import { LinksAttributes } from './content_management';
|
||||
|
||||
jest.mock('../public/services/attribute_service', () => {
|
||||
return {
|
||||
getLinksAttributeService: jest.fn(() => {
|
||||
return {
|
||||
saveMethod: jest.fn(),
|
||||
unwrapMethod: jest.fn(),
|
||||
checkForDuplicateTitle: jest.fn(),
|
||||
unwrapAttributes: jest.fn((attributes: LinksByValueInput) => Promise.resolve(attributes)),
|
||||
wrapAttributes: jest.fn((attributes: LinksAttributes) => Promise.resolve(attributes)),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
export const mockLinksInput = (partial?: Partial<LinksByValueInput>): LinksByValueInput => ({
|
||||
id: 'mocked_links_panel',
|
||||
attributes: {
|
||||
title: 'mocked_links',
|
||||
},
|
||||
...(partial ?? {}),
|
||||
});
|
||||
|
||||
export const mockLinksPanel = async ({
|
||||
explicitInput,
|
||||
dashboardExplicitInput,
|
||||
}: {
|
||||
explicitInput?: Partial<LinksByValueInput>;
|
||||
dashboardExplicitInput?: Partial<DashboardContainerInput>;
|
||||
}) => {
|
||||
const dashboardContainer = buildMockDashboard({
|
||||
overrides: dashboardExplicitInput,
|
||||
savedObjectId: '123',
|
||||
});
|
||||
const linksFactoryStub = new LinksFactoryDefinition();
|
||||
|
||||
const links = await linksFactoryStub.create(mockLinksInput(explicitInput), dashboardContainer);
|
||||
|
||||
return links;
|
||||
};
|
9
src/plugins/links/common/persistable_state/index.ts
Normal file
9
src/plugins/links/common/persistable_state/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { extractReferences, injectReferences } from './references';
|
163
src/plugins/links/common/persistable_state/references.test.ts
Normal file
163
src/plugins/links/common/persistable_state/references.test.ts
Normal file
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from '../content_management';
|
||||
import { extractReferences, injectReferences } from './references';
|
||||
|
||||
describe('extractReferences', () => {
|
||||
test('should handle missing links attribute', () => {
|
||||
const attributes = {
|
||||
title: 'my links',
|
||||
};
|
||||
expect(extractReferences({ attributes })).toEqual({
|
||||
attributes: {
|
||||
title: 'my links',
|
||||
},
|
||||
references: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('should extract dashboard references from dashboard links', () => {
|
||||
const attributes = {
|
||||
title: 'my links',
|
||||
links: [
|
||||
{
|
||||
id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5',
|
||||
type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE,
|
||||
destination: '19e149f0-e95e-404b-b6f8-fc751317c6be',
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa',
|
||||
type: EXTERNAL_LINK_TYPE as typeof EXTERNAL_LINK_TYPE,
|
||||
destination: 'https://example.com',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c',
|
||||
type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE,
|
||||
destination: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da',
|
||||
order: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(extractReferences({ attributes })).toEqual({
|
||||
attributes: {
|
||||
title: 'my links',
|
||||
links: [
|
||||
{
|
||||
id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5',
|
||||
type: 'dashboardLink',
|
||||
destinationRefName: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard',
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa',
|
||||
type: 'externalLink',
|
||||
destination: 'https://example.com',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c',
|
||||
type: 'dashboardLink',
|
||||
destinationRefName: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard',
|
||||
order: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '19e149f0-e95e-404b-b6f8-fc751317c6be',
|
||||
name: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard',
|
||||
type: 'dashboard',
|
||||
},
|
||||
{
|
||||
id: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da',
|
||||
name: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard',
|
||||
type: 'dashboard',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectReferences', () => {
|
||||
test('should handle missing links attribute', () => {
|
||||
const attributes = {
|
||||
title: 'my links',
|
||||
};
|
||||
expect(injectReferences({ attributes, references: [] })).toEqual({
|
||||
attributes: {
|
||||
title: 'my links',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should inject dashboard references into dashboard links', () => {
|
||||
const attributes = {
|
||||
title: 'my links',
|
||||
links: [
|
||||
{
|
||||
id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5',
|
||||
type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE,
|
||||
destinationRefName: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard',
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa',
|
||||
type: EXTERNAL_LINK_TYPE as typeof EXTERNAL_LINK_TYPE,
|
||||
destination: 'https://example.com',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c',
|
||||
type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE,
|
||||
destinationRefName: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard',
|
||||
order: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
const references = [
|
||||
{
|
||||
id: '19e149f0-e95e-404b-b6f8-fc751317c6be',
|
||||
name: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard',
|
||||
type: 'dashboard',
|
||||
},
|
||||
{
|
||||
id: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da',
|
||||
name: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard',
|
||||
type: 'dashboard',
|
||||
},
|
||||
];
|
||||
expect(injectReferences({ attributes, references })).toEqual({
|
||||
attributes: {
|
||||
title: 'my links',
|
||||
links: [
|
||||
{
|
||||
id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5',
|
||||
type: 'dashboardLink',
|
||||
destination: '19e149f0-e95e-404b-b6f8-fc751317c6be',
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa',
|
||||
type: 'externalLink',
|
||||
destination: 'https://example.com',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c',
|
||||
type: 'dashboardLink',
|
||||
destination: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da',
|
||||
order: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
81
src/plugins/links/common/persistable_state/references.ts
Normal file
81
src/plugins/links/common/persistable_state/references.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Reference } from '@kbn/content-management-utils';
|
||||
import { DASHBOARD_LINK_TYPE, LinksAttributes } from '../content_management';
|
||||
|
||||
export function extractReferences({
|
||||
attributes,
|
||||
references = [],
|
||||
}: {
|
||||
attributes: LinksAttributes;
|
||||
references?: Reference[];
|
||||
}) {
|
||||
if (!attributes.links) {
|
||||
return { attributes, references };
|
||||
}
|
||||
|
||||
const { links } = attributes;
|
||||
const extractedReferences: Reference[] = [];
|
||||
links.forEach((link) => {
|
||||
if (link.type === DASHBOARD_LINK_TYPE && link.destination) {
|
||||
const refName = `link_${link.id}_dashboard`;
|
||||
link.destinationRefName = refName;
|
||||
extractedReferences.push({
|
||||
name: refName,
|
||||
type: 'dashboard',
|
||||
id: link.destination,
|
||||
});
|
||||
delete link.destination;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
attributes: {
|
||||
...attributes,
|
||||
links,
|
||||
},
|
||||
references: references.concat(extractedReferences),
|
||||
};
|
||||
}
|
||||
|
||||
function findReference(targetName: string, references: Reference[]) {
|
||||
const reference = references.find(({ name }) => name === targetName);
|
||||
if (!reference) {
|
||||
throw new Error(`Could not find reference "${targetName}"`);
|
||||
}
|
||||
return reference;
|
||||
}
|
||||
|
||||
export function injectReferences({
|
||||
attributes,
|
||||
references,
|
||||
}: {
|
||||
attributes: LinksAttributes;
|
||||
references: Reference[];
|
||||
}) {
|
||||
if (!attributes.links) {
|
||||
return { attributes };
|
||||
}
|
||||
|
||||
const { links } = attributes;
|
||||
links.forEach((link) => {
|
||||
if (link.type === DASHBOARD_LINK_TYPE && link.destinationRefName) {
|
||||
const reference = findReference(link.destinationRefName, references);
|
||||
link.destination = reference.id;
|
||||
delete link.destinationRefName;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
attributes: {
|
||||
...attributes,
|
||||
links,
|
||||
},
|
||||
};
|
||||
}
|
19
src/plugins/links/common/types.ts
Normal file
19
src/plugins/links/common/types.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { SavedObjectsResolveResponse } from '@kbn/core-saved-objects-api-server';
|
||||
import { CONTENT_ID } from './constants';
|
||||
|
||||
export type LinksContentType = typeof CONTENT_ID;
|
||||
|
||||
export interface SharingSavedObjectProps {
|
||||
outcome: SavedObjectsResolveResponse['outcome'];
|
||||
aliasTargetId?: SavedObjectsResolveResponse['alias_target_id'];
|
||||
aliasPurpose?: SavedObjectsResolveResponse['alias_purpose'];
|
||||
sourceId?: string;
|
||||
}
|
17
src/plugins/links/jest.config.js
Normal file
17
src/plugins/links/jest.config.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../..',
|
||||
roots: ['<rootDir>/src/plugins/links'],
|
||||
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/src/plugins/links',
|
||||
coverageReporters: ['text', 'html'],
|
||||
collectCoverageFrom: ['<rootDir>/src/plugins/links/{common,public,server}/**/*.{ts,tsx}'],
|
||||
setupFiles: ['<rootDir>/src/plugins/links/jest_setup.ts'],
|
||||
};
|
13
src/plugins/links/jest_setup.ts
Normal file
13
src/plugins/links/jest_setup.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { setStubDashboardServices } from '@kbn/dashboard-plugin/public/services/mocks';
|
||||
import { setStubKibanaServices } from './public/mocks';
|
||||
|
||||
setStubKibanaServices();
|
||||
setStubDashboardServices();
|
22
src/plugins/links/kibana.jsonc
Normal file
22
src/plugins/links/kibana.jsonc
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"type": "plugin",
|
||||
"id": "@kbn/links-plugin",
|
||||
"owner": "@elastic/kibana-presentation",
|
||||
"description": "A dashboard panel for creating links to dashboards or external links.",
|
||||
"plugin": {
|
||||
"id": "links",
|
||||
"server": true,
|
||||
"browser": true,
|
||||
"requiredPlugins": [
|
||||
"contentManagement",
|
||||
"dashboard",
|
||||
"embeddable",
|
||||
"kibanaReact",
|
||||
"presentationUtil",
|
||||
"uiActionsEnhanced",
|
||||
"kibanaUtils"
|
||||
],
|
||||
"optionalPlugins": ["triggersActionsUi"],
|
||||
"requiredBundles": ["savedObjects"]
|
||||
}
|
||||
}
|
38
src/plugins/links/public/_mixins.scss
Normal file
38
src/plugins/links/public/_mixins.scss
Normal file
|
@ -0,0 +1,38 @@
|
|||
@import '../../../core/public/mixins';
|
||||
|
||||
@keyframes euiFlyoutOpenAnimation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes euiFlyoutCloseAnimation {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(0%);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin euiFlyout {
|
||||
@include kibanaFullBodyHeight();
|
||||
position: fixed;
|
||||
display: flex;
|
||||
inline-size: 50vw;
|
||||
z-index: $euiZFlyout;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
border-left: $euiBorderThin;
|
||||
background: $euiColorEmptyShade;
|
||||
min-width: ($euiSizeXL * 13) + $euiSizeS; // 424px
|
||||
}
|
|
@ -0,0 +1,224 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS } from '@kbn/presentation-util-plugin/public';
|
||||
import { DashboardLinkStrings } from './dashboard_link_strings';
|
||||
import { LinksEmbeddable, LinksContext } from '../../embeddable/links_embeddable';
|
||||
import { mockLinksPanel } from '../../../common/mocks';
|
||||
import { LINKS_VERTICAL_LAYOUT } from '../../../common/content_management';
|
||||
import { DashboardLinkComponent } from './dashboard_link_component';
|
||||
import { fetchDashboard, getDashboardHref, getDashboardLocator } from './dashboard_link_tools';
|
||||
import { coreServices } from '../../services/kibana_services';
|
||||
|
||||
jest.mock('./dashboard_link_tools');
|
||||
|
||||
describe('Dashboard link component', () => {
|
||||
const mockDashboards = [
|
||||
{
|
||||
id: '456',
|
||||
status: 'success',
|
||||
attributes: {
|
||||
title: 'another dashboard',
|
||||
description: 'something awesome',
|
||||
panelsJSON: [],
|
||||
timeRestore: false,
|
||||
version: '1',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '123',
|
||||
status: 'success',
|
||||
attributes: {
|
||||
title: 'current dashboard',
|
||||
description: '',
|
||||
panelsJSON: [],
|
||||
timeRestore: false,
|
||||
version: '1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const defaultLinkInfo = {
|
||||
destination: '456',
|
||||
order: 1,
|
||||
id: 'foo',
|
||||
type: 'dashboardLink' as const,
|
||||
};
|
||||
|
||||
let linksEmbeddable: LinksEmbeddable;
|
||||
beforeEach(async () => {
|
||||
window.open = jest.fn();
|
||||
(fetchDashboard as jest.Mock).mockResolvedValue(mockDashboards[0]);
|
||||
(getDashboardLocator as jest.Mock).mockResolvedValue({
|
||||
app: 'dashboard',
|
||||
path: '/dashboardItem/456',
|
||||
state: {},
|
||||
});
|
||||
(getDashboardHref as jest.Mock).mockReturnValue('https://my-kibana.com/dashboard/123');
|
||||
linksEmbeddable = await mockLinksPanel({
|
||||
dashboardExplicitInput: mockDashboards[1].attributes,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('by default uses navigateToApp to open in same tab', async () => {
|
||||
render(
|
||||
<LinksContext.Provider value={linksEmbeddable}>
|
||||
<DashboardLinkComponent link={defaultLinkInfo} layout={LINKS_VERTICAL_LAYOUT} />
|
||||
</LinksContext.Provider>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1));
|
||||
expect(fetchDashboard).toHaveBeenCalledWith(defaultLinkInfo.destination);
|
||||
expect(getDashboardLocator).toHaveBeenCalledTimes(1);
|
||||
expect(getDashboardLocator).toHaveBeenCalledWith({
|
||||
link: {
|
||||
...defaultLinkInfo,
|
||||
options: DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
|
||||
},
|
||||
linksEmbeddable,
|
||||
});
|
||||
|
||||
const link = await screen.findByTestId('dashboardLink--foo');
|
||||
expect(link).toHaveTextContent('another dashboard');
|
||||
await userEvent.click(link);
|
||||
expect(coreServices.application.navigateToApp).toBeCalledTimes(1);
|
||||
expect(coreServices.application.navigateToApp).toBeCalledWith('dashboard', {
|
||||
path: '/dashboardItem/456',
|
||||
state: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('modified click does not trigger event.preventDefault', async () => {
|
||||
render(
|
||||
<LinksContext.Provider value={linksEmbeddable}>
|
||||
<DashboardLinkComponent link={defaultLinkInfo} layout={LINKS_VERTICAL_LAYOUT} />
|
||||
</LinksContext.Provider>
|
||||
);
|
||||
await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1));
|
||||
const link = await screen.findByTestId('dashboardLink--foo');
|
||||
const clickEvent = createEvent.click(link, { ctrlKey: true });
|
||||
const preventDefault = jest.spyOn(clickEvent, 'preventDefault');
|
||||
fireEvent(link, clickEvent);
|
||||
expect(preventDefault).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('openInNewTab uses window.open, not navigateToApp', async () => {
|
||||
const linkInfo = {
|
||||
...defaultLinkInfo,
|
||||
options: { ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, openInNewTab: true },
|
||||
};
|
||||
render(
|
||||
<LinksContext.Provider value={linksEmbeddable}>
|
||||
<DashboardLinkComponent link={linkInfo} layout={LINKS_VERTICAL_LAYOUT} />
|
||||
</LinksContext.Provider>
|
||||
);
|
||||
await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1));
|
||||
expect(fetchDashboard).toHaveBeenCalledWith(linkInfo.destination);
|
||||
expect(getDashboardLocator).toHaveBeenCalledWith({ link: linkInfo, linksEmbeddable });
|
||||
const link = await screen.findByTestId('dashboardLink--foo');
|
||||
expect(link).toBeInTheDocument();
|
||||
await userEvent.click(link);
|
||||
expect(coreServices.application.navigateToApp).toBeCalledTimes(0);
|
||||
expect(window.open).toHaveBeenCalledWith('https://my-kibana.com/dashboard/123', '_blank');
|
||||
});
|
||||
|
||||
test('passes linkOptions to getDashboardLocator', async () => {
|
||||
const linkInfo = {
|
||||
...defaultLinkInfo,
|
||||
options: {
|
||||
...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
|
||||
useCurrentFilters: false,
|
||||
useCurrentTimeRange: false,
|
||||
useCurrentDateRange: false,
|
||||
},
|
||||
};
|
||||
render(
|
||||
<LinksContext.Provider value={linksEmbeddable}>
|
||||
<DashboardLinkComponent link={linkInfo} layout={LINKS_VERTICAL_LAYOUT} />
|
||||
</LinksContext.Provider>
|
||||
);
|
||||
await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1));
|
||||
expect(getDashboardLocator).toHaveBeenCalledWith({ link: linkInfo, linksEmbeddable });
|
||||
});
|
||||
|
||||
test('shows an error when fetchDashboard fails', async () => {
|
||||
(fetchDashboard as jest.Mock).mockRejectedValue(new Error('some error'));
|
||||
const linkInfo = {
|
||||
...defaultLinkInfo,
|
||||
id: 'notfound',
|
||||
};
|
||||
render(
|
||||
<LinksContext.Provider value={linksEmbeddable}>
|
||||
<DashboardLinkComponent link={linkInfo} layout={LINKS_VERTICAL_LAYOUT} />
|
||||
</LinksContext.Provider>
|
||||
);
|
||||
await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1));
|
||||
const link = await screen.findByTestId('dashboardLink--notfound--error');
|
||||
expect(link).toHaveTextContent(DashboardLinkStrings.getDashboardErrorLabel());
|
||||
});
|
||||
|
||||
test('current dashboard is not a clickable href', async () => {
|
||||
const linkInfo = {
|
||||
...defaultLinkInfo,
|
||||
destination: '123',
|
||||
id: 'bar',
|
||||
};
|
||||
render(
|
||||
<LinksContext.Provider value={linksEmbeddable}>
|
||||
<DashboardLinkComponent link={linkInfo} layout={LINKS_VERTICAL_LAYOUT} />
|
||||
</LinksContext.Provider>
|
||||
);
|
||||
await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1));
|
||||
const link = await screen.findByTestId('dashboardLink--bar');
|
||||
expect(link).toHaveTextContent('current dashboard');
|
||||
await userEvent.click(link);
|
||||
expect(coreServices.application.navigateToApp).toBeCalledTimes(0);
|
||||
expect(window.open).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test('shows dashboard title and description in tooltip', async () => {
|
||||
render(
|
||||
<LinksContext.Provider value={linksEmbeddable}>
|
||||
<DashboardLinkComponent link={defaultLinkInfo} layout={LINKS_VERTICAL_LAYOUT} />
|
||||
</LinksContext.Provider>
|
||||
);
|
||||
await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1));
|
||||
const link = await screen.findByTestId('dashboardLink--foo');
|
||||
await userEvent.hover(link);
|
||||
const tooltip = await screen.findByTestId('dashboardLink--foo--tooltip');
|
||||
expect(tooltip).toHaveTextContent('another dashboard'); // title
|
||||
expect(tooltip).toHaveTextContent('something awesome'); // description
|
||||
});
|
||||
|
||||
test('can override link label', async () => {
|
||||
const label = 'my custom label';
|
||||
const linkInfo = {
|
||||
...defaultLinkInfo,
|
||||
label,
|
||||
};
|
||||
render(
|
||||
<LinksContext.Provider value={linksEmbeddable}>
|
||||
<DashboardLinkComponent link={linkInfo} layout={LINKS_VERTICAL_LAYOUT} />
|
||||
</LinksContext.Provider>
|
||||
);
|
||||
await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1));
|
||||
const link = await screen.findByTestId('dashboardLink--foo');
|
||||
expect(link).toHaveTextContent(label);
|
||||
await userEvent.hover(link);
|
||||
const tooltip = await screen.findByTestId('dashboardLink--foo--tooltip');
|
||||
expect(tooltip).toHaveTextContent(label);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,171 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
|
||||
import {
|
||||
DashboardDrilldownOptions,
|
||||
DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
|
||||
} from '@kbn/presentation-util-plugin/public';
|
||||
import { EuiButtonEmpty, EuiListGroupItem } from '@elastic/eui';
|
||||
import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container';
|
||||
|
||||
import { LINKS_VERTICAL_LAYOUT, LinksLayoutType, Link } from '../../../common/content_management';
|
||||
import { coreServices } from '../../services/kibana_services';
|
||||
import { DashboardLinkStrings } from './dashboard_link_strings';
|
||||
import { useLinks } from '../../embeddable/links_embeddable';
|
||||
import { fetchDashboard, getDashboardHref, getDashboardLocator } from './dashboard_link_tools';
|
||||
|
||||
export const DashboardLinkComponent = ({
|
||||
link,
|
||||
layout,
|
||||
}: {
|
||||
link: Link;
|
||||
layout: LinksLayoutType;
|
||||
}) => {
|
||||
const linksEmbeddable = useLinks();
|
||||
const [error, setError] = useState<Error | undefined>();
|
||||
|
||||
const dashboardContainer = linksEmbeddable.parent as DashboardContainer;
|
||||
const parentDashboardInput = useObservable(dashboardContainer.getInput$());
|
||||
const parentDashboardId = dashboardContainer.select((state) => state.componentState.lastSavedId);
|
||||
|
||||
/** Fetch the dashboard that the link is pointing to */
|
||||
const { loading: loadingDestinationDashboard, value: destinationDashboard } =
|
||||
useAsync(async () => {
|
||||
if (link.id !== parentDashboardId && link.destination) {
|
||||
/**
|
||||
* only fetch the dashboard if it's not the current dashboard - if it is the current dashboard,
|
||||
* use `dashboardContainer` and its corresponding state (title, description, etc.) instead.
|
||||
*/
|
||||
const dashboard = await fetchDashboard(link.destination)
|
||||
.then((result) => {
|
||||
setError(undefined);
|
||||
return result;
|
||||
})
|
||||
.catch((e) => setError(e));
|
||||
return dashboard;
|
||||
}
|
||||
}, [link, parentDashboardId]);
|
||||
|
||||
/**
|
||||
* Returns the title and description of the dashboard that the link points to; note that, if the link points to
|
||||
* the current dashboard, then we need to get the most up-to-date information via the `parentDashboardInput` - this
|
||||
* will respond to changes so that the link label/tooltip remains in sync with the dashboard title/description.
|
||||
*/
|
||||
const [dashboardTitle, dashboardDescription] = useMemo(() => {
|
||||
return link.destination === parentDashboardId
|
||||
? [parentDashboardInput?.title, parentDashboardInput?.description]
|
||||
: [destinationDashboard?.attributes.title, destinationDashboard?.attributes.description];
|
||||
}, [link.destination, parentDashboardId, parentDashboardInput, destinationDashboard]);
|
||||
|
||||
/**
|
||||
* Memoized link information
|
||||
*/
|
||||
const linkLabel = useMemo(() => {
|
||||
return link.label || (dashboardTitle ?? DashboardLinkStrings.getDashboardErrorLabel());
|
||||
}, [link, dashboardTitle]);
|
||||
|
||||
const { tooltipTitle, tooltipMessage } = useMemo(() => {
|
||||
if (error) {
|
||||
return {
|
||||
tooltipTitle: DashboardLinkStrings.getDashboardErrorLabel(),
|
||||
tooltipMessage: error.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tooltipTitle: Boolean(dashboardDescription) ? linkLabel : undefined,
|
||||
tooltipMessage: dashboardDescription || linkLabel,
|
||||
};
|
||||
}, [error, linkLabel, dashboardDescription]);
|
||||
|
||||
/**
|
||||
* Dashboard-to-dashboard navigation
|
||||
*/
|
||||
const { loading: loadingOnClickProps, value: onClickProps } = useAsync(async () => {
|
||||
/** If the link points to the current dashboard, then there should be no `onClick` or `href` prop */
|
||||
if (link.destination === parentDashboardId) return;
|
||||
|
||||
const linkOptions = {
|
||||
...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
|
||||
...link.options,
|
||||
} as DashboardDrilldownOptions;
|
||||
|
||||
const locator = await getDashboardLocator({
|
||||
link: { ...link, options: linkOptions },
|
||||
linksEmbeddable,
|
||||
});
|
||||
if (!locator) return;
|
||||
|
||||
const href = getDashboardHref(locator);
|
||||
return {
|
||||
href,
|
||||
onClick: async (event: React.MouseEvent) => {
|
||||
/**
|
||||
* If the link is being opened via a modified click, then we should use the default `href` navigation behaviour
|
||||
* by passing all the dashboard state via the URL - this will keep behaviour consistent across all browsers.
|
||||
*/
|
||||
const modifiedClick = event.ctrlKey || event.metaKey || event.shiftKey;
|
||||
if (modifiedClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** Otherwise, prevent the default behaviour and handle click depending on `openInNewTab` option */
|
||||
event.preventDefault();
|
||||
if (linkOptions.openInNewTab) {
|
||||
window.open(href, '_blank');
|
||||
} else {
|
||||
const { app, path, state } = locator;
|
||||
await coreServices.application.navigateToApp(app, {
|
||||
path,
|
||||
state,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [link]);
|
||||
|
||||
const id = `dashboardLink--${link.id}`;
|
||||
|
||||
return loadingDestinationDashboard ? (
|
||||
<li id={`${id}--loading`}>
|
||||
<EuiButtonEmpty size="s" isLoading={true} data-test-subj={`${id}--loading`}>
|
||||
{DashboardLinkStrings.getLoadingDashboardLabel()}
|
||||
</EuiButtonEmpty>
|
||||
</li>
|
||||
) : (
|
||||
<EuiListGroupItem
|
||||
size="s"
|
||||
color="text"
|
||||
{...onClickProps}
|
||||
id={`dashboardLink--${link.id}`}
|
||||
showToolTip={true}
|
||||
toolTipProps={{
|
||||
title: tooltipTitle,
|
||||
content: tooltipMessage,
|
||||
position: layout === LINKS_VERTICAL_LAYOUT ? 'right' : 'bottom',
|
||||
repositionOnScroll: true,
|
||||
delay: 'long',
|
||||
'data-test-subj': `${id}--tooltip`,
|
||||
}}
|
||||
iconType={error ? 'warning' : undefined}
|
||||
iconProps={{ className: 'dashboardLinkIcon' }}
|
||||
isDisabled={Boolean(error) || loadingOnClickProps}
|
||||
className={classNames('linksPanelLink', {
|
||||
linkCurrent: link.destination === parentDashboardId,
|
||||
dashboardLinkError: Boolean(error),
|
||||
'dashboardLinkError--noLabel': !link.label,
|
||||
})}
|
||||
label={linkLabel}
|
||||
data-test-subj={error ? `${id}--error` : `${id}`}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,149 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import useMount from 'react-use/lib/useMount';
|
||||
import useUnmount from 'react-use/lib/useUnmount';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiComboBox,
|
||||
EuiFlexItem,
|
||||
EuiHighlight,
|
||||
EuiFlexGroup,
|
||||
EuiComboBoxOptionOption,
|
||||
} from '@elastic/eui';
|
||||
import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container';
|
||||
|
||||
import { DashboardItem } from '../../embeddable/types';
|
||||
import { DashboardLinkStrings } from './dashboard_link_strings';
|
||||
import { fetchDashboard, fetchDashboards } from './dashboard_link_tools';
|
||||
|
||||
type DashboardComboBoxOption = EuiComboBoxOptionOption<DashboardItem>;
|
||||
|
||||
export const DashboardLinkDestinationPicker = ({
|
||||
onDestinationPicked,
|
||||
initialSelection,
|
||||
parentDashboard,
|
||||
onUnmount,
|
||||
...other
|
||||
}: {
|
||||
initialSelection?: string;
|
||||
parentDashboard?: DashboardContainer;
|
||||
onUnmount: (dashboardId?: string) => void;
|
||||
onDestinationPicked: (selectedDashboard?: DashboardItem) => void;
|
||||
}) => {
|
||||
const [searchString, setSearchString] = useState<string>('');
|
||||
const [selectedOption, setSelectedOption] = useState<DashboardComboBoxOption[]>([]);
|
||||
|
||||
const parentDashboardId = parentDashboard?.select((state) => state.componentState.lastSavedId);
|
||||
|
||||
const getDashboardItem = useCallback((dashboard: DashboardItem) => {
|
||||
return {
|
||||
key: dashboard.id,
|
||||
value: dashboard,
|
||||
label: dashboard.attributes.title,
|
||||
className: 'linksDashboardItem',
|
||||
};
|
||||
}, []);
|
||||
|
||||
useMount(async () => {
|
||||
if (initialSelection) {
|
||||
const dashboard = await fetchDashboard(initialSelection).catch(() => {
|
||||
/**
|
||||
* Swallow the error that is thrown, since this just means the selected dashboard was deleted and
|
||||
* so we should treat this the same as "no previous selection."
|
||||
*/
|
||||
});
|
||||
if (dashboard) {
|
||||
onDestinationPicked(dashboard);
|
||||
setSelectedOption([getDashboardItem(dashboard)]);
|
||||
} else {
|
||||
onDestinationPicked(undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useUnmount(() => {
|
||||
/** Save the current selection so we can re-populate it if we switch back to this link editor */
|
||||
onUnmount(selectedOption[0]?.key);
|
||||
});
|
||||
|
||||
const { loading: loadingDashboardList, value: dashboardList } = useAsync(async () => {
|
||||
const dashboards = await fetchDashboards({
|
||||
search: searchString,
|
||||
parentDashboardId,
|
||||
selectedDashboardId: initialSelection,
|
||||
});
|
||||
const dashboardOptions = (dashboards ?? []).map((dashboard: DashboardItem) => {
|
||||
return getDashboardItem(dashboard);
|
||||
});
|
||||
return dashboardOptions;
|
||||
}, [searchString, parentDashboardId, getDashboardItem]);
|
||||
|
||||
const debouncedSetSearch = useMemo(
|
||||
() =>
|
||||
debounce((newSearch: string) => {
|
||||
setSearchString(newSearch);
|
||||
}, 250),
|
||||
[setSearchString]
|
||||
);
|
||||
|
||||
const renderOption = useCallback(
|
||||
(option, searchValue, contentClassName) => {
|
||||
const { label, key: dashboardId } = option;
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" className={contentClassName}>
|
||||
{dashboardId === parentDashboardId && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBadge>{DashboardLinkStrings.getCurrentDashboardLabel()}</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem className={'linksPanelLinkText'}>
|
||||
<EuiHighlight search={searchValue} className={'wrapText'}>
|
||||
{label}
|
||||
</EuiHighlight>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
},
|
||||
[parentDashboardId]
|
||||
);
|
||||
|
||||
/* {...other} is needed so the EuiComboBox is treated as part of the form */
|
||||
return (
|
||||
<EuiComboBox
|
||||
{...other}
|
||||
async
|
||||
fullWidth
|
||||
className={'linksDashboardPicker'}
|
||||
isLoading={loadingDashboardList}
|
||||
aria-label={DashboardLinkStrings.getDashboardPickerAriaLabel()}
|
||||
placeholder={DashboardLinkStrings.getDashboardPickerPlaceholder()}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
options={dashboardList}
|
||||
onSearchChange={(searchValue) => {
|
||||
debouncedSetSearch(searchValue);
|
||||
}}
|
||||
renderOption={renderOption}
|
||||
selectedOptions={selectedOption}
|
||||
onChange={(option) => {
|
||||
setSelectedOption(option);
|
||||
if (option.length > 0) {
|
||||
// single select is `true`, so there is only ever one item in the array
|
||||
onDestinationPicked(option[0].value);
|
||||
} else {
|
||||
onDestinationPicked(undefined);
|
||||
}
|
||||
}}
|
||||
data-test-subj="links--linkEditor--dashboardLink--comboBox"
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const DashboardLinkStrings = {
|
||||
getType: () =>
|
||||
i18n.translate('links.dashboardLink.type', {
|
||||
defaultMessage: 'Dashboard link',
|
||||
}),
|
||||
getDisplayName: () =>
|
||||
i18n.translate('links.dashboardLink.displayName', {
|
||||
defaultMessage: 'Dashboard',
|
||||
}),
|
||||
getDescription: () =>
|
||||
i18n.translate('links.dashboardLink.description', {
|
||||
defaultMessage: 'Go to dashboard',
|
||||
}),
|
||||
getDashboardPickerPlaceholder: () =>
|
||||
i18n.translate('links.dashboardLink.editor.dashboardComboBoxPlaceholder', {
|
||||
defaultMessage: 'Search for a dashboard',
|
||||
}),
|
||||
getDashboardPickerAriaLabel: () =>
|
||||
i18n.translate('links.dashboardLink.editor.dashboardPickerAriaLabel', {
|
||||
defaultMessage: 'Pick a destination dashboard',
|
||||
}),
|
||||
getCurrentDashboardLabel: () =>
|
||||
i18n.translate('links.dashboardLink.editor.currentDashboardLabel', {
|
||||
defaultMessage: 'Current',
|
||||
}),
|
||||
getLoadingDashboardLabel: () =>
|
||||
i18n.translate('links.dashboardLink.editor.loadingDashboardLabel', {
|
||||
defaultMessage: 'Loading...',
|
||||
}),
|
||||
getDashboardErrorLabel: () =>
|
||||
i18n.translate('links.dashboardLink.editor.dashboardErrorLabel', {
|
||||
defaultMessage: 'Error fetching dashboard',
|
||||
}),
|
||||
};
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { isEmpty, filter } from 'lodash';
|
||||
|
||||
import {
|
||||
cleanEmptyKeys,
|
||||
getEmbeddableParams,
|
||||
DashboardAppLocatorParams,
|
||||
} from '@kbn/dashboard-plugin/public';
|
||||
import { isFilterPinned } from '@kbn/es-query';
|
||||
import { KibanaLocation } from '@kbn/share-plugin/public';
|
||||
import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public';
|
||||
import { DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public';
|
||||
|
||||
import { DashboardItem } from '../../embeddable/types';
|
||||
import type { LinksEmbeddable } from '../../embeddable';
|
||||
import { Link } from '../../../common/content_management';
|
||||
import { coreServices, dashboardServices } from '../../services/kibana_services';
|
||||
|
||||
/**
|
||||
* ----------------------------------
|
||||
* Fetch a single dashboard
|
||||
* ----------------------------------
|
||||
*/
|
||||
|
||||
export const fetchDashboard = async (dashboardId: string): Promise<DashboardItem> => {
|
||||
const findDashboardsService = await dashboardServices.findDashboardsService();
|
||||
const response = await findDashboardsService.findById(dashboardId);
|
||||
if (response.status === 'error') {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* ----------------------------------
|
||||
* Fetch lists of dashboards
|
||||
* ----------------------------------
|
||||
*/
|
||||
|
||||
interface FetchDashboardsProps {
|
||||
size?: number;
|
||||
search?: string;
|
||||
parentDashboardId?: string;
|
||||
selectedDashboardId?: string;
|
||||
}
|
||||
|
||||
export const fetchDashboards = async ({
|
||||
search = '',
|
||||
size = 10,
|
||||
parentDashboardId,
|
||||
selectedDashboardId,
|
||||
}: FetchDashboardsProps): Promise<DashboardItem[]> => {
|
||||
const findDashboardsService = await dashboardServices.findDashboardsService();
|
||||
const responses = await findDashboardsService.search({
|
||||
search,
|
||||
size,
|
||||
options: { onlyTitle: true },
|
||||
});
|
||||
|
||||
let dashboardList: DashboardItem[] = responses.hits;
|
||||
|
||||
/** If there is no search string... */
|
||||
if (isEmpty(search)) {
|
||||
/** ... filter out both the parent and selected dashboard from the list ... */
|
||||
dashboardList = filter(dashboardList, (dash) => {
|
||||
return dash.id !== parentDashboardId && dash.id !== selectedDashboardId;
|
||||
});
|
||||
|
||||
/** ... so that we can force them to the top of the list as necessary. */
|
||||
if (parentDashboardId) {
|
||||
dashboardList.unshift(await fetchDashboard(parentDashboardId));
|
||||
}
|
||||
|
||||
if (selectedDashboardId && selectedDashboardId !== parentDashboardId) {
|
||||
const selectedDashboard = await fetchDashboard(selectedDashboardId).catch(() => {
|
||||
/**
|
||||
* Swallow the error thrown, since this just means the selected dashboard was deleted and therefore
|
||||
* it should not be added to the top of the dashboard list
|
||||
*/
|
||||
});
|
||||
if (selectedDashboard) dashboardList.unshift(await fetchDashboard(selectedDashboardId));
|
||||
}
|
||||
}
|
||||
|
||||
/** Then, only return the parts of the dashboard object that we need */
|
||||
const simplifiedDashboardList = dashboardList.map((hit) => {
|
||||
return { id: hit.id, attributes: hit.attributes };
|
||||
});
|
||||
|
||||
return simplifiedDashboardList;
|
||||
};
|
||||
|
||||
/**
|
||||
* ----------------------------------
|
||||
* Navigate from one dashboard to another
|
||||
* ----------------------------------
|
||||
*/
|
||||
|
||||
interface GetDashboardLocatorProps {
|
||||
link: Link & { options: DashboardDrilldownOptions };
|
||||
linksEmbeddable: LinksEmbeddable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the locator to use for dashboard navigation
|
||||
* @param props `GetDashboardLocatorProps`
|
||||
* @returns The locator to use for dashboard navigation
|
||||
*/
|
||||
export const getDashboardLocator = async ({ link, linksEmbeddable }: GetDashboardLocatorProps) => {
|
||||
const params: DashboardAppLocatorParams = {
|
||||
dashboardId: link.destination,
|
||||
...getEmbeddableParams(linksEmbeddable, link.options),
|
||||
};
|
||||
|
||||
const locator = dashboardServices.locator; // TODO: Make this generic as part of https://github.com/elastic/kibana/issues/164748
|
||||
if (locator) {
|
||||
const location: KibanaLocation<DashboardAppLocatorParams> = await locator.getLocation(params);
|
||||
return location;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get URL for dashboard app - should only be used when relying on native `href` functionality
|
||||
* @param locator Locator that should be used to get the URL
|
||||
* @returns A full URL to the dashboard, with all state included
|
||||
*/
|
||||
export const getDashboardHref = ({
|
||||
app,
|
||||
path,
|
||||
state,
|
||||
}: KibanaLocation<DashboardAppLocatorParams>): string => {
|
||||
return coreServices.application.getUrlForApp(app, {
|
||||
path: setStateToKbnUrl(
|
||||
'_a',
|
||||
cleanEmptyKeys({
|
||||
query: state.query,
|
||||
filters: state.filters?.filter((f) => !isFilterPinned(f)),
|
||||
}),
|
||||
{ useHash: false, storeInHashQuery: true },
|
||||
path
|
||||
),
|
||||
absolute: true,
|
||||
});
|
||||
};
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container';
|
||||
|
||||
import { EuiFormRow } from '@elastic/eui';
|
||||
import {
|
||||
LinkType,
|
||||
EXTERNAL_LINK_TYPE,
|
||||
DASHBOARD_LINK_TYPE,
|
||||
} from '../../../common/content_management';
|
||||
import { UnorderedLink } from '../../editor/open_link_editor_flyout';
|
||||
import { ExternalLinkDestinationPicker } from '../external_link/external_link_destination_picker';
|
||||
import { DashboardLinkDestinationPicker } from '../dashboard_link/dashboard_link_destination_picker';
|
||||
import { LinksStrings } from '../links_strings';
|
||||
|
||||
export const LinkDestination = ({
|
||||
link,
|
||||
setDestination,
|
||||
parentDashboard,
|
||||
selectedLinkType,
|
||||
}: {
|
||||
selectedLinkType: LinkType;
|
||||
parentDashboard?: DashboardContainer;
|
||||
link?: UnorderedLink;
|
||||
setDestination: (destination?: string, defaultLabel?: string) => void;
|
||||
}) => {
|
||||
const [destinationError, setDestinationError] = useState<string | undefined>();
|
||||
|
||||
/**
|
||||
* Store the dashboard / external destinations separately so that we can remember the selections
|
||||
* made in each component even when the selected link type changes
|
||||
*/
|
||||
const [dashboardLinkDestination, setDashboardLinkDestination] = useState<string | undefined>(
|
||||
link && link.type === DASHBOARD_LINK_TYPE ? link.destination : undefined
|
||||
);
|
||||
const [externalLinkDestination, setExternalLinkDestination] = useState<string | undefined>(
|
||||
link && link.type === EXTERNAL_LINK_TYPE ? link.destination : undefined
|
||||
);
|
||||
|
||||
const isInvalid = Boolean(destinationError);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
error={destinationError}
|
||||
isInvalid={isInvalid}
|
||||
label={LinksStrings.editor.linkEditor.getLinkDestinationLabel()}
|
||||
data-test-subj={`links--linkDestination${isInvalid ? '--error' : ''}`}
|
||||
>
|
||||
{selectedLinkType === DASHBOARD_LINK_TYPE ? (
|
||||
<DashboardLinkDestinationPicker
|
||||
onUnmount={(selectedDashboardId) => {
|
||||
setDestination(undefined, undefined);
|
||||
if (selectedDashboardId) setDashboardLinkDestination(selectedDashboardId);
|
||||
}}
|
||||
parentDashboard={parentDashboard}
|
||||
initialSelection={dashboardLinkDestination}
|
||||
onDestinationPicked={(dashboard) =>
|
||||
setDestination(dashboard?.id, dashboard?.attributes.title)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ExternalLinkDestinationPicker
|
||||
onUnmount={(selectedUrl) => {
|
||||
setDestinationError(undefined);
|
||||
setDestination(undefined, undefined);
|
||||
if (selectedUrl) setExternalLinkDestination(selectedUrl);
|
||||
}}
|
||||
initialSelection={externalLinkDestination}
|
||||
onDestinationPicked={(url) => setDestination(url, url)}
|
||||
setDestinationError={setDestinationError}
|
||||
/>
|
||||
)}
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
188
src/plugins/links/public/components/editor/link_editor.tsx
Normal file
188
src/plugins/links/public/components/editor/link_editor.tsx
Normal file
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
EuiForm,
|
||||
EuiIcon,
|
||||
EuiTitle,
|
||||
EuiButton,
|
||||
EuiFormRow,
|
||||
EuiFlexItem,
|
||||
EuiFieldText,
|
||||
EuiFocusTrap,
|
||||
EuiFlexGroup,
|
||||
EuiRadioGroup,
|
||||
EuiFlyoutBody,
|
||||
EuiButtonEmpty,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutHeader,
|
||||
EuiRadioGroupOption,
|
||||
} from '@elastic/eui';
|
||||
import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container';
|
||||
|
||||
import {
|
||||
LinkType,
|
||||
EXTERNAL_LINK_TYPE,
|
||||
DASHBOARD_LINK_TYPE,
|
||||
LinkOptions,
|
||||
Link,
|
||||
} from '../../../common/content_management';
|
||||
import { LinksStrings } from '../links_strings';
|
||||
import { LinkInfo } from '../../embeddable/types';
|
||||
import { LinkOptionsComponent } from './link_options';
|
||||
import { UnorderedLink } from '../../editor/open_link_editor_flyout';
|
||||
import { LinkDestination } from './link_destination';
|
||||
|
||||
export const LinkEditor = ({
|
||||
link,
|
||||
onSave,
|
||||
onClose,
|
||||
parentDashboard,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
parentDashboard?: DashboardContainer;
|
||||
link?: UnorderedLink; // will only be defined if **editing** a link; otherwise, creating a new link
|
||||
onSave: (newLink: Omit<Link, 'order'>) => void;
|
||||
}) => {
|
||||
const [selectedLinkType, setSelectedLinkType] = useState<LinkType>(
|
||||
link?.type ?? DASHBOARD_LINK_TYPE
|
||||
);
|
||||
const [defaultLinkLabel, setDefaultLinkLabel] = useState<string | undefined>();
|
||||
const [currentLinkLabel, setCurrentLinkLabel] = useState<string>(link?.label ?? '');
|
||||
const [linkOptions, setLinkOptions] = useState<LinkOptions | undefined>();
|
||||
const [linkDestination, setLinkDestination] = useState<string | undefined>(link?.destination);
|
||||
|
||||
const linkTypes: EuiRadioGroupOption[] = useMemo(() => {
|
||||
return ([DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE] as LinkType[]).map((type) => {
|
||||
return {
|
||||
id: type,
|
||||
label: (
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" aria-label={LinkInfo[type].description}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type={LinkInfo[type].icon} color="text" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>{LinkInfo[type].displayName}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
'data-test-subj': `links--linkEditor--${type}--radioBtn`,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** When a new destination is picked, handle the logic for what to display as the current + default labels */
|
||||
const handleDestinationPicked = useCallback(
|
||||
(destination?: string, label?: string) => {
|
||||
setLinkDestination(destination);
|
||||
if (!currentLinkLabel || defaultLinkLabel === currentLinkLabel) {
|
||||
setCurrentLinkLabel(label ?? '');
|
||||
}
|
||||
setDefaultLinkLabel(label);
|
||||
},
|
||||
[defaultLinkLabel, currentLinkLabel]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFocusTrap className={'linkEditor in'} data-test-subj="links--linkEditor--flyout">
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiButtonEmpty
|
||||
className="linkEditorBackButton"
|
||||
flush="left"
|
||||
color="text"
|
||||
iconType={'arrowLeft'}
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
<EuiTitle size="m" aria-label={LinksStrings.editor.linkEditor.getGoBackAriaLabel()}>
|
||||
<h2>
|
||||
{link
|
||||
? LinksStrings.editor.getEditLinkTitle()
|
||||
: LinksStrings.editor.getAddButtonLabel()}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<EuiForm component="form" fullWidth>
|
||||
<EuiFormRow label={LinksStrings.editor.linkEditor.getLinkTypePickerLabel()}>
|
||||
<EuiRadioGroup
|
||||
options={linkTypes}
|
||||
idSelected={selectedLinkType}
|
||||
onChange={(id) => {
|
||||
if (currentLinkLabel === defaultLinkLabel) {
|
||||
setCurrentLinkLabel(link?.type === id ? link.label ?? '' : '');
|
||||
}
|
||||
setSelectedLinkType(id as LinkType);
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<LinkDestination
|
||||
link={link}
|
||||
parentDashboard={parentDashboard}
|
||||
selectedLinkType={selectedLinkType}
|
||||
setDestination={handleDestinationPicked}
|
||||
/>
|
||||
<EuiFormRow label={LinksStrings.editor.linkEditor.getLinkTextLabel()}>
|
||||
<EuiFieldText
|
||||
placeholder={
|
||||
(linkDestination ? defaultLinkLabel : '') ||
|
||||
LinksStrings.editor.linkEditor.getLinkTextPlaceholder()
|
||||
}
|
||||
value={currentLinkLabel}
|
||||
onChange={(e) => setCurrentLinkLabel(e.target.value)}
|
||||
data-test-subj="links--linkEditor--linkLabel--input"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<LinkOptionsComponent
|
||||
link={link}
|
||||
setLinkOptions={setLinkOptions}
|
||||
selectedLinkType={selectedLinkType}
|
||||
/>
|
||||
</EuiForm>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup responsive={false} justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
onClick={() => onClose()}
|
||||
iconType="cross"
|
||||
data-test-subj="links--linkEditor--closeBtn"
|
||||
>
|
||||
{LinksStrings.editor.getCancelButtonLabel()}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
disabled={!linkDestination}
|
||||
onClick={() => {
|
||||
// this check should always be true, since the button is disabled otherwise - this is just for type safety
|
||||
if (linkDestination) {
|
||||
onSave({
|
||||
label: currentLinkLabel === defaultLinkLabel ? undefined : currentLinkLabel,
|
||||
type: selectedLinkType,
|
||||
id: link?.id ?? uuidv4(),
|
||||
destination: linkDestination,
|
||||
options: linkOptions,
|
||||
});
|
||||
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
data-test-subj="links--linkEditor--saveBtn"
|
||||
>
|
||||
{link
|
||||
? LinksStrings.editor.getUpdateButtonLabel()
|
||||
: LinksStrings.editor.getAddButtonLabel()}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFocusTrap>
|
||||
);
|
||||
};
|
71
src/plugins/links/public/components/editor/link_options.tsx
Normal file
71
src/plugins/links/public/components/editor/link_options.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { EuiFormRow } from '@elastic/eui';
|
||||
import {
|
||||
DashboardDrilldownOptions,
|
||||
DashboardDrilldownOptionsComponent,
|
||||
DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
|
||||
} from '@kbn/presentation-util-plugin/public';
|
||||
import {
|
||||
UrlDrilldownOptions,
|
||||
UrlDrilldownOptionsComponent,
|
||||
DEFAULT_URL_DRILLDOWN_OPTIONS,
|
||||
} from '@kbn/ui-actions-enhanced-plugin/public';
|
||||
|
||||
import {
|
||||
LinkType,
|
||||
EXTERNAL_LINK_TYPE,
|
||||
DASHBOARD_LINK_TYPE,
|
||||
LinkOptions,
|
||||
} from '../../../common/content_management';
|
||||
import { LinksStrings } from '../links_strings';
|
||||
import { UnorderedLink } from '../../editor/open_link_editor_flyout';
|
||||
|
||||
export const LinkOptionsComponent = ({
|
||||
link,
|
||||
setLinkOptions,
|
||||
selectedLinkType,
|
||||
}: {
|
||||
selectedLinkType: LinkType;
|
||||
link?: UnorderedLink;
|
||||
setLinkOptions: (options: LinkOptions) => void;
|
||||
}) => {
|
||||
const [dashboardLinkOptions, setDashboardLinkOptions] = useState<DashboardDrilldownOptions>({
|
||||
...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
|
||||
...(link && link.type === DASHBOARD_LINK_TYPE ? link.options : {}),
|
||||
});
|
||||
const [externalLinkOptions, setExternalLinkOptions] = useState<UrlDrilldownOptions>({
|
||||
...DEFAULT_URL_DRILLDOWN_OPTIONS,
|
||||
...(link && link.type === EXTERNAL_LINK_TYPE ? link.options : {}),
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiFormRow label={LinksStrings.editor.linkEditor.getLinkOptionsLabel()}>
|
||||
{selectedLinkType === DASHBOARD_LINK_TYPE ? (
|
||||
<DashboardDrilldownOptionsComponent
|
||||
options={dashboardLinkOptions}
|
||||
onOptionChange={(change) => {
|
||||
setDashboardLinkOptions({ ...dashboardLinkOptions, ...change });
|
||||
setLinkOptions({ ...dashboardLinkOptions, ...change });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<UrlDrilldownOptionsComponent
|
||||
options={externalLinkOptions}
|
||||
onOptionChange={(change) => {
|
||||
setExternalLinkOptions({ ...externalLinkOptions, ...change });
|
||||
setLinkOptions({ ...externalLinkOptions, ...change });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
77
src/plugins/links/public/components/editor/links_editor.scss
Normal file
77
src/plugins/links/public/components/editor/links_editor.scss
Normal file
|
@ -0,0 +1,77 @@
|
|||
@import '../../mixins';
|
||||
|
||||
.linksPanelEditor {
|
||||
.linkEditor {
|
||||
@include euiFlyout;
|
||||
max-inline-size: $euiSizeXXL * 18; // 40px * 18 = 720px
|
||||
|
||||
&.in {
|
||||
animation: euiFlyoutOpenAnimation $euiAnimSpeedNormal $euiAnimSlightResistance;
|
||||
}
|
||||
|
||||
&.out {
|
||||
animation: euiFlyoutCloseAnimation $euiAnimSpeedNormal $euiAnimSlightResistance;
|
||||
}
|
||||
|
||||
.linkEditorBackButton {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.linksDashboardItem {
|
||||
.euiBadge {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
// in order to ensure that the "Current" badge doesn't recieve an underline on hover, we have to set the
|
||||
// text-decoration to `none` for the entire list item and manually set the underline **only** on the text
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.linksPanelLinkText {
|
||||
&:hover {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.linksPanelLink {
|
||||
padding: $euiSizeXS $euiSizeS;
|
||||
color: $euiTextColor;
|
||||
|
||||
.linksPanelLinkText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&.linkError {
|
||||
border: 1px solid transparentize($euiColorWarningText, .7);
|
||||
|
||||
.linksPanelLinkText {
|
||||
color: $euiColorWarningText;
|
||||
}
|
||||
|
||||
.linksPanelLinkText--noLabel {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.links_hoverActions {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: visibility $euiAnimSpeedNormal, opacity $euiAnimSpeedNormal;
|
||||
}
|
||||
|
||||
&:hover, &:focus-within {
|
||||
.links_hoverActions {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.linksDroppableLinksArea {
|
||||
margin: 0 (-$euiSizeXS);
|
||||
}
|
121
src/plugins/links/public/components/editor/links_editor.test.tsx
Normal file
121
src/plugins/links/public/components/editor/links_editor.test.tsx
Normal file
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import LinksEditor from './links_editor';
|
||||
import { LinksStrings } from '../links_strings';
|
||||
import { Link, LINKS_VERTICAL_LAYOUT } from '../../../common/content_management';
|
||||
import { fetchDashboard } from '../dashboard_link/dashboard_link_tools';
|
||||
|
||||
jest.mock('../dashboard_link/dashboard_link_tools', () => {
|
||||
return {
|
||||
fetchDashboard: jest.fn().mockImplementation((id: string) =>
|
||||
Promise.resolve({
|
||||
id,
|
||||
status: 'success',
|
||||
attributes: {
|
||||
title: `dashboard #${id}`,
|
||||
description: '',
|
||||
panelsJSON: [],
|
||||
timeRestore: false,
|
||||
version: '1',
|
||||
},
|
||||
references: [],
|
||||
})
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
describe('LinksEditor', () => {
|
||||
const defaultProps = {
|
||||
onSaveToLibrary: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
onAddToDashboard: jest.fn(),
|
||||
onClose: jest.fn(),
|
||||
isByReference: false,
|
||||
};
|
||||
|
||||
const someLinks: Link[] = [
|
||||
{
|
||||
id: 'foo',
|
||||
type: 'dashboardLink' as const,
|
||||
order: 1,
|
||||
destination: '123',
|
||||
},
|
||||
{
|
||||
id: 'bar',
|
||||
type: 'dashboardLink' as const,
|
||||
order: 4,
|
||||
destination: '456',
|
||||
},
|
||||
{
|
||||
id: 'bizz',
|
||||
type: 'externalLink' as const,
|
||||
order: 3,
|
||||
destination: 'http://example.com',
|
||||
},
|
||||
{
|
||||
id: 'buzz',
|
||||
type: 'externalLink' as const,
|
||||
order: 2,
|
||||
destination: 'http://elastic.co',
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('shows empty state with no links', async () => {
|
||||
render(<LinksEditor {...defaultProps} />);
|
||||
expect(screen.getByTestId('links--panelEditor--title')).toHaveTextContent(
|
||||
LinksStrings.editor.panelEditor.getCreateFlyoutTitle()
|
||||
);
|
||||
expect(screen.getByTestId('links--panelEditor--emptyPrompt')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('links--panelEditor--saveBtn')).toBeDisabled();
|
||||
|
||||
await userEvent.click(screen.getByTestId('links--panelEditor--closeBtn'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('shows links in order', async () => {
|
||||
const expectedLinkIds = [...someLinks].sort((a, b) => a.order - b.order).map(({ id }) => id);
|
||||
render(<LinksEditor {...defaultProps} initialLinks={someLinks} />);
|
||||
await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByTestId('links--panelEditor--title')).toHaveTextContent(
|
||||
LinksStrings.editor.panelEditor.getEditFlyoutTitle()
|
||||
);
|
||||
const draggableLinks = screen.getAllByTestId('links--panelEditor--draggableLink');
|
||||
expect(draggableLinks.length).toEqual(4);
|
||||
|
||||
draggableLinks.forEach((link, idx) => {
|
||||
expect(link).toHaveAttribute('data-rfd-draggable-id', expectedLinkIds[idx]);
|
||||
});
|
||||
});
|
||||
|
||||
test('saving by reference panels calls onSaveToLibrary', async () => {
|
||||
const orderedLinks = [...someLinks].sort((a, b) => a.order - b.order);
|
||||
render(<LinksEditor {...defaultProps} initialLinks={someLinks} isByReference />);
|
||||
await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2));
|
||||
const saveButton = screen.getByTestId('links--panelEditor--saveBtn');
|
||||
await userEvent.click(saveButton);
|
||||
await waitFor(() => expect(defaultProps.onSaveToLibrary).toHaveBeenCalledTimes(1));
|
||||
expect(defaultProps.onSaveToLibrary).toHaveBeenCalledWith(orderedLinks, LINKS_VERTICAL_LAYOUT);
|
||||
});
|
||||
|
||||
test('saving by value panel calls onAddToDashboard', async () => {
|
||||
const orderedLinks = [...someLinks].sort((a, b) => a.order - b.order);
|
||||
render(<LinksEditor {...defaultProps} initialLinks={someLinks} isByReference={false} />);
|
||||
await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2));
|
||||
const saveButton = screen.getByTestId('links--panelEditor--saveBtn');
|
||||
await userEvent.click(saveButton);
|
||||
expect(defaultProps.onAddToDashboard).toHaveBeenCalledTimes(1);
|
||||
expect(defaultProps.onAddToDashboard).toHaveBeenCalledWith(orderedLinks, LINKS_VERTICAL_LAYOUT);
|
||||
});
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue