[Dashboard] Clone ReferenceOrValueEmbeddables by value. (#122199)

* Clone all panels by value.

* Moved removal of byReference properties to getInputAsValueType.

* Fixed handling of clone titles.

* Fixed functional and unit clone tests.

* Removed duplicate check for byReference.

* Unset title on Visualize embeddable when by value.

* Remove unused import.

* Added by reference logic for saved search embeddables.

* Re-added unit tests for cloning by reference.

* Added functional tests.

* Added Jest unit tests.

* Ignored TypeScript errors for calling private functions in Jest tests.

* Adjusted logic for generating clone titles.

* Edited unit and functional tests for clone titles.

* Fixed typo in Jest tests.

* Keep hidden panel title status.

* Fix Jest test description.

* Remove unused import.

* Fixed Jest tests after new title logic.

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2022-01-20 08:33:14 -07:00 committed by GitHub
parent 460f89a200
commit b1d7731afa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 286 additions and 77 deletions

View file

@ -33,7 +33,8 @@ setup.registerEmbeddableFactory(
const start = doStart();
let container: DashboardContainer;
let embeddable: ContactCardEmbeddable;
let byRefOrValEmbeddable: ContactCardEmbeddable;
let genericEmbeddable: ContactCardEmbeddable;
let coreStart: CoreStart;
beforeEach(async () => {
coreStart = coreMock.createStart();
@ -70,18 +71,38 @@ beforeEach(async () => {
});
container = new DashboardContainer(input, options);
const contactCardEmbeddable = await container.addNewEmbeddable<
const refOrValContactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Kibana',
firstName: 'RefOrValEmbeddable',
});
const genericContactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'NotRefOrValEmbeddable',
});
if (isErrorEmbeddable(contactCardEmbeddable)) {
throw new Error('Failed to create embeddable');
if (
isErrorEmbeddable(refOrValContactCardEmbeddable) ||
isErrorEmbeddable(genericContactCardEmbeddable)
) {
throw new Error('Failed to create embeddables');
} else {
embeddable = contactCardEmbeddable;
byRefOrValEmbeddable = embeddablePluginMock.mockRefOrValEmbeddable<
ContactCardEmbeddable,
ContactCardEmbeddableInput
>(refOrValContactCardEmbeddable, {
mockedByReferenceInput: {
savedObjectId: 'testSavedObjectId',
id: refOrValContactCardEmbeddable.id,
},
mockedByValueInput: { firstName: 'Kibanana', id: refOrValContactCardEmbeddable.id },
});
genericEmbeddable = genericContactCardEmbeddable;
}
});
@ -90,17 +111,17 @@ test('Clone is incompatible with Error Embeddables', async () => {
const errorEmbeddable = new ErrorEmbeddable(
'Wow what an awful error',
{ id: ' 404' },
embeddable.getRoot() as IContainer
byRefOrValEmbeddable.getRoot() as IContainer
);
expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false);
});
test('Clone adds a new embeddable', async () => {
const dashboard = embeddable.getRoot() as IContainer;
const dashboard = byRefOrValEmbeddable.getRoot() as IContainer;
const originalPanelCount = Object.keys(dashboard.getInput().panels).length;
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
const action = new ClonePanelAction(coreStart);
await action.execute({ embeddable });
await action.execute({ embeddable: byRefOrValEmbeddable });
expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount + 1);
const newPanelId = Object.keys(container.getInput().panels).find(
(key) => !originalPanelKeySet.has(key)
@ -113,56 +134,159 @@ test('Clone adds a new embeddable', async () => {
await new Promise((r) => process.nextTick(r)); // Allow the current loop of the event loop to run to completion
// now wait for the full embeddable to replace it
const loadedPanel = await dashboard.untilEmbeddableLoaded(newPanelId!);
expect(loadedPanel.type).toEqual(embeddable.type);
expect(loadedPanel.type).toEqual(byRefOrValEmbeddable.type);
});
test('Clones an embeddable without a saved object ID', async () => {
const dashboard = embeddable.getRoot() as IContainer;
const panel = dashboard.getInput().panels[embeddable.id] as DashboardPanelState;
test('Clones a RefOrVal embeddable by value', async () => {
const dashboard = byRefOrValEmbeddable.getRoot() as IContainer;
const panel = dashboard.getInput().panels[byRefOrValEmbeddable.id] as DashboardPanelState;
const action = new ClonePanelAction(coreStart);
// @ts-ignore
const newPanel = await action.cloneEmbeddable(panel, embeddable.type);
expect(newPanel.type).toEqual(embeddable.type);
const newPanel = await action.cloneEmbeddable(panel, byRefOrValEmbeddable);
expect(coreStart.savedObjects.client.get).toHaveBeenCalledTimes(0);
expect(coreStart.savedObjects.client.find).toHaveBeenCalledTimes(0);
expect(coreStart.savedObjects.client.create).toHaveBeenCalledTimes(0);
expect(newPanel.type).toEqual(byRefOrValEmbeddable.type);
});
test('Clones an embeddable with a saved object ID', async () => {
const dashboard = embeddable.getRoot() as IContainer;
const panel = dashboard.getInput().panels[embeddable.id] as DashboardPanelState;
test('Clones a non-RefOrVal embeddable by value if the panel does not have a savedObjectId', async () => {
const dashboard = genericEmbeddable.getRoot() as IContainer;
const panel = dashboard.getInput().panels[genericEmbeddable.id] as DashboardPanelState;
const action = new ClonePanelAction(coreStart);
// @ts-ignore
const newPanelWithoutId = await action.cloneEmbeddable(panel, genericEmbeddable);
expect(coreStart.savedObjects.client.get).toHaveBeenCalledTimes(0);
expect(coreStart.savedObjects.client.find).toHaveBeenCalledTimes(0);
expect(coreStart.savedObjects.client.create).toHaveBeenCalledTimes(0);
expect(newPanelWithoutId.type).toEqual(genericEmbeddable.type);
});
test('Clones a non-RefOrVal embeddable by reference if the panel has a savedObjectId', async () => {
const dashboard = genericEmbeddable.getRoot() as IContainer;
const panel = dashboard.getInput().panels[genericEmbeddable.id] as DashboardPanelState;
panel.explicitInput.savedObjectId = 'holySavedObjectBatman';
const action = new ClonePanelAction(coreStart);
// @ts-ignore
const newPanel = await action.cloneEmbeddable(panel, embeddable.type);
const newPanel = await action.cloneEmbeddable(panel, genericEmbeddable);
expect(coreStart.savedObjects.client.get).toHaveBeenCalledTimes(1);
expect(coreStart.savedObjects.client.find).toHaveBeenCalledTimes(1);
expect(coreStart.savedObjects.client.create).toHaveBeenCalledTimes(1);
expect(newPanel.type).toEqual(embeddable.type);
expect(newPanel.type).toEqual(genericEmbeddable.type);
});
test('Gets a unique title ', async () => {
test('Gets a unique title from the saved objects library', async () => {
const dashboard = genericEmbeddable.getRoot() as IContainer;
const panel = dashboard.getInput().panels[genericEmbeddable.id] as DashboardPanelState;
panel.explicitInput.savedObjectId = 'holySavedObjectBatman';
coreStart.savedObjects.client.find = jest.fn().mockImplementation(({ search }) => {
if (search === '"testFirstTitle"') return { total: 1 };
else if (search === '"testSecondTitle"') return { total: 41 };
else if (search === '"testThirdTitle"') return { total: 90 };
if (search === '"testFirstClone"') {
return {
savedObjects: [
{
attributes: { title: 'testFirstClone' },
get: jest.fn().mockReturnValue('testFirstClone'),
},
],
total: 1,
};
} else if (search === '"testBeforePageLimit"') {
return {
savedObjects: [
{
attributes: { title: 'testBeforePageLimit (copy 9)' },
get: jest.fn().mockReturnValue('testBeforePageLimit (copy 9)'),
},
],
total: 10,
};
} else if (search === '"testMaxLogic"') {
return {
savedObjects: [
{
attributes: { title: 'testMaxLogic (copy 10000)' },
get: jest.fn().mockReturnValue('testMaxLogic (copy 10000)'),
},
],
total: 2,
};
} else if (search === '"testAfterPageLimit"') {
return { total: 11 };
}
});
const action = new ClonePanelAction(coreStart);
// @ts-ignore
expect(await action.getUniqueTitle('testFirstTitle', embeddable.type)).toEqual(
'testFirstTitle (copy)'
expect(await action.getCloneTitle(genericEmbeddable, 'testFirstClone')).toEqual(
'testFirstClone (copy)'
);
// @ts-ignore
expect(await action.getUniqueTitle('testSecondTitle (copy 39)', embeddable.type)).toEqual(
'testSecondTitle (copy 40)'
expect(await action.getCloneTitle(genericEmbeddable, 'testBeforePageLimit')).toEqual(
'testBeforePageLimit (copy 10)'
);
// @ts-ignore
expect(await action.getUniqueTitle('testSecondTitle (copy 20)', embeddable.type)).toEqual(
'testSecondTitle (copy 40)'
expect(await action.getCloneTitle(genericEmbeddable, 'testBeforePageLimit (copy 9)')).toEqual(
'testBeforePageLimit (copy 10)'
);
// @ts-ignore
expect(await action.getUniqueTitle('testThirdTitle', embeddable.type)).toEqual(
'testThirdTitle (copy 89)'
expect(await action.getCloneTitle(genericEmbeddable, 'testMaxLogic')).toEqual(
'testMaxLogic (copy 10001)'
);
// @ts-ignore
expect(await action.getUniqueTitle('testThirdTitle (copy 10000)', embeddable.type)).toEqual(
'testThirdTitle (copy 89)'
expect(await action.getCloneTitle(genericEmbeddable, 'testAfterPageLimit')).toEqual(
'testAfterPageLimit (copy 11)'
);
// @ts-ignore
expect(await action.getCloneTitle(genericEmbeddable, 'testAfterPageLimit (copy 10)')).toEqual(
'testAfterPageLimit (copy 11)'
);
// @ts-ignore
expect(await action.getCloneTitle(genericEmbeddable, 'testAfterPageLimit (copy 10000)')).toEqual(
'testAfterPageLimit (copy 11)'
);
});
test('Gets a unique title from the dashboard', async () => {
const dashboard = genericEmbeddable.getRoot() as DashboardContainer;
const action = new ClonePanelAction(coreStart);
// @ts-ignore
expect(await action.getCloneTitle(byRefOrValEmbeddable, '')).toEqual('');
dashboard.getPanelTitles = jest.fn().mockImplementation(() => {
return ['testDuplicateTitle', 'testDuplicateTitle (copy)', 'testUniqueTitle'];
});
// @ts-ignore
expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testUniqueTitle')).toEqual(
'testUniqueTitle (copy)'
);
// @ts-ignore
expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual(
'testDuplicateTitle (copy 1)'
);
dashboard.getPanelTitles = jest.fn().mockImplementation(() => {
return ['testDuplicateTitle', 'testDuplicateTitle (copy)'].concat(
Array.from([...Array(39)], (_, index) => `testDuplicateTitle (copy ${index + 1})`)
);
});
// @ts-ignore
expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual(
'testDuplicateTitle (copy 40)'
);
// @ts-ignore
expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle (copy 100)')).toEqual(
'testDuplicateTitle (copy 40)'
);
dashboard.getPanelTitles = jest.fn().mockImplementation(() => {
return ['testDuplicateTitle (copy 100)'];
});
// @ts-ignore
expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual(
'testDuplicateTitle (copy 101)'
);
// @ts-ignore
expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle (copy 100)')).toEqual(
'testDuplicateTitle (copy 101)'
);
});

View file

@ -20,6 +20,7 @@ import {
EmbeddableInput,
SavedObjectEmbeddableInput,
isErrorEmbeddable,
isReferenceOrValueEmbeddable,
} from '../../services/embeddable';
import {
placePanelBeside,
@ -78,7 +79,7 @@ export class ClonePanelAction implements Action<ClonePanelActionContext> {
}
dashboard.showPlaceholderUntil(
this.cloneEmbeddable(panelToClone, embeddable.type),
this.cloneEmbeddable(panelToClone, embeddable),
placePanelBeside,
{
width: panelToClone.gridData.w,
@ -89,56 +90,106 @@ export class ClonePanelAction implements Action<ClonePanelActionContext> {
);
}
private async getUniqueTitle(rawTitle: string, embeddableType: string): Promise<string> {
private async getCloneTitle(embeddable: IEmbeddable, rawTitle: string) {
if (rawTitle === '') return ''; // If
const clonedTag = dashboardClonePanelAction.getClonedTag();
const cloneRegex = new RegExp(`\\(${clonedTag}\\)`, 'g');
const cloneNumberRegex = new RegExp(`\\(${clonedTag} [0-9]+\\)`, 'g');
const baseTitle = rawTitle.replace(cloneNumberRegex, '').replace(cloneRegex, '').trim();
let similarTitles: string[];
if (
isReferenceOrValueEmbeddable(embeddable) ||
!_.has(embeddable.getExplicitInput(), 'savedObjectId')
) {
const dashboard: DashboardContainer = embeddable.getRoot() as DashboardContainer;
similarTitles = _.filter(await dashboard.getPanelTitles(), (title: string) => {
return title.startsWith(baseTitle);
});
} else {
const perPage = 10;
const similarSavedObjects = await this.core.savedObjects.client.find<SavedObject>({
type: embeddable.type,
perPage,
fields: ['title'],
searchFields: ['title'],
search: `"${baseTitle}"`,
});
if (similarSavedObjects.total <= perPage) {
similarTitles = similarSavedObjects.savedObjects.map((savedObject) => {
return savedObject.get('title');
});
} else {
similarTitles = [baseTitle + ` (${clonedTag} ${similarSavedObjects.total - 1})`];
}
}
const similarSavedObjects = await this.core.savedObjects.client.find<SavedObject>({
type: embeddableType,
perPage: 0,
fields: ['title'],
searchFields: ['title'],
search: `"${baseTitle}"`,
const cloneNumbers = _.map(similarTitles, (title: string) => {
if (title.match(cloneRegex)) return 0;
const cloneTag = title.match(cloneNumberRegex);
return cloneTag ? parseInt(cloneTag[0].replace(/[^0-9.]/g, ''), 10) : -1;
});
const similarBaseTitlesCount: number = similarSavedObjects.total - 1;
const similarBaseTitlesCount = _.max(cloneNumbers) || 0;
return similarBaseTitlesCount <= 0
return similarBaseTitlesCount < 0
? baseTitle + ` (${clonedTag})`
: baseTitle + ` (${clonedTag} ${similarBaseTitlesCount})`;
: baseTitle + ` (${clonedTag} ${similarBaseTitlesCount + 1})`;
}
private async addCloneToLibrary(
embeddable: IEmbeddable,
objectIdToClone: string
): Promise<string> {
const savedObjectToClone = await this.core.savedObjects.client.get<SavedObject>(
embeddable.type,
objectIdToClone
);
// Clone the saved object
const newTitle = await this.getCloneTitle(embeddable, savedObjectToClone.attributes.title);
const clonedSavedObject = await this.core.savedObjects.client.create(
embeddable.type,
{
..._.cloneDeep(savedObjectToClone.attributes),
title: newTitle,
},
{ references: _.cloneDeep(savedObjectToClone.references) }
);
return clonedSavedObject.id;
}
private async cloneEmbeddable(
panelToClone: DashboardPanelState,
embeddableType: string
embeddable: IEmbeddable
): Promise<Partial<PanelState>> {
const panelState: PanelState<EmbeddableInput> = {
type: embeddableType,
explicitInput: {
...panelToClone.explicitInput,
id: uuid.v4(),
},
};
let newTitle: string = '';
if (panelToClone.explicitInput.savedObjectId) {
// Fetch existing saved object
const savedObjectToClone = await this.core.savedObjects.client.get<SavedObject>(
embeddableType,
panelToClone.explicitInput.savedObjectId
);
// Clone the saved object
newTitle = await this.getUniqueTitle(savedObjectToClone.attributes.title, embeddableType);
const clonedSavedObject = await this.core.savedObjects.client.create(
embeddableType,
{
..._.cloneDeep(savedObjectToClone.attributes),
let panelState: PanelState<EmbeddableInput>;
if (isReferenceOrValueEmbeddable(embeddable)) {
const newTitle = await this.getCloneTitle(embeddable, embeddable.getTitle() || '');
panelState = {
type: embeddable.type,
explicitInput: {
...(await embeddable.getInputAsValueType()),
id: uuid.v4(),
title: newTitle,
hidePanelTitles: panelToClone.explicitInput.hidePanelTitles,
},
{ references: _.cloneDeep(savedObjectToClone.references) }
);
(panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId = clonedSavedObject.id;
};
} else {
panelState = {
type: embeddable.type,
explicitInput: {
...panelToClone.explicitInput,
id: uuid.v4(),
},
};
if (panelToClone.explicitInput.savedObjectId) {
const clonedSavedObjectId = await this.addCloneToLibrary(
embeddable,
panelToClone.explicitInput.savedObjectId
);
(panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId =
clonedSavedObjectId;
}
}
this.core.notifications.toasts.addSuccess({
title: dashboardClonePanelAction.getSuccessMessage(),

View file

@ -79,9 +79,6 @@ export class UnlinkFromLibraryAction implements Action<UnlinkFromLibraryActionCo
type: embeddable.type,
explicitInput: { ...newInput, title: embeddable.getTitle() },
};
// since by value visualizations should not have default titles, unlinking a visualization should remove
// the library title from the attributes.
_.unset(newPanel, 'explicitInput.attributes.title');
dashboard.replacePanel(panelToReplace, newPanel, true);
const title = dashboardUnlinkFromLibraryAction.getSuccessMessage(

View file

@ -107,6 +107,20 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
return Object.keys(this.getInput().panels).length;
};
public async getPanelTitles(): Promise<string[]> {
const titles: string[] = [];
const ids: string[] = Object.keys(this.getInput().panels);
for (const panelId of ids) {
await this.untilEmbeddableLoaded(panelId);
const child: IEmbeddable<EmbeddableInput, EmbeddableOutput> = this.getChild(panelId);
const title = child.getTitle();
if (title) {
titles.push(title);
}
}
return titles;
}
constructor(
initialInput: DashboardContainerInput,
private readonly services: DashboardContainerServices,

View file

@ -137,10 +137,14 @@ export class AttributeService<
return input as ValType;
}
const { attributes } = await this.unwrapAttributes(input);
const libraryTitle = attributes.title;
const { savedObjectId, ...originalInputToPropagate } = input;
return {
...originalInputToPropagate,
attributes,
// by value visualizations should not have default titles and/or descriptions
...{ attributes: omit(attributes, ['title', 'description']) },
title: libraryTitle,
} as unknown as ValType;
};

View file

@ -455,10 +455,8 @@ export class VisualizeEmbeddable
const input = {
savedVis: this.vis.serialize(),
};
if (this.getTitle()) {
input.savedVis.title = this.getTitle();
}
delete input.savedVis.id;
_.unset(input, 'savedVis.title');
return new Promise<VisualizeByValueInput>((resolve) => {
resolve({ ...(input as VisualizeByValueInput) });
});

View file

@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const dashboardPanelActions = getService('dashboardPanelActions');
const dashboardAddPanel = getService('dashboardAddPanel');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects([
'dashboard',
'header',
@ -53,6 +54,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(panelDimensions[0]).to.eql(panelDimensions[1]);
});
it('clone of a by reference embeddable is by value', async () => {
const panelName = PIE_CHART_VIS_NAME.replace(/\s+/g, '');
const clonedPanel = await testSubjects.find(`embeddablePanelHeading-${panelName}(copy)`);
const descendants = await testSubjects.findAllDescendant(
'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION',
clonedPanel
);
expect(descendants.length).to.equal(0);
});
it('gives a correct title to the clone of a clone', async () => {
const initialPanelTitles = await PageObjects.dashboard.getPanelTitles();
const clonedPanelName = initialPanelTitles[initialPanelTitles.length - 1];
@ -65,5 +76,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
PIE_CHART_VIS_NAME + ' (copy 1)'
);
});
it('clone of a by value embeddable is by value', async () => {
const panelName = PIE_CHART_VIS_NAME.replace(/\s+/g, '');
const clonedPanel = await testSubjects.find(`embeddablePanelHeading-${panelName}(copy1)`);
const descendants = await testSubjects.findAllDescendant(
'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION',
clonedPanel
);
expect(descendants.length).to.equal(0);
});
});
}