From ccd0520a8b1f07297009708d3099cdaa26196f1a Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Wed, 20 Sep 2023 13:14:17 +0800 Subject: [PATCH] feat: Duplicate dashboard visualize (#148) * rename copy to duplicate Signed-off-by: yuye-aws * duplicate in visualization Signed-off-by: yuye-aws * duplicate in dashboard Signed-off-by: yuye-aws * resolve conflict Signed-off-by: yuye-aws * update test and snapshots Signed-off-by: yuye-aws * re-duplicate if some objects cannot be duplicated Signed-off-by: yuye-aws * remove clone for dashboard Signed-off-by: yuye-aws * rename duplicateState to duplicateMode Signed-off-by: yuye-aws * change workspace prop to currentWorkspace in SavedObjectsDuplicateModal Signed-off-by: yuye-aws * change wording Signed-off-by: yuye-aws * move duplicate modal to saved_objects for reuse Signed-off-by: yuye-aws * move duplicate modal to saved objects management for reuse Signed-off-by: yuye-aws * remove minimal duplicate modal props logic Signed-off-by: yuye-aws * refactor duplicate modal props for dashboard and visualization Signed-off-by: yuye-aws * Update getDuplicateWorkspaces function Co-authored-by: Yulong Ruan * update function onDuplicate for dashboard Signed-off-by: yuye-aws * Update doDuplicate for visualization Co-authored-by: Yulong Ruan * refactor function getDuplicateWorkspaces Signed-off-by: yuye-aws * add i18n context to saved objects table duplicate modal Signed-off-by: yuye-aws * refactor duplicate modal logic in saved object table Signed-off-by: yuye-aws * add error message for partial duplicate failed Signed-off-by: yuye-aws * merge commits Signed-off-by: yuye-aws * add type info for dashboard and visualization Signed-off-by: yuye-aws * remote create vis reference logic Signed-off-by: yuye-aws * Revert "remove clone for dashboard" This reverts commit 84f77fb044dfbc62d208584ed8e74018fe59306f. * hide duplicate when workspace disabled in dashboard Signed-off-by: yuye-aws * feat: skip permission validate when no workspaces and permissions attributes (#163) * feat: skip permission validate when saved object without workspaces and permissions attributes Signed-off-by: Lin Wang * feat: add annontation to skip permission check Signed-off-by: Lin Wang * refactor: remove bind and simplify validate logic Signed-off-by: Lin Wang * feat: remove library write for object based ACL Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * remove get workspaces with write permission logic and add readonly props to workspace attribute Signed-off-by: yuye-aws * change type definition logic Signed-off-by: yuye-aws * Fix typo (#176) --------- Signed-off-by: Yulong Ruan * remove exit workspace logic (#179) Signed-off-by: yuye-aws * rename copy to duplicate Signed-off-by: yuye-aws * duplicate in visualization Signed-off-by: yuye-aws * duplicate in dashboard Signed-off-by: yuye-aws * resolve conflict Signed-off-by: yuye-aws * update test and snapshots Signed-off-by: yuye-aws * re-duplicate if some objects cannot be duplicated Signed-off-by: yuye-aws * remove clone for dashboard Signed-off-by: yuye-aws * rename duplicateState to duplicateMode Signed-off-by: yuye-aws * change workspace prop to currentWorkspace in SavedObjectsDuplicateModal Signed-off-by: yuye-aws * change wording Signed-off-by: yuye-aws * move duplicate modal to saved_objects for reuse Signed-off-by: yuye-aws * move duplicate modal to saved objects management for reuse Signed-off-by: yuye-aws * remove minimal duplicate modal props logic Signed-off-by: yuye-aws * refactor duplicate modal props for dashboard and visualization Signed-off-by: yuye-aws * Update getDuplicateWorkspaces function Co-authored-by: Yulong Ruan * update function onDuplicate for dashboard Signed-off-by: yuye-aws * Update doDuplicate for visualization Co-authored-by: Yulong Ruan * refactor function getDuplicateWorkspaces Signed-off-by: yuye-aws * add i18n context to saved objects table duplicate modal Signed-off-by: yuye-aws * refactor duplicate modal logic in saved object table Signed-off-by: yuye-aws * add error message for partial duplicate failed Signed-off-by: yuye-aws * merge commits Signed-off-by: yuye-aws * add type info for dashboard and visualization Signed-off-by: yuye-aws * remote create vis reference logic Signed-off-by: yuye-aws * Revert "remove clone for dashboard" This reverts commit 84f77fb044dfbc62d208584ed8e74018fe59306f. * hide duplicate when workspace disabled in dashboard Signed-off-by: yuye-aws * remove get workspaces with write permission logic and add readonly props to workspace attribute Signed-off-by: yuye-aws * change type definition logic Signed-off-by: yuye-aws * rename variable and function name Signed-off-by: yuye-aws * change permission mode to get target workspaces when duplicate Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws Signed-off-by: Lin Wang Signed-off-by: Yulong Ruan Co-authored-by: Yulong Ruan Co-authored-by: Lin Wang Co-authored-by: Yulong Ruan --- .../public/workspace/workspaces_service.ts | 5 +- .../dashboard/opensearch_dashboards.json | 2 +- .../dashboard_top_nav/dashboard_top_nav.tsx | 3 +- .../top_nav/get_top_nav_config.ts | 24 +- .../dashboard_top_nav/top_nav/top_nav_ids.ts | 1 + .../application/utils/get_nav_actions.tsx | 74 +++++- .../public/top_nav_menu/top_nav_menu.tsx | 2 +- .../public/constants.ts | 2 +- .../saved_objects_management/public/index.ts | 15 +- ..._objects.ts => duplicate_saved_objects.ts} | 2 +- .../get_workspaces_with_write_permission.ts | 21 -- .../public/lib/index.ts | 3 +- .../public/management_section/index.ts | 1 + .../saved_objects_table.test.tsx.snap | 6 +- .../{copy_modal.tsx => duplicate_modal.tsx} | 139 ++++++----- .../objects_table/components/header.test.tsx | 11 +- .../objects_table/components/header.tsx | 8 +- .../objects_table/components/index.ts | 2 + .../components/show_duplicate_modal.tsx | 64 ++++++ .../objects_table/components/table.tsx | 12 +- .../management_section/objects_table/index.ts | 3 +- .../objects_table/saved_objects_table.tsx | 216 +++++++++--------- .../saved_objects_table_page.tsx | 1 + .../components/visualize_top_nav.tsx | 2 +- .../application/utils/get_top_nav_config.tsx | 101 +++++++- .../workspace/public/workspace_client.ts | 22 +- .../workspace_saved_objects_client_wrapper.ts | 2 +- 27 files changed, 506 insertions(+), 238 deletions(-) rename src/plugins/saved_objects_management/public/lib/{copy_saved_objects.ts => duplicate_saved_objects.ts} (93%) delete mode 100644 src/plugins/saved_objects_management/public/lib/get_workspaces_with_write_permission.ts rename src/plugins/saved_objects_management/public/management_section/objects_table/components/{copy_modal.tsx => duplicate_modal.tsx} (75%) create mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/show_duplicate_modal.tsx diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index b94bf0a17e23..b05d1af58b06 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -20,10 +20,11 @@ type WorkspaceMenuRenderFn = ({ observables: WorkspaceObservables; }) => JSX.Element | null; +type WorkspaceObject = WorkspaceAttribute & { libraryReadonly?: boolean }; export interface WorkspaceObservables { currentWorkspaceId$: BehaviorSubject; - currentWorkspace$: BehaviorSubject; - workspaceList$: BehaviorSubject; + currentWorkspace$: BehaviorSubject; + workspaceList$: BehaviorSubject; workspaceEnabled$: BehaviorSubject; initialized$: BehaviorSubject; } diff --git a/src/plugins/dashboard/opensearch_dashboards.json b/src/plugins/dashboard/opensearch_dashboards.json index 348a0c9fe9dc..30f8bddc3389 100644 --- a/src/plugins/dashboard/opensearch_dashboards.json +++ b/src/plugins/dashboard/opensearch_dashboards.json @@ -14,5 +14,5 @@ "optionalPlugins": ["home", "share", "usageCollection"], "server": true, "ui": true, - "requiredBundles": ["opensearchDashboardsUtils", "opensearchDashboardsReact", "home"] + "requiredBundles": ["opensearchDashboardsUtils", "opensearchDashboardsReact", "home", "savedObjectsManagement"] } diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/components/dashboard_top_nav/dashboard_top_nav.tsx index 1cc58c78ebc1..7c091013dabd 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/dashboard_top_nav.tsx @@ -89,7 +89,8 @@ const TopNav = ({ getTopNavConfig( currentAppState?.viewMode, navActions, - dashboardConfig.getHideWriteControls() + dashboardConfig.getHideWriteControls(), + services.workspaces.workspaceEnabled$.value ) ); } diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/get_top_nav_config.ts b/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/get_top_nav_config.ts index f91f4d47a854..0b39b673f7e5 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/get_top_nav_config.ts +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/get_top_nav_config.ts @@ -42,7 +42,8 @@ import { NavAction } from '../../../../types'; export function getTopNavConfig( dashboardMode: ViewMode, actions: { [key: string]: NavAction }, - hideWriteControls: boolean + hideWriteControls: boolean, + workspaceEnabled?: boolean ) { switch (dashboardMode) { case ViewMode.VIEW: @@ -54,7 +55,9 @@ export function getTopNavConfig( : [ getFullScreenConfig(actions[TopNavIds.FULL_SCREEN]), getShareConfig(actions[TopNavIds.SHARE]), - getCloneConfig(actions[TopNavIds.CLONE]), + ...(workspaceEnabled + ? [getDuplicateConfig(actions[TopNavIds.DUPLICATE])] + : [getCloneConfig(actions[TopNavIds.CLONE])]), getEditConfig(actions[TopNavIds.ENTER_EDIT_MODE]), ]; case ViewMode.EDIT: @@ -158,6 +161,23 @@ function getCloneConfig(action: NavAction) { }; } +/** + * @returns {osdTopNavConfig} + */ +function getDuplicateConfig(action: NavAction) { + return { + id: 'duplicate', + label: i18n.translate('dashboard.topNave.duplicateButtonAriaLabel', { + defaultMessage: 'Duplicate', + }), + description: i18n.translate('dashboard.topNave.duplicateConfigDescription', { + defaultMessage: 'Duplicate your dashboard', + }), + testId: 'dashboardDuplicate', + run: action, + }; +} + /** * @returns {osdTopNavConfig} */ diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/top_nav_ids.ts b/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/top_nav_ids.ts index 0917f7632872..240fbd9ea8b1 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/top_nav_ids.ts +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/top_nav_ids.ts @@ -35,6 +35,7 @@ export const TopNavIds = { EXIT_EDIT_MODE: 'exitEditMode', ENTER_EDIT_MODE: 'enterEditMode', CLONE: 'clone', + DUPLICATE: 'duplicate', FULL_SCREEN: 'fullScreenMode', VISUALIZE: 'visualize', ADD_EXISTING: 'addExisting', diff --git a/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx b/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx index 3f823f52676d..c20d2db2cacc 100644 --- a/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx +++ b/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx @@ -8,9 +8,9 @@ import { i18n } from '@osd/i18n'; import { EUI_MODAL_CANCEL_BUTTON, EuiCheckboxGroup } from '@elastic/eui'; import { EuiCheckboxGroupIdToSelectedMap } from '@elastic/eui/src/components/form/checkbox/checkbox_group'; import { - SaveResult, - SavedObjectSaveOpts, getSavedObjectFinder, + SavedObjectSaveOpts, + SaveResult, showSaveModal, } from '../../../../saved_objects/public'; import { DashboardAppStateContainer, DashboardServices, NavAction } from '../../types'; @@ -24,15 +24,21 @@ import { import { EmbeddableFactoryNotFoundError, EmbeddableInput, - ViewMode, isErrorEmbeddable, openAddPanelFlyout, + ViewMode, } from '../../../../embeddable/public'; import { saveDashboard } from '../utils'; import { DashboardContainer } from '../embeddable/dashboard_container'; -import { DashboardConstants, createDashboardEditUrl } from '../../dashboard_constants'; +import { createDashboardEditUrl, DashboardConstants } from '../../dashboard_constants'; import { unhashUrl } from '../../../../opensearch_dashboards_utils/public'; import { Dashboard } from '../../dashboard'; +import { SavedObjectWithMetadata } from '../../../../saved_objects_management/common'; +import { + DuplicateMode, + showDuplicateModal, + duplicateSavedObjects, +} from '../../../../saved_objects_management/public'; interface UrlParamsSelectedMap { [UrlParams.SHOW_TOP_MENU]: boolean; @@ -65,10 +71,13 @@ export const getNavActions = ( share, dashboardConfig, dashboardCapabilities, + http, + workspaces, } = services; const navActions: { [key: string]: NavAction; } = {}; + const workspaceEnabled = workspaces.workspaceEnabled$.value; if (!stateContainer) { return navActions; @@ -133,7 +142,7 @@ export const getNavActions = ( title={currentTitle} description={currentDescription} timeRestore={currentTimeRestore} - showCopyOnSave={savedDashboard.id ? true : false} + showCopyOnSave={!!savedDashboard.id} /> ); showSaveModal(dashboardSaveModal, I18nContext); @@ -166,6 +175,59 @@ export const getNavActions = ( showCloneModal(onClone, currentTitle); }; + if (workspaceEnabled) { + navActions[TopNavIds.DUPLICATE] = () => { + const onDuplicate = async ( + dashboardSavedObjects: SavedObjectWithMetadata[], + includeReferencesDeep: boolean, + targetWorkspace: string + ) => { + const objectsToDuplicate = dashboardSavedObjects.map((obj) => ({ + id: obj.id, + type: obj.type, + })); + + try { + await duplicateSavedObjects( + http, + objectsToDuplicate, + includeReferencesDeep, + targetWorkspace + ); + + notifications.toasts.addSuccess({ + title: i18n.translate('dashboard.dashboardWasDuplicatedSuccessMessage', { + defaultMessage: 'Duplicate dashboard successfully', + }), + }); + } catch (e) { + notifications.toasts.addDanger({ + title: i18n.translate('dashboard.dashboardWasNotDuplicatedDangerMessage', { + defaultMessage: 'Unable to duplicate dashboard', + }), + }); + } + }; + + const dashboardSavedObject = ({ + ...currentContainer, + ...savedDashboard, + } as unknown) as SavedObjectWithMetadata; + dashboardSavedObject.meta = { title: savedDashboard.title }; + + const showDuplicateModalProps = { + http, + workspaces, + onDuplicate, + notifications, + duplicateMode: DuplicateMode.Selected, + selectedSavedObjects: [dashboardSavedObject], + }; + + showDuplicateModal(showDuplicateModalProps, I18nContext); + }; + } + navActions[TopNavIds.ADD_EXISTING] = () => { if (currentContainer && !isErrorEmbeddable(currentContainer)) { openAddPanelFlyout({ @@ -203,7 +265,7 @@ export const getNavActions = ( }; if (share) { - // the share button is only availabale if "share" plugin contract enabled + // the share button is only available if "share" plugin contract enabled navActions[TopNavIds.SHARE] = (anchorElement) => { const EmbedUrlParamExtension = ({ setParamValue, diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 15b6c6bff057..76cb54dc8cb8 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -106,7 +106,7 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { } function renderSearchBar(): ReactElement | null { - // Validate presense of all required fields + // Validate presence of all required fields if (!showSearchBar || !props.data) return null; const { SearchBar } = props.data.ui; return ; diff --git a/src/plugins/saved_objects_management/public/constants.ts b/src/plugins/saved_objects_management/public/constants.ts index e33e782a7501..e010eca18b86 100644 --- a/src/plugins/saved_objects_management/public/constants.ts +++ b/src/plugins/saved_objects_management/public/constants.ts @@ -40,4 +40,4 @@ export const SAVED_QUERIES_WORDINGS = i18n.translate( } ); -export const SAVED_OBJECT_TYPE_WORKSAPCE = 'workspace'; +export const SAVED_OBJECT_TYPE_WORKSPACE = 'workspace'; diff --git a/src/plugins/saved_objects_management/public/index.ts b/src/plugins/saved_objects_management/public/index.ts index 317b3079efa0..8db0d65c934c 100644 --- a/src/plugins/saved_objects_management/public/index.ts +++ b/src/plugins/saved_objects_management/public/index.ts @@ -46,11 +46,22 @@ 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 { SAVED_OBJECT_TYPE_WORKSPACE } from './constants'; +export { + showDuplicateModal, + SavedObjectsDuplicateModal, + DuplicateMode, +} from './management_section'; export function plugin(initializerContext: PluginInitializerContext) { return new SavedObjectsManagementPlugin(); } diff --git a/src/plugins/saved_objects_management/public/lib/copy_saved_objects.ts b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts similarity index 93% rename from src/plugins/saved_objects_management/public/lib/copy_saved_objects.ts rename to src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts index c28893589367..1128cef9d2f3 100644 --- a/src/plugins/saved_objects_management/public/lib/copy_saved_objects.ts +++ b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts @@ -11,7 +11,7 @@ import { HttpStart } from 'src/core/public'; -export async function copySavedObjects( +export async function duplicateSavedObjects( http: HttpStart, objects: any[], includeReferencesDeep: boolean = true, diff --git a/src/plugins/saved_objects_management/public/lib/get_workspaces_with_write_permission.ts b/src/plugins/saved_objects_management/public/lib/get_workspaces_with_write_permission.ts deleted file mode 100644 index 2a8b47a86aae..000000000000 --- a/src/plugins/saved_objects_management/public/lib/get_workspaces_with_write_permission.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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'; -import { WorkspacePermissionMode } from '../../../../core/public'; - -export async function getWorkspacesWithWritePermission(http: HttpStart) { - return await http.post('/api/workspaces/_list', { - body: JSON.stringify({ - permissionModes: [WorkspacePermissionMode.Management, WorkspacePermissionMode.LibraryWrite], - }), - }); -} diff --git a/src/plugins/saved_objects_management/public/lib/index.ts b/src/plugins/saved_objects_management/public/lib/index.ts index a25aaf669066..80630b8780e7 100644 --- a/src/plugins/saved_objects_management/public/lib/index.ts +++ b/src/plugins/saved_objects_management/public/lib/index.ts @@ -57,5 +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'; -export { getWorkspacesWithWritePermission } from './get_workspaces_with_write_permission'; +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..1f29fa548559 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 { showDuplicateModal, SavedObjectsDuplicateModal, DuplicateMode } from './objects_table'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index 131ea78797b7..d45055d69925 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 @@ -211,7 +211,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` filteredCount={4} hideImport={false} objectCount={4} - onCopy={[Function]} + onDuplicate={[Function]} onExportAll={[Function]} onImport={[Function]} onRefresh={[Function]} @@ -379,9 +379,9 @@ exports[`SavedObjectsTable should render normally 1`] = ` ] } onActionRefresh={[Function]} - onCopySelected={[Function]} - onCopySingle={[Function]} onDelete={[Function]} + onDuplicateSelected={[Function]} + onDuplicateSingle={[Function]} onExport={[Function]} onQueryChange={[Function]} onShowRelationships={[Function]} 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/duplicate_modal.tsx similarity index 75% rename from src/plugins/saved_objects_management/public/management_section/objects_table/components/copy_modal.tsx rename to src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx index 4a792ffc72f0..81013ef2575e 100644 --- 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/duplicate_modal.tsx @@ -31,28 +31,39 @@ import { EuiCallOut, EuiText, } from '@elastic/eui'; -import { WorkspaceAttribute, WorkspaceStart } from 'opensearch-dashboards/public'; +import { + HttpSetup, + NotificationsStart, + WorkspaceAttribute, + WorkspaceStart, +} from 'opensearch-dashboards/public'; import { i18n } from '@osd/i18n'; -import { SavedObjectWithMetadata } from '../../../types'; -import { getSavedObjectLabel } from '../../../lib'; -import { SAVED_OBJECT_TYPE_WORKSAPCE } from '../../../constants'; -import { CopyState } from '../'; +import { SavedObjectWithMetadata } from '../../../../common'; +import { getSavedObjectLabel, SAVED_OBJECT_TYPE_WORKSPACE } from '../../../../public'; type WorkspaceOption = EuiComboBoxOptionOption; -interface Props { - workspaces: WorkspaceStart; - onCopy: ( +export enum DuplicateMode { + Selected = 'selected', + All = 'all', +} +export interface ShowDuplicateModalProps { + onDuplicate: ( savedObjects: SavedObjectWithMetadata[], includeReferencesDeep: boolean, targetWorkspace: string ) => Promise; - onClose: () => void; - copyState: CopyState; - getCopyWorkspaces: () => Promise; + http: HttpSetup; + workspaces: WorkspaceStart; + duplicateMode: DuplicateMode; + notifications: NotificationsStart; selectedSavedObjects: SavedObjectWithMetadata[]; } +interface Props extends ShowDuplicateModalProps { + onClose: () => void; +} + interface State { allSelectedObjects: SavedObjectWithMetadata[]; workspaceOptions: WorkspaceOption[]; @@ -67,7 +78,7 @@ function capitalizeFirstLetter(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } -export class SavedObjectsCopyModal extends React.Component { +export class SavedObjectsDuplicateModal extends React.Component { private isMounted = false; constructor(props: Props) { @@ -101,15 +112,15 @@ export class SavedObjectsCopyModal extends React.Component { }; async componentDidMount() { - const { workspaces, getCopyWorkspaces } = this.props; - const workspaceList = await getCopyWorkspaces(); + const { workspaces } = this.props; const currentWorkspace = workspaces.currentWorkspace$.value; const currentWorkspaceName = currentWorkspace?.name; + const targetWorkspaces = this.getTargetWorkspaces(); // current workspace is the first option const workspaceOptions = [ ...(currentWorkspace ? [this.workspaceToOption(currentWorkspace, currentWorkspaceName)] : []), - ...workspaceList + ...targetWorkspaces .filter((workspace: WorkspaceAttribute) => workspace.name !== currentWorkspaceName) .map((workspace: WorkspaceAttribute) => this.workspaceToOption(workspace, currentWorkspaceName) @@ -121,8 +132,8 @@ export class SavedObjectsCopyModal extends React.Component { allWorkspaceOptions: workspaceOptions, }); - const { copyState } = this.props; - if (copyState === CopyState.All) { + const { duplicateMode } = this.props; + if (duplicateMode === DuplicateMode.All) { const { allSelectedObjects } = this.state; const categorizedObjects = groupBy(allSelectedObjects, (object) => object.type); const savedObjectTypeInfoMap = new Map(); @@ -139,14 +150,20 @@ export class SavedObjectsCopyModal extends React.Component { this.isMounted = false; } - copySavedObjects = async (savedObjects: SavedObjectWithMetadata[]) => { + getTargetWorkspaces = () => { + const { workspaces } = this.props; + const workspaceList = workspaces.workspaceList$.value; + return workspaceList.filter((workspace) => !workspace.libraryReadonly); + }; + + duplicateSavedObjects = async (savedObjects: SavedObjectWithMetadata[]) => { this.setState({ isLoading: true, }); const targetWorkspace = this.state.targetWorkspaceOption[0].key; - await this.props.onCopy( + await this.props.onDuplicate( savedObjects, this.state.isIncludeReferencesDeepChecked, targetWorkspace! @@ -189,7 +206,7 @@ export class SavedObjectsCopyModal extends React.Component { } }; - renderCopyObjectCategory = ( + renderDuplicateObjectCategory = ( savedObjectType: string, savedObjectTypeCount: number, savedObjectTypeChecked: boolean @@ -200,7 +217,10 @@ export class SavedObjectsCopyModal extends React.Component { key={savedObjectType} label={ { ); }; - renderCopyObjectCategories = () => { + renderDuplicateObjectCategories = () => { const { savedObjectTypeInfoMap } = this.state; const checkboxList: JSX.Element[] = []; savedObjectTypeInfoMap.forEach( ([savedObjectTypeCount, savedObjectTypeChecked], savedObjectType) => checkboxList.push( - this.renderCopyObjectCategory( + this.renderDuplicateObjectCategory( savedObjectType, savedObjectTypeCount, savedObjectTypeChecked @@ -241,31 +261,25 @@ export class SavedObjectsCopyModal extends React.Component { isIncludeReferencesDeepChecked, allSelectedObjects, } = this.state; - const { copyState } = this.props; + const { duplicateMode, onClose } = this.props; const targetWorkspaceId = targetWorkspaceOption?.at(0)?.key; let selectedObjects = allSelectedObjects; - if (copyState === CopyState.All) { + if (duplicateMode === DuplicateMode.All) { selectedObjects = selectedObjects.filter((item) => this.isSavedObjectTypeIncluded(item.type)); } const includedSelectedObjects = selectedObjects.filter((item) => !!targetWorkspaceId && !!item.workspaces ? !item.workspaces.includes(targetWorkspaceId) - : item.type !== SAVED_OBJECT_TYPE_WORKSAPCE + : item.type !== SAVED_OBJECT_TYPE_WORKSPACE ); const ignoredSelectedObjectsLength = selectedObjects.length - includedSelectedObjects.length; - let confirmCopyButtonEnabled = false; + let confirmDuplicateButtonEnabled = false; if (!!targetWorkspaceId && includedSelectedObjects.length > 0) { - confirmCopyButtonEnabled = true; + confirmDuplicateButtonEnabled = true; } - const confirmMessageForAllObjects = `Duplicate (${includedSelectedObjects.length})`; - const confirmMessageForSingleOrSelectedObjects = 'Duplicate'; - const confirmMessage = - copyState === CopyState.All - ? confirmMessageForAllObjects - : confirmMessageForSingleOrSelectedObjects; const warningMessageForOnlyOneSavedObject = (

1 saved object will not be @@ -298,17 +312,17 @@ export class SavedObjectsCopyModal extends React.Component { return ( { fullWidth label={ } @@ -338,20 +352,20 @@ export class SavedObjectsCopyModal extends React.Component { singleSelection={{ asPlainText: true }} onSearchChange={this.onSearchWorkspaceChange} isClearable={false} - isInvalid={!confirmCopyButtonEnabled} + isInvalid={!confirmDuplicateButtonEnabled} /> - {copyState && this.renderCopyObjectCategories()} - {copyState && } + {duplicateMode === DuplicateMode.All && this.renderDuplicateObjectCategories()} + {duplicateMode === DuplicateMode.All && } } @@ -367,7 +381,7 @@ export class SavedObjectsCopyModal extends React.Component { id={'includeReferencesDeep'} label={ } @@ -381,7 +395,7 @@ export class SavedObjectsCopyModal extends React.Component { {ignoredSelectedObjectsLength === 0 ? null : ignoreSomeObjectsChildren}

@@ -392,26 +406,29 @@ export class SavedObjectsCopyModal extends React.Component { { field: 'type', name: i18n.translate( - 'savedObjectsManagement.objectsTable.copyModal.typeColumnName', + 'savedObjectsManagement.objectsTable.duplicateModal.typeColumnName', { defaultMessage: 'Type' } ), width: '50px', render: (type, object) => ( - + ), }, { field: 'id', - name: i18n.translate('savedObjectsManagement.objectsTable.copyModal.idColumnName', { - defaultMessage: 'Id', - }), + name: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicateModal.idColumnName', + { + defaultMessage: 'Id', + } + ), }, { field: 'meta.title', name: i18n.translate( - 'savedObjectsManagement.objectsTable.copyModal.titleColumnName', + 'savedObjectsManagement.objectsTable.duplicateModal.titleColumnName', { defaultMessage: 'Title' } ), }, @@ -422,23 +439,27 @@ export class SavedObjectsCopyModal extends React.Component { - + this.copySavedObjects(includedSelectedObjects)} + data-test-subj="duplicateConfirmButton" + onClick={() => this.duplicateSavedObjects(includedSelectedObjects)} isLoading={this.state.isLoading} - disabled={!confirmCopyButtonEnabled} + disabled={!confirmDuplicateButtonEnabled} > 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 18dfb3a7ae17..d98fe7257fbd 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 @@ -39,9 +39,10 @@ describe('Header', () => { onImport: () => {}, onRefresh: () => {}, onCopy: () => {}, + onDuplicate: () => {}, title: 'Saved Objects', selectedCount: 0, - totalCount: 4, + objectCount: 4, filteredCount: 2, showDuplicateAll: false, hideImport: false, @@ -60,9 +61,10 @@ describe('Header - workspace enabled', () => { onImport: () => {}, onRefresh: () => {}, onCopy: () => {}, + onDuplicate: () => {}, title: 'Saved Objects', selectedCount: 0, - totalCount: 4, + objectCount: 4, filteredCount: 2, showDuplicateAll: true, hideImport: false, @@ -70,7 +72,7 @@ describe('Header - workspace enabled', () => { const component = shallow(
); - expect(component.find('EuiButtonEmpty[data-test-subj="copyObjects"]').exists()).toBe(true); + expect(component.find('EuiButtonEmpty[data-test-subj="duplicateObjects"]').exists()).toBe(true); }); it('should hide `Import` button for application home state', () => { @@ -79,9 +81,10 @@ describe('Header - workspace enabled', () => { onImport: () => {}, onRefresh: () => {}, onCopy: () => {}, + onDuplicate: () => {}, title: 'Saved Objects', selectedCount: 0, - totalCount: 4, + objectCount: 4, filteredCount: 2, showDuplicateAll: true, hideImport: 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 21682896d871..003ec95ee7dc 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,7 +43,7 @@ import { FormattedMessage } from '@osd/i18n/react'; export const Header = ({ onExportAll, onImport, - onCopy, + onDuplicate, onRefresh, filteredCount, title, @@ -53,7 +53,7 @@ export const Header = ({ }: { onExportAll: () => void; onImport: () => void; - onCopy: () => void; + onDuplicate: () => void; onRefresh: () => void; filteredCount: number; title: string; @@ -75,8 +75,8 @@ export const Header = ({ { + ReactDOM.unmountComponentAtNode(container); + document.body.removeChild(container); + }; + + const { + http, + workspaces, + onDuplicate, + duplicateMode, + notifications, + selectedSavedObjects, + } = showDuplicateModalProps; + + const onDuplicateConfirmed: ShowDuplicateModalProps['onDuplicate'] = async (...args) => { + await onDuplicate(...args); + closeModal(); + }; + + const duplicateModal = ( + + ); + + document.body.appendChild(container); + + ReactDOM.render({duplicateModal}, container); +} 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 211af14e4c76..c948669596a8 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 @@ -70,8 +70,8 @@ export interface TableProps { filters: any[]; canDelete: boolean; onDelete: () => void; - onCopySelected: () => void; - onCopySingle: (object: SavedObjectWithMetadata) => void; + onDuplicateSelected: () => void; + onDuplicateSingle: (object: SavedObjectWithMetadata) => void; onActionRefresh: (object: SavedObjectWithMetadata) => void; onExport: (includeReferencesDeep: boolean) => void; goInspectObject: (obj: SavedObjectWithMetadata) => void; @@ -172,8 +172,8 @@ export class Table extends PureComponent { filters, selectionConfig: selection, onDelete, - onCopySelected, - onCopySingle, + onDuplicateSelected, + onDuplicateSingle, onActionRefresh, selectedSavedObjects, onTableChange, @@ -326,7 +326,7 @@ export class Table extends PureComponent { type: 'icon', icon: 'copyClipboard', isPrimary: true, - onClick: (object: SavedObjectWithMetadata) => onCopySingle(object), + onClick: (object: SavedObjectWithMetadata) => onDuplicateSingle(object), 'data-test-subj': 'savedObjectsTableAction-duplicate', }, ] @@ -449,7 +449,7 @@ export class Table extends PureComponent { 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 75d036961186..3270414b9e9f 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 @@ -28,4 +28,5 @@ * under the License. */ -export { SavedObjectsTable, CopyState } from './saved_objects_table'; +export { SavedObjectsTable } from './saved_objects_table'; +export { showDuplicateModal, SavedObjectsDuplicateModal, DuplicateMode } from './components'; 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 66cf041eaefb..2ddfabf05019 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 @@ -77,7 +77,6 @@ import { SavedObjectCountOptions, getRelationships, getSavedObjectLabel, - getWorkspacesWithWritePermission, fetchExportObjects, fetchExportByTypeAndSearch, filterQuery, @@ -85,7 +84,7 @@ import { findObject, extractExportDetails, SavedObjectsExportResultDetails, - copySavedObjects, + duplicateSavedObjects, } from '../../lib'; import { SavedObjectWithMetadata } from '../../types'; import { @@ -94,22 +93,15 @@ import { SavedObjectsManagementColumnServiceStart, SavedObjectsManagementNamespaceServiceStart, } from '../../services'; -import { Header, Table, Flyout, Relationships } from './components'; +import { Header, Table, Flyout, Relationships, SavedObjectsDuplicateModal } from './components'; import { DataPublicPluginStart } from '../../../../data/public'; -import { SavedObjectsCopyModal } from './components/copy_modal'; -import { PUBLIC_WORKSPACE_ID, MANAGEMENT_WORKSPACE_ID } from '../../../../../core/public'; - -export enum CopyState { - Single = 'single', - Selected = 'selected', - All = 'all', -} +import { PUBLIC_WORKSPACE_ID } from '../../../../../core/public'; +import { DuplicateMode } from './'; interface ExportAllOption { id: string; label: string; } - export interface SavedObjectsTableProps { allowedTypes: string[]; serviceRegistry: ISavedObjectsManagementServiceRegistry; @@ -140,10 +132,10 @@ export interface SavedObjectsTableState { savedObjectCounts: Record>; activeQuery: Query; selectedSavedObjects: SavedObjectWithMetadata[]; - copySelectedSavedObjects: SavedObjectWithMetadata[]; + duplicateSelectedSavedObjects: SavedObjectWithMetadata[]; isShowingImportFlyout: boolean; - isShowingCopyModal: boolean; - copyState: CopyState; + duplicateMode: DuplicateMode; + isShowingDuplicateModal: boolean; isSearching: boolean; filteredItemCount: number; isShowingRelationships: boolean; @@ -181,10 +173,10 @@ export class SavedObjectsTable extends Component>, activeQuery: Query.parse(''), selectedSavedObjects: [], - copySelectedSavedObjects: [], + duplicateSelectedSavedObjects: [], isShowingImportFlyout: false, - isShowingCopyModal: false, - copyState: CopyState.Selected, + duplicateMode: DuplicateMode.Selected, + isShowingDuplicateModal: false, isSearching: false, filteredItemCount: 0, isShowingRelationships: false, @@ -501,67 +493,6 @@ export class SavedObjectsTable extends Component => { - const { notifications, http } = this.props; - let result; - try { - result = await getWorkspacesWithWritePermission(http); - } catch (error) { - notifications?.toasts.addDanger({ - title: i18n.translate( - 'savedObjectsManagement.objectsTable.copyWorkspaces.dangerNotification', - { - defaultMessage: 'Unable to get workspaces with write permission', - } - ), - text: error instanceof Error ? error.message : JSON.stringify(error), - }); - } - if (result?.success) { - return result.result?.workspaces ?? []; - } else { - return []; - } - }; - - onCopy = async ( - savedObjects: SavedObjectWithMetadata[], - includeReferencesDeep: boolean, - targetWorkspace: string - ) => { - const { notifications, http } = this.props; - const objectsToCopy = savedObjects.map((obj) => ({ id: obj.id, type: obj.type })); - let result; - try { - result = await copySavedObjects(http, objectsToCopy, includeReferencesDeep, targetWorkspace); - if (result.success) { - notifications.toasts.addSuccess({ - title: i18n.translate('savedObjectsManagement.objectsTable.copy.successNotification', { - defaultMessage: - 'Copy ' + savedObjects.length.toString() + ' saved objects successfully', - }), - }); - } else { - const failedCount = savedObjects.length - result.successCount; - notifications.toasts.addSuccess({ - title: i18n.translate('savedObjectsManagement.objectsTable.copy.dangerNotification', { - defaultMessage: 'Unable to copy ' + failedCount.toString() + ' saved objects', - }), - }); - } - } catch (e) { - notifications.toasts.addDanger({ - title: i18n.translate('savedObjectsManagement.objectsTable.copy.dangerNotification', { - defaultMessage: 'Unable to copy all saved objects', - }), - }); - throw e; - } - - this.hideCopyModal(); - this.refreshObjects(); - }; - onExport = async (includeReferencesDeep: boolean) => { const { selectedSavedObjects } = this.state; const { notifications, http } = this.props; @@ -668,14 +599,6 @@ export class SavedObjectsTable extends Component { - this.setState({ isShowingCopyModal: true }); - }; - - hideCopyModal = () => { - this.setState({ isShowingCopyModal: false }); - }; - onDelete = () => { this.setState({ isShowingDeleteConfirmModal: true }); }; @@ -745,22 +668,95 @@ export class SavedObjectsTable extends Component { + this.setState({ isShowingDuplicateModal: true }); + }; + + hideDuplicateModal = () => { + this.setState({ isShowingDuplicateModal: false }); + }; + + renderDuplicateModal() { + const { workspaces, http, notifications } = this.props; + const { isShowingDuplicateModal, duplicateSelectedSavedObjects, duplicateMode } = this.state; - if (!isShowingCopyModal) { + if (!isShowingDuplicateModal) { return null; } + const onDuplicate = async ( + savedObjects: SavedObjectWithMetadata[], + includeReferencesDeep: boolean, + targetWorkspace: string + ) => { + const objectsToDuplicate = savedObjects.map((obj) => ({ id: obj.id, type: obj.type })); + let result; + try { + result = await duplicateSavedObjects( + http, + objectsToDuplicate, + includeReferencesDeep, + targetWorkspace + ); + if (result.success) { + notifications.toasts.addSuccess({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicate.successNotification', + { + defaultMessage: + 'Duplicate ' + savedObjects.length.toString() + ' saved objects successfully', + } + ), + }); + } else if (result.errors) { + const errorsIds = result.errors.map((item: { id: string }) => item.id); + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicate.dangerNotification', + { + defaultMessage: + 'Unable to duplicate ' + + savedObjects.length.toString() + + ' saved objects. These objects cannot be duplicated:' + + errorsIds.join(','), + } + ), + }); + } else { + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicate.dangerNotification', + { + defaultMessage: + 'Unable to duplicate ' + savedObjects.length.toString() + ' saved objects', + } + ), + }); + } + } catch (e) { + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicate.dangerNotification', + { + defaultMessage: + 'Unable to duplicate ' + savedObjects.length.toString() + ' saved objects', + } + ), + }); + } + this.hideDuplicateModal(); + await this.refreshObjects(); + }; + return ( - ); } @@ -1092,17 +1088,17 @@ export class SavedObjectsTable extends Component this.setState({ isShowingExportAllOptionsModal: true })} onImport={this.showImportFlyout} hideImport={hideImport} showDuplicateAll={workspaceEnabled} - onCopy={() => + onDuplicate={() => this.setState({ - copySelectedSavedObjects: savedObjects, - isShowingCopyModal: true, - copyState: CopyState.All, + duplicateSelectedSavedObjects: savedObjects, + isShowingDuplicateModal: true, + duplicateMode: DuplicateMode.All, }) } onRefresh={this.refreshObjects} @@ -1125,18 +1121,18 @@ export class SavedObjectsTable extends Component + onDuplicateSelected={() => this.setState({ - isShowingCopyModal: true, - copyState: CopyState.Selected, - copySelectedSavedObjects: selectedSavedObjects, + isShowingDuplicateModal: true, + duplicateMode: DuplicateMode.Selected, + duplicateSelectedSavedObjects: selectedSavedObjects, }) } - onCopySingle={(object) => + onDuplicateSingle={(object) => this.setState({ - copySelectedSavedObjects: [object], - isShowingCopyModal: true, - copyState: CopyState.Single, + duplicateSelectedSavedObjects: [object], + isShowingDuplicateModal: true, + duplicateMode: DuplicateMode.Selected, }) } onActionRefresh={this.refreshObject} 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 ce74b52ca1bd..d37ba4c7ee95 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 @@ -36,6 +36,7 @@ import { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, SavedObjectsManagementColumnServiceStart, + SavedObjectsManagementNamespaceServiceStart, } from '../services'; import { SavedObjectsTable } from './objects_table'; diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx index d0a1755f275e..5f99b685bf5c 100644 --- a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -204,7 +204,7 @@ const TopNav = ({ /** * Most visualizations have all search bar components enabled. * Some visualizations have fewer options, but all visualizations have the search bar. - * That's is why the showSearchBar prop is set. + * That is why the showSearchBar prop is set. * All visualizations also have the timepicker\autorefresh component, * it is enabled by default in the TopNavMenu component. */ diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index 562f96b872ba..9bd5d2142b47 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -30,26 +30,29 @@ import React from 'react'; import { i18n } from '@osd/i18n'; - import { TopNavMenuData } from 'src/plugins/navigation/public'; import { AppMountParameters } from 'opensearch-dashboards/public'; import { VISUALIZE_EMBEDDABLE_TYPE, VisualizeInput } from '../../../../visualizations/public'; import { - showSaveModal, + OnSaveProps, SavedObjectSaveModalOrigin, SavedObjectSaveOpts, - OnSaveProps, + showSaveModal, } from '../../../../saved_objects/public'; import { unhashUrl } from '../../../../opensearch_dashboards_utils/public'; - import { - VisualizeServices, VisualizeAppStateContainer, VisualizeEditorVisInstance, + VisualizeServices, } from '../types'; import { VisualizeConstants } from '../visualize_constants'; import { getEditBreadcrumbs } from './breadcrumbs'; import { EmbeddableStateTransfer } from '../../../../embeddable/public'; +import { + duplicateSavedObjects, + SavedObjectWithMetadata, +} from '../../../../saved_objects_management/public/'; +import { DuplicateMode, showDuplicateModal } from '../../../../saved_objects_management/public'; interface TopNavConfigParams { hasUnsavedChanges: boolean; @@ -91,10 +94,15 @@ export const getTopNavConfig = ( visualizeCapabilities, i18n: { Context: I18nContext }, dashboard, + http, + notifications, + workspaces, }: VisualizeServices ) => { + const workspaceEnabled = workspaces.workspaceEnabled$.value; const { vis, embeddableHandler } = visInstance; const savedVis = 'savedVis' in visInstance ? visInstance.savedVis : undefined; + /** * Called when the user clicks "Save" button. */ @@ -245,6 +253,89 @@ export const getTopNavConfig = ( // disable the Share button if no action specified disableButton: !share || !!embeddableId, }, + ...(savedVis?.id && workspaceEnabled + ? [ + { + id: 'duplicate', + label: i18n.translate('visualize.topNavMenu.duplicateVisualizationButtonLabel', { + defaultMessage: 'duplicate', + }), + description: i18n.translate( + 'visualize.topNavMenu.duplicateVisualizationButtonAriaLabel', + { + defaultMessage: 'Duplicate Visualization', + } + ), + testId: 'visualizeDuplicateButton', + disableButton: hasUnappliedChanges, + tooltip() { + if (hasUnappliedChanges) { + return i18n.translate( + 'visualize.topNavMenu.duplicateVisualizationDisabledButtonTooltip', + { + defaultMessage: 'Apply or Discard your changes before duplicating', + } + ); + } + }, + run: (anchorElement: HTMLElement) => { + const onDuplicate = async ( + visualizationSavedObjects: SavedObjectWithMetadata[], + includeReferencesDeep: boolean, + targetWorkspace: string + ) => { + const objectsToDuplicate = visualizationSavedObjects.map((obj) => ({ + id: obj.id, + type: obj.type, + })); + + try { + await duplicateSavedObjects( + http, + objectsToDuplicate, + includeReferencesDeep, + targetWorkspace + ); + notifications.toasts.addSuccess({ + title: i18n.translate('visualize.topNavMenu.duplicate.successNotification', { + defaultMessage: 'Duplicate visualization successfully', + }), + }); + } catch (e) { + notifications.toasts.addDanger({ + title: i18n.translate('visualize.topNavMenu.duplicate.dangerNotification', { + defaultMessage: 'Unable to duplicate visualization', + }), + }); + } + }; + + const visualizationSavedObject = ({ + ...embeddableHandler, + ...savedVis, + } as unknown) as SavedObjectWithMetadata; + visualizationSavedObject.meta = { title: savedVis.title }; // meta is missing in savedVis + + const showDuplicateModalProps = { + http, + workspaces, + onDuplicate, + notifications, + duplicateMode: DuplicateMode.Selected, + selectedSavedObjects: [visualizationSavedObject], + }; + + onAppLeave((actions) => { + return actions.default(); + }); + + if (savedVis) { + showDuplicateModal(showDuplicateModalProps, I18nContext); + } + }, + }, + ] + : []), ...(originatingApp === 'dashboards' || originatingApp === 'canvas' ? [ { diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts index 7302a9a63f1c..fbd783a1ba71 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -111,7 +111,21 @@ 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 + ); + let workspaces = result.result.workspaces; + workspaces = result.result.workspaces.map((workspace: WorkspaceAttribute) => ({ + ...workspace, + libraryReadonly: !workspaceIdsWithWritePermission.includes(workspace.id), + })); + this.workspaces.workspaceList$.next(workspaces); + } } } @@ -174,7 +188,7 @@ export class WorkspaceClient { }); if (result.success) { - this.updateWorkspaceList(); + await this.updateWorkspaceList(); } return result; @@ -190,7 +204,7 @@ export class WorkspaceClient { const result = await this.safeFetch(this.getPath([id]), { method: 'DELETE' }); if (result.success) { - this.updateWorkspaceList(); + await this.updateWorkspaceList(); } return result; @@ -265,7 +279,7 @@ export class WorkspaceClient { }); if (result.success) { - this.updateWorkspaceList(); + await this.updateWorkspaceList(); } return result; diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index df978636195f..79f797a3598a 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -361,7 +361,7 @@ export class WorkspaceSavedObjectsClientWrapper { options.ACLSearchParams = {}; } if (this.isRelatedToWorkspace(options.type)) { - options.ACLSearchParams.permissionModes = [ + options.ACLSearchParams.permissionModes = options.permissionModes ?? [ WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Management,