Skip to content

Commit 7c54629

Browse files
committed
[Dashboard First] Unlink from Library Action With ReferenceOrValueEmbeddable (#74905)
* Added an unlink from library action which works with the ReferenceOrValue interface. Once
1 parent 8ea2bb3 commit 7c54629

File tree

8 files changed

+306
-2
lines changed

8 files changed

+306
-2
lines changed

examples/embeddable_examples/public/book/add_book_to_library_action.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n';
2121
import { createAction, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public';
2222
import { BookEmbeddable, BOOK_EMBEDDABLE } from './book_embeddable';
2323
import { ViewMode, isReferenceOrValueEmbeddable } from '../../../../src/plugins/embeddable/public';
24+
import { DASHBOARD_CONTAINER_TYPE } from '../../../../src/plugins/dashboard/public';
2425

2526
interface ActionContext {
2627
embeddable: BookEmbeddable;
@@ -41,6 +42,8 @@ export const createAddBookToLibraryAction = () =>
4142
return (
4243
embeddable.type === BOOK_EMBEDDABLE &&
4344
embeddable.getInput().viewMode === ViewMode.EDIT &&
45+
embeddable.getRoot().isContainer &&
46+
embeddable.getRoot().type !== DASHBOARD_CONTAINER_TYPE &&
4447
isReferenceOrValueEmbeddable(embeddable) &&
4548
!embeddable.inputIsRefType(embeddable.getInput())
4649
);

examples/embeddable_examples/public/book/book_embeddable.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
EmbeddableOutput,
2727
SavedObjectEmbeddableInput,
2828
ReferenceOrValueEmbeddable,
29+
Container,
2930
} from '../../../../src/plugins/embeddable/public';
3031
import { BookSavedObjectAttributes } from '../../common';
3132
import { BookEmbeddableComponent } from './book_component';
@@ -103,7 +104,12 @@ export class BookEmbeddable extends Embeddable<BookEmbeddableInput, BookEmbeddab
103104
};
104105

105106
getInputAsValueType = async (): Promise<BookByValueInput> => {
106-
return this.attributeService.getInputAsValueType(this.input);
107+
const input =
108+
this.getRoot() && (this.getRoot() as Container).getInput().panels[this.id].explicitInput
109+
? ((this.getRoot() as Container).getInput().panels[this.id]
110+
.explicitInput as BookEmbeddableInput)
111+
: this.input;
112+
return this.attributeService.getInputAsValueType(input);
107113
};
108114

109115
getInputAsRefType = async (): Promise<BookByReferenceInput> => {

examples/embeddable_examples/public/book/unlink_book_from_library_action.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n';
2121
import { createAction, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public';
2222
import { BookEmbeddable, BOOK_EMBEDDABLE } from './book_embeddable';
2323
import { ViewMode, isReferenceOrValueEmbeddable } from '../../../../src/plugins/embeddable/public';
24+
import { DASHBOARD_CONTAINER_TYPE } from '../../../../src/plugins/dashboard/public';
2425

2526
interface ActionContext {
2627
embeddable: BookEmbeddable;
@@ -41,6 +42,8 @@ export const createUnlinkBookFromLibraryAction = () =>
4142
return (
4243
embeddable.type === BOOK_EMBEDDABLE &&
4344
embeddable.getInput().viewMode === ViewMode.EDIT &&
45+
embeddable.getRoot().isContainer &&
46+
embeddable.getRoot().type !== DASHBOARD_CONTAINER_TYPE &&
4447
isReferenceOrValueEmbeddable(embeddable) &&
4548
embeddable.inputIsRefType(embeddable.getInput())
4649
);

src/plugins/dashboard/public/application/actions/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,8 @@ export {
3232
ClonePanelActionContext,
3333
ACTION_CLONE_PANEL,
3434
} from './clone_panel_action';
35+
export {
36+
UnlinkFromLibraryActionContext,
37+
ACTION_UNLINK_FROM_LIBRARY,
38+
UnlinkFromLibraryAction,
39+
} from './unlink_from_library_action';
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import { isErrorEmbeddable, IContainer, ReferenceOrValueEmbeddable } from '../../embeddable_plugin';
20+
import { DashboardContainer } from '../embeddable';
21+
import { getSampleDashboardInput } from '../test_helpers';
22+
import {
23+
CONTACT_CARD_EMBEDDABLE,
24+
ContactCardEmbeddableFactory,
25+
ContactCardEmbeddable,
26+
ContactCardEmbeddableInput,
27+
ContactCardEmbeddableOutput,
28+
} from '../../embeddable_plugin_test_samples';
29+
import { coreMock } from '../../../../../core/public/mocks';
30+
import { CoreStart } from 'kibana/public';
31+
import { UnlinkFromLibraryAction } from '.';
32+
import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks';
33+
import { ViewMode } from '../../../../embeddable/public';
34+
35+
const { setup, doStart } = embeddablePluginMock.createInstance();
36+
setup.registerEmbeddableFactory(
37+
CONTACT_CARD_EMBEDDABLE,
38+
new ContactCardEmbeddableFactory((() => null) as any, {} as any)
39+
);
40+
const start = doStart();
41+
42+
let container: DashboardContainer;
43+
let embeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable;
44+
let coreStart: CoreStart;
45+
beforeEach(async () => {
46+
coreStart = coreMock.createStart();
47+
48+
const containerOptions = {
49+
ExitFullScreenButton: () => null,
50+
SavedObjectFinder: () => null,
51+
application: {} as any,
52+
embeddable: start,
53+
inspector: {} as any,
54+
notifications: {} as any,
55+
overlays: coreStart.overlays,
56+
savedObjectMetaData: {} as any,
57+
uiActions: {} as any,
58+
};
59+
60+
container = new DashboardContainer(getSampleDashboardInput(), containerOptions);
61+
62+
const contactCardEmbeddable = await container.addNewEmbeddable<
63+
ContactCardEmbeddableInput,
64+
ContactCardEmbeddableOutput,
65+
ContactCardEmbeddable
66+
>(CONTACT_CARD_EMBEDDABLE, {
67+
firstName: 'Kibanana',
68+
});
69+
70+
if (isErrorEmbeddable(contactCardEmbeddable)) {
71+
throw new Error('Failed to create embeddable');
72+
}
73+
embeddable = embeddablePluginMock.mockRefOrValEmbeddable<
74+
ContactCardEmbeddable,
75+
ContactCardEmbeddableInput
76+
>(contactCardEmbeddable, {
77+
mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: contactCardEmbeddable.id },
78+
mockedByValueInput: { firstName: 'Kibanana', id: contactCardEmbeddable.id },
79+
});
80+
embeddable.updateInput({ viewMode: ViewMode.EDIT });
81+
});
82+
83+
test('Unlink is compatible when embeddable on dashboard has reference type input', async () => {
84+
const action = new UnlinkFromLibraryAction();
85+
embeddable.updateInput(await embeddable.getInputAsRefType());
86+
expect(await action.isCompatible({ embeddable })).toBe(true);
87+
});
88+
89+
test('Unlink is not compatible when embeddable input is by value', async () => {
90+
const action = new UnlinkFromLibraryAction();
91+
embeddable.updateInput(await embeddable.getInputAsValueType());
92+
expect(await action.isCompatible({ embeddable })).toBe(false);
93+
});
94+
95+
test('Unlink is not compatible when view mode is set to view', async () => {
96+
const action = new UnlinkFromLibraryAction();
97+
embeddable.updateInput(await embeddable.getInputAsRefType());
98+
embeddable.updateInput({ viewMode: ViewMode.VIEW });
99+
expect(await action.isCompatible({ embeddable })).toBe(false);
100+
});
101+
102+
test('Unlink is not compatible when embeddable is not in a dashboard container', async () => {
103+
let orphanContactCard = await container.addNewEmbeddable<
104+
ContactCardEmbeddableInput,
105+
ContactCardEmbeddableOutput,
106+
ContactCardEmbeddable
107+
>(CONTACT_CARD_EMBEDDABLE, {
108+
firstName: 'Orphan',
109+
});
110+
orphanContactCard = embeddablePluginMock.mockRefOrValEmbeddable<
111+
ContactCardEmbeddable,
112+
ContactCardEmbeddableInput
113+
>(orphanContactCard, {
114+
mockedByReferenceInput: { savedObjectId: 'test', id: orphanContactCard.id },
115+
mockedByValueInput: { firstName: 'Kibanana', id: orphanContactCard.id },
116+
});
117+
const action = new UnlinkFromLibraryAction();
118+
expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false);
119+
});
120+
121+
test('Unlink replaces embeddableId but retains panel count', async () => {
122+
const dashboard = embeddable.getRoot() as IContainer;
123+
const originalPanelCount = Object.keys(dashboard.getInput().panels).length;
124+
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
125+
const action = new UnlinkFromLibraryAction();
126+
await action.execute({ embeddable });
127+
expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount);
128+
129+
const newPanelId = Object.keys(container.getInput().panels).find(
130+
(key) => !originalPanelKeySet.has(key)
131+
);
132+
expect(newPanelId).toBeDefined();
133+
const newPanel = container.getInput().panels[newPanelId!];
134+
expect(newPanel.type).toEqual(embeddable.type);
135+
});
136+
137+
test('Unlink unwraps all attributes from savedObject', async () => {
138+
const complicatedAttributes = {
139+
attribute1: 'The best attribute',
140+
attribute2: 22,
141+
attribute3: ['array', 'of', 'strings'],
142+
attribute4: { nestedattribute: 'hello from the nest' },
143+
};
144+
145+
embeddable = embeddablePluginMock.mockRefOrValEmbeddable<ContactCardEmbeddable>(embeddable, {
146+
mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id },
147+
mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id },
148+
});
149+
const dashboard = embeddable.getRoot() as IContainer;
150+
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
151+
const action = new UnlinkFromLibraryAction();
152+
await action.execute({ embeddable });
153+
const newPanelId = Object.keys(container.getInput().panels).find(
154+
(key) => !originalPanelKeySet.has(key)
155+
);
156+
expect(newPanelId).toBeDefined();
157+
const newPanel = container.getInput().panels[newPanelId!];
158+
expect(newPanel.type).toEqual(embeddable.type);
159+
expect(newPanel.explicitInput.attributes).toEqual(complicatedAttributes);
160+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { i18n } from '@kbn/i18n';
21+
import _ from 'lodash';
22+
import uuid from 'uuid';
23+
import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin';
24+
import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin';
25+
import {
26+
PanelNotFoundError,
27+
EmbeddableInput,
28+
isReferenceOrValueEmbeddable,
29+
} from '../../../../embeddable/public';
30+
import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..';
31+
32+
export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary';
33+
34+
export interface UnlinkFromLibraryActionContext {
35+
embeddable: IEmbeddable;
36+
}
37+
38+
export class UnlinkFromLibraryAction implements ActionByType<typeof ACTION_UNLINK_FROM_LIBRARY> {
39+
public readonly type = ACTION_UNLINK_FROM_LIBRARY;
40+
public readonly id = ACTION_UNLINK_FROM_LIBRARY;
41+
public order = 15;
42+
43+
constructor() {}
44+
45+
public getDisplayName({ embeddable }: UnlinkFromLibraryActionContext) {
46+
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
47+
throw new IncompatibleActionError();
48+
}
49+
return i18n.translate('dashboard.panel.unlinkFromLibrary', {
50+
defaultMessage: 'Unlink from library item',
51+
});
52+
}
53+
54+
public getIconType({ embeddable }: UnlinkFromLibraryActionContext) {
55+
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
56+
throw new IncompatibleActionError();
57+
}
58+
return 'folderExclamation';
59+
}
60+
61+
public async isCompatible({ embeddable }: UnlinkFromLibraryActionContext) {
62+
return Boolean(
63+
embeddable.getInput()?.viewMode !== ViewMode.VIEW &&
64+
embeddable.getRoot() &&
65+
embeddable.getRoot().isContainer &&
66+
embeddable.getRoot().type === DASHBOARD_CONTAINER_TYPE &&
67+
isReferenceOrValueEmbeddable(embeddable) &&
68+
embeddable.inputIsRefType(embeddable.getInput())
69+
);
70+
}
71+
72+
public async execute({ embeddable }: UnlinkFromLibraryActionContext) {
73+
if (!isReferenceOrValueEmbeddable(embeddable)) {
74+
throw new IncompatibleActionError();
75+
}
76+
77+
const newInput = await embeddable.getInputAsValueType();
78+
embeddable.updateInput(newInput);
79+
80+
const dashboard = embeddable.getRoot() as DashboardContainer;
81+
const panelToReplace = dashboard.getInput().panels[embeddable.id] as DashboardPanelState;
82+
if (!panelToReplace) {
83+
throw new PanelNotFoundError();
84+
}
85+
86+
const newPanel: PanelState<EmbeddableInput> = {
87+
type: embeddable.type,
88+
explicitInput: { ...newInput, id: uuid.v4() },
89+
};
90+
dashboard.replacePanel(panelToReplace, newPanel);
91+
}
92+
}

src/plugins/dashboard/public/plugin.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ import {
8080
RenderDeps,
8181
ReplacePanelAction,
8282
ReplacePanelActionContext,
83+
ACTION_UNLINK_FROM_LIBRARY,
84+
UnlinkFromLibraryActionContext,
85+
UnlinkFromLibraryAction,
8386
} from './application';
8487
import {
8588
createDashboardUrlGenerator,
@@ -152,6 +155,7 @@ declare module '../../../plugins/ui_actions/public' {
152155
[ACTION_EXPAND_PANEL]: ExpandPanelActionContext;
153156
[ACTION_REPLACE_PANEL]: ReplacePanelActionContext;
154157
[ACTION_CLONE_PANEL]: ClonePanelActionContext;
158+
[ACTION_UNLINK_FROM_LIBRARY]: UnlinkFromLibraryActionContext;
155159
}
156160
}
157161

@@ -163,13 +167,17 @@ export class DashboardPlugin
163167
private stopUrlTracking: (() => void) | undefined = undefined;
164168
private getActiveUrl: (() => string) | undefined = undefined;
165169
private currentHistory: ScopedHistory | undefined = undefined;
170+
private dashboardFeatureFlagConfig?: DashboardFeatureFlagConfig;
166171

167172
private dashboardUrlGenerator?: DashboardUrlGenerator;
168173

169174
public setup(
170175
core: CoreSetup<StartDependencies, DashboardStart>,
171176
{ share, uiActions, embeddable, home, kibanaLegacy, data, usageCollection }: SetupDependencies
172177
): Setup {
178+
this.dashboardFeatureFlagConfig = this.initializerContext.config.get<
179+
DashboardFeatureFlagConfig
180+
>();
173181
const expandPanelAction = new ExpandPanelAction();
174182
uiActions.registerAction(expandPanelAction);
175183
uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id);
@@ -415,6 +423,12 @@ export class DashboardPlugin
415423
uiActions.registerAction(clonePanelAction);
416424
uiActions.attachAction(CONTEXT_MENU_TRIGGER, clonePanelAction.id);
417425

426+
if (this.dashboardFeatureFlagConfig?.allowByValueEmbeddables) {
427+
const unlinkFromLibraryAction = new UnlinkFromLibraryAction();
428+
uiActions.registerAction(unlinkFromLibraryAction);
429+
uiActions.attachAction(CONTEXT_MENU_TRIGGER, unlinkFromLibraryAction.id);
430+
}
431+
418432
const savedDashboardLoader = createSavedDashboardLoader({
419433
savedObjectsClient: core.savedObjects.client,
420434
indexPatterns,
@@ -430,7 +444,7 @@ export class DashboardPlugin
430444
getSavedDashboardLoader: () => savedDashboardLoader,
431445
addEmbeddableToDashboard: this.addEmbeddableToDashboard.bind(this, core),
432446
dashboardUrlGenerator: this.dashboardUrlGenerator,
433-
dashboardFeatureFlagConfig: this.initializerContext.config.get<DashboardFeatureFlagConfig>(),
447+
dashboardFeatureFlagConfig: this.dashboardFeatureFlagConfig!,
434448
DashboardContainerByValueRenderer: createDashboardContainerByValueRenderer({
435449
factory: dashboardContainerFactory,
436450
}),

0 commit comments

Comments
 (0)