From 783863b0b5b64ae8351468d7e7470a8ab5b719db Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Fri, 21 Jul 2023 16:41:29 +0800 Subject: [PATCH 01/24] Add copy saved objects among workspaces functionality (#53) * Add copy saved objects among workspaces functionality Signed-off-by: gaobinlong Signed-off-by: gaobinlong * Fix bug Signed-off-by: gaobinlong * Fix bug Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong # Conflicts: # src/core/server/saved_objects/routes/copy.ts # src/plugins/saved_objects_management/public/constants.ts # src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx # src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx --- src/core/server/saved_objects/routes/copy.ts | 40 ++- src/core/server/saved_objects/routes/index.ts | 1 + .../public/lib/copy_saved_objects.ts | 27 ++ .../public/lib/index.ts | 1 + .../objects_table/components/copy_modal.tsx | 318 ++++++++++++++++++ .../objects_table/components/header.tsx | 18 + .../objects_table/saved_objects_table.tsx | 60 ++++ 7 files changed, 450 insertions(+), 15 deletions(-) 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/server/saved_objects/routes/copy.ts b/src/core/server/saved_objects/routes/copy.ts index 95e79ffd40a1..7bace54db583 100644 --- a/src/core/server/saved_objects/routes/copy.ts +++ b/src/core/server/saved_objects/routes/copy.ts @@ -1,12 +1,19 @@ /* - * Copyright OpenSearch Contributors * 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) => { @@ -17,11 +24,14 @@ export const registerCopyRoute = (router: IRouter, config: SavedObjectConfig) => path: '/_copy', validate: { body: schema.object({ - objects: schema.arrayOf( - schema.object({ - type: schema.string(), - id: schema.string(), - }) + objects: schema.maybe( + schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }), + { maxSize: maxImportExportSize } + ) ), includeReferencesDeep: schema.boolean({ defaultValue: false }), targetWorkspace: schema.string(), @@ -37,15 +47,15 @@ export const registerCopyRoute = (router: IRouter, config: SavedObjectConfig) => .getImportableAndExportableTypes() .map((t) => t.name); - const invalidObjects = objects.filter((obj) => !supportedTypes.includes(obj.type)); - if (invalidObjects.length) { - return res.badRequest({ - body: { - message: `Trying to copy object(s) with unsupported types: ${invalidObjects - .map((obj) => `${obj.type}:${obj.id}`) - .join(', ')}`, - }, - }); + if (objects) { + const validationError = validateObjects(objects, supportedTypes); + if (validationError) { + return res.badRequest({ + body: { + message: validationError, + }, + }); + } } const objectsListStream = await exportSavedObjectsToStream({ diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index 6c70276d7387..62b31589a4d4 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -71,6 +71,7 @@ export function registerRoutes({ registerLogLegacyImportRoute(router, logger); registerExportRoute(router, config); registerImportRoute(router, config); + registerCopyRoute(router, config); registerResolveImportErrorsRoute(router, config); registerCopyRoute(router, config); 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 a22e349d5240..f43a54fae0ec 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx @@ -43,13 +43,17 @@ import { FormattedMessage } from '@osd/i18n/react'; export const Header = ({ onExportAll, onImport, + onCopy, onRefresh, filteredCount, + selectedCount, }: { onExportAll: () => void; onImport: () => void; + onCopy: () => void; onRefresh: () => void; filteredCount: number; + selectedCount: number; }) => ( @@ -95,6 +99,20 @@ export const Header = ({ /> + + + + + { + 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; @@ -596,6 +628,14 @@ export class SavedObjectsTable extends Component { + this.setState({ isShowingCopyModal: true }); + }; + + hideCopyModal = () => { + this.setState({ isShowingCopyModal: false }); + }; + onDelete = () => { this.setState({ isShowingDeleteConfirmModal: true }); }; @@ -671,6 +711,23 @@ export class SavedObjectsTable extends Component + ); + } + renderRelationships() { if (!this.state.isShowingRelationships) { return null; @@ -998,11 +1055,14 @@ export class SavedObjectsTable extends Component this.setState({ isShowingExportAllOptionsModal: true })} onImport={this.showImportFlyout} + onCopy={() => this.setState({ isShowingCopyModal: true })} onRefresh={this.refreshObjects} filteredCount={filteredItemCount} + selectedCount={selectedSavedObjects.length} /> From 5e472e12b54a4edeb3d7ba0f1f13f790f7f436ce Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Wed, 30 Aug 2023 21:43:50 +0800 Subject: [PATCH 02/24] feat: duplicate selected objects (#113) * fix typo Signed-off-by: yuye-aws * adjust copy modal Signed-off-by: yuye-aws * list workspace with write permission on copy modal Signed-off-by: yuye-aws * add copy icon and move getcopyworkspaces function from copy_modal to saved_object table Signed-off-by: yuye-aws * fix duplicate error in public workspace and change copy to duplicate all in header Signed-off-by: yuye-aws * bug fix: create saved objects in public workspace Signed-off-by: yuye-aws * update snapshots Signed-off-by: yuye-aws * remove unused import Signed-off-by: yuye-aws * change validate schema Signed-off-by: yuye-aws * behavior subject bug fix for workspace plugin Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws # Conflicts: # src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap # src/core/server/saved_objects/permission_control/acl.test.ts # src/core/server/saved_objects/permission_control/client.ts # src/plugins/saved_objects_management/public/management_section/objects_table/components/copy_modal.tsx # src/plugins/workspace/server/plugin.ts # src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts --- .../header/__snapshots__/header.test.tsx.snap | 1535 +++++++++++++++-- .../get_workspaces_with_write_permission.ts | 21 + .../public/lib/index.ts | 1 + .../saved_objects_table.test.tsx.snap | 1 + .../__snapshots__/header.test.tsx.snap | 15 + .../__snapshots__/table.test.tsx.snap | 10 + .../objects_table/components/copy_modal.tsx | 115 +- .../objects_table/components/header.tsx | 27 +- .../objects_table/components/table.tsx | 12 +- .../objects_table/saved_objects_table.tsx | 32 +- .../workspace/public/workspace_client.ts | 2 + .../workspace_saved_objects_client_wrapper.ts | 3 +- src/plugins/workspace/server/types.ts | 1 + 13 files changed, 1560 insertions(+), 215 deletions(-) create mode 100644 src/plugins/saved_objects_management/public/lib/get_workspaces_with_write_permission.ts diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 8d244a212d1f..00a13549f828 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -1928,6 +1928,169 @@ exports[`Header handles visibility and lock changes 1`] = ` } } survey="/" + workspaces={ + Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + }, + "hasFetchedWorkspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "renderWorkspaceMenu": [MockFunction], + "workspaceEnabled$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + } + } >
- - -