diff --git a/src/plugins/saved_objects_management/public/index.ts b/src/plugins/saved_objects_management/public/index.ts index 317b3079efa0..eb954bf80f7b 100644 --- a/src/plugins/saved_objects_management/public/index.ts +++ b/src/plugins/saved_objects_management/public/index.ts @@ -46,11 +46,17 @@ export { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementServiceRegistryEntry, } from './services'; -export { ProcessedImportResponse, processImportResponse, FailedImport } from './lib'; +export { + ProcessedImportResponse, + processImportResponse, + FailedImport, + duplicateSavedObjects, + getSavedObjectLabel, +} from './lib'; export { SavedObjectRelation, SavedObjectWithMetadata, SavedObjectMetadata } from './types'; export { SAVED_OBJECT_DELETE_TRIGGER, savedObjectDeleteTrigger } from './triggers'; export { SavedObjectDeleteContext } from './ui_actions_bootstrap'; - +export { SavedObjectsDuplicateModal, DuplicateMode } from './management_section'; export function plugin(initializerContext: PluginInitializerContext) { return new SavedObjectsManagementPlugin(); } diff --git a/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.test.ts b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.test.ts new file mode 100644 index 000000000000..5bf84740db81 --- /dev/null +++ b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { httpServiceMock } from '../../../../core/public/mocks'; +import { duplicateSavedObjects } from './duplicate_saved_objects'; + +describe('copy saved objects', () => { + it('make http call with body provided', async () => { + const httpClient = httpServiceMock.createStartContract(); + const objects = [ + { type: 'dashboard', id: '1' }, + { type: 'visualization', id: '2' }, + ]; + const includeReferencesDeep = true; + const targetWorkspace = '1'; + await duplicateSavedObjects(httpClient, objects, includeReferencesDeep, targetWorkspace); + expect(httpClient.post).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + "/api/workspaces/_duplicate_saved_objects", + Object { + "body": "{\\"objects\\":[{\\"type\\":\\"dashboard\\",\\"id\\":\\"1\\"},{\\"type\\":\\"visualization\\",\\"id\\":\\"2\\"}],\\"includeReferencesDeep\\":true,\\"targetWorkspace\\":\\"1\\"}", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + } + `); + + await duplicateSavedObjects(httpClient, objects, undefined, targetWorkspace); + expect(httpClient.post).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + "/api/workspaces/_duplicate_saved_objects", + Object { + "body": "{\\"objects\\":[{\\"type\\":\\"dashboard\\",\\"id\\":\\"1\\"},{\\"type\\":\\"visualization\\",\\"id\\":\\"2\\"}],\\"includeReferencesDeep\\":true,\\"targetWorkspace\\":\\"1\\"}", + }, + ], + Array [ + "/api/workspaces/_duplicate_saved_objects", + Object { + "body": "{\\"objects\\":[{\\"type\\":\\"dashboard\\",\\"id\\":\\"1\\"},{\\"type\\":\\"visualization\\",\\"id\\":\\"2\\"}],\\"includeReferencesDeep\\":true,\\"targetWorkspace\\":\\"1\\"}", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], + } + `); + }); +}); diff --git a/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts new file mode 100644 index 000000000000..bf7d209bca7a --- /dev/null +++ b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpStart } from 'src/core/public'; + +export async function duplicateSavedObjects( + http: HttpStart, + objects: any[], + includeReferencesDeep: boolean = true, + targetWorkspace: string +) { + return await http.post('/api/workspaces/_duplicate_saved_objects', { + body: JSON.stringify({ + objects, + includeReferencesDeep, + targetWorkspace, + }), + }); +} diff --git a/src/plugins/saved_objects_management/public/lib/index.ts b/src/plugins/saved_objects_management/public/lib/index.ts index fae58cad3eb2..80630b8780e7 100644 --- a/src/plugins/saved_objects_management/public/lib/index.ts +++ b/src/plugins/saved_objects_management/public/lib/index.ts @@ -57,3 +57,4 @@ export { extractExportDetails, SavedObjectsExportResultDetails } from './extract export { createFieldList } from './create_field_list'; export { getAllowedTypes } from './get_allowed_types'; export { filterQuery } from './filter_query'; +export { duplicateSavedObjects } from './duplicate_saved_objects'; diff --git a/src/plugins/saved_objects_management/public/management_section/index.ts b/src/plugins/saved_objects_management/public/management_section/index.ts index 333bee71b0c0..3d0c166a4823 100644 --- a/src/plugins/saved_objects_management/public/management_section/index.ts +++ b/src/plugins/saved_objects_management/public/management_section/index.ts @@ -29,3 +29,4 @@ */ export { mountManagementSection } from './mount_section'; +export { SavedObjectsDuplicateModal, DuplicateMode } from './objects_table'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index f527bb7984c9..9983d219de64 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -74,6 +74,610 @@ exports[`SavedObjectsTable delete should show a confirm modal 1`] = ` `; +exports[`SavedObjectsTable duplicate should allow the user to choose on header when duplicating all 1`] = ` + +`; + +exports[`SavedObjectsTable duplicate should allow the user to choose on table when duplicating all 1`] = ` + +`; + +exports[`SavedObjectsTable duplicate should allow the user to choose on table when duplicating single 1`] = ` + +`; + exports[`SavedObjectsTable export should allow the user to choose when exporting all 1`] = `
`; + +exports[`SavedObjectsTable should unmount normally 1`] = `""`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap new file mode 100644 index 000000000000..9ac13848a068 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap @@ -0,0 +1,688 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DuplicateModal should Unmount normally 1`] = `""`; + +exports[`DuplicateModal should render normally 1`] = ` +HTMLCollection [ + + + + + + + +
+
+
+
+ +
+
+
+ + Duplicate all objects? + +
+
+
+
+
+
+ +
+
+
+
+ Specify a workspace where the objects will be duplicated. +
+
+
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+
+
+ +
+
+
+
+ We recommended duplicating related objects to ensure your duplicated objects will continue to function. +
+
+
+
+ +
+ +
+
+
+
+

+ + The following saved objects will be copied: + +

+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + Type + + + + + + Id + + + + + + Title + + +
+
+ Type +
+
+ + + +
+
+
+ Id +
+
+ + 1 + +
+
+
+ Title +
+
+ + Dashboard_1 + +
+
+
+ Type +
+
+ + + +
+
+
+ Id +
+
+ + 2 + +
+
+
+ Title +
+
+ + Visualization + +
+
+
+ Type +
+
+ + + +
+
+
+ Id +
+
+ + 3 + +
+
+
+ Title +
+
+ + Dashboard_2 + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ + +
+
+
+
+
+ + , +] +`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_object_categories.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_object_categories.test.tsx.snap new file mode 100644 index 000000000000..3171694c6efd --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_object_categories.test.tsx.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RenderDuplicateObjectCategories renders checkboxes correctly 1`] = ` +Array [ + + } + onChange={[Function]} + />, + + } + onChange={[Function]} + />, +] +`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap index 038e1aaf2d8f..fc7c75d80cda 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap @@ -104,3 +104,108 @@ exports[`Header should render normally 1`] = ` /> `; + +exports[`Header should render normally when showDuplicateAll is undefined 1`] = ` + + + + +

+ +

+
+
+ + + + + + + + + + + + + + + + + + + +
+ + +

+ + + +

+
+ +
+`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index e22f7f3a0128..0c8a1d1c1bd4 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -226,6 +226,246 @@ exports[`Table prevents saved objects from being deleted 1`] = ` `; +exports[`Table should call onDuplicateSingle when show duplicate 1`] = ` + + + + , + , + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + > + + } + labelType="label" + > + + } + name="includeReferencesDeep" + onChange={[Function]} + /> + + + + + + + , + ] + } + /> + +
+ +
+
+`; + exports[`Table should render normally 1`] = ` void; +} + +describe('DuplicateModal', () => { + let selectedModeProps: Props; + let allModeProps: Props; + let http: ReturnType; + let notifications: ReturnType; + let workspaces: ReturnType; + const selectedSavedObjects: SavedObjectWithMetadata[] = [ + { + id: '1', + type: 'dashboard', + workspaces: ['workspace1'], + attributes: {}, + references: [], + meta: { + title: 'Dashboard_1', + icon: 'dashboardApp', + }, + }, + { + id: '2', + type: 'visualization', + workspaces: ['workspace2'], + attributes: {}, + references: [], + meta: { + title: 'Visualization', + icon: 'visualizationApp', + }, + }, + { + id: '3', + type: 'dashboard', + workspaces: ['workspace2'], + attributes: {}, + references: [], + meta: { + title: 'Dashboard_2', + }, + }, + ]; + const workspaceList: WorkspaceObject[] = [ + { + id: 'workspace1', + name: 'foo', + }, + { + id: 'workspace2', + name: 'bar', + }, + ]; + beforeEach(() => { + http = httpServiceMock.createStartContract(); + notifications = notificationServiceMock.createStartContract(); + workspaces = workspacesServiceMock.createStartContract(); + + selectedModeProps = { + onDuplicate: jest.fn(), + onClose: jest.fn(), + http, + workspaces, + duplicateMode: DuplicateMode.Selected, + notifications, + selectedSavedObjects, + }; + + allModeProps = { + ...selectedModeProps, + duplicateMode: DuplicateMode.All, + selectedSavedObjects, + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render normally', async () => { + render(); + expect(document.children).toMatchSnapshot(); + }); + + it('should Unmount normally', async () => { + const component = shallowWithI18nProvider( + + ); + expect(component.unmount()).toMatchSnapshot(); + }); + + it('should show all target workspace options when not in any workspace', async () => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next(''); + workspaces.currentWorkspace$.next(null); + selectedModeProps = { ...selectedModeProps, workspaces }; + const component = shallowWithI18nProvider( + + ); + await new Promise((resolve) => process.nextTick(resolve)); + component.update(); + const options = component.find('EuiComboBox').prop('options') as WorkspaceOption[]; + expect(options.length).toEqual(2); + expect(options[0].label).toEqual('foo'); + expect(options[1].label).toEqual('bar'); + }); + + it('should display the suffix (current) in target workspace options when in workspace1', async () => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next('workspace1'); + workspaces.currentWorkspace$.next({ + id: 'workspace1', + name: 'foo', + }); + selectedModeProps = { ...selectedModeProps, workspaces }; + const component = shallowWithI18nProvider( + + ); + await new Promise((resolve) => process.nextTick(resolve)); + component.update(); + const options = component.find('EuiComboBox').prop('options') as WorkspaceOption[]; + expect(options.length).toEqual(2); + expect(options[0].label).toEqual('foo (current)'); + expect(options[1].label).toEqual('bar'); + }); + + it('should only show saved objects belong to workspace1 when target workspace is workspace2', async () => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next(''); + workspaces.currentWorkspace$.next(null); + selectedModeProps = { ...selectedModeProps, workspaces }; + const component = shallowWithI18nProvider( + + ); + const selectedObjects = component + .find('EuiInMemoryTable') + .prop('items') as SavedObjectWithMetadata[]; + expect(selectedObjects.length).toEqual(3); + const comboBox = component.find('EuiComboBox'); + comboBox.simulate('change', [{ label: 'bar', key: 'workspace2', value: workspaceList[1] }]); + component.update(); + + const targetWorkspaceOption = component.state('targetWorkspaceOption') as WorkspaceOption[]; + expect(targetWorkspaceOption.length).toEqual(1); + expect(targetWorkspaceOption[0].key).toEqual('workspace2'); + + const includedSelectedObjects = component + .find('EuiInMemoryTable') + .prop('items') as SavedObjectWithMetadata[]; + expect(includedSelectedObjects.length).toEqual(1); + expect(includedSelectedObjects[0].workspaces).toEqual(['workspace1']); + expect(includedSelectedObjects[0].id).toEqual('1'); + + expect(component.find('EuiCallOut').prop('aria-disabled')).toEqual(false); + }); + + it('should ignore one saved object when target workspace is workspace1', async () => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next(''); + workspaces.currentWorkspace$.next(null); + selectedModeProps = { ...selectedModeProps, workspaces }; + const component = shallowWithI18nProvider( + + ); + const comboBox = component.find('EuiComboBox'); + comboBox.simulate('change', [{ label: 'foo', key: 'workspace1', value: workspaceList[0] }]); + + const includedSelectedObjects = component + .find('EuiInMemoryTable') + .prop('items') as SavedObjectWithMetadata[]; + expect(includedSelectedObjects.length).toEqual(2); + }); + + it('should show saved objects type when duplicate mode is all', async () => { + const component = shallowWithI18nProvider(); + const savedObjectTypeInfoMap = component.state('savedObjectTypeInfoMap') as Map< + string, + [number, boolean] + >; + expect(savedObjectTypeInfoMap.get('dashboard')).toEqual([2, true]); + + const euiCheckbox = component.find('EuiCheckbox').at(0); + expect(euiCheckbox.prop('checked')).toEqual(true); + expect(euiCheckbox.prop('id')).toEqual('includeSavedObjectType.dashboard'); + + euiCheckbox.simulate('change', { target: { checked: false } }); + const euiCheckboxUnCheced = component.find('EuiCheckbox').at(0); + expect(euiCheckboxUnCheced.prop('checked')).toEqual(false); + expect(savedObjectTypeInfoMap.get('dashboard')).toEqual([2, false]); + + (component.instance() as any).changeIncludeSavedObjectType('invalid'); + }); + + it('should uncheck duplicate related objects', async () => { + const component = shallowWithI18nProvider( + + ); + + const euiCheckbox = component.find('EuiCheckbox').at(0); + expect(euiCheckbox.prop('checked')).toEqual(true); + expect(euiCheckbox.prop('id')).toEqual('includeReferencesDeep'); + expect(component.state('isIncludeReferencesDeepChecked')).toEqual(true); + + euiCheckbox.simulate('change', { target: { checked: false } }); + expect(component.state('isIncludeReferencesDeepChecked')).toEqual(false); + }); + + it('should call onClose function when cancle button is clicked', () => { + const component = shallowWithI18nProvider( + + ); + component.find('[data-test-subj="duplicateCancelButton"]').simulate('click'); + expect(selectedModeProps.onClose).toHaveBeenCalled(); + }); + + it('should call onDuplicate function when confirm button is clicked', () => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next(''); + workspaces.currentWorkspace$.next(null); + selectedModeProps = { ...selectedModeProps, workspaces }; + const component = shallowWithI18nProvider( + + ); + const comboBox = component.find('EuiComboBox'); + comboBox.simulate('change', [{ label: 'bar', key: 'workspace2', value: workspaceList[1] }]); + const confirmButton = component.find('[data-test-subj="duplicateConfirmButton"]'); + expect(confirmButton.prop('isLoading')).toBe(false); + expect(confirmButton.prop('disabled')).toBe(false); + confirmButton.simulate('click'); + expect(selectedModeProps.onDuplicate).toHaveBeenCalled(); + }); + + it('should not change isLoading when isMounted is false ', async () => { + const component = shallowWithI18nProvider( + + ); + const comboBox = component.find('EuiComboBox'); + comboBox.simulate('change', [{ label: 'bar', key: 'workspace2', value: workspaceList[1] }]); + const confirmButton = component.find('[data-test-subj="duplicateConfirmButton"]'); + (component.instance() as any).isMounted = false; + confirmButton.simulate('click'); + expect(selectedModeProps.onDuplicate).toHaveBeenCalled(); + expect(component.state('isLoading')).toBe(true); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx new file mode 100644 index 000000000000..9ba53a1c6ac0 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx @@ -0,0 +1,369 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { groupBy } from 'lodash'; +import { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiComboBox, + EuiFormRow, + EuiCheckbox, + EuiInMemoryTable, + EuiToolTip, + EuiIcon, + EuiCallOut, + EuiText, + EuiTextColor, +} from '@elastic/eui'; +import { HttpSetup, NotificationsStart, WorkspacesStart } from 'opensearch-dashboards/public'; +import { i18n } from '@osd/i18n'; +import { SavedObjectWithMetadata } from '../../../../common'; +import { getSavedObjectLabel } from '../../../../public'; +import { WorkspaceOption, getTargetWorkspacesOptions, workspaceToOption } from './utils'; +import { DuplicateMode } from '../../types'; +import RenderDuplicateObjectCategories from './duplicate_object_categories'; + +export interface ShowDuplicateModalProps { + onDuplicate: ( + savedObjects: SavedObjectWithMetadata[], + includeReferencesDeep: boolean, + targetWorkspace: string + ) => Promise; + http: HttpSetup; + workspaces: WorkspacesStart; + duplicateMode: DuplicateMode; + notifications: NotificationsStart; + selectedSavedObjects: SavedObjectWithMetadata[]; +} + +interface Props extends ShowDuplicateModalProps { + onClose: () => void; +} + +interface State { + allSelectedObjects: SavedObjectWithMetadata[]; + workspaceOptions: WorkspaceOption[]; + targetWorkspaceOption: WorkspaceOption[]; + isLoading: boolean; + isIncludeReferencesDeepChecked: boolean; + savedObjectTypeInfoMap: Map; +} + +export class SavedObjectsDuplicateModal extends React.Component { + private isMounted = false; + + constructor(props: Props) { + super(props); + + const { workspaces, duplicateMode } = props; + const currentWorkspace = workspaces.currentWorkspace$.value; + const currentWorkspaceId = currentWorkspace?.id; + const targetWorkspacesOptions = getTargetWorkspacesOptions(workspaces, currentWorkspaceId); + + // If user click 'Duplicate All' button, saved objects will be categoried by type. + const savedObjectTypeInfoMap = new Map(); + if (duplicateMode === DuplicateMode.All) { + const categorizedObjects = groupBy(props.selectedSavedObjects, (object) => object.type); + for (const [savedObjectType, savedObjects] of Object.entries(categorizedObjects)) { + savedObjectTypeInfoMap.set(savedObjectType, [savedObjects.length, true]); + } + } + + this.state = { + allSelectedObjects: props.selectedSavedObjects, + // current workspace is the first option + workspaceOptions: [ + ...(currentWorkspace ? [workspaceToOption(currentWorkspace, currentWorkspaceId)] : []), + ...targetWorkspacesOptions, + ], + targetWorkspaceOption: [], + isLoading: false, + isIncludeReferencesDeepChecked: true, + savedObjectTypeInfoMap, + }; + this.isMounted = true; + } + + componentWillUnmount() { + this.isMounted = false; + } + + duplicateSavedObjects = async (savedObjects: SavedObjectWithMetadata[]) => { + this.setState({ + isLoading: true, + }); + + const targetWorkspace = this.state.targetWorkspaceOption[0].key; + + await this.props.onDuplicate( + savedObjects, + this.state.isIncludeReferencesDeepChecked, + targetWorkspace! + ); + + if (this.isMounted) { + this.setState({ + isLoading: false, + }); + } + }; + + onTargetWorkspaceChange = (targetWorkspaceOption: WorkspaceOption[]) => { + this.setState({ + targetWorkspaceOption, + }); + }; + + changeIncludeReferencesDeep = () => { + this.setState((state) => ({ + isIncludeReferencesDeepChecked: !state.isIncludeReferencesDeepChecked, + })); + }; + + // Choose whether to copy a certain type or not. + changeIncludeSavedObjectType = (savedObjectType: string) => { + const { savedObjectTypeInfoMap } = this.state; + const savedObjectTypeInfo = savedObjectTypeInfoMap.get(savedObjectType); + if (savedObjectTypeInfo) { + const [count, checked] = savedObjectTypeInfo; + savedObjectTypeInfoMap.set(savedObjectType, [count, !checked]); + this.setState({ savedObjectTypeInfoMap }); + } + }; + + isSavedObjectTypeIncluded = (savedObjectType: string) => { + const { savedObjectTypeInfoMap } = this.state; + const savedObjectTypeInfo = savedObjectTypeInfoMap.get(savedObjectType); + return savedObjectTypeInfo && savedObjectTypeInfo[1]; + }; + + getIncludeAndNotDuplicateObjects = (targetWorkspaceId: string | undefined) => { + let selectedObjects = this.state.allSelectedObjects; + if (this.props.duplicateMode === DuplicateMode.All) { + selectedObjects = selectedObjects.filter((item) => this.isSavedObjectTypeIncluded(item.type)); + } + // If the target workspace is not selected, all saved objects will be retained. + // If the target workspace has been selected, filter out the saved objects that belongs to the workspace. + const includedSelectedObjects = selectedObjects.filter((item) => + !!targetWorkspaceId && !!item.workspaces ? !item.workspaces.includes(targetWorkspaceId) : true + ); + const ignoredSelectedObjectsLength = selectedObjects.length - includedSelectedObjects.length; + return { includedSelectedObjects, ignoredSelectedObjectsLength }; + }; + + render() { + const { + workspaceOptions, + targetWorkspaceOption, + isIncludeReferencesDeepChecked, + allSelectedObjects, + } = this.state; + const { duplicateMode, onClose } = this.props; + const targetWorkspaceId = targetWorkspaceOption?.at(0)?.key; + + const { + includedSelectedObjects, + ignoredSelectedObjectsLength, + } = this.getIncludeAndNotDuplicateObjects(targetWorkspaceId); + + let confirmDuplicateButtonEnabled = false; + if (!!targetWorkspaceId && includedSelectedObjects.length > 0) { + confirmDuplicateButtonEnabled = true; + } + + const warningMessage = ( + + {ignoredSelectedObjectsLength} saved object + {ignoredSelectedObjectsLength === 1 ? ' will' : 's will'}{' '} + not be copied, because{' '} + {ignoredSelectedObjectsLength === 1 ? 'it has' : 'they have'} already existed in the + selected workspace. + + ); + + // Show the number and reason why some saved objects cannot be duplicated. + const ignoreSomeObjectsChildren: React.ReactChild = ( + <> + + {warningMessage} + + + + ); + + return ( + + + + + + + + + + <> + + {i18n.translate( + 'savedObjectsManagement.objectsTable.duplicateModal.targetWorkspaceNotice', + { defaultMessage: 'Specify a workspace where the objects will be duplicated.' } + )} + + + + + + + + {duplicateMode === DuplicateMode.All && + RenderDuplicateObjectCategories( + this.state.savedObjectTypeInfoMap, + this.changeIncludeSavedObjectType + )} + {duplicateMode === DuplicateMode.All && } + + + <> + + {i18n.translate( + 'savedObjectsManagement.objectsTable.duplicateModal.relatedObjectsNotice', + { + defaultMessage: + 'We recommended duplicating related objects to ensure your duplicated objects will continue to function.', + } + )} + + + + + + + + {ignoredSelectedObjectsLength === 0 ? null : ignoreSomeObjectsChildren} +

+ +

+ + ( + + + + ), + }, + { + field: 'id', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicateModal.idColumnName', + { + defaultMessage: 'Id', + } + ), + }, + { + field: 'meta.title', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicateModal.titleColumnName', + { defaultMessage: 'Title' } + ), + }, + ]} + pagination={true} + sorting={false} + /> +
+ + + + + + + this.duplicateSavedObjects(includedSelectedObjects)} + isLoading={this.state.isLoading} + disabled={!confirmDuplicateButtonEnabled} + > + + + +
+ ); + } +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.test.tsx new file mode 100644 index 000000000000..c14757f16c2b --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.test.tsx @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import RenderDuplicateObjectCategories from './duplicate_object_categories'; + +describe('RenderDuplicateObjectCategories', () => { + test('renders checkboxes correctly', () => { + const savedObjectTypeInfoMap: Map = new Map([ + ['type1', [5, true]], + ['type2', [10, false]], + ]); + const changeIncludeSavedObjectTypeMock = jest.fn(); + + const renderDuplicateObjectCategories = RenderDuplicateObjectCategories( + savedObjectTypeInfoMap, + changeIncludeSavedObjectTypeMock + ); + + expect(renderDuplicateObjectCategories).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.tsx new file mode 100644 index 000000000000..948c87de1f74 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.tsx @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiCheckbox } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { capitalizeFirstLetter } from './utils'; + +// A checkbox showing the type and count of save objects. +function renderDuplicateObjectCategory( + savedObjectType: string, + savedObjectTypeCount: number, + savedObjectTypeChecked: boolean, + changeIncludeSavedObjectType: (savedObjectType: string) => void +) { + return ( + + } + checked={savedObjectTypeChecked} + onChange={() => changeIncludeSavedObjectType(savedObjectType)} + /> + ); +} + +// eslint-disable-next-line import/no-default-export +export default function RenderDuplicateObjectCategories( + savedObjectTypeInfoMap: Map, + changeIncludeSavedObjectType: (savedObjectType: string) => void +) { + const checkboxList: React.JSX.Element[] = []; + savedObjectTypeInfoMap.forEach( + ([savedObjectTypeCount, savedObjectTypeChecked], savedObjectType) => + checkboxList.push( + renderDuplicateObjectCategory( + savedObjectType, + savedObjectTypeCount, + savedObjectTypeChecked, + changeIncludeSavedObjectType + ) + ) + ); + return checkboxList; +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx index 1b0f40e9cd02..da6f241f382c 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx @@ -38,12 +38,48 @@ describe('Header', () => { onExportAll: () => {}, onImport: () => {}, onRefresh: () => {}, - totalCount: 4, + onDuplicate: () => {}, + objectCount: 4, filteredCount: 2, + showDuplicateAll: false, }; const component = shallow(
); expect(component).toMatchSnapshot(); }); + + it('should render normally when showDuplicateAll is undefined', () => { + const props = { + onExportAll: () => {}, + onImport: () => {}, + onRefresh: () => {}, + onDuplicate: () => {}, + objectCount: 4, + filteredCount: 2, + showDuplicateAll: undefined, + }; + + const component = shallow(
); + + expect(component).toMatchSnapshot(); + }); +}); + +describe('Header - workspace enabled', () => { + it('should render `Duplicate All` button when workspace enabled', () => { + const props = { + onExportAll: () => {}, + onImport: () => {}, + onRefresh: () => {}, + onDuplicate: () => {}, + objectCount: 4, + filteredCount: 2, + showDuplicateAll: true, + }; + + const component = shallow(
); + + expect(component.find('EuiButtonEmpty[data-test-subj="duplicateObjects"]').exists()).toBe(true); + }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx index a22e349d5240..47df820a9e8e 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx @@ -43,13 +43,19 @@ import { FormattedMessage } from '@osd/i18n/react'; export const Header = ({ onExportAll, onImport, + onDuplicate, onRefresh, filteredCount, + objectCount, + showDuplicateAll = false, }: { onExportAll: () => void; onImport: () => void; + onDuplicate: () => void; onRefresh: () => void; filteredCount: number; + objectCount: number; + showDuplicateAll: boolean; }) => ( @@ -66,6 +72,21 @@ export const Header = ({ + {showDuplicateAll && ( + + + + + + )} {}, canDelete: true, + onDuplicateSelected: () => {}, + onDuplicateSingle: () => {}, + showDuplicate: false, }; describe('Table', () => { @@ -222,4 +225,23 @@ describe('Table', () => { someAction.onClick(); expect(onActionRefresh).toHaveBeenCalled(); }); + + it('should call onDuplicateSingle when show duplicate', () => { + const onDuplicateSingle = jest.fn(); + const showDuplicate = true; + const customizedProps = { ...defaultProps, onDuplicateSingle, showDuplicate }; + const component = shallowWithI18nProvider(); + expect(component).toMatchSnapshot(); + + const table = component.find('EuiBasicTable'); + const columns = table.prop('columns') as any[]; + const actionColumn = columns.find((x) => x.hasOwnProperty('actions')) as { actions: any[] }; + const duplicateAction = actionColumn.actions.find( + (x) => x['data-test-subj'] === 'savedObjectsTableAction-duplicate' + ); + + expect(onDuplicateSingle).not.toHaveBeenCalled(); + duplicateAction.onClick(); + expect(onDuplicateSingle).toHaveBeenCalled(); + }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index 1a1df64e3752..588cf0e93b27 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -46,6 +46,7 @@ import { EuiText, EuiTableFieldDataColumnType, EuiTableActionsColumnType, + EuiButtonIcon, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; @@ -62,7 +63,6 @@ export interface TableProps { basePath: IBasePath; actionRegistry: SavedObjectsManagementActionServiceStart; columnRegistry: SavedObjectsManagementColumnServiceStart; - namespaceRegistry: SavedObjectsManagementNamespaceServiceStart; selectedSavedObjects: SavedObjectWithMetadata[]; selectionConfig: { onSelectionChange: (selection: SavedObjectWithMetadata[]) => void; @@ -70,6 +70,8 @@ export interface TableProps { filters: any[]; canDelete: boolean; onDelete: () => void; + onDuplicateSelected: () => void; + onDuplicateSingle: (object: SavedObjectWithMetadata) => void; onActionRefresh: (object: SavedObjectWithMetadata) => void; onExport: (includeReferencesDeep: boolean) => void; goInspectObject: (obj: SavedObjectWithMetadata) => void; @@ -86,6 +88,7 @@ export interface TableProps { dateFormat: string; availableWorkspaces?: WorkspaceAttribute[]; currentWorkspaceId?: string; + showDuplicate: boolean; } interface TableState { @@ -170,6 +173,8 @@ export class Table extends PureComponent { filters, selectionConfig: selection, onDelete, + onDuplicateSelected, + onDuplicateSingle, onActionRefresh, selectedSavedObjects, onTableChange, @@ -178,10 +183,10 @@ export class Table extends PureComponent { basePath, actionRegistry, columnRegistry, - namespaceRegistry, dateFormat, availableWorkspaces, currentWorkspaceId, + showDuplicate, } = this.props; const visibleWsIds = availableWorkspaces?.map((ws) => ws.id) || []; @@ -312,6 +317,25 @@ export class Table extends PureComponent { onClick: (object) => onShowRelationships(object), 'data-test-subj': 'savedObjectsTableAction-relationships', }, + ...(showDuplicate + ? [ + { + name: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnActions.duplicateActionName', + { defaultMessage: 'Duplicate' } + ), + description: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnActions.duplicateActionDescription', + { defaultMessage: 'Duplicate this saved object' } + ), + type: 'icon', + icon: 'copyClipboard', + isPrimary: true, + onClick: (object: SavedObjectWithMetadata) => onDuplicateSingle(object), + 'data-test-subj': 'savedObjectsTableAction-duplicate', + }, + ] + : []), ...actionRegistry.getAll().map((action) => { return { ...action.euiAction, @@ -368,6 +392,78 @@ export class Table extends PureComponent { const activeActionContents = this.state.activeAction?.render() ?? null; + const tools = [ + + + , + + + } + > + + } + checked={this.state.isIncludeReferencesDeepChecked} + onChange={this.toggleIsIncludeReferencesDeepChecked} + /> + + + + + + + , + ]; + + const duplicateButton = ( + + ); + + if (showDuplicate) { + tools.splice(1, 0, duplicateButton); + } + return ( {activeActionContents} @@ -375,63 +471,7 @@ export class Table extends PureComponent { box={{ 'data-test-subj': 'savedObjectSearchBar' }} filters={filters as any} onChange={this.onChange} - toolsRight={[ - - - , - - - } - > - - } - checked={this.state.isIncludeReferencesDeepChecked} - onChange={this.toggleIsIncludeReferencesDeepChecked} - /> - - - - - - - , - ]} + toolsRight={tools} /> {queryParseError} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.test.tsx new file mode 100644 index 000000000000..5fb950782989 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspaceAttribute, WorkspaceObject, WorkspacesStart } from 'opensearch-dashboards/public'; +import { + WorkspaceOption, + capitalizeFirstLetter, + getTargetWorkspacesOptions, + workspaceToOption, +} from './utils'; +import { BehaviorSubject } from 'rxjs'; + +describe('duplicate mode utils', () => { + it('should covert workspace to option', () => { + const workspace: WorkspaceAttribute = { + id: '1', + name: 'Workspace 1', + }; + const workspaceOption: WorkspaceOption = workspaceToOption(workspace); + expect(workspaceOption.label).toBe(workspace.name); + expect(workspaceOption.key).toBe(workspace.id); + expect(workspaceOption.value).toBe(workspace); + }); + + it('should add suffix when workspace is current workspace', () => { + const workspace: WorkspaceAttribute = { + id: '1', + name: 'Workspace 1', + }; + const workspaceOption: WorkspaceOption = workspaceToOption(workspace, '1'); + expect(workspaceOption.label).toBe('Workspace 1 (current)'); + expect(workspaceOption.key).toBe(workspace.id); + expect(workspaceOption.value).toBe(workspace); + }); + + it('should get target workspace options', () => { + const workspaces: WorkspacesStart = { + currentWorkspaceId$: new BehaviorSubject('1'), + currentWorkspace$: new BehaviorSubject({ + id: '1', + name: 'Workspace 1', + }), + workspaceList$: new BehaviorSubject([ + { id: '1', name: 'Workspace 1' }, + { id: '2', name: 'Workspace 2', libraryReadonly: false }, + { id: '3', name: 'Workspace 3', libraryReadonly: true }, + ]), + initialized$: new BehaviorSubject(true), + }; + const optionContainCurrent: WorkspaceOption[] = getTargetWorkspacesOptions(workspaces, '1'); + expect(optionContainCurrent.length).toBe(1); + expect(optionContainCurrent[0].key).toBe('2'); + expect(optionContainCurrent[0].label).toBe('Workspace 2'); + + const workspaceOption: WorkspaceOption[] = getTargetWorkspacesOptions(workspaces); + expect(workspaceOption.length).toBe(2); + }); + + it('should capitalize first letter', () => { + const workspaceName: string = 'workspace'; + const capitalizedName: string = capitalizeFirstLetter(workspaceName); + expect(capitalizedName).toBe('Workspace'); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts new file mode 100644 index 000000000000..33b653e1d8ac --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { WorkspaceAttribute, WorkspacesStart } from 'opensearch-dashboards/public'; + +export type WorkspaceOption = EuiComboBoxOptionOption; + +// Convert workspace to option which can be displayed in the drop-down box. +export function workspaceToOption( + workspace: WorkspaceAttribute, + currentWorkspaceId?: string +): WorkspaceOption { + // add (current) after current workspace name + let workspaceName = workspace.name; + if (workspace.id === currentWorkspaceId) { + workspaceName += ' (current)'; + } + return { + label: workspaceName, + key: workspace.id, + value: workspace, + }; +} + +export function getTargetWorkspacesOptions( + workspaces: WorkspacesStart, + currentWorkspaceId?: string +): WorkspaceOption[] { + const workspaceList = workspaces.workspaceList$.value; + const targetWorkspaces = workspaceList.filter( + (workspace) => workspace.id !== currentWorkspaceId && !workspace.libraryReadonly + ); + return targetWorkspaces.map((workspace) => workspaceToOption(workspace, currentWorkspaceId)); +} + +export function capitalizeFirstLetter(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts index b2153648057f..e9bd1293def2 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts @@ -29,3 +29,4 @@ */ export { SavedObjectsTable } from './saved_objects_table'; +export { SavedObjectsDuplicateModal, DuplicateMode } from './components'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.mocks.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.mocks.ts index b856b8662479..f91c7103eeac 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.mocks.ts +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.mocks.ts @@ -80,3 +80,8 @@ export const getRelationshipsMock = jest.fn(); jest.doMock('../../lib/get_relationships', () => ({ getRelationships: getRelationshipsMock, })); + +export const getDuplicateSavedObjectsMock = jest.fn(); +jest.doMock('../../lib/duplicate_saved_objects', () => ({ + duplicateSavedObjects: getDuplicateSavedObjectsMock, +})); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index fdcd16f1a068..dcdfc844f61b 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -33,6 +33,7 @@ import { fetchExportByTypeAndSearchMock, fetchExportObjectsMock, findObjectsMock, + getDuplicateSavedObjectsMock, getRelationshipsMock, getSavedObjectCountsMock, saveAsMock, @@ -244,6 +245,22 @@ describe('SavedObjectsTable', () => { expect(component).toMatchSnapshot(); }); + it('should unmount normally', async () => { + const component = shallowRender(); + const mockDebouncedFetchObjects = { + cancel: jest.fn(), + flush: jest.fn(), + }; + component.instance().debouncedFetchObjects = mockDebouncedFetchObjects as any; + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + // component.update(); + + component.unmount(); + expect(component).toMatchSnapshot(); + }); + it('should add danger toast when find fails', async () => { findObjectsMock.mockImplementation(() => { throw new Error('Simulated find error'); @@ -839,4 +856,194 @@ describe('SavedObjectsTable', () => { }); }); }); + + describe('duplicate', () => { + const applications = applicationServiceMock.createStartContract(); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: false, + }, + workspaces: { + enabled: true, + }, + }; + + const workspaceList: WorkspaceObject[] = [ + { + id: 'workspace1', + name: 'foo', + }, + { + id: 'workspace2', + name: 'bar', + }, + ]; + + const mockSelectedSavedObjects = [ + { id: '1', type: 'dashboard', references: [], attributes: [], meta: {} }, + { id: '2', type: 'dashboard', references: [], attributes: [], meta: {} }, + ] as SavedObjectWithMetadata[]; + + beforeEach(() => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next('workspace1'); + workspaces.currentWorkspace$.next(workspaceList[0]); + }); + + it('should duplicate selected object', async () => { + getDuplicateSavedObjectsMock.mockImplementation(() => ({ success: true })); + + const component = shallowRender({ applications, workspaces }); + component.setState({ isShowingDuplicateModal: true }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component.state('isShowingDuplicateModal')).toEqual(true); + expect(component.find('SavedObjectsDuplicateModal').length).toEqual(1); + + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2'); + + expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( + http, + [ + { id: '1', type: 'dashboard' }, + { id: '2', type: 'dashboard' }, + ], + false, + 'workspace2' + ); + component.update(); + + expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ + title: 'Duplicate 2 saved objects successfully', + }); + }); + + it('should show error when duplicating selected object is fail', async () => { + getDuplicateSavedObjectsMock.mockImplementationOnce(() => ({ success: false })); + + const component = shallowRender({ applications, workspaces }); + component.setState({ isShowingDuplicateModal: true }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2'); + + expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( + http, + [ + { id: '1', type: 'dashboard' }, + { id: '2', type: 'dashboard' }, + ], + false, + 'workspace2' + ); + component.update(); + + expect(notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Unable to duplicate 2 saved objects.', + }); + + getDuplicateSavedObjectsMock.mockImplementationOnce(() => ({ + success: false, + errors: [{ id: '1' }, { id: '2' }], + })); + + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2'); + expect(notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Unable to duplicate 2 saved objects. These objects cannot be duplicated:1, 2', + }); + }); + + it('should catch error when duplicating selected object is fail', async () => { + getDuplicateSavedObjectsMock.mockImplementationOnce(() => undefined); + + const component = shallowRender({ applications, workspaces }); + component.setState({ isShowingDuplicateModal: true }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2'); + + expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( + http, + [ + { id: '1', type: 'dashboard' }, + { id: '2', type: 'dashboard' }, + ], + false, + 'workspace2' + ); + component.update(); + + expect(notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Unable to duplicate 2 saved objects', + }); + }); + + it('should allow the user to choose on header when duplicating all', async () => { + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + const header = component.find('Header') as any; + expect(header.prop('showDuplicateAll')).toEqual(true); + header.prop('onDuplicate')(); + + await new Promise((resolve) => process.nextTick(resolve)); + component.update(); + + expect(component.state('isShowingDuplicateModal')).toEqual(true); + expect(component.find('SavedObjectsDuplicateModal')).toMatchSnapshot(); + }); + + it('should allow the user to choose on table when duplicating all', async () => { + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + const table = component.find('Table') as any; + table.prop('onDuplicateSelected')(); + component.update(); + + expect(component.state('isShowingDuplicateModal')).toEqual(true); + expect(component.find('SavedObjectsDuplicateModal')).toMatchSnapshot(); + }); + + it('should allow the user to choose on table when duplicating single', async () => { + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + const table = component.find('Table') as any; + table.prop('onDuplicateSingle')([{ id: '1', type: 'dashboard', workspaces: ['workspace1'] }]); + component.update(); + + expect(component.state('isShowingDuplicateModal')).toEqual(true); + expect(component.find('SavedObjectsDuplicateModal')).toMatchSnapshot(); + }); + }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 0a078d5ee044..b88b4b84ee88 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -85,6 +85,7 @@ import { findObject, extractExportDetails, SavedObjectsExportResultDetails, + duplicateSavedObjects, } from '../../lib'; import { SavedObjectWithMetadata } from '../../types'; import { @@ -93,14 +94,14 @@ import { SavedObjectsManagementColumnServiceStart, SavedObjectsManagementNamespaceServiceStart, } from '../../services'; -import { Header, Table, Flyout, Relationships } from './components'; +import { Header, Table, Flyout, Relationships, SavedObjectsDuplicateModal } from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; +import { DuplicateMode } from '../types'; interface ExportAllOption { id: string; label: string; } - export interface SavedObjectsTableProps { allowedTypes: string[]; serviceRegistry: ISavedObjectsManagementServiceRegistry; @@ -131,7 +132,10 @@ export interface SavedObjectsTableState { savedObjectCounts: Record>; activeQuery: Query; selectedSavedObjects: SavedObjectWithMetadata[]; + duplicateSelectedSavedObjects: SavedObjectWithMetadata[]; isShowingImportFlyout: boolean; + duplicateMode: DuplicateMode; + isShowingDuplicateModal: boolean; isSearching: boolean; filteredItemCount: number; isShowingRelationships: boolean; @@ -151,6 +155,7 @@ export class SavedObjectsTable extends Component>, activeQuery: Query.parse(''), selectedSavedObjects: [], + duplicateSelectedSavedObjects: [], isShowingImportFlyout: false, + duplicateMode: DuplicateMode.Selected, + isShowingDuplicateModal: false, isSearching: false, filteredItemCount: 0, isShowingRelationships: false, @@ -185,6 +193,49 @@ export class SavedObjectsTable extends Component ns.id) || []; + if (availableNamespaces.length) { + const filteredNamespaces = filterQuery(availableNamespaces, visibleNamespaces); + findOptions.namespaces = filteredNamespaces; + } + + if (visibleWorkspaces?.length) { + const workspaceIds: string[] = visibleWorkspaces.map( + (wsName) => this.workspaceNameIdLookup?.get(wsName) || '' + ); + findOptions.workspaces = workspaceIds; + } + + if (findOptions.workspaces) { + if (findOptions.workspaces.indexOf(PUBLIC_WORKSPACE_ID) !== -1) { + // search both saved objects with workspace and without workspace + findOptions.workspacesSearchOperator = 'OR'; + } + } + + if (findOptions.type.length > 1) { + findOptions.sortField = 'type'; + } + + return findOptions; + } + private get workspaceIdQuery() { const { availableWorkspaces, currentWorkspaceId, workspaceEnabled } = this.state; // workspace is turned off @@ -234,6 +285,7 @@ export class SavedObjectsTable extends Component { @@ -328,47 +380,11 @@ export class SavedObjectsTable extends Component { - const { activeQuery: query, page, perPage } = this.state; - const { notifications, http, allowedTypes, namespaceRegistry } = this.props; - const { queryText, visibleTypes, visibleNamespaces, visibleWorkspaces } = parseQuery(query); - const filteredTypes = filterQuery(allowedTypes, visibleTypes); - // "searchFields" is missing from the "findOptions" but gets injected via the API. - // The API extracts the fields from each uiExports.savedObjectsManagement "defaultSearchField" attribute - const findOptions: SavedObjectsFindOptions = this.formatWorkspaceIdParams({ - search: queryText ? `${queryText}*` : undefined, - perPage, - page: page + 1, - fields: ['id'], - type: filteredTypes, - workspaces: this.workspaceIdQuery, - }); - - const availableNamespaces = namespaceRegistry.getAll()?.map((ns) => ns.id) || []; - if (availableNamespaces.length) { - const filteredNamespaces = filterQuery(availableNamespaces, visibleNamespaces); - findOptions.namespaces = filteredNamespaces; - } - - if (visibleWorkspaces?.length) { - const workspaceIds: string[] = visibleWorkspaces.map( - (wsName) => this.workspaceNameIdLookup?.get(wsName) || '' - ); - findOptions.workspaces = workspaceIds; - } - - if (findOptions.workspaces) { - if (findOptions.workspaces.indexOf(PUBLIC_WORKSPACE_ID) !== -1) { - // search both saved objects with workspace and without workspace - findOptions.workspacesSearchOperator = 'OR'; - } - } - - if (findOptions.type.length > 1) { - findOptions.sortField = 'type'; - } + const { activeQuery: query } = this.state; + const { notifications, http } = this.props; try { - const resp = await findObjects(http, findOptions); + const resp = await findObjects(http, this.findOptions); if (!this._isMounted) { return; } @@ -671,6 +687,117 @@ export class SavedObjectsTable extends Component { + this.setState({ isShowingDuplicateModal: false }); + }; + + onDuplicateAll = async () => { + const { notifications, http } = this.props; + let duplicateAllSavedObjects: SavedObjectWithMetadata[] = []; + const findOptions = this.findOptions; + findOptions.sortField = 'updated_at'; + findOptions.page = 1; + + while (duplicateAllSavedObjects.length < this.state.filteredItemCount) { + try { + const resp = await findObjects(http, findOptions); + const savedObjects = resp.savedObjects; + duplicateAllSavedObjects = duplicateAllSavedObjects.concat(savedObjects); + } catch (error) { + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage', + { defaultMessage: 'Unable find saved objects' } + ), + text: `${error}`, + }); + break; + } + findOptions.page++; + } + + this.setState({ + duplicateSelectedSavedObjects: duplicateAllSavedObjects, + isShowingDuplicateModal: true, + duplicateMode: DuplicateMode.All, + }); + }; + + onDuplicate = async ( + savedObjects: SavedObjectWithMetadata[], + includeReferencesDeep: boolean, + targetWorkspace: string + ) => { + const { http, notifications } = this.props; + const objectsToDuplicate = savedObjects.map((obj) => ({ id: obj.id, type: obj.type })); + let result; + try { + result = await duplicateSavedObjects( + http, + objectsToDuplicate, + includeReferencesDeep, + targetWorkspace + ); + if (result.success) { + notifications.toasts.addSuccess({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicate.successNotification', + { + defaultMessage: + 'Duplicate ' + savedObjects.length.toString() + ' saved objects successfully', + } + ), + }); + } else { + const errorIdMessages = result.errors + ? ' These objects cannot be duplicated:' + + result.errors.map((item: { id: string }) => item.id).join(', ') + : ''; + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicate.dangerNotification', + { + defaultMessage: + 'Unable to duplicate ' + + savedObjects.length.toString() + + ' saved objects.' + + errorIdMessages, + } + ), + }); + } + } catch (e) { + notifications.toasts.addDanger({ + title: i18n.translate('savedObjectsManagement.objectsTable.duplicate.dangerNotification', { + defaultMessage: + 'Unable to duplicate ' + savedObjects.length.toString() + ' saved objects', + }), + }); + } + this.hideDuplicateModal(); + await this.refreshObjects(); + }; + + renderDuplicateModal() { + const { isShowingDuplicateModal, duplicateSelectedSavedObjects, duplicateMode } = this.state; + + if (!isShowingDuplicateModal) { + return null; + } + + return ( + + ); + } + renderRelationships() { if (!this.state.isShowingRelationships) { return null; @@ -1001,11 +1128,15 @@ export class SavedObjectsTable extends Component this.setState({ isShowingExportAllOptionsModal: true })} onImport={this.showImportFlyout} + showDuplicateAll={workspaceEnabled} + onDuplicate={this.onDuplicateAll} onRefresh={this.refreshObjects} filteredCount={filteredItemCount} + objectCount={savedObjects.length} /> @@ -1022,6 +1153,20 @@ export class SavedObjectsTable extends Component + this.setState({ + isShowingDuplicateModal: true, + duplicateMode: DuplicateMode.Selected, + duplicateSelectedSavedObjects: selectedSavedObjects, + }) + } + onDuplicateSingle={(object) => + this.setState({ + duplicateSelectedSavedObjects: [object], + isShowingDuplicateModal: true, + duplicateMode: DuplicateMode.Selected, + }) + } onActionRefresh={this.refreshObject} goInspectObject={this.props.goInspectObject} pageIndex={page} @@ -1034,6 +1179,7 @@ export class SavedObjectsTable extends Component diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx index ad5f83fc8238..69c3d858320b 100644 --- a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx +++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx @@ -37,6 +37,7 @@ import { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, SavedObjectsManagementColumnServiceStart, + SavedObjectsManagementNamespaceServiceStart, } from '../services'; import { SavedObjectsTable } from './objects_table'; diff --git a/src/plugins/saved_objects_management/public/management_section/types.ts b/src/plugins/saved_objects_management/public/management_section/types.ts index 77fcc824fef4..8b174a304f68 100644 --- a/src/plugins/saved_objects_management/public/management_section/types.ts +++ b/src/plugins/saved_objects_management/public/management_section/types.ts @@ -47,3 +47,8 @@ export interface SubmittedFormData { attributes: any; references: SavedObjectReference[]; } + +export enum DuplicateMode { + Selected = 'selected', + All = 'all', +} diff --git a/src/plugins/workspace/public/workspace_client.test.ts b/src/plugins/workspace/public/workspace_client.test.ts index ce51ddc1e501..bdae5a446a0e 100644 --- a/src/plugins/workspace/public/workspace_client.test.ts +++ b/src/plugins/workspace/public/workspace_client.test.ts @@ -213,6 +213,7 @@ describe('#WorkspaceClient', () => { expect(workspaceMock.workspaceList$.getValue()).toEqual([ { id: 'foo', + libraryReadonly: false, }, ]); expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { @@ -277,4 +278,28 @@ describe('#WorkspaceClient', () => { }); expect(workspaceMock.workspaceList$.getValue()).toEqual([]); }); + + it('#init with resultWithWritePermission is not success ', async () => { + const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); + httpSetupMock.fetch + .mockResolvedValueOnce({ + success: true, + result: { + workspaces: [ + { + id: 'foo', + name: 'foo', + }, + ], + total: 1, + per_page: 999, + page: 1, + }, + }) + .mockResolvedValueOnce({ + success: false, + }); + await workspaceClient.init(); + expect(workspaceMock.workspaceList$.getValue()).toEqual([]); + }); }); diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts index 76bbb618b506..edd9613cd9c1 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -12,6 +12,7 @@ import { WorkspacesSetup, } from '../../../core/public'; import { SavedObjectPermissions, WorkspaceAttributeWithPermission } from '../../../core/types'; +import { WorkspacePermissionMode } from '../common/constants'; const WORKSPACES_API_BASE_URL = '/api/workspaces'; @@ -38,6 +39,7 @@ interface WorkspaceFindOptions { searchFields?: string[]; sortField?: string; sortOrder?: string; + permissionModes?: WorkspacePermissionMode[]; } /** @@ -118,7 +120,20 @@ export class WorkspaceClient { }); if (result?.success) { - this.workspaces.workspaceList$.next(result.result.workspaces); + const resultWithWritePermission = await this.list({ + perPage: 999, + permissionModes: [WorkspacePermissionMode.LibraryWrite], + }); + if (resultWithWritePermission?.success) { + const workspaceIdsWithWritePermission = resultWithWritePermission.result.workspaces.map( + (workspace: WorkspaceAttribute) => workspace.id + ); + const workspaces = result.result.workspaces.map((workspace: WorkspaceAttribute) => ({ + ...workspace, + libraryReadonly: !workspaceIdsWithWritePermission.includes(workspace.id), + })); + this.workspaces.workspaceList$.next(workspaces); + } } else { this.workspaces.workspaceList$.next([]); } @@ -231,6 +246,7 @@ export class WorkspaceClient { * @property {integer} [options.page=1] * @property {integer} [options.perPage=20] * @property {array} options.fields + * @property {string array} permissionModes * @returns A find result with workspaces matching the specified search. */ public list( diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index 3101b74598bb..8141882b689d 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -33,6 +33,7 @@ import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, WorkspacePermissionMode, } from '../../common/constants'; +import { WorkspaceFindOptions } from '../types'; // Can't throw unauthorized for now, the page will be refreshed if unauthorized const generateWorkspacePermissionError = () => @@ -413,7 +414,7 @@ export class WorkspaceSavedObjectsClientWrapper { }; const findWithWorkspacePermissionControl = async ( - options: SavedObjectsFindOptions + options: SavedObjectsFindOptions & Pick ) => { const principals = this.permissionControl.getPrincipalsFromRequest(wrapperOptions.request); if (!options.ACLSearchParams) { diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index 6a9009a06375..bb8073b3a857 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -14,6 +14,7 @@ import { SavedObjectsServiceStart, } from '../../../core/server'; import { WorkspaceAttributeWithPermission } from '../../../core/types'; +import { WorkspacePermissionMode } from '../common/constants'; export interface WorkspaceFindOptions { page?: number; @@ -22,6 +23,7 @@ export interface WorkspaceFindOptions { searchFields?: string[]; sortField?: string; sortOrder?: string; + permissionModes?: WorkspacePermissionMode[]; } export interface IRequestDetail {