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..7bace54db583 --- /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: false, + 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..d18c78dae0ad --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/copy_modal.tsx @@ -0,0 +1,318 @@ +/* + * 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 { iteratorSymbol } from 'immer/dist/internal'; +import { SavedObjectWithMetadata } from '../../../types'; +import { getSavedObjectLabel } from '../../../lib'; +import { SAVED_OBJECT_TYPE_WORKSAPCE } from '../../../constants'; + +type WorkspaceOption = EuiComboBoxOptionOption; + +interface Props { + workspaces: WorkspacesStart; + onCopy: ( + savedObjects: SavedObjectWithMetadata[], + includeReferencesDeep: boolean, + targetWorkspace: string + ) => Promise; + onClose: () => void; + seletedSavedObjects: SavedObjectWithMetadata[]; +} + +interface State { + 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 = { + 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 { workspaces } = this.props; + const workspaceList = await workspaces.client.workspaceList$; + const currentWorkspace = await workspaces.client.currentWorkspace$; + + if (!!currentWorkspace?.value?.name) { + const currentWorkspaceName = currentWorkspace.value.name; + const filteredWorkspaceOptions = workspaceList.value + .map(this.workspaceToOption) + .filter((item) => item.label !== currentWorkspaceName); + this.setState({ + workspaceOptions: filteredWorkspaceOptions, + allWorkspaceOptions: filteredWorkspaceOptions, + }); + } else { + const allWorkspaceOptions = workspaceList.value.map(this.workspaceToOption); + this.setState({ + workspaceOptions: allWorkspaceOptions, + allWorkspaceOptions, + }); + } + + this.isMounted = true; + } + + componentWillUnmount() { + this.isMounted = false; + } + + copySavedObjects = async (savedObjects: SavedObjectWithMetadata[]) => { + this.setState({ + isLoading: true, + }); + + const targetWorkspace = this.state.targetWorkspaceOption[0].key; + + await this.props.onCopy( + savedObjects, + this.state.isIncludeReferencesDeepChecked, + targetWorkspace! + ); + + 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, + allSeletedObjects, + } = this.state; + const targetWorkspaceId = targetWorkspaceOption?.at(0)?.key; + const includedSeletedObjects = allSeletedObjects.filter((item) => + !!targetWorkspaceId && !!item.workspaces + ? !item.workspaces.includes(targetWorkspaceId) + : true && item.type !== SAVED_OBJECT_TYPE_WORKSAPCE + ); + const ignoredSeletedObjectsLength = allSeletedObjects.length - includedSeletedObjects.length; + + let confirmCopyButtonEnabled = false; + if (!!targetWorkspaceId && 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} + /> +
+ + + + + + + this.copySavedObjects(includedSeletedObjects)} + isLoading={this.state.isLoading} + disabled={!confirmCopyButtonEnabled} + > + + + +
+ ); + } +} 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 = ({ /> + + + + + { savedObjectsClient: savedObjects.client, indexPatterns: dataPluginMock.createStartContract().indexPatterns, http, + workspaces, overlays, notifications, applications, @@ -164,7 +165,6 @@ describe('SavedObjectsTable', () => { goInspectObject: () => {}, canGoInApp: () => true, search, - workspaces, }; findObjectsMock.mockImplementation(() => ({ 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 b91e5c0cb2a8..d300d489a86e 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, @@ -82,6 +83,7 @@ import { findObject, extractExportDetails, SavedObjectsExportResultDetails, + copySavedObjects, } from '../../lib'; import { SavedObjectWithMetadata } from '../../types'; import { @@ -92,6 +94,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; @@ -107,6 +110,7 @@ export interface SavedObjectsTableProps { savedObjectsClient: SavedObjectsClientContract; indexPatterns: IndexPatternsContract; http: HttpStart; + workspaces: WorkspacesStart; search: DataPublicPluginStart['search']; overlays: OverlayStart; notifications: NotificationsStart; @@ -129,6 +133,7 @@ export interface SavedObjectsTableState { activeQuery: Query; selectedSavedObjects: SavedObjectWithMetadata[]; isShowingImportFlyout: boolean; + isShowingCopyModal: boolean; isSearching: boolean; filteredItemCount: number; isShowingRelationships: boolean; @@ -160,6 +165,7 @@ export class SavedObjectsTable extends Component { + const { notifications, http } = this.props; + const objectsToCopy = savedObjects.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; @@ -515,6 +549,14 @@ export class SavedObjectsTable extends Component { + this.setState({ isShowingCopyModal: true }); + }; + + hideCopyModal = () => { + this.setState({ isShowingCopyModal: false }); + }; + onDelete = () => { this.setState({ isShowingDeleteConfirmModal: true }); }; @@ -586,6 +628,23 @@ export class SavedObjectsTable extends Component + ); + } + renderRelationships() { if (!this.state.isShowingRelationships) { return null; @@ -879,12 +938,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 8180cc87f230..ce74b52ca1bd 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,10 +89,10 @@ const SavedObjectsTablePage = ({ indexPatterns={dataStart.indexPatterns} search={dataStart.search} http={coreStart.http} + workspaces={coreStart.workspaces} overlays={coreStart.overlays} notifications={coreStart.notifications} applications={coreStart.application} - workspaces={coreStart.workspaces} perPageConfig={itemsPerPage} goInspectObject={(savedObject) => { const { editUrl } = savedObject.meta;