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 [
+
+
+
+
+
+
+
+
+
+
+ ,
+]
+`;
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 4d5d03641b5f..0539c4849576 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
@@ -32,6 +32,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 = () =>
@@ -412,7 +413,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 b506bb493a4c..3c6c78c8a736 100644
--- a/src/plugins/workspace/server/types.ts
+++ b/src/plugins/workspace/server/types.ts
@@ -13,6 +13,7 @@ import {
SavedObjectsServiceStart,
} from '../../../core/server';
import { WorkspaceAttributeWithPermission } from '../../../core/types';
+import { WorkspacePermissionMode } from '../common/constants';
export interface WorkspaceFindOptions {
page?: number;
@@ -21,6 +22,7 @@ export interface WorkspaceFindOptions {
searchFields?: string[];
sortField?: string;
sortOrder?: string;
+ permissionModes?: WorkspacePermissionMode[];
}
export interface IRequestDetail {