diff --git a/changelogs/fragments/6478.yml b/changelogs/fragments/6478.yml new file mode 100644 index 000000000000..d8009500a060 --- /dev/null +++ b/changelogs/fragments/6478.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace] Duplicate selected/all saved objects ([#6478](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6478)) \ No newline at end of file diff --git a/src/plugins/saved_objects_management/public/index.ts b/src/plugins/saved_objects_management/public/index.ts index 317b3079efa0..ba04384a28a2 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 } 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..def28a431551 --- /dev/null +++ b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.test.ts @@ -0,0 +1,71 @@ +/* + * 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', () => { + const httpClient = httpServiceMock.createStartContract(); + const objects = [ + { type: 'dashboard', id: '1' }, + { type: 'visualization', id: '2' }, + ]; + const targetWorkspace = '1'; + + it('make http call with all parameter provided', async () => { + const includeReferencesDeep = true; + await duplicateSavedObjects(httpClient, objects, targetWorkspace, includeReferencesDeep); + 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, + }, + ], + } + `); + }); + + it('make http call without includeReferencesDeep parameter provided', async () => { + await duplicateSavedObjects(httpClient, objects, 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..560e0c1fddb3 --- /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[], + targetWorkspace: string, + includeReferencesDeep: boolean = true +) { + 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..25488f636741 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 } 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 d01a503c2620..45deb7c2f2f4 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 @@ -226,6 +226,611 @@ exports[`SavedObjectsTable delete should show error toast when failing to delete `; +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..1e8c8acc083b --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap @@ -0,0 +1,252 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DuplicateModal should Unmount normally 1`] = `""`; + +exports[`DuplicateModal should render normally 1`] = ` +HTMLCollection [ + + + + + + + + + + + + + + + + + + + Copy 3 objects to another workspace? + + + + + + + + + Workspace + + + + + + Move copied saved objects to the selected workspace. + + + + + + + + + Select a workspace + + + + + + + + + + + + + + + + + + + + + Copy related objects + + + + + + + + + Copy the selected object and any related objects (recommended). + + + + + + + + + + + + Cancel + + + + + + + + + Copy + + + + + + + + + + + , +] +`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_result_flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_result_flyout.test.tsx.snap new file mode 100644 index 000000000000..b82303876131 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_result_flyout.test.tsx.snap @@ -0,0 +1,375 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DuplicateResultFlyout copy count is null 1`] = ` +HTMLCollection [ + + + + + + + + + + + + + + + + Copy saved objects to targetWorkspace + + + + + + + + 0 saved objects copied + + + + + + + + + + + , +] +`; + +exports[`DuplicateResultFlyout renders the flyout with correct title and result 1`] = ` +HTMLCollection [ + + + + + + + + + + + + + + + + Copy saved objects to targetWorkspace + + + + + + + + 4 saved objects copied + + + + + + + + 2 Successful + + + + + + + 2 Error copying file + + + + + + + + + + + + + + + Failed Config Title + + + + + + + + + + + + + + + + + + + + + dashboard [id=2] + + + + + + + + + + + + + + + + + + + + + search [id=4] + + + + + + + + + + + + + + + + + + + + + Successful Visualization Title + + + + + + + + + + + + + + + + + + , +] +`; 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 926932968083..cf2bac1ec93e 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 @@ -39,13 +39,112 @@ exports[`Header should render normally 1`] = ` size="s" > + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +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 b39b8116078a..48dcf32c4eda 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 @@ -26,6 +26,7 @@ exports[`Table prevents saved objects from being deleted 1`] = ` onChange={[Function]} toolsRight={ Array [ + , `; +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 duplicateProps: 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: WorkspaceAttribute[] = [ + { + id: 'workspace1', + name: 'foo', + }, + { + id: 'workspace2', + name: 'bar', + }, + ]; + beforeEach(() => { + http = httpServiceMock.createStartContract(); + notifications = notificationServiceMock.createStartContract(); + workspaces = workspacesServiceMock.createStartContract(); + + duplicateProps = { + onDuplicate: jest.fn(), + onClose: jest.fn(), + http, + workspaces, + notifications, + 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); + duplicateProps = { ...duplicateProps, 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 it is the current workspace', async () => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next('workspace1'); + workspaces.currentWorkspace$.next({ + id: 'workspace1', + name: 'foo', + }); + duplicateProps = { ...duplicateProps, 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 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(duplicateProps.onClose).toHaveBeenCalled(); + }); + + it('should call onDuplicate function when confirm button is clicked', () => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next(''); + workspaces.currentWorkspace$.next(null); + duplicateProps = { ...duplicateProps, 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(duplicateProps.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(duplicateProps.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..072934a919a2 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx @@ -0,0 +1,223 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiComboBox, + EuiFormRow, + EuiCheckbox, + EuiText, +} from '@elastic/eui'; +import { HttpSetup, NotificationsStart, WorkspacesStart } from 'opensearch-dashboards/public'; +import { i18n } from '@osd/i18n'; +import { WorkspaceOption, getTargetWorkspacesOptions } from './utils'; +import { DuplicateObject } from '../../types'; + +export interface ShowDuplicateModalProps { + onDuplicate: ( + savedObjects: DuplicateObject[], + includeReferencesDeep: boolean, + targetWorkspace: string, + targetWorkspaceName: string + ) => Promise; + http: HttpSetup; + workspaces: WorkspacesStart; + notifications: NotificationsStart; + selectedSavedObjects: DuplicateObject[]; + onClose: () => void; +} + +interface State { + allSelectedObjects: DuplicateObject[]; + workspaceOptions: WorkspaceOption[]; + targetWorkspaceOption: WorkspaceOption[]; + isLoading: boolean; + isIncludeReferencesDeepChecked: boolean; +} + +export class SavedObjectsDuplicateModal extends React.Component { + private isMounted = false; + + constructor(props: ShowDuplicateModalProps) { + super(props); + + const { workspaces } = props; + const currentWorkspace = workspaces.currentWorkspace$.value; + const targetWorkspacesOptions = getTargetWorkspacesOptions(workspaces, currentWorkspace!); + + this.state = { + allSelectedObjects: props.selectedSavedObjects, + workspaceOptions: targetWorkspacesOptions, + targetWorkspaceOption: [], + isLoading: false, + isIncludeReferencesDeepChecked: true, + }; + this.isMounted = true; + } + + componentWillUnmount() { + this.isMounted = false; + } + + duplicateSavedObjects = async (savedObjects: DuplicateObject[]) => { + const selectedWorkspace = this.state.targetWorkspaceOption[0]; + if (!selectedWorkspace) { + return; + } + const targetWorkspace = selectedWorkspace.key; + const targetWorkspaceName = selectedWorkspace.label; + + this.setState({ + isLoading: true, + }); + + await this.props.onDuplicate( + savedObjects, + this.state.isIncludeReferencesDeepChecked, + targetWorkspace!, + targetWorkspaceName + ); + + if (this.isMounted) { + this.setState({ + isLoading: false, + }); + } + }; + + onTargetWorkspaceChange = (targetWorkspaceOption: WorkspaceOption[]) => { + this.setState({ + targetWorkspaceOption, + }); + }; + + changeIncludeReferencesDeep = (e: React.ChangeEvent) => { + this.setState({ + isIncludeReferencesDeepChecked: e.target.checked, + }); + }; + + render() { + const { + workspaceOptions, + targetWorkspaceOption, + isIncludeReferencesDeepChecked, + allSelectedObjects, + } = this.state; + const { onClose } = this.props; + const targetWorkspaceId = targetWorkspaceOption?.at(0)?.key; + + return ( + + + + + + + + + + <> + + {i18n.translate( + 'savedObjectsManagement.objectsTable.duplicateModal.targetWorkspaceNotice', + { + defaultMessage: `Move copied saved object${ + allSelectedObjects.length > 1 ? `s` : `` + } to the selected workspace.`, + } + )} + + + + > + + + + + + <> + + + > + + + + + + + + + this.duplicateSavedObjects(allSelectedObjects)} + isLoading={this.state.isLoading} + disabled={!targetWorkspaceId} + > + + + + + ); + } +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.test.tsx new file mode 100644 index 000000000000..146a578ef03c --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.test.tsx @@ -0,0 +1,98 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsImportError, SavedObjectsImportSuccess } from 'opensearch-dashboards/public'; +import { DuplicateResultFlyout, DuplicateResultFlyoutProps } from './duplicate_result_flyout'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +describe('DuplicateResultFlyout', () => { + const failedCopies: SavedObjectsImportError[] = [ + { + type: 'config', + id: '1', + meta: { title: 'Failed Config Title' }, + error: { type: 'unknown', message: 'An error occurred', statusCode: 500 }, + }, + { + type: 'dashboard', + id: '2', + meta: {}, + error: { type: 'unsupported_type' }, + }, + ]; + const successfulCopies: SavedObjectsImportSuccess[] = [ + { + type: 'visualization', + id: '3', + meta: { title: 'Successful Visualization Title' }, + }, + { + type: 'search', + id: '4', + meta: {}, + }, + ]; + const workspaceName = 'targetWorkspace'; + const onCloseMock = jest.fn(); + const duplicateResultFlyoutProps: DuplicateResultFlyoutProps = { + workspaceName, + failedCopies, + successfulCopies, + onClose: onCloseMock, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the flyout with correct title and result', () => { + render(); + expect(document.children).toMatchSnapshot(); + + // Check title + expect(screen.getByText('Copy saved objects to targetWorkspace')).toBeInTheDocument(); + + // Check result counts + expect(screen.getByText('4 saved objects copied')).toBeInTheDocument(); + expect(screen.getByText('2 Successful')).toBeInTheDocument(); + expect(screen.getByText('2 Error copying file')).toBeInTheDocument(); + + // Check successful copy icon and message + expect(screen.getByLabelText('visualization')).toBeInTheDocument(); + expect(screen.getByText('Successful Visualization Title')).toBeInTheDocument(); + + expect(screen.getByLabelText('search')).toBeInTheDocument(); + expect(screen.getByText('search [id=4]')).toBeInTheDocument(); + + // Check failed copy icon and message + expect(screen.getByLabelText('dashboard')).toBeInTheDocument(); + expect(screen.getByText('dashboard [id=2]')).toBeInTheDocument(); + + expect(screen.getByLabelText('config')).toBeInTheDocument(); + expect(screen.getByText('Failed Config Title')).toBeInTheDocument(); + }); + + it('calls onClose when the close button is clicked', () => { + render(); + + const closeButton = screen.getByTestId('euiFlyoutCloseButton'); + fireEvent.click(closeButton); + + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); + + it('copy count is null', () => { + render( + + ); + expect(document.children).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.tsx new file mode 100644 index 000000000000..f1efcad4317d --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.tsx @@ -0,0 +1,232 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './import_summary.scss'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiHorizontalRule, + EuiIcon, + EuiIconTip, + EuiSpacer, + EuiText, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import _ from 'lodash'; +import React, { Fragment } from 'react'; +import { i18n } from '@osd/i18n'; +import { SavedObjectsImportError, SavedObjectsImportSuccess } from 'opensearch-dashboards/public'; +import { FormattedMessage } from '@osd/i18n/react'; +import { getSavedObjectLabel } from '../../..'; +import { getDefaultTitle } from '../../../lib'; + +interface CopyItem { + type: string; + id: string; + title: string; + icon: string; + outcome: 'copied' | 'error'; + errorMessage?: string; +} + +export interface CopyResultProps { + failedCopies: SavedObjectsImportError[]; + successfulCopies: SavedObjectsImportSuccess[]; +} + +export interface DuplicateResultFlyoutProps { + workspaceName: string; + failedCopies: SavedObjectsImportError[]; + successfulCopies: SavedObjectsImportSuccess[]; + onClose: () => void; +} + +interface State { + isLoading: boolean; +} + +const DEFAULT_ICON = 'apps'; + +const unsupportedTypeErrorMessage = i18n.translate( + 'savedObjectsManagement.objectsTable.copyResult.unsupportedTypeError', + { defaultMessage: 'Unsupported object type' } +); + +const getErrorMessage = ({ error }: SavedObjectsImportError) => { + if (error.type === 'unknown') { + return error.message; + } else if (error.type === 'unsupported_type') { + return unsupportedTypeErrorMessage; + } +}; + +export class DuplicateResultFlyout extends React.Component { + constructor(props: DuplicateResultFlyoutProps) { + super(props); + this.state = { isLoading: false }; + } + + getCountIndicators(copyItems: CopyItem[]) { + if (!copyItems.length) { + return null; + } + + const outcomeCounts = copyItems.reduce( + (acc, { outcome }) => acc.set(outcome, (acc.get(outcome) ?? 0) + 1), + new Map() + ); + const copiedCount = outcomeCounts.get('copied'); + const errorCount = outcomeCounts.get('error'); + + return ( + + {copiedCount && ( + + + + + + + + )} + {errorCount && ( + + + + + + + + )} + + ); + } + + getStatusIndicator({ outcome, errorMessage = 'Error' }: CopyItem) { + switch (outcome) { + case 'copied': + return ( + + ); + case 'error': + return ( + + ); + } + } + + mapFailedCopy(failure: SavedObjectsImportError): CopyItem { + const { type, id, meta } = failure; + const title = meta.title || getDefaultTitle({ type, id }); + const icon = meta.icon || DEFAULT_ICON; + const errorMessage = getErrorMessage(failure); + return { type, id, title, icon, outcome: 'error', errorMessage }; + } + + mapCopySuccess(obj: SavedObjectsImportSuccess): CopyItem { + const { type, id, meta } = obj; + const title = meta.title || getDefaultTitle(obj); + const icon = meta.icon || DEFAULT_ICON; + return { type, id, title, icon, outcome: 'copied' }; + } + + copyResult({ failedCopies, successfulCopies }: CopyResultProps) { + const copyItems: CopyItem[] = _.sortBy( + [ + ...failedCopies.map((object) => this.mapFailedCopy(object)), + ...successfulCopies.map((object) => this.mapCopySuccess(object)), + ], + ['type', 'title'] + ); + + return ( + + + + + + + + {this.getCountIndicators(copyItems)} + + {copyItems.map((item, index) => { + const { type, title, icon } = item; + return ( + + + + + + + + + + {title} + + + + + {this.getStatusIndicator(item)} + + + ); + })} + + ); + } + + render() { + const { onClose, failedCopies, successfulCopies, workspaceName } = this.props; + return ( + + + + + + + {this.copyResult({ failedCopies, successfulCopies })} + + ); + } +} 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 bf2244cd984a..f83cdfaf69f2 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 @@ -42,13 +42,17 @@ 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; }) => ( @@ -65,6 +69,22 @@ export const Header = ({ + {showDuplicateAll && ( + + + + + + )} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts index 75e2f60c04f0..766def0786e0 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts @@ -32,3 +32,5 @@ export { Header } from './header'; export { Table } from './table'; export { Flyout } from './flyout'; export { Relationships } from './relationships'; +export { SavedObjectsDuplicateModal } from './duplicate_modal'; +export { DuplicateResultFlyout } from './duplicate_result_flyout'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx index 3cc6d04cfac2..e9b5595dd45d 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx @@ -107,6 +107,9 @@ const defaultProps: TableProps = { isSearching: false, onShowRelationships: () => {}, canDelete: true, + onDuplicate: () => {}, + onDuplicateSingle: () => {}, + showDuplicate: false, }; describe('Table', () => { @@ -224,4 +227,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 9effd038f49b..968ce9162434 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, + EuiSearchBarProps, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; @@ -67,9 +68,11 @@ export interface TableProps { selectionConfig: { onSelectionChange: (selection: SavedObjectWithMetadata[]) => void; }; - filters: any[]; + filters: EuiSearchBarProps['filters']; canDelete: boolean; onDelete: () => void; + onDuplicate: () => void; + onDuplicateSingle: (object: SavedObjectWithMetadata) => void; onActionRefresh: (object: SavedObjectWithMetadata) => void; onExport: (includeReferencesDeep: boolean) => void; goInspectObject: (obj: SavedObjectWithMetadata) => void; @@ -86,6 +89,7 @@ export interface TableProps { dateFormat: string; availableWorkspaces?: WorkspaceAttribute[]; currentWorkspaceId?: string; + showDuplicate: boolean; } interface TableState { @@ -170,6 +174,8 @@ export class Table extends PureComponent { filters, selectionConfig: selection, onDelete, + onDuplicate, + onDuplicateSingle, onActionRefresh, selectedSavedObjects, onTableChange, @@ -182,6 +188,7 @@ export class Table extends PureComponent { dateFormat, availableWorkspaces, currentWorkspaceId, + showDuplicate, } = this.props; const visibleWsIds = availableWorkspaces?.map((ws) => ws.id) || []; @@ -312,6 +319,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: 'Copy to...' } + ), + description: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnActions.duplicateActionDescription', + { defaultMessage: 'Copy this saved object' } + ), + type: 'icon', + icon: 'copy', + onClick: (object: SavedObjectWithMetadata) => onDuplicateSingle(object), + available: (object: SavedObjectWithMetadata) => object.type !== 'config', + 'data-test-subj': 'savedObjectsTableAction-duplicate', + }, + ] + : []), ...actionRegistry.getAll().map((action) => { return { ...action.euiAction, @@ -368,14 +394,30 @@ export class Table extends PureComponent { const activeActionContents = this.state.activeAction?.render() ?? null; + const duplicateButton = ( + + + + ); + return ( {activeActionContents} {showDuplicate && duplicateButton}>, { + 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 correct target workspace options in a workspace', () => { + const workspaces: WorkspacesStart = { + currentWorkspaceId$: new BehaviorSubject('1'), + currentWorkspace$: new BehaviorSubject({ + id: '1', + name: 'Workspace 1', + }), + workspaceList$: new BehaviorSubject([ + { id: '1', name: 'Workspace 1', readonly: false }, + { id: '2', name: 'Workspace 2', readonly: false }, + { id: '3', name: 'Workspace 3', readonly: true }, + ]), + initialized$: new BehaviorSubject(true), + }; + const optionContainCurrent: WorkspaceOption[] = getTargetWorkspacesOptions(workspaces, { + id: '1', + name: 'Workspace 1', + readonly: false, + }); + expect(optionContainCurrent.length).toBe(2); + expect(optionContainCurrent[0].key).toBe('1'); + expect(optionContainCurrent[0].label).toBe('Workspace 1 (current)'); + + expect(optionContainCurrent[1].key).toBe('2'); + expect(optionContainCurrent[1].label).toBe('Workspace 2'); + }); + + it('should get correct target workspace options not in a workspace', () => { + const workspaces: WorkspacesStart = { + currentWorkspaceId$: new BehaviorSubject(''), + currentWorkspace$: new BehaviorSubject({ + id: '', + name: '', + }), + workspaceList$: new BehaviorSubject([ + { id: '1', name: 'Workspace 1', readonly: false }, + { id: '2', name: 'Workspace 2', readonly: false }, + { id: '3', name: 'Workspace 3', readonly: true }, + ]), + initialized$: new BehaviorSubject(true), + }; + + const workspaceOption: WorkspaceOption[] = getTargetWorkspacesOptions(workspaces); + expect(workspaceOption.length).toBe(2); + expect(workspaceOption[0].key).toBe('1'); + expect(workspaceOption[0].label).toBe('Workspace 1'); + expect(workspaceOption[1].key).toBe('2'); + expect(workspaceOption[1].label).toBe('Workspace 2'); + }); +}); 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..a7a5b8b77852 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { WorkspaceObject, 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: WorkspaceObject, + 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, + currentWorkspace?: WorkspaceObject +): WorkspaceOption[] { + const currentWorkspaceId = currentWorkspace?.id; + const workspaceList = workspaces.workspaceList$.value; + const targetWorkspaces = workspaceList.filter( + (workspace) => workspace.id !== currentWorkspaceId && !workspace.readonly + ); + // current workspace is the first option + if (currentWorkspace && !currentWorkspace.readonly) targetWorkspaces.unshift(currentWorkspace); + return targetWorkspaces.map((workspace) => workspaceToOption(workspace, currentWorkspaceId)); +} 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..2375151af1db 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 } 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 a33a59786617..f8d7e7b8b9ef 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, @@ -241,6 +242,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'); @@ -858,4 +875,214 @@ 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: { title: 'object-1' } }, + { id: '2', type: 'dashboard', references: [], attributes: [], meta: { title: 'object-2' } }, + ] as SavedObjectWithMetadata[]; + + beforeEach(() => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next('workspace1'); + workspaces.currentWorkspace$.next(workspaceList[0]); + }); + + it('should duplicate selected objects', 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', 'bar'); + + expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( + http, + [ + { id: '1', type: 'dashboard' }, + { id: '2', type: 'dashboard' }, + ], + 'workspace2', + false + ); + component.update(); + + expect(component.state('isShowingDuplicateResultFlyout')).toEqual(true); + expect(component.find('DuplicateResultFlyout').length).toEqual(1); + }); + + it('should duplicate single 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(); + + await component + .instance() + .onDuplicate([mockSelectedSavedObjects[0]], true, 'workspace2', 'bar'); + + expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( + http, + [{ id: '1', type: 'dashboard' }], + 'workspace2', + true + ); + component.update(); + + expect(component.state('isShowingDuplicateResultFlyout')).toEqual(true); + expect(component.find('DuplicateResultFlyout').length).toEqual(1); + }); + + it('should show result flyout when duplicating success and failure coexist', async () => { + 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(); + + getDuplicateSavedObjectsMock.mockImplementationOnce(() => ({ + success: false, + successCount: 1, + successResults: [{ id: '1' }], + errors: [{ id: '2' }], + })); + + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2', 'bar'); + + expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( + http, + [ + { id: '1', type: 'dashboard' }, + { id: '2', type: 'dashboard' }, + ], + 'workspace2', + false + ); + component.update(); + + expect(component.state('isShowingDuplicateResultFlyout')).toEqual(true); + expect(component.find('DuplicateResultFlyout').length).toEqual(1); + }); + + 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', 'bar'); + + expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( + http, + [ + { id: '1', type: 'dashboard' }, + { id: '2', type: 'dashboard' }, + ], + 'workspace2', + false + ); + component.update(); + + expect(notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Unable to copy 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('onDuplicate')(); + 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 28d320fc6fb6..7b9f1a09b49b 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 @@ -55,6 +55,7 @@ import { EuiCompressedFormRow, EuiFlexGroup, EuiFlexItem, + EuiSearchBarProps, EuiText, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; @@ -69,6 +70,8 @@ import { ApplicationStart, WorkspacesStart, WorkspaceAttribute, + SavedObjectsImportSuccess, + SavedObjectsImportError, } from 'src/core/public'; import { Subscription } from 'rxjs'; import { RedirectAppLinks } from '../../../../opensearch_dashboards_react/public'; @@ -86,6 +89,7 @@ import { findObject, extractExportDetails, SavedObjectsExportResultDetails, + duplicateSavedObjects, } from '../../lib'; import { SavedObjectWithMetadata } from '../../types'; import { @@ -94,15 +98,22 @@ import { SavedObjectsManagementColumnServiceStart, SavedObjectsManagementNamespaceServiceStart, } from '../../services'; -import { Header, Table, Flyout, Relationships } from './components'; +import { + Header, + Table, + Flyout, + Relationships, + SavedObjectsDuplicateModal, + DuplicateResultFlyout, +} from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; +import { DuplicateObject } from '../types'; import { formatWorkspaceIdParams } from '../../utils'; interface ExportAllOption { id: string; label: string; } - export interface SavedObjectsTableProps { allowedTypes: string[]; serviceRegistry: ISavedObjectsManagementServiceRegistry; @@ -133,7 +144,9 @@ export interface SavedObjectsTableState { savedObjectCounts: Record>; activeQuery: Query; selectedSavedObjects: SavedObjectWithMetadata[]; + duplicateSelectedSavedObjects: DuplicateObject[]; isShowingImportFlyout: boolean; + isShowingDuplicateModal: boolean; isSearching: boolean; filteredItemCount: number; isShowingRelationships: boolean; @@ -147,6 +160,10 @@ export interface SavedObjectsTableState { currentWorkspaceId?: string; workspaceEnabled: boolean; availableWorkspaces?: WorkspaceAttribute[]; + isShowingDuplicateResultFlyout: boolean; + failedCopies: SavedObjectsImportError[]; + successfulCopies: SavedObjectsImportSuccess[]; + targetWorkspaceName: string; } export class SavedObjectsTable extends Component { private _isMounted = false; @@ -169,7 +186,9 @@ export class SavedObjectsTable extends Component>, activeQuery: Query.parse(''), selectedSavedObjects: [], + duplicateSelectedSavedObjects: [], isShowingImportFlyout: false, + isShowingDuplicateModal: false, isSearching: false, filteredItemCount: 0, isShowingRelationships: false, @@ -183,9 +202,46 @@ export class SavedObjectsTable extends Component ns.id) || []; + if (availableNamespaces.length) { + const filteredNamespaces = filterQuery(availableNamespaces, visibleNamespaces); + findOptions.namespaces = filteredNamespaces; + } + + if (visibleWorkspaces?.length) { + findOptions.workspaces = this.workspaceNamesToIds(visibleWorkspaces); + } + + if (findOptions.type.length > 1) { + findOptions.sortField = 'type'; + } + + return findOptions; + } + private get workspaceIdQuery() { const { currentWorkspaceId, workspaceEnabled } = this.state; // workspace is turned off @@ -332,37 +388,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 = 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) { - findOptions.workspaces = this.workspaceNamesToIds(visibleWorkspaces); - } - - 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; } @@ -666,6 +696,120 @@ export class SavedObjectsTable extends Component { + this.setState({ isShowingDuplicateModal: false }); + }; + + onDuplicateAll = async () => { + const { notifications, http } = this.props; + const findOptions = this.findOptions; + findOptions.perPage = 9999; + findOptions.page = 1; + + try { + const resp = await findObjects(http, findOptions); + const duplicateObjects = resp.savedObjects.map((obj) => ({ + id: obj.id, + type: obj.type, + meta: obj.meta, + workspaces: obj.workspaces, + })); + this.setState({ + duplicateSelectedSavedObjects: duplicateObjects, + isShowingDuplicateModal: true, + }); + } catch (error) { + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage', + { defaultMessage: 'Unable find saved objects' } + ), + text: `${error}`, + }); + } + }; + + onDuplicate = async ( + savedObjects: DuplicateObject[], + includeReferencesDeep: boolean, + targetWorkspace: string, + targetWorkspaceName: 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, + targetWorkspace, + includeReferencesDeep + ); + + this.setState({ + isShowingDuplicateResultFlyout: true, + failedCopies: result.success ? [] : result.errors, + successfulCopies: result.successCount > 0 ? result.successResults : [], + targetWorkspaceName, + }); + } catch (e) { + notifications.toasts.addDanger({ + title: i18n.translate('savedObjectsManagement.objectsTable.duplicate.dangerNotification', { + defaultMessage: + 'Unable to copy {errorCount, plural, one {# saved object} other {# saved objects}}.', + values: { errorCount: savedObjects.length }, + }), + }); + } + this.hideDuplicateModal(); + await this.refreshObjects(); + }; + + renderDuplicateModal() { + const { isShowingDuplicateModal, duplicateSelectedSavedObjects } = this.state; + + if (!isShowingDuplicateModal) { + return null; + } + + return ( + + ); + } + + hideDuplicateResultFlyout = () => { + this.setState({ isShowingDuplicateResultFlyout: false }); + }; + + renderDuplicateResultFlyout() { + const { + isShowingDuplicateResultFlyout, + targetWorkspaceName, + failedCopies, + successfulCopies, + } = this.state; + + if (!isShowingDuplicateResultFlyout) { + return null; + } + + return ( + + ); + } + renderRelationships() { if (!this.state.isShowingRelationships) { return null; @@ -919,7 +1063,7 @@ export class SavedObjectsTable extends Component this.setState({ isShowingExportAllOptionsModal: true })} onImport={this.showImportFlyout} + showDuplicateAll={this.state.workspaceEnabled} + onDuplicate={this.onDuplicateAll} onRefresh={this.refreshObjects} - filteredCount={filteredItemCount} + objectCount={savedObjects.length} /> @@ -1006,6 +1156,18 @@ export class SavedObjectsTable extends Component + this.setState({ + isShowingDuplicateModal: true, + duplicateSelectedSavedObjects: selectedSavedObjects, + }) + } + onDuplicateSingle={(object) => + this.setState({ + duplicateSelectedSavedObjects: [object], + isShowingDuplicateModal: true, + }) + } onActionRefresh={this.refreshObject} goInspectObject={this.props.goInspectObject} pageIndex={page} @@ -1018,6 +1180,7 @@ export class SavedObjectsTable extends Component 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..e8bf3e203c8c 100644 --- a/src/plugins/saved_objects_management/public/management_section/types.ts +++ b/src/plugins/saved_objects_management/public/management_section/types.ts @@ -29,6 +29,7 @@ */ import { SavedObjectReference } from '../../../../core/types'; +import { SavedObjectWithMetadata } from '../../common'; export interface ObjectField { type: FieldType; @@ -47,3 +48,5 @@ export interface SubmittedFormData { attributes: any; references: SavedObjectReference[]; } + +export type DuplicateObject = Pick; diff --git a/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx b/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx index 3d964009ee86..4391f8d2c858 100644 --- a/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx +++ b/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx @@ -36,7 +36,7 @@ export function getWorkspaceColumn( align: 'left', field: 'workspaces', name: i18n.translate('savedObjectsManagement.objectsTable.table.columnWorkspacesName', { - defaultMessage: 'Workspaces', + defaultMessage: 'Workspace', }), render: (workspaces: string[]) => { return ; diff --git a/src/plugins/workspace/public/workspace_client.test.ts b/src/plugins/workspace/public/workspace_client.test.ts index c18ed3db64e7..a36c68ff3bf3 100644 --- a/src/plugins/workspace/public/workspace_client.test.ts +++ b/src/plugins/workspace/public/workspace_client.test.ts @@ -178,6 +178,7 @@ describe('#WorkspaceClient', () => { expect(workspaceMock.workspaceList$.getValue()).toEqual([ { id: 'foo', + readonly: false, }, ]); expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { @@ -209,4 +210,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 e62f9518916f..d9931f95c135 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -11,6 +11,7 @@ import { WorkspaceAttribute, WorkspacesSetup, } from '../../../core/public'; +import { WorkspacePermissionMode } from '../common/constants'; import { SavedObjectPermissions, WorkspaceAttributeWithPermission } from '../../../core/types'; 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, + readonly: !workspaceIdsWithWritePermission.includes(workspace.id), + })); + this.workspaces.workspaceList$.next(workspaces); + } } else { this.workspaces.workspaceList$.next([]); } @@ -233,6 +248,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/routes/duplicate.ts b/src/plugins/workspace/server/routes/duplicate.ts index 001f924c31bb..dbd9a8d5c9c6 100644 --- a/src/plugins/workspace/server/routes/duplicate.ts +++ b/src/plugins/workspace/server/routes/duplicate.ts @@ -90,6 +90,7 @@ export const registerDuplicateRoute = ( overwrite: false, createNewCopies: true, workspaces: [targetWorkspace], + dataSourceEnabled: true, }); return res.ok({ body: result }); diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index 82d4bc594a7e..5e88e5acb3ae 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -18,6 +18,7 @@ import { export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { permissions?: Permissions; } +import { WorkspacePermissionMode } from '../common/constants'; export interface WorkspaceFindOptions { page?: number; @@ -26,6 +27,7 @@ export interface WorkspaceFindOptions { searchFields?: string[]; sortField?: string; sortOrder?: string; + permissionModes?: WorkspacePermissionMode[]; } export interface IRequestDetail { diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index 0b7d7c8a57c1..7cc8bcec6baa 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -174,6 +174,7 @@ export class WorkspaceClient implements IWorkspaceClientImpl { { ...options, type: WORKSPACE_TYPE, + ACLSearchParams: { permissionModes: options.permissionModes }, } ); return {
+ Select a workspace +
+ Failed Config Title +
+ dashboard [id=2] +
+ search [id=4] +
+ Successful Visualization Title +
+ + + +
+ {title} +