From ffa51fc21dbb9d6b6367ba33fa0c5e555f6ad309 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Thu, 13 Jul 2023 17:50:35 +0800 Subject: [PATCH] Add copy saved objects among workspaces functionality Signed-off-by: gaobinlong Signed-off-by: gaobinlong --- src/core/public/mocks.ts | 1 + src/core/server/saved_objects/routes/copy.ts | 82 +++++ src/core/server/saved_objects/routes/index.ts | 2 + .../public/constants.ts | 2 + .../public/lib/copy_saved_objects.ts | 27 ++ .../public/lib/index.ts | 1 + .../objects_table/components/copy_modal.tsx | 320 ++++++++++++++++++ .../objects_table/components/header.tsx | 18 + .../saved_objects_table.test.tsx | 4 + .../objects_table/saved_objects_table.tsx | 59 ++++ .../saved_objects_table_page.tsx | 1 + 11 files changed, 517 insertions(+) create mode 100644 src/core/server/saved_objects/routes/copy.ts create mode 100644 src/plugins/saved_objects_management/public/lib/copy_saved_objects.ts create mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/copy_modal.tsx diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index e863d627c801..5b0e1cd89dae 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -60,6 +60,7 @@ export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; export { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; export { scopedHistoryMock } from './application/scoped_history.mock'; export { applicationServiceMock } from './application/application_service.mock'; +export { workspacesServiceMock } from './fatal_errors/fatal_errors_service.mock'; function createCoreSetupMock({ basePath = '', diff --git a/src/core/server/saved_objects/routes/copy.ts b/src/core/server/saved_objects/routes/copy.ts new file mode 100644 index 000000000000..27de8212d328 --- /dev/null +++ b/src/core/server/saved_objects/routes/copy.ts @@ -0,0 +1,82 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { schema } from '@osd/config-schema'; +import { IRouter } from '../../http'; +import { SavedObjectConfig } from '../saved_objects_config'; +import { exportSavedObjectsToStream } from '../export'; +import { validateObjects } from './utils'; +import { importSavedObjectsFromStream } from '../import'; + +export const registerCopyRoute = (router: IRouter, config: SavedObjectConfig) => { + const { maxImportExportSize } = config; + + router.post( + { + path: '/_copy', + validate: { + body: schema.object({ + objects: schema.maybe( + schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }), + { maxSize: maxImportExportSize } + ) + ), + includeReferencesDeep: schema.boolean({ defaultValue: false }), + targetWorkspace: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const savedObjectsClient = context.core.savedObjects.client; + const { objects, includeReferencesDeep, targetWorkspace } = req.body; + + // need to access the registry for type validation, can't use the schema for this + const supportedTypes = context.core.savedObjects.typeRegistry + .getImportableAndExportableTypes() + .map((t) => t.name); + + if (objects) { + const validationError = validateObjects(objects, supportedTypes); + if (validationError) { + return res.badRequest({ + body: { + message: validationError, + }, + }); + } + } + + const objectsListStream = await exportSavedObjectsToStream({ + savedObjectsClient, + objects, + exportSizeLimit: maxImportExportSize, + includeReferencesDeep, + excludeExportDetails: true, + }); + + const result = await importSavedObjectsFromStream({ + savedObjectsClient: context.core.savedObjects.client, + typeRegistry: context.core.savedObjects.typeRegistry, + readStream: objectsListStream, + objectLimit: maxImportExportSize, + overwrite: true, + createNewCopies: true, + workspaces: [targetWorkspace], + }); + + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index 7149474e446c..57dbe8f3ca7c 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -45,6 +45,7 @@ import { registerExportRoute } from './export'; import { registerImportRoute } from './import'; import { registerResolveImportErrorsRoute } from './resolve_import_errors'; import { registerMigrateRoute } from './migrate'; +import { registerCopyRoute } from './copy'; export function registerRoutes({ http, @@ -70,6 +71,7 @@ export function registerRoutes({ registerLogLegacyImportRoute(router, logger); registerExportRoute(router, config); registerImportRoute(router, config); + registerCopyRoute(router, config); registerResolveImportErrorsRoute(router, config); const internalRouter = http.createRouter('/internal/saved_objects/'); diff --git a/src/plugins/saved_objects_management/public/constants.ts b/src/plugins/saved_objects_management/public/constants.ts index dec0d4e7be68..e66d808dcf4c 100644 --- a/src/plugins/saved_objects_management/public/constants.ts +++ b/src/plugins/saved_objects_management/public/constants.ts @@ -29,3 +29,5 @@ export const SAVED_QUERIES_WORDINGS = i18n.translate( defaultMessage: 'Saved filters', } ); + +export const SAVED_OBJECT_TYPE_WORKSAPCE = 'workspace'; diff --git a/src/plugins/saved_objects_management/public/lib/copy_saved_objects.ts b/src/plugins/saved_objects_management/public/lib/copy_saved_objects.ts new file mode 100644 index 000000000000..c28893589367 --- /dev/null +++ b/src/plugins/saved_objects_management/public/lib/copy_saved_objects.ts @@ -0,0 +1,27 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { HttpStart } from 'src/core/public'; + +export async function copySavedObjects( + http: HttpStart, + objects: any[], + includeReferencesDeep: boolean = true, + targetWorkspace: string +) { + return await http.post('/api/saved_objects/_copy', { + 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..7bb6f9168cbd 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 { copySavedObjects } from './copy_saved_objects'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/copy_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/copy_modal.tsx new file mode 100644 index 000000000000..80ee4d1c5894 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/copy_modal.tsx @@ -0,0 +1,320 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiComboBox, + EuiFormRow, + EuiSwitch, + EuiComboBoxOptionOption, + EuiInMemoryTable, + EuiToolTip, + EuiIcon, + EuiCallOut, +} from '@elastic/eui'; +import { WorkspaceAttribute, WorkspacesStart } from 'opensearch-dashboards/public'; +import { i18n } from '@osd/i18n'; +import { SavedObjectWithMetadata } from '../../../types'; +import { getSavedObjectLabel } from '../../../lib'; +import { SAVED_OBJECT_TYPE_WORKSAPCE } from '../../../constants'; + +type WorkspaceOption = EuiComboBoxOptionOption; + +interface Props { + workspacesStart: WorkspacesStart; + onCopy: (includeReferencesDeep: boolean, targetWorkspace: string) => Promise; + onClose: () => void; + seletedSavedObjects: SavedObjectWithMetadata[]; +} + +interface State { + ignoredSeletedObjects: SavedObjectWithMetadata[]; + allSeletedObjects: SavedObjectWithMetadata[]; + workspaceOptions: WorkspaceOption[]; + allWorkspaceOptions: WorkspaceOption[]; + targetWorkspaceOption: WorkspaceOption[]; + isLoading: boolean; + isIncludeReferencesDeepChecked: boolean; +} + +export class SavedObjectsCopyModal extends React.Component { + private isMounted = false; + + constructor(props: Props) { + super(props); + + this.state = { + ignoredSeletedObjects: [], + allSeletedObjects: this.props.seletedSavedObjects, + workspaceOptions: [], + allWorkspaceOptions: [], + targetWorkspaceOption: [], + isLoading: false, + isIncludeReferencesDeepChecked: true, + }; + } + + workspaceToOption = (workspace: WorkspaceAttribute): WorkspaceOption => { + return { label: workspace.name, key: workspace.id, value: workspace }; + }; + + async componentDidMount() { + const { workspacesStart } = this.props; + const workspaceList = await workspacesStart.client.workspaceList$; + const currentWorkspace = await workspacesStart.client.currentWorkspace$; + + if (!!currentWorkspace?.value?.name) { + const currentWorkspaceName = currentWorkspace.value.name; + const ignoredSeletedObjects = this.state.allSeletedObjects.filter( + (item) => + item.workspaces?.includes(currentWorkspaceName) || + item.type === SAVED_OBJECT_TYPE_WORKSAPCE + ); + const filteredWorkspaceOptions = workspaceList.value + .map(this.workspaceToOption) + .filter((item) => item.label !== currentWorkspaceName); + this.setState({ + workspaceOptions: filteredWorkspaceOptions, + allWorkspaceOptions: filteredWorkspaceOptions, + ignoredSeletedObjects, + }); + } else { + const allWorkspaceOptions = workspaceList.value.map(this.workspaceToOption); + this.setState({ + workspaceOptions: allWorkspaceOptions, + allWorkspaceOptions, + }); + } + + this.isMounted = true; + } + + componentWillUnmount() { + this.isMounted = false; + } + + copySavedObjects = async () => { + this.setState({ + isLoading: true, + }); + + const targetWorkspaceName = this.state.targetWorkspaceOption[0].label; + + await this.props.onCopy(this.state.isIncludeReferencesDeepChecked, targetWorkspaceName!); + + if (this.isMounted) { + this.setState({ + isLoading: false, + }); + } + }; + + onSearchWorkspaceChange = (searchValue: string) => { + this.setState({ + workspaceOptions: this.state.allWorkspaceOptions.filter((item) => + item.label.includes(searchValue) + ), + }); + }; + + onTargetWorkspaceChange = (targetWorkspaceOption: WorkspaceOption[]) => { + this.setState({ + targetWorkspaceOption, + }); + }; + + changeIncludeReferencesDeep = () => { + this.setState((state) => ({ + isIncludeReferencesDeepChecked: !state.isIncludeReferencesDeepChecked, + })); + }; + + render() { + const { + workspaceOptions, + targetWorkspaceOption, + isIncludeReferencesDeepChecked, + ignoredSeletedObjects, + allSeletedObjects, + } = this.state; + const includedSeletedObjects = allSeletedObjects.filter( + (item) => !ignoredSeletedObjects.some((ignoredItem) => item.id === ignoredItem.id) + ); + const ignoredSeletedObjectsLength = ignoredSeletedObjects.length; + + let confirmCopyButtonEnabled = false; + if ( + !!targetWorkspaceOption && + targetWorkspaceOption.length === 1 && + !!targetWorkspaceOption[0].label && + includedSeletedObjects.length > 0 + ) { + confirmCopyButtonEnabled = true; + } + + const warningMessageForOnlyOneSavedObject = ( +

+ 1 saved object will not be + copied, because it has already existed in the selected workspace or it is worksapce itself. +

+ ); + const warningMessageForMultipleSavedObjects = ( +

+ {ignoredSeletedObjectsLength} saved objects will{' '} + not be copied, because they have already existed in the + selected workspace or they are worksapces themselves. +

+ ); + + const ignoreSomeObjectsChildren: React.ReactChild = ( + <> + + {ignoredSeletedObjectsLength === 1 + ? warningMessageForOnlyOneSavedObject + : warningMessageForMultipleSavedObjects} + + + + ); + + return ( + + + + + + + + + + } + > + + + + + + } + checked={isIncludeReferencesDeepChecked} + onChange={this.changeIncludeReferencesDeep} + /> + + + {ignoredSeletedObjectsLength === 0 ? null : ignoreSomeObjectsChildren} +

+ +

+ + ( + + + + ), + }, + { + field: 'id', + name: i18n.translate('savedObjectsManagement.objectsTable.copyModal.idColumnName', { + defaultMessage: 'Id', + }), + }, + { + field: 'meta.title', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.copyModal.titleColumnName', + { defaultMessage: 'Title' } + ), + }, + ]} + pagination={true} + sorting={false} + /> +
+ + + + + + + + + + +
+ ); + } +} 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 9d46f1cca67c..176e605297f1 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,15 +43,19 @@ import { FormattedMessage } from '@osd/i18n/react'; export const Header = ({ onExportAll, onImport, + onCopy, onRefresh, filteredCount, title, + selectedCount, }: { onExportAll: () => void; onImport: () => void; + onCopy: () => void; onRefresh: () => void; filteredCount: number; title: string; + selectedCount: number; }) => ( @@ -92,6 +96,20 @@ export const Header = ({ /> + + + + + { let notifications: ReturnType; let savedObjects: ReturnType; let search: ReturnType['search']; + let workspacesStart: ReturnType; const shallowRender = (overrides: Partial = {}) => { return (shallowWithI18nProvider( @@ -121,6 +123,7 @@ describe('SavedObjectsTable', () => { notifications = notificationServiceMock.createStartContract(); savedObjects = savedObjectsServiceMock.createStartContract(); search = dataPluginMock.createStartContract().search; + workspacesStart = workspacesServiceMock.createStartContract(); const applications = applicationServiceMock.createStartContract(); applications.capabilities = { @@ -154,6 +157,7 @@ describe('SavedObjectsTable', () => { savedObjectsClient: savedObjects.client, indexPatterns: dataPluginMock.createStartContract().indexPatterns, http, + workspacesStart, overlays, notifications, applications, 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 412047ba66f0..f39d760e87f8 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 @@ -61,6 +61,7 @@ import { FormattedMessage } from '@osd/i18n/react'; import { SavedObjectsClientContract, SavedObjectsFindOptions, + WorkspacesStart, HttpStart, OverlayStart, NotificationsStart, @@ -81,6 +82,7 @@ import { findObject, extractExportDetails, SavedObjectsExportResultDetails, + copySavedObjects, } from '../../lib'; import { SavedObjectWithMetadata } from '../../types'; import { @@ -91,6 +93,7 @@ import { } from '../../services'; import { Header, Table, Flyout, Relationships } from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; +import { SavedObjectsCopyModal } from './components/copy_modal'; interface ExportAllOption { id: string; @@ -106,6 +109,7 @@ export interface SavedObjectsTableProps { savedObjectsClient: SavedObjectsClientContract; indexPatterns: IndexPatternsContract; http: HttpStart; + workspacesStart: WorkspacesStart; search: DataPublicPluginStart['search']; overlays: OverlayStart; notifications: NotificationsStart; @@ -127,6 +131,7 @@ export interface SavedObjectsTableState { activeQuery: Query; selectedSavedObjects: SavedObjectWithMetadata[]; isShowingImportFlyout: boolean; + isShowingCopyModal: boolean; isSearching: boolean; filteredItemCount: number; isShowingRelationships: boolean; @@ -157,6 +162,7 @@ export class SavedObjectsTable extends Component { + const { selectedSavedObjects } = this.state; + const { notifications, http } = this.props; + const objectsToCopy = selectedSavedObjects.map((obj) => ({ id: obj.id, type: obj.type })); + + try { + await copySavedObjects(http, objectsToCopy, includeReferencesDeep, targetWorkspace); + } catch (e) { + notifications.toasts.addDanger({ + title: i18n.translate('savedObjectsManagement.objectsTable.copy.dangerNotification', { + defaultMessage: 'Unable to copy saved objects', + }), + }); + throw e; + } + + this.hideCopyModal(); + this.refreshObjects(); + notifications.toasts.addSuccess({ + title: i18n.translate('savedObjectsManagement.objectsTable.copy.successNotification', { + defaultMessage: 'Copy saved objects successly', + }), + }); + }; + onExport = async (includeReferencesDeep: boolean) => { const { selectedSavedObjects } = this.state; const { notifications, http } = this.props; @@ -494,6 +525,14 @@ export class SavedObjectsTable extends Component { + this.setState({ isShowingCopyModal: true }); + }; + + hideCopyModal = () => { + this.setState({ isShowingCopyModal: false }); + }; + onDelete = () => { this.setState({ isShowingDeleteConfirmModal: true }); }; @@ -564,6 +603,23 @@ export class SavedObjectsTable extends Component + ); + } + renderRelationships() { if (!this.state.isShowingRelationships) { return null; @@ -857,12 +913,15 @@ export class SavedObjectsTable extends Component this.setState({ isShowingExportAllOptionsModal: true })} onImport={this.showImportFlyout} + onCopy={() => this.setState({ isShowingCopyModal: true })} onRefresh={this.refreshObjects} filteredCount={filteredItemCount} title={this.props.title} + selectedCount={selectedSavedObjects.length} /> 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 ec3837762317..2670e3cf0c91 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 @@ -89,6 +89,7 @@ const SavedObjectsTablePage = ({ indexPatterns={dataStart.indexPatterns} search={dataStart.search} http={coreStart.http} + workspacesStart={coreStart.workspaces} overlays={coreStart.overlays} notifications={coreStart.notifications} applications={coreStart.application}