[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


![image](7616443e-0cb0-43ce-a1d0-41f8bee6cbfc)


### 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:
Hannah Mudge 2023-09-29 08:25:51 -06:00 committed by GitHub
parent 9d3213e137
commit 9e8312f2e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
187 changed files with 7203 additions and 758 deletions

View file

@ -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
View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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",

View file

@ -1062,6 +1062,24 @@
}
}
},
"links": {
"dynamic": false,
"properties": {
"id": {
"type": "text"
},
"title": {
"type": "text"
},
"description": {
"type": "text"
},
"links": {
"dynamic": false,
"properties": {}
}
}
},
"lens": {
"properties": {
"title": {

View file

@ -87,6 +87,7 @@ pageLoadAssetSize:
lens: 38000
licenseManagement: 41817
licensing: 29004
links: 44490
lists: 22900
logExplorer: 39045
logsShared: 281060

View file

@ -96,6 +96,7 @@ const STANDARD_LIST_TYPES = [
'dashboard',
'search',
'lens',
'links',
'map',
'cases',
// synthetics based objects

View file

@ -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",

View file

@ -81,6 +81,7 @@ const previouslyRegisteredTypes = [
'legacy-url-alias',
'lens',
'lens-ui-telemetry',
'links',
'maintenance-window',
'map',
'maps-telemetry',

View file

@ -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",

View file

@ -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 (

View file

@ -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) {

View file

@ -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();

View file

@ -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,
}),
},
},
});

View file

@ -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: {

View file

@ -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,
}),
},
},
});

View file

@ -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,
}),
},
},
});

View file

@ -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,
}),
},
},
});

View file

@ -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);

View file

@ -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;
};

View file

@ -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);

View file

@ -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
// ------------------------------------------------------------------

View file

@ -1,7 +1,6 @@
@import '../../../embeddable/public/variables';
@import './component/grid/index';
@import './component/panel/index';
@import './component/viewport/index';
.dashboardContainer, .dashboardViewport {

View file

@ -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 />

View file

@ -1 +1,2 @@
@import './dashboard_grid';
@import './dashboard_panel';

View file

@ -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' },
},
},
},
});

View file

@ -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}

View file

@ -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}>

View file

@ -1 +0,0 @@
@import './dashboard_panel';

View file

@ -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);
});

View file

@ -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,
};
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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;

View file

@ -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' }
);
});

View file

@ -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,
};
}

View file

@ -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>;
}

View file

@ -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>
);

View file

@ -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;
}

View file

@ -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,

View file

@ -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',

View file

@ -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) {

View file

@ -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);

View file

@ -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;
}

View file

@ -44,6 +44,7 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen
hits,
});
}),
findById: jest.fn(),
findByIds: jest.fn().mockImplementation(() =>
Promise.resolve([
{

View 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 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);
}
}

View file

@ -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),
},

View file

@ -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);
};

View file

@ -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(

View file

@ -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 {

View file

@ -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 };

View file

@ -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>;
}

View 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();

View file

@ -103,7 +103,8 @@ export const AddPanelFlyout = ({
const embeddable = await container.addNewEmbeddable<SavedObjectEmbeddableInput>(
factoryForSavedObjectType.type,
{ savedObjectId: id }
{ savedObjectId: id },
savedObject.attributes
);
onAddPanel?.(embeddable.id);

View file

@ -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
);
}

View file

@ -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;
}

View file

@ -76,6 +76,7 @@ export {
EmbeddableRenderer,
useEmbeddableFactory,
isFilterableEmbeddable,
isExplicitInputWithAttributes,
shouldFetch$,
shouldRefreshFilterCompareOptions,
PANEL_HOVER_TRIGGER,

View file

@ -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>);

View file

@ -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>;
}

View file

@ -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,

View file

@ -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.

View file

@ -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.' },

View file

@ -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;

View 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.

View 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',
});

View 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,
};

View 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';

View 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';

View 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,
},
},
},
};

View 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';

View 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';

View 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;
};

View 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',
},
});
});

View 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,
};
};

View 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';

View 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',
});
});

View 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;
}
};

View 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;
};

View 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';

View 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;
};

View 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';

View 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,
},
],
},
});
});
});

View 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,
},
};
}

View 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;
}

View 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'],
};

View 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();

View 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"]
}
}

View 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
}

View file

@ -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);
});
});

View file

@ -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}`}
/>
);
};

View file

@ -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"
/>
);
};

View file

@ -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',
}),
};

View file

@ -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,
});
};

View file

@ -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>
);
};

View 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>
);
};

View 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>
);
};

View 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);
}

View 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