Skip to content

Commit 29be25d

Browse files
[7.x] [Dashboard] Fix cloning panels reactive issue (#74253) (#82812)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
1 parent f461df2 commit 29be25d

File tree

7 files changed

+120
-68
lines changed

7 files changed

+120
-68
lines changed

src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -134,19 +134,15 @@ test('Add to library is not compatible when embeddable is not in a dashboard con
134134
expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false);
135135
});
136136

137-
test('Add to library replaces embeddableId but retains panel count', async () => {
137+
test('Add to library replaces embeddableId and retains panel count', async () => {
138138
const dashboard = embeddable.getRoot() as IContainer;
139139
const originalPanelCount = Object.keys(dashboard.getInput().panels).length;
140-
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
140+
141141
const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts });
142142
await action.execute({ embeddable });
143143
expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount);
144-
145-
const newPanelId = Object.keys(container.getInput().panels).find(
146-
(key) => !originalPanelKeySet.has(key)
147-
);
148-
expect(newPanelId).toBeDefined();
149-
const newPanel = container.getInput().panels[newPanelId!];
144+
expect(Object.keys(container.getInput().panels)).toContain(embeddable.id);
145+
const newPanel = container.getInput().panels[embeddable.id!];
150146
expect(newPanel.type).toEqual(embeddable.type);
151147
});
152148

@@ -162,15 +158,10 @@ test('Add to library returns reference type input', async () => {
162158
mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id },
163159
mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id } as EmbeddableInput,
164160
});
165-
const dashboard = embeddable.getRoot() as IContainer;
166-
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
167161
const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts });
168162
await action.execute({ embeddable });
169-
const newPanelId = Object.keys(container.getInput().panels).find(
170-
(key) => !originalPanelKeySet.has(key)
171-
);
172-
expect(newPanelId).toBeDefined();
173-
const newPanel = container.getInput().panels[newPanelId!];
163+
expect(Object.keys(container.getInput().panels)).toContain(embeddable.id);
164+
const newPanel = container.getInput().panels[embeddable.id!];
174165
expect(newPanel.type).toEqual(embeddable.type);
175166
expect(newPanel.explicitInput.attributes).toBeUndefined();
176167
expect(newPanel.explicitInput.savedObjectId).toBe('testSavedObjectId');

src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,12 @@ test('Clone adds a new embeddable', async () => {
108108
);
109109
expect(newPanelId).toBeDefined();
110110
const newPanel = container.getInput().panels[newPanelId!];
111-
expect(newPanel.type).toEqual(embeddable.type);
111+
expect(newPanel.type).toEqual('placeholder');
112+
// let the placeholder load
113+
await dashboard.untilEmbeddableLoaded(newPanelId!);
114+
// now wait for the full embeddable to replace it
115+
const loadedPanel = await dashboard.untilEmbeddableLoaded(newPanelId!);
116+
expect(loadedPanel.type).toEqual(embeddable.type);
112117
});
113118

114119
test('Clones an embeddable without a saved object ID', async () => {

src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -132,19 +132,14 @@ test('Unlink is not compatible when embeddable is not in a dashboard container',
132132
expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false);
133133
});
134134

135-
test('Unlink replaces embeddableId but retains panel count', async () => {
135+
test('Unlink replaces embeddableId and retains panel count', async () => {
136136
const dashboard = embeddable.getRoot() as IContainer;
137137
const originalPanelCount = Object.keys(dashboard.getInput().panels).length;
138-
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
139138
const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts });
140139
await action.execute({ embeddable });
141140
expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount);
142-
143-
const newPanelId = Object.keys(container.getInput().panels).find(
144-
(key) => !originalPanelKeySet.has(key)
145-
);
146-
expect(newPanelId).toBeDefined();
147-
const newPanel = container.getInput().panels[newPanelId!];
141+
expect(Object.keys(container.getInput().panels)).toContain(embeddable.id);
142+
const newPanel = container.getInput().panels[embeddable.id!];
148143
expect(newPanel.type).toEqual(embeddable.type);
149144
});
150145

@@ -164,15 +159,10 @@ test('Unlink unwraps all attributes from savedObject', async () => {
164159
mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id },
165160
mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id },
166161
});
167-
const dashboard = embeddable.getRoot() as IContainer;
168-
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
169162
const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts });
170163
await action.execute({ embeddable });
171-
const newPanelId = Object.keys(container.getInput().panels).find(
172-
(key) => !originalPanelKeySet.has(key)
173-
);
174-
expect(newPanelId).toBeDefined();
175-
const newPanel = container.getInput().panels[newPanelId!];
164+
expect(Object.keys(container.getInput().panels)).toContain(embeddable.id);
165+
const newPanel = container.getInput().panels[embeddable.id!];
176166
expect(newPanel.type).toEqual(embeddable.type);
177167
expect(newPanel.explicitInput.attributes).toEqual(complicatedAttributes);
178168
});

src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
ContactCardEmbeddableInput,
2828
ContactCardEmbeddable,
2929
ContactCardEmbeddableOutput,
30+
EMPTY_EMBEDDABLE,
3031
} from '../../embeddable_plugin_test_samples';
3132
import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks';
3233

@@ -100,6 +101,48 @@ test('DashboardContainer.addNewEmbeddable', async () => {
100101
expect(embeddableInContainer.id).toBe(embeddable.id);
101102
});
102103

104+
test('DashboardContainer.replacePanel', async (done) => {
105+
const ID = '123';
106+
const initialInput = getSampleDashboardInput({
107+
panels: {
108+
[ID]: getSampleDashboardPanel<ContactCardEmbeddableInput>({
109+
explicitInput: { firstName: 'Sam', id: ID },
110+
type: CONTACT_CARD_EMBEDDABLE,
111+
}),
112+
},
113+
});
114+
115+
const container = new DashboardContainer(initialInput, options);
116+
let counter = 0;
117+
118+
const subscriptionHandler = jest.fn(({ panels }) => {
119+
counter++;
120+
expect(panels[ID]).toBeDefined();
121+
// It should be called exactly 2 times and exit the second time
122+
switch (counter) {
123+
case 1:
124+
return expect(panels[ID].type).toBe(CONTACT_CARD_EMBEDDABLE);
125+
126+
case 2: {
127+
expect(panels[ID].type).toBe(EMPTY_EMBEDDABLE);
128+
subscription.unsubscribe();
129+
done();
130+
}
131+
132+
default:
133+
throw Error('Called too many times!');
134+
}
135+
});
136+
137+
const subscription = container.getInput$().subscribe(subscriptionHandler);
138+
139+
// replace the panel now
140+
container.replacePanel(container.getInput().panels[ID], {
141+
type: EMPTY_EMBEDDABLE,
142+
explicitInput: { id: ID },
143+
});
144+
});
145+
103146
test('Container view mode change propagates to existing children', async () => {
104147
const initialInput = getSampleDashboardInput({
105148
panels: {

src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -154,42 +154,43 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
154154
placementMethod,
155155
placementArgs
156156
);
157+
157158
this.updateInput({
158159
panels: {
159160
...this.input.panels,
160161
[placeholderPanelState.explicitInput.id]: placeholderPanelState,
161162
},
162163
});
163-
newStateComplete.then((newPanelState: Partial<PanelState>) =>
164-
this.replacePanel(placeholderPanelState, newPanelState)
165-
);
164+
165+
// wait until the placeholder is ready, then replace it with new panel
166+
// this is useful as sometimes panels can load faster than the placeholder one (i.e. by value embeddables)
167+
this.untilEmbeddableLoaded(originalPanelState.explicitInput.id)
168+
.then(() => newStateComplete)
169+
.then((newPanelState: Partial<PanelState>) =>
170+
this.replacePanel(placeholderPanelState, newPanelState)
171+
);
166172
}
167173

168174
public replacePanel(
169175
previousPanelState: DashboardPanelState<EmbeddableInput>,
170176
newPanelState: Partial<PanelState>
171177
) {
172-
// TODO: In the current infrastructure, embeddables in a container do not react properly to
173-
// changes. Removing the existing embeddable, and adding a new one is a temporary workaround
174-
// until the container logic is fixed.
175-
176-
const finalPanels = { ...this.input.panels };
177-
delete finalPanels[previousPanelState.explicitInput.id];
178-
const newPanelId = newPanelState.explicitInput?.id ? newPanelState.explicitInput.id : uuid.v4();
179-
finalPanels[newPanelId] = {
180-
...previousPanelState,
181-
...newPanelState,
182-
gridData: {
183-
...previousPanelState.gridData,
184-
i: newPanelId,
185-
},
186-
explicitInput: {
187-
...newPanelState.explicitInput,
188-
id: newPanelId,
178+
// Because the embeddable type can change, we have to operate at the container level here
179+
return this.updateInput({
180+
panels: {
181+
...this.input.panels,
182+
[previousPanelState.explicitInput.id]: {
183+
...previousPanelState,
184+
...newPanelState,
185+
gridData: {
186+
...previousPanelState.gridData,
187+
},
188+
explicitInput: {
189+
...newPanelState.explicitInput,
190+
id: previousPanelState.explicitInput.id,
191+
},
192+
},
189193
},
190-
};
191-
this.updateInput({
192-
panels: finalPanels,
193194
lastReloadRequestTime: new Date().getTime(),
194195
});
195196
}
@@ -201,16 +202,15 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
201202
>(type: string, explicitInput: Partial<EEI>, embeddableId?: string) {
202203
const idToReplace = embeddableId || explicitInput.id;
203204
if (idToReplace && this.input.panels[idToReplace]) {
204-
this.replacePanel(this.input.panels[idToReplace], {
205+
return this.replacePanel(this.input.panels[idToReplace], {
205206
type,
206207
explicitInput: {
207208
...explicitInput,
208-
id: uuid.v4(),
209+
id: idToReplace,
209210
},
210211
});
211-
} else {
212-
this.addNewEmbeddable<EEI, EEO, E>(type, explicitInput);
213212
}
213+
return this.addNewEmbeddable<EEI, EEO, E>(type, explicitInput);
214214
}
215215

216216
public render(dom: HTMLElement) {

src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,16 @@ class DashboardGridUi extends React.Component<DashboardGridProps, State> {
265265
<div
266266
style={{ zIndex: focusedPanelIndex === panel.explicitInput.id ? 2 : 'auto' }}
267267
className={classes}
268+
// This key is required for the ReactGridLayout to work properly
268269
key={panel.explicitInput.id}
269270
data-test-subj="dashboardPanel"
270271
ref={(reactGridItem) => {
271272
this.gridItems[panel.explicitInput.id] = reactGridItem;
272273
}}
273274
>
274275
<EmbeddableChildPanel
276+
// This key is used to force rerendering on embeddable type change while the id remains the same
277+
key={panel.type}
275278
embeddableId={panel.explicitInput.id}
276279
container={this.props.container}
277280
PanelComponent={this.props.PanelComponent}

src/plugins/embeddable/public/lib/containers/container.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import uuid from 'uuid';
2121
import { merge, Subscription } from 'rxjs';
22+
import { startWith, pairwise } from 'rxjs/operators';
2223
import {
2324
Embeddable,
2425
EmbeddableInput,
@@ -55,7 +56,12 @@ export abstract class Container<
5556
parent?: Container
5657
) {
5758
super(input, output, parent);
58-
this.subscription = this.getInput$().subscribe(() => this.maybeUpdateChildren());
59+
this.subscription = this.getInput$()
60+
// At each update event, get both the previous and current state
61+
.pipe(startWith(input), pairwise())
62+
.subscribe(([{ panels: prevPanels }, { panels: currentPanels }]) => {
63+
this.maybeUpdateChildren(currentPanels, prevPanels);
64+
});
5965
}
6066

6167
public updateInputForChild<EEI extends EmbeddableInput = EmbeddableInput>(
@@ -329,16 +335,30 @@ export abstract class Container<
329335
return embeddable;
330336
}
331337

332-
private maybeUpdateChildren() {
333-
const allIds = Object.keys({ ...this.input.panels, ...this.output.embeddableLoaded });
338+
private panelHasChanged(currentPanel: PanelState, prevPanel: PanelState) {
339+
if (currentPanel.type !== prevPanel.type) {
340+
return true;
341+
}
342+
}
343+
344+
private maybeUpdateChildren(
345+
currentPanels: TContainerInput['panels'],
346+
prevPanels: TContainerInput['panels']
347+
) {
348+
const allIds = Object.keys({ ...currentPanels, ...this.output.embeddableLoaded });
334349
allIds.forEach((id) => {
335-
if (this.input.panels[id] !== undefined && this.output.embeddableLoaded[id] === undefined) {
336-
this.onPanelAdded(this.input.panels[id]);
337-
} else if (
338-
this.input.panels[id] === undefined &&
339-
this.output.embeddableLoaded[id] !== undefined
340-
) {
341-
this.onPanelRemoved(id);
350+
if (currentPanels[id] !== undefined && this.output.embeddableLoaded[id] === undefined) {
351+
return this.onPanelAdded(currentPanels[id]);
352+
}
353+
if (currentPanels[id] === undefined && this.output.embeddableLoaded[id] !== undefined) {
354+
return this.onPanelRemoved(id);
355+
}
356+
// In case of type change, remove and add a panel with the same id
357+
if (currentPanels[id] && prevPanels[id]) {
358+
if (this.panelHasChanged(currentPanels[id], prevPanels[id])) {
359+
this.onPanelRemoved(id);
360+
this.onPanelAdded(currentPanels[id]);
361+
}
342362
}
343363
});
344364
}

0 commit comments

Comments
 (0)