From 41d33da6555f9893ab96e7baf65cdf01470bacbf Mon Sep 17 00:00:00 2001 From: yubonluo Date: Tue, 16 Apr 2024 16:56:51 +0800 Subject: [PATCH 01/18] [Worksapce][UI] duplicate selected/all saved objects Signed-off-by: yubonluo --- CHANGELOG.md | 1 + .../saved_objects_management/public/index.ts | 10 +- .../lib/duplicate_saved_objects.test.ts | 68 ++ .../public/lib/duplicate_saved_objects.ts | 21 + .../public/lib/index.ts | 1 + .../public/management_section/index.ts | 1 + .../saved_objects_table.test.tsx.snap | 612 ++++++++++++++++ .../duplicate_modal.test.tsx.snap | 688 ++++++++++++++++++ .../duplicate_object_categories.test.tsx.snap | 36 + .../__snapshots__/header.test.tsx.snap | 105 +++ .../__snapshots__/table.test.tsx.snap | 240 ++++++ .../components/duplicate_modal.test.tsx | 265 +++++++ .../components/duplicate_modal.tsx | 369 ++++++++++ .../duplicate_object_categories.test.tsx | 23 + .../duplicate_object_categories.tsx | 56 ++ .../objects_table/components/header.test.tsx | 38 +- .../objects_table/components/header.tsx | 21 + .../objects_table/components/index.ts | 1 + .../objects_table/components/table.test.tsx | 22 + .../objects_table/components/table.tsx | 158 ++-- .../objects_table/components/utils.test.tsx | 66 ++ .../objects_table/components/utils.ts | 41 ++ .../management_section/objects_table/index.ts | 1 + .../saved_objects_table.test.mocks.ts | 5 + .../saved_objects_table.test.tsx | 208 ++++++ .../objects_table/saved_objects_table.tsx | 206 +++++- .../saved_objects_table_page.tsx | 1 + .../public/management_section/types.ts | 5 + .../workspace/public/workspace_client.test.ts | 25 + .../workspace/public/workspace_client.ts | 18 +- .../workspace_saved_objects_client_wrapper.ts | 3 +- src/plugins/workspace/server/types.ts | 2 + 32 files changed, 3225 insertions(+), 92 deletions(-) create mode 100644 src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.test.ts create mode 100644 src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts create mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap create mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_object_categories.test.tsx.snap create mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.test.tsx create mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx create mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.test.tsx create mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.tsx create mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.test.tsx create mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c823709af27..7714bf97b455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Add workspace list page ([#6182](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6182)) - [Workspace] Add API to duplicate saved objects among workspaces ([#6288](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6288)) - [Workspace] Add workspaces column to saved objects page ([#6225](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6225)) +- [Workspace] Add UI to duplicate saved objects among workspaces ([#6478](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6478)) - [Multiple Datasource] Enhanced data source selector with default datasource shows as first choice ([#6293](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6293)) - [Multiple Datasource] Add multi data source support to sample vega visualizations ([#6218](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6218)) - [Multiple Datasource] Fetch data source title for DataSourceView when only id is provided ([#6315](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6315) diff --git a/src/plugins/saved_objects_management/public/index.ts b/src/plugins/saved_objects_management/public/index.ts index 317b3079efa0..eb954bf80f7b 100644 --- a/src/plugins/saved_objects_management/public/index.ts +++ b/src/plugins/saved_objects_management/public/index.ts @@ -46,11 +46,17 @@ export { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementServiceRegistryEntry, } from './services'; -export { ProcessedImportResponse, processImportResponse, FailedImport } from './lib'; +export { + ProcessedImportResponse, + processImportResponse, + FailedImport, + duplicateSavedObjects, + getSavedObjectLabel, +} from './lib'; export { SavedObjectRelation, SavedObjectWithMetadata, SavedObjectMetadata } from './types'; export { SAVED_OBJECT_DELETE_TRIGGER, savedObjectDeleteTrigger } from './triggers'; export { SavedObjectDeleteContext } from './ui_actions_bootstrap'; - +export { SavedObjectsDuplicateModal, DuplicateMode } from './management_section'; export function plugin(initializerContext: PluginInitializerContext) { return new SavedObjectsManagementPlugin(); } diff --git a/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.test.ts b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.test.ts new file mode 100644 index 000000000000..5bf84740db81 --- /dev/null +++ b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { httpServiceMock } from '../../../../core/public/mocks'; +import { duplicateSavedObjects } from './duplicate_saved_objects'; + +describe('copy saved objects', () => { + it('make http call with body provided', async () => { + const httpClient = httpServiceMock.createStartContract(); + const objects = [ + { type: 'dashboard', id: '1' }, + { type: 'visualization', id: '2' }, + ]; + const includeReferencesDeep = true; + const targetWorkspace = '1'; + await duplicateSavedObjects(httpClient, objects, includeReferencesDeep, targetWorkspace); + expect(httpClient.post).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + "/api/workspaces/_duplicate_saved_objects", + Object { + "body": "{\\"objects\\":[{\\"type\\":\\"dashboard\\",\\"id\\":\\"1\\"},{\\"type\\":\\"visualization\\",\\"id\\":\\"2\\"}],\\"includeReferencesDeep\\":true,\\"targetWorkspace\\":\\"1\\"}", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + } + `); + + await duplicateSavedObjects(httpClient, objects, undefined, targetWorkspace); + expect(httpClient.post).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + "/api/workspaces/_duplicate_saved_objects", + Object { + "body": "{\\"objects\\":[{\\"type\\":\\"dashboard\\",\\"id\\":\\"1\\"},{\\"type\\":\\"visualization\\",\\"id\\":\\"2\\"}],\\"includeReferencesDeep\\":true,\\"targetWorkspace\\":\\"1\\"}", + }, + ], + Array [ + "/api/workspaces/_duplicate_saved_objects", + Object { + "body": "{\\"objects\\":[{\\"type\\":\\"dashboard\\",\\"id\\":\\"1\\"},{\\"type\\":\\"visualization\\",\\"id\\":\\"2\\"}],\\"includeReferencesDeep\\":true,\\"targetWorkspace\\":\\"1\\"}", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], + } + `); + }); +}); diff --git a/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts new file mode 100644 index 000000000000..bf7d209bca7a --- /dev/null +++ b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpStart } from 'src/core/public'; + +export async function duplicateSavedObjects( + http: HttpStart, + objects: any[], + includeReferencesDeep: boolean = true, + targetWorkspace: string +) { + return await http.post('/api/workspaces/_duplicate_saved_objects', { + body: JSON.stringify({ + objects, + includeReferencesDeep, + targetWorkspace, + }), + }); +} diff --git a/src/plugins/saved_objects_management/public/lib/index.ts b/src/plugins/saved_objects_management/public/lib/index.ts index fae58cad3eb2..80630b8780e7 100644 --- a/src/plugins/saved_objects_management/public/lib/index.ts +++ b/src/plugins/saved_objects_management/public/lib/index.ts @@ -57,3 +57,4 @@ export { extractExportDetails, SavedObjectsExportResultDetails } from './extract export { createFieldList } from './create_field_list'; export { getAllowedTypes } from './get_allowed_types'; export { filterQuery } from './filter_query'; +export { duplicateSavedObjects } from './duplicate_saved_objects'; diff --git a/src/plugins/saved_objects_management/public/management_section/index.ts b/src/plugins/saved_objects_management/public/management_section/index.ts index 333bee71b0c0..3d0c166a4823 100644 --- a/src/plugins/saved_objects_management/public/management_section/index.ts +++ b/src/plugins/saved_objects_management/public/management_section/index.ts @@ -29,3 +29,4 @@ */ export { mountManagementSection } from './mount_section'; +export { SavedObjectsDuplicateModal, 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 f074f9715c99..f096f12efbd9 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 @@ -74,6 +74,610 @@ exports[`SavedObjectsTable delete should show a confirm modal 1`] = ` `; +exports[`SavedObjectsTable duplicate should allow the user to choose on header when duplicating all 1`] = ` + +`; + +exports[`SavedObjectsTable duplicate should allow the user to choose on table when duplicating all 1`] = ` + +`; + +exports[`SavedObjectsTable duplicate should allow the user to choose on table when duplicating single 1`] = ` + +`; + exports[`SavedObjectsTable export should allow the user to choose when exporting all 1`] = `
`; + +exports[`SavedObjectsTable should unmount normally 1`] = `""`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap new file mode 100644 index 000000000000..9ac13848a068 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap @@ -0,0 +1,688 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DuplicateModal should Unmount normally 1`] = `""`; + +exports[`DuplicateModal should render normally 1`] = ` +HTMLCollection [ + + + + + + + +
+
+
+
+ +
+
+
+ + Duplicate all objects? + +
+
+
+
+
+
+ +
+
+
+
+ Specify a workspace where the objects will be duplicated. +
+
+
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+
+
+ +
+
+
+
+ We recommended duplicating related objects to ensure your duplicated objects will continue to function. +
+
+
+
+ +
+ +
+
+
+
+

+ + The following saved objects will be copied: + +

+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + Type + + + + + + Id + + + + + + Title + + +
+
+ Type +
+
+ + + +
+
+
+ Id +
+
+ + 1 + +
+
+
+ Title +
+
+ + Dashboard_1 + +
+
+
+ Type +
+
+ + + +
+
+
+ Id +
+
+ + 2 + +
+
+
+ Title +
+
+ + Visualization + +
+
+
+ Type +
+
+ + + +
+
+
+ Id +
+
+ + 3 + +
+
+
+ Title +
+
+ + Dashboard_2 + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ + +
+
+
+
+
+ + , +] +`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_object_categories.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_object_categories.test.tsx.snap new file mode 100644 index 000000000000..3171694c6efd --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_object_categories.test.tsx.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RenderDuplicateObjectCategories renders checkboxes correctly 1`] = ` +Array [ + + } + onChange={[Function]} + />, + + } + onChange={[Function]} + />, +] +`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap index 038e1aaf2d8f..fc7c75d80cda 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap @@ -104,3 +104,108 @@ exports[`Header should render normally 1`] = ` /> `; + +exports[`Header should render normally when showDuplicateAll is undefined 1`] = ` + + + + +

+ +

+
+
+ + + + + + + + + + + + + + + + + + + +
+ + +

+ + + +

+
+ +
+`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index e22f7f3a0128..0c8a1d1c1bd4 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -226,6 +226,246 @@ exports[`Table prevents saved objects from being deleted 1`] = ` `; +exports[`Table should call onDuplicateSingle when show duplicate 1`] = ` + + + + , + , + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + > + + } + labelType="label" + > + + } + name="includeReferencesDeep" + onChange={[Function]} + /> + + + + + + + , + ] + } + /> + +
+ +
+
+`; + exports[`Table should render normally 1`] = ` void; +} + +describe('DuplicateModal', () => { + let selectedModeProps: Props; + let allModeProps: Props; + let http: ReturnType; + let notifications: ReturnType; + let workspaces: ReturnType; + const selectedSavedObjects: SavedObjectWithMetadata[] = [ + { + id: '1', + type: 'dashboard', + workspaces: ['workspace1'], + attributes: {}, + references: [], + meta: { + title: 'Dashboard_1', + icon: 'dashboardApp', + }, + }, + { + id: '2', + type: 'visualization', + workspaces: ['workspace2'], + attributes: {}, + references: [], + meta: { + title: 'Visualization', + icon: 'visualizationApp', + }, + }, + { + id: '3', + type: 'dashboard', + workspaces: ['workspace2'], + attributes: {}, + references: [], + meta: { + title: 'Dashboard_2', + }, + }, + ]; + const workspaceList: WorkspaceObject[] = [ + { + id: 'workspace1', + name: 'foo', + }, + { + id: 'workspace2', + name: 'bar', + }, + ]; + beforeEach(() => { + http = httpServiceMock.createStartContract(); + notifications = notificationServiceMock.createStartContract(); + workspaces = workspacesServiceMock.createStartContract(); + + selectedModeProps = { + onDuplicate: jest.fn(), + onClose: jest.fn(), + http, + workspaces, + duplicateMode: DuplicateMode.Selected, + notifications, + selectedSavedObjects, + }; + + allModeProps = { + ...selectedModeProps, + duplicateMode: DuplicateMode.All, + selectedSavedObjects, + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render normally', async () => { + render(); + expect(document.children).toMatchSnapshot(); + }); + + it('should Unmount normally', async () => { + const component = shallowWithI18nProvider( + + ); + expect(component.unmount()).toMatchSnapshot(); + }); + + it('should show all target workspace options when not in any workspace', async () => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next(''); + workspaces.currentWorkspace$.next(null); + selectedModeProps = { ...selectedModeProps, workspaces }; + const component = shallowWithI18nProvider( + + ); + await new Promise((resolve) => process.nextTick(resolve)); + component.update(); + const options = component.find('EuiComboBox').prop('options') as WorkspaceOption[]; + expect(options.length).toEqual(2); + expect(options[0].label).toEqual('foo'); + expect(options[1].label).toEqual('bar'); + }); + + it('should display the suffix (current) in target workspace options when in workspace1', async () => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next('workspace1'); + workspaces.currentWorkspace$.next({ + id: 'workspace1', + name: 'foo', + }); + selectedModeProps = { ...selectedModeProps, workspaces }; + const component = shallowWithI18nProvider( + + ); + await new Promise((resolve) => process.nextTick(resolve)); + component.update(); + const options = component.find('EuiComboBox').prop('options') as WorkspaceOption[]; + expect(options.length).toEqual(2); + expect(options[0].label).toEqual('foo (current)'); + expect(options[1].label).toEqual('bar'); + }); + + it('should only show saved objects belong to workspace1 when target workspace is workspace2', async () => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next(''); + workspaces.currentWorkspace$.next(null); + selectedModeProps = { ...selectedModeProps, workspaces }; + const component = shallowWithI18nProvider( + + ); + const selectedObjects = component + .find('EuiInMemoryTable') + .prop('items') as SavedObjectWithMetadata[]; + expect(selectedObjects.length).toEqual(3); + const comboBox = component.find('EuiComboBox'); + comboBox.simulate('change', [{ label: 'bar', key: 'workspace2', value: workspaceList[1] }]); + component.update(); + + const targetWorkspaceOption = component.state('targetWorkspaceOption') as WorkspaceOption[]; + expect(targetWorkspaceOption.length).toEqual(1); + expect(targetWorkspaceOption[0].key).toEqual('workspace2'); + + const includedSelectedObjects = component + .find('EuiInMemoryTable') + .prop('items') as SavedObjectWithMetadata[]; + expect(includedSelectedObjects.length).toEqual(1); + expect(includedSelectedObjects[0].workspaces).toEqual(['workspace1']); + expect(includedSelectedObjects[0].id).toEqual('1'); + + expect(component.find('EuiCallOut').prop('aria-disabled')).toEqual(false); + }); + + it('should ignore one saved object when target workspace is workspace1', async () => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next(''); + workspaces.currentWorkspace$.next(null); + selectedModeProps = { ...selectedModeProps, workspaces }; + const component = shallowWithI18nProvider( + + ); + const comboBox = component.find('EuiComboBox'); + comboBox.simulate('change', [{ label: 'foo', key: 'workspace1', value: workspaceList[0] }]); + + const includedSelectedObjects = component + .find('EuiInMemoryTable') + .prop('items') as SavedObjectWithMetadata[]; + expect(includedSelectedObjects.length).toEqual(2); + }); + + it('should show saved objects type when duplicate mode is all', async () => { + const component = shallowWithI18nProvider(); + const savedObjectTypeInfoMap = component.state('savedObjectTypeInfoMap') as Map< + string, + [number, boolean] + >; + expect(savedObjectTypeInfoMap.get('dashboard')).toEqual([2, true]); + + const euiCheckbox = component.find('EuiCheckbox').at(0); + expect(euiCheckbox.prop('checked')).toEqual(true); + expect(euiCheckbox.prop('id')).toEqual('includeSavedObjectType.dashboard'); + + euiCheckbox.simulate('change', { target: { checked: false } }); + const euiCheckboxUnCheced = component.find('EuiCheckbox').at(0); + expect(euiCheckboxUnCheced.prop('checked')).toEqual(false); + expect(savedObjectTypeInfoMap.get('dashboard')).toEqual([2, false]); + + (component.instance() as any).changeIncludeSavedObjectType('invalid'); + }); + + it('should uncheck duplicate related objects', async () => { + const component = shallowWithI18nProvider( + + ); + + const euiCheckbox = component.find('EuiCheckbox').at(0); + expect(euiCheckbox.prop('checked')).toEqual(true); + expect(euiCheckbox.prop('id')).toEqual('includeReferencesDeep'); + expect(component.state('isIncludeReferencesDeepChecked')).toEqual(true); + + euiCheckbox.simulate('change', { target: { checked: false } }); + expect(component.state('isIncludeReferencesDeepChecked')).toEqual(false); + }); + + it('should call onClose function when cancle button is clicked', () => { + const component = shallowWithI18nProvider( + + ); + component.find('[data-test-subj="duplicateCancelButton"]').simulate('click'); + expect(selectedModeProps.onClose).toHaveBeenCalled(); + }); + + it('should call onDuplicate function when confirm button is clicked', () => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next(''); + workspaces.currentWorkspace$.next(null); + selectedModeProps = { ...selectedModeProps, workspaces }; + const component = shallowWithI18nProvider( + + ); + const comboBox = component.find('EuiComboBox'); + comboBox.simulate('change', [{ label: 'bar', key: 'workspace2', value: workspaceList[1] }]); + const confirmButton = component.find('[data-test-subj="duplicateConfirmButton"]'); + expect(confirmButton.prop('isLoading')).toBe(false); + expect(confirmButton.prop('disabled')).toBe(false); + confirmButton.simulate('click'); + expect(selectedModeProps.onDuplicate).toHaveBeenCalled(); + }); + + it('should not change isLoading when isMounted is false ', async () => { + const component = shallowWithI18nProvider( + + ); + const comboBox = component.find('EuiComboBox'); + comboBox.simulate('change', [{ label: 'bar', key: 'workspace2', value: workspaceList[1] }]); + const confirmButton = component.find('[data-test-subj="duplicateConfirmButton"]'); + (component.instance() as any).isMounted = false; + confirmButton.simulate('click'); + expect(selectedModeProps.onDuplicate).toHaveBeenCalled(); + expect(component.state('isLoading')).toBe(true); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx new file mode 100644 index 000000000000..9ba53a1c6ac0 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx @@ -0,0 +1,369 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { groupBy } from 'lodash'; +import { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiComboBox, + EuiFormRow, + EuiCheckbox, + EuiInMemoryTable, + EuiToolTip, + EuiIcon, + EuiCallOut, + EuiText, + EuiTextColor, +} from '@elastic/eui'; +import { HttpSetup, NotificationsStart, WorkspacesStart } from 'opensearch-dashboards/public'; +import { i18n } from '@osd/i18n'; +import { SavedObjectWithMetadata } from '../../../../common'; +import { getSavedObjectLabel } from '../../../../public'; +import { WorkspaceOption, getTargetWorkspacesOptions, workspaceToOption } from './utils'; +import { DuplicateMode } from '../../types'; +import RenderDuplicateObjectCategories from './duplicate_object_categories'; + +export interface ShowDuplicateModalProps { + onDuplicate: ( + savedObjects: SavedObjectWithMetadata[], + includeReferencesDeep: boolean, + targetWorkspace: string + ) => Promise; + http: HttpSetup; + workspaces: WorkspacesStart; + duplicateMode: DuplicateMode; + notifications: NotificationsStart; + selectedSavedObjects: SavedObjectWithMetadata[]; +} + +interface Props extends ShowDuplicateModalProps { + onClose: () => void; +} + +interface State { + allSelectedObjects: SavedObjectWithMetadata[]; + workspaceOptions: WorkspaceOption[]; + targetWorkspaceOption: WorkspaceOption[]; + isLoading: boolean; + isIncludeReferencesDeepChecked: boolean; + savedObjectTypeInfoMap: Map; +} + +export class SavedObjectsDuplicateModal extends React.Component { + private isMounted = false; + + constructor(props: Props) { + super(props); + + const { workspaces, duplicateMode } = props; + const currentWorkspace = workspaces.currentWorkspace$.value; + const currentWorkspaceId = currentWorkspace?.id; + const targetWorkspacesOptions = getTargetWorkspacesOptions(workspaces, currentWorkspaceId); + + // If user click 'Duplicate All' button, saved objects will be categoried by type. + const savedObjectTypeInfoMap = new Map(); + if (duplicateMode === DuplicateMode.All) { + const categorizedObjects = groupBy(props.selectedSavedObjects, (object) => object.type); + for (const [savedObjectType, savedObjects] of Object.entries(categorizedObjects)) { + savedObjectTypeInfoMap.set(savedObjectType, [savedObjects.length, true]); + } + } + + this.state = { + allSelectedObjects: props.selectedSavedObjects, + // current workspace is the first option + workspaceOptions: [ + ...(currentWorkspace ? [workspaceToOption(currentWorkspace, currentWorkspaceId)] : []), + ...targetWorkspacesOptions, + ], + targetWorkspaceOption: [], + isLoading: false, + isIncludeReferencesDeepChecked: true, + savedObjectTypeInfoMap, + }; + this.isMounted = true; + } + + componentWillUnmount() { + this.isMounted = false; + } + + duplicateSavedObjects = async (savedObjects: SavedObjectWithMetadata[]) => { + this.setState({ + isLoading: true, + }); + + const targetWorkspace = this.state.targetWorkspaceOption[0].key; + + await this.props.onDuplicate( + savedObjects, + this.state.isIncludeReferencesDeepChecked, + targetWorkspace! + ); + + if (this.isMounted) { + this.setState({ + isLoading: false, + }); + } + }; + + onTargetWorkspaceChange = (targetWorkspaceOption: WorkspaceOption[]) => { + this.setState({ + targetWorkspaceOption, + }); + }; + + changeIncludeReferencesDeep = () => { + this.setState((state) => ({ + isIncludeReferencesDeepChecked: !state.isIncludeReferencesDeepChecked, + })); + }; + + // Choose whether to copy a certain type or not. + changeIncludeSavedObjectType = (savedObjectType: string) => { + const { savedObjectTypeInfoMap } = this.state; + const savedObjectTypeInfo = savedObjectTypeInfoMap.get(savedObjectType); + if (savedObjectTypeInfo) { + const [count, checked] = savedObjectTypeInfo; + savedObjectTypeInfoMap.set(savedObjectType, [count, !checked]); + this.setState({ savedObjectTypeInfoMap }); + } + }; + + isSavedObjectTypeIncluded = (savedObjectType: string) => { + const { savedObjectTypeInfoMap } = this.state; + const savedObjectTypeInfo = savedObjectTypeInfoMap.get(savedObjectType); + return savedObjectTypeInfo && savedObjectTypeInfo[1]; + }; + + getIncludeAndNotDuplicateObjects = (targetWorkspaceId: string | undefined) => { + let selectedObjects = this.state.allSelectedObjects; + if (this.props.duplicateMode === DuplicateMode.All) { + selectedObjects = selectedObjects.filter((item) => this.isSavedObjectTypeIncluded(item.type)); + } + // If the target workspace is not selected, all saved objects will be retained. + // If the target workspace has been selected, filter out the saved objects that belongs to the workspace. + const includedSelectedObjects = selectedObjects.filter((item) => + !!targetWorkspaceId && !!item.workspaces ? !item.workspaces.includes(targetWorkspaceId) : true + ); + const ignoredSelectedObjectsLength = selectedObjects.length - includedSelectedObjects.length; + return { includedSelectedObjects, ignoredSelectedObjectsLength }; + }; + + render() { + const { + workspaceOptions, + targetWorkspaceOption, + isIncludeReferencesDeepChecked, + allSelectedObjects, + } = this.state; + const { duplicateMode, onClose } = this.props; + const targetWorkspaceId = targetWorkspaceOption?.at(0)?.key; + + const { + includedSelectedObjects, + ignoredSelectedObjectsLength, + } = this.getIncludeAndNotDuplicateObjects(targetWorkspaceId); + + let confirmDuplicateButtonEnabled = false; + if (!!targetWorkspaceId && includedSelectedObjects.length > 0) { + confirmDuplicateButtonEnabled = true; + } + + const warningMessage = ( + + {ignoredSelectedObjectsLength} saved object + {ignoredSelectedObjectsLength === 1 ? ' will' : 's will'}{' '} + not be copied, because{' '} + {ignoredSelectedObjectsLength === 1 ? 'it has' : 'they have'} already existed in the + selected workspace. + + ); + + // Show the number and reason why some saved objects cannot be duplicated. + const ignoreSomeObjectsChildren: React.ReactChild = ( + <> + + {warningMessage} + + + + ); + + return ( + + + + + + + + + + <> + + {i18n.translate( + 'savedObjectsManagement.objectsTable.duplicateModal.targetWorkspaceNotice', + { defaultMessage: 'Specify a workspace where the objects will be duplicated.' } + )} + + + + + + + + {duplicateMode === DuplicateMode.All && + RenderDuplicateObjectCategories( + this.state.savedObjectTypeInfoMap, + this.changeIncludeSavedObjectType + )} + {duplicateMode === DuplicateMode.All && } + + + <> + + {i18n.translate( + 'savedObjectsManagement.objectsTable.duplicateModal.relatedObjectsNotice', + { + defaultMessage: + 'We recommended duplicating related objects to ensure your duplicated objects will continue to function.', + } + )} + + + + + + + + {ignoredSelectedObjectsLength === 0 ? null : ignoreSomeObjectsChildren} +

+ +

+ + ( + + + + ), + }, + { + field: 'id', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicateModal.idColumnName', + { + defaultMessage: 'Id', + } + ), + }, + { + field: 'meta.title', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicateModal.titleColumnName', + { defaultMessage: 'Title' } + ), + }, + ]} + pagination={true} + sorting={false} + /> +
+ + + + + + + this.duplicateSavedObjects(includedSelectedObjects)} + isLoading={this.state.isLoading} + disabled={!confirmDuplicateButtonEnabled} + > + + + +
+ ); + } +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.test.tsx new file mode 100644 index 000000000000..c14757f16c2b --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.test.tsx @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import RenderDuplicateObjectCategories from './duplicate_object_categories'; + +describe('RenderDuplicateObjectCategories', () => { + test('renders checkboxes correctly', () => { + const savedObjectTypeInfoMap: Map = new Map([ + ['type1', [5, true]], + ['type2', [10, false]], + ]); + const changeIncludeSavedObjectTypeMock = jest.fn(); + + const renderDuplicateObjectCategories = RenderDuplicateObjectCategories( + savedObjectTypeInfoMap, + changeIncludeSavedObjectTypeMock + ); + + expect(renderDuplicateObjectCategories).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.tsx new file mode 100644 index 000000000000..948c87de1f74 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.tsx @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiCheckbox } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { capitalizeFirstLetter } from './utils'; + +// A checkbox showing the type and count of save objects. +function renderDuplicateObjectCategory( + savedObjectType: string, + savedObjectTypeCount: number, + savedObjectTypeChecked: boolean, + changeIncludeSavedObjectType: (savedObjectType: string) => void +) { + return ( + + } + checked={savedObjectTypeChecked} + onChange={() => changeIncludeSavedObjectType(savedObjectType)} + /> + ); +} + +// eslint-disable-next-line import/no-default-export +export default function RenderDuplicateObjectCategories( + savedObjectTypeInfoMap: Map, + changeIncludeSavedObjectType: (savedObjectType: string) => void +) { + const checkboxList: React.JSX.Element[] = []; + savedObjectTypeInfoMap.forEach( + ([savedObjectTypeCount, savedObjectTypeChecked], savedObjectType) => + checkboxList.push( + renderDuplicateObjectCategory( + savedObjectType, + savedObjectTypeCount, + savedObjectTypeChecked, + changeIncludeSavedObjectType + ) + ) + ); + return checkboxList; +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx index 1b0f40e9cd02..da6f241f382c 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx @@ -38,12 +38,48 @@ describe('Header', () => { onExportAll: () => {}, onImport: () => {}, onRefresh: () => {}, - totalCount: 4, + onDuplicate: () => {}, + objectCount: 4, filteredCount: 2, + showDuplicateAll: false, }; const component = shallow(
); expect(component).toMatchSnapshot(); }); + + it('should render normally when showDuplicateAll is undefined', () => { + const props = { + onExportAll: () => {}, + onImport: () => {}, + onRefresh: () => {}, + onDuplicate: () => {}, + objectCount: 4, + filteredCount: 2, + showDuplicateAll: undefined, + }; + + const component = shallow(
); + + expect(component).toMatchSnapshot(); + }); +}); + +describe('Header - workspace enabled', () => { + it('should render `Duplicate All` button when workspace enabled', () => { + const props = { + onExportAll: () => {}, + onImport: () => {}, + onRefresh: () => {}, + onDuplicate: () => {}, + objectCount: 4, + filteredCount: 2, + showDuplicateAll: true, + }; + + const component = shallow(
); + + expect(component.find('EuiButtonEmpty[data-test-subj="duplicateObjects"]').exists()).toBe(true); + }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx index a22e349d5240..47df820a9e8e 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,19 @@ import { FormattedMessage } from '@osd/i18n/react'; export const Header = ({ onExportAll, onImport, + onDuplicate, onRefresh, filteredCount, + objectCount, + showDuplicateAll = false, }: { onExportAll: () => void; onImport: () => void; + onDuplicate: () => void; onRefresh: () => void; filteredCount: number; + objectCount: number; + showDuplicateAll: boolean; }) => ( @@ -66,6 +72,21 @@ export const Header = ({ + {showDuplicateAll && ( + + + + + + )} {}, canDelete: true, + onDuplicateSelected: () => {}, + onDuplicateSingle: () => {}, + showDuplicate: false, }; describe('Table', () => { @@ -224,4 +227,23 @@ describe('Table', () => { someAction.onClick(); expect(onActionRefresh).toHaveBeenCalled(); }); + + it('should call onDuplicateSingle when show duplicate', () => { + const onDuplicateSingle = jest.fn(); + const showDuplicate = true; + const customizedProps = { ...defaultProps, onDuplicateSingle, showDuplicate }; + const component = shallowWithI18nProvider(); + expect(component).toMatchSnapshot(); + + const table = component.find('EuiBasicTable'); + const columns = table.prop('columns') as any[]; + const actionColumn = columns.find((x) => x.hasOwnProperty('actions')) as { actions: any[] }; + const duplicateAction = actionColumn.actions.find( + (x) => x['data-test-subj'] === 'savedObjectsTableAction-duplicate' + ); + + expect(onDuplicateSingle).not.toHaveBeenCalled(); + duplicateAction.onClick(); + expect(onDuplicateSingle).toHaveBeenCalled(); + }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index 56606711db98..798ad789b41d 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -46,6 +46,7 @@ import { EuiText, EuiTableFieldDataColumnType, EuiTableActionsColumnType, + EuiButtonIcon, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; @@ -62,7 +63,6 @@ export interface TableProps { basePath: IBasePath; actionRegistry: SavedObjectsManagementActionServiceStart; columnRegistry: SavedObjectsManagementColumnServiceStart; - namespaceRegistry: SavedObjectsManagementNamespaceServiceStart; selectedSavedObjects: SavedObjectWithMetadata[]; selectionConfig: { onSelectionChange: (selection: SavedObjectWithMetadata[]) => void; @@ -70,6 +70,8 @@ export interface TableProps { filters: any[]; canDelete: boolean; onDelete: () => void; + onDuplicateSelected: () => void; + onDuplicateSingle: (object: SavedObjectWithMetadata) => void; onActionRefresh: (object: SavedObjectWithMetadata) => void; onExport: (includeReferencesDeep: boolean) => void; goInspectObject: (obj: SavedObjectWithMetadata) => void; @@ -86,6 +88,7 @@ export interface TableProps { dateFormat: string; availableWorkspaces?: WorkspaceAttribute[]; currentWorkspaceId?: string; + showDuplicate: boolean; } interface TableState { @@ -170,6 +173,8 @@ export class Table extends PureComponent { filters, selectionConfig: selection, onDelete, + onDuplicateSelected, + onDuplicateSingle, onActionRefresh, selectedSavedObjects, onTableChange, @@ -178,10 +183,10 @@ export class Table extends PureComponent { basePath, actionRegistry, columnRegistry, - namespaceRegistry, dateFormat, availableWorkspaces, currentWorkspaceId, + showDuplicate, } = this.props; const visibleWsIds = availableWorkspaces?.map((ws) => ws.id) || []; @@ -312,6 +317,25 @@ export class Table extends PureComponent { onClick: (object) => onShowRelationships(object), 'data-test-subj': 'savedObjectsTableAction-relationships', }, + ...(showDuplicate + ? [ + { + name: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnActions.duplicateActionName', + { defaultMessage: 'Duplicate' } + ), + description: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnActions.duplicateActionDescription', + { defaultMessage: 'Duplicate this saved object' } + ), + type: 'icon', + icon: 'copyClipboard', + isPrimary: true, + onClick: (object: SavedObjectWithMetadata) => onDuplicateSingle(object), + 'data-test-subj': 'savedObjectsTableAction-duplicate', + }, + ] + : []), ...actionRegistry.getAll().map((action) => { return { ...action.euiAction, @@ -368,6 +392,78 @@ export class Table extends PureComponent { const activeActionContents = this.state.activeAction?.render() ?? null; + const tools = [ + + + , + + + } + > + + } + checked={this.state.isIncludeReferencesDeepChecked} + onChange={this.toggleIsIncludeReferencesDeepChecked} + /> + + + + + + + , + ]; + + const duplicateButton = ( + + ); + + if (showDuplicate) { + tools.splice(1, 0, duplicateButton); + } + return ( {activeActionContents} @@ -375,63 +471,7 @@ export class Table extends PureComponent { box={{ 'data-test-subj': 'savedObjectSearchBar' }} filters={filters as any} onChange={this.onChange} - toolsRight={[ - - - , - - - } - > - - } - checked={this.state.isIncludeReferencesDeepChecked} - onChange={this.toggleIsIncludeReferencesDeepChecked} - /> - - - - - - - , - ]} + toolsRight={tools} /> {queryParseError} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.test.tsx new file mode 100644 index 000000000000..5fb950782989 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspaceAttribute, WorkspaceObject, WorkspacesStart } from 'opensearch-dashboards/public'; +import { + WorkspaceOption, + capitalizeFirstLetter, + getTargetWorkspacesOptions, + workspaceToOption, +} from './utils'; +import { BehaviorSubject } from 'rxjs'; + +describe('duplicate mode utils', () => { + it('should covert workspace to option', () => { + const workspace: WorkspaceAttribute = { + id: '1', + name: 'Workspace 1', + }; + const workspaceOption: WorkspaceOption = workspaceToOption(workspace); + expect(workspaceOption.label).toBe(workspace.name); + expect(workspaceOption.key).toBe(workspace.id); + expect(workspaceOption.value).toBe(workspace); + }); + + it('should add suffix when workspace is current workspace', () => { + const workspace: WorkspaceAttribute = { + id: '1', + name: 'Workspace 1', + }; + const workspaceOption: WorkspaceOption = workspaceToOption(workspace, '1'); + expect(workspaceOption.label).toBe('Workspace 1 (current)'); + expect(workspaceOption.key).toBe(workspace.id); + expect(workspaceOption.value).toBe(workspace); + }); + + it('should get target workspace options', () => { + const workspaces: WorkspacesStart = { + currentWorkspaceId$: new BehaviorSubject('1'), + currentWorkspace$: new BehaviorSubject({ + id: '1', + name: 'Workspace 1', + }), + workspaceList$: new BehaviorSubject([ + { id: '1', name: 'Workspace 1' }, + { id: '2', name: 'Workspace 2', libraryReadonly: false }, + { id: '3', name: 'Workspace 3', libraryReadonly: true }, + ]), + initialized$: new BehaviorSubject(true), + }; + const optionContainCurrent: WorkspaceOption[] = getTargetWorkspacesOptions(workspaces, '1'); + expect(optionContainCurrent.length).toBe(1); + expect(optionContainCurrent[0].key).toBe('2'); + expect(optionContainCurrent[0].label).toBe('Workspace 2'); + + const workspaceOption: WorkspaceOption[] = getTargetWorkspacesOptions(workspaces); + expect(workspaceOption.length).toBe(2); + }); + + it('should capitalize first letter', () => { + const workspaceName: string = 'workspace'; + const capitalizedName: string = capitalizeFirstLetter(workspaceName); + expect(capitalizedName).toBe('Workspace'); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts new file mode 100644 index 000000000000..33b653e1d8ac --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { WorkspaceAttribute, WorkspacesStart } from 'opensearch-dashboards/public'; + +export type WorkspaceOption = EuiComboBoxOptionOption; + +// Convert workspace to option which can be displayed in the drop-down box. +export function workspaceToOption( + workspace: WorkspaceAttribute, + currentWorkspaceId?: string +): WorkspaceOption { + // add (current) after current workspace name + let workspaceName = workspace.name; + if (workspace.id === currentWorkspaceId) { + workspaceName += ' (current)'; + } + return { + label: workspaceName, + key: workspace.id, + value: workspace, + }; +} + +export function getTargetWorkspacesOptions( + workspaces: WorkspacesStart, + currentWorkspaceId?: string +): WorkspaceOption[] { + const workspaceList = workspaces.workspaceList$.value; + const targetWorkspaces = workspaceList.filter( + (workspace) => workspace.id !== currentWorkspaceId && !workspace.libraryReadonly + ); + return targetWorkspaces.map((workspace) => workspaceToOption(workspace, currentWorkspaceId)); +} + +export function capitalizeFirstLetter(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts index b2153648057f..e9bd1293def2 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts @@ -29,3 +29,4 @@ */ export { SavedObjectsTable } from './saved_objects_table'; +export { SavedObjectsDuplicateModal, DuplicateMode } from './components'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.mocks.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.mocks.ts index b856b8662479..f91c7103eeac 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.mocks.ts +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.mocks.ts @@ -80,3 +80,8 @@ export const getRelationshipsMock = jest.fn(); jest.doMock('../../lib/get_relationships', () => ({ getRelationships: getRelationshipsMock, })); + +export const getDuplicateSavedObjectsMock = jest.fn(); +jest.doMock('../../lib/duplicate_saved_objects', () => ({ + duplicateSavedObjects: getDuplicateSavedObjectsMock, +})); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 74ae23c34dcb..2a4569d4a5d7 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -33,6 +33,7 @@ import { fetchExportByTypeAndSearchMock, fetchExportObjectsMock, findObjectsMock, + getDuplicateSavedObjectsMock, getRelationshipsMock, getSavedObjectCountsMock, saveAsMock, @@ -62,6 +63,7 @@ import { } from './saved_objects_table'; import { Flyout, Relationships } from './components'; import { SavedObjectWithMetadata } from '../../types'; +import { WorkspaceObject } from 'opensearch-dashboards/public'; const allowedTypes = ['index-pattern', 'visualization', 'dashboard', 'search']; @@ -237,6 +239,22 @@ describe('SavedObjectsTable', () => { expect(component).toMatchSnapshot(); }); + it('should unmount normally', async () => { + const component = shallowRender(); + const mockDebouncedFetchObjects = { + cancel: jest.fn(), + flush: jest.fn(), + }; + component.instance().debouncedFetchObjects = mockDebouncedFetchObjects as any; + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + // component.update(); + + component.unmount(); + expect(component).toMatchSnapshot(); + }); + it('should add danger toast when find fails', async () => { findObjectsMock.mockImplementation(() => { throw new Error('Simulated find error'); @@ -576,4 +594,194 @@ describe('SavedObjectsTable', () => { expect(component.state('selectedSavedObjects').length).toBe(0); }); }); + + describe('duplicate', () => { + const applications = applicationServiceMock.createStartContract(); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: false, + }, + workspaces: { + enabled: true, + }, + }; + + const workspaceList: WorkspaceObject[] = [ + { + id: 'workspace1', + name: 'foo', + }, + { + id: 'workspace2', + name: 'bar', + }, + ]; + + const mockSelectedSavedObjects = [ + { id: '1', type: 'dashboard', references: [], attributes: [], meta: {} }, + { id: '2', type: 'dashboard', references: [], attributes: [], meta: {} }, + ] as SavedObjectWithMetadata[]; + + beforeEach(() => { + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next('workspace1'); + workspaces.currentWorkspace$.next(workspaceList[0]); + }); + + it('should duplicate selected object', async () => { + getDuplicateSavedObjectsMock.mockImplementation(() => ({ success: true })); + + const component = shallowRender({ applications, workspaces }); + component.setState({ isShowingDuplicateModal: true }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component.state('isShowingDuplicateModal')).toEqual(true); + expect(component.find('SavedObjectsDuplicateModal').length).toEqual(1); + + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2'); + + expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( + http, + [ + { id: '1', type: 'dashboard' }, + { id: '2', type: 'dashboard' }, + ], + false, + 'workspace2' + ); + component.update(); + + expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ + title: 'Duplicate 2 saved objects successfully', + }); + }); + + it('should show error when duplicating selected object is fail', async () => { + getDuplicateSavedObjectsMock.mockImplementationOnce(() => ({ success: false })); + + const component = shallowRender({ applications, workspaces }); + component.setState({ isShowingDuplicateModal: true }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2'); + + expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( + http, + [ + { id: '1', type: 'dashboard' }, + { id: '2', type: 'dashboard' }, + ], + false, + 'workspace2' + ); + component.update(); + + expect(notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Unable to duplicate 2 saved objects.', + }); + + getDuplicateSavedObjectsMock.mockImplementationOnce(() => ({ + success: false, + errors: [{ id: '1' }, { id: '2' }], + })); + + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2'); + expect(notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Unable to duplicate 2 saved objects. These objects cannot be duplicated:1, 2', + }); + }); + + it('should catch error when duplicating selected object is fail', async () => { + getDuplicateSavedObjectsMock.mockImplementationOnce(() => undefined); + + const component = shallowRender({ applications, workspaces }); + component.setState({ isShowingDuplicateModal: true }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2'); + + expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( + http, + [ + { id: '1', type: 'dashboard' }, + { id: '2', type: 'dashboard' }, + ], + false, + 'workspace2' + ); + component.update(); + + expect(notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Unable to duplicate 2 saved objects', + }); + }); + + it('should allow the user to choose on header when duplicating all', async () => { + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + const header = component.find('Header') as any; + expect(header.prop('showDuplicateAll')).toEqual(true); + header.prop('onDuplicate')(); + + await new Promise((resolve) => process.nextTick(resolve)); + component.update(); + + expect(component.state('isShowingDuplicateModal')).toEqual(true); + expect(component.find('SavedObjectsDuplicateModal')).toMatchSnapshot(); + }); + + it('should allow the user to choose on table when duplicating all', async () => { + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + const table = component.find('Table') as any; + table.prop('onDuplicateSelected')(); + component.update(); + + expect(component.state('isShowingDuplicateModal')).toEqual(true); + expect(component.find('SavedObjectsDuplicateModal')).toMatchSnapshot(); + }); + + it('should allow the user to choose on table when duplicating single', async () => { + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + const table = component.find('Table') as any; + table.prop('onDuplicateSingle')([{ id: '1', type: 'dashboard', workspaces: ['workspace1'] }]); + component.update(); + + expect(component.state('isShowingDuplicateModal')).toEqual(true); + expect(component.find('SavedObjectsDuplicateModal')).toMatchSnapshot(); + }); + }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 127ed08423e3..ba7cf30d3c06 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 @@ -84,6 +84,7 @@ import { findObject, extractExportDetails, SavedObjectsExportResultDetails, + duplicateSavedObjects, } from '../../lib'; import { SavedObjectWithMetadata } from '../../types'; import { @@ -92,14 +93,14 @@ import { SavedObjectsManagementColumnServiceStart, SavedObjectsManagementNamespaceServiceStart, } from '../../services'; -import { Header, Table, Flyout, Relationships } from './components'; +import { Header, Table, Flyout, Relationships, SavedObjectsDuplicateModal } from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; +import { DuplicateMode } from '../types'; interface ExportAllOption { id: string; label: string; } - export interface SavedObjectsTableProps { allowedTypes: string[]; serviceRegistry: ISavedObjectsManagementServiceRegistry; @@ -130,7 +131,10 @@ export interface SavedObjectsTableState { savedObjectCounts: Record; activeQuery: Query; selectedSavedObjects: SavedObjectWithMetadata[]; + duplicateSelectedSavedObjects: SavedObjectWithMetadata[]; isShowingImportFlyout: boolean; + duplicateMode: DuplicateMode; + isShowingDuplicateModal: boolean; isSearching: boolean; filteredItemCount: number; isShowingRelationships: boolean; @@ -143,11 +147,13 @@ export interface SavedObjectsTableState { isIncludeReferencesDeepChecked: boolean; currentWorkspaceId?: string; availableWorkspaces?: WorkspaceAttribute[]; + workspaceEnabled: boolean; } export class SavedObjectsTable extends Component { private _isMounted = false; private currentWorkspaceIdSubscription?: Subscription; private workspacesSubscription?: Subscription; + private workspacesEnabledSubscription?: Subscription; constructor(props: SavedObjectsTableProps) { super(props); @@ -163,7 +169,10 @@ export class SavedObjectsTable extends Component), activeQuery: Query.parse(''), selectedSavedObjects: [], + duplicateSelectedSavedObjects: [], isShowingImportFlyout: false, + duplicateMode: DuplicateMode.Selected, + isShowingDuplicateModal: false, isSearching: false, filteredItemCount: 0, isShowingRelationships: false, @@ -174,7 +183,36 @@ export class SavedObjectsTable extends Component ns.id) || []; + if (availableNamespaces.length) { + const filteredNamespaces = filterQuery(availableNamespaces); + findOptions.namespaces = filteredNamespaces; + } + + if (findOptions.type.length > 1) { + findOptions.sortField = 'type'; + } + + return findOptions; } componentDidMount() { @@ -188,11 +226,14 @@ export class SavedObjectsTable extends Component { const { allowedTypes, namespaceRegistry } = this.props; - const { queryText, visibleTypes, visibleNamespaces } = parseQuery(this.state.activeQuery); + const { queryText, visibleTypes } = parseQuery(this.state.activeQuery); const filteredTypes = filterQuery(allowedTypes, visibleTypes); @@ -204,7 +245,7 @@ export class SavedObjectsTable extends Component { - const { activeQuery: query, page, perPage } = this.state; - const { notifications, http, allowedTypes, namespaceRegistry } = this.props; - const { queryText, visibleTypes, visibleNamespaces } = parseQuery(query); - const filteredTypes = filterQuery(allowedTypes, visibleTypes); - // "searchFields" is missing from the "findOptions" but gets injected via the API. - // The API extracts the fields from each uiExports.savedObjectsManagement "defaultSearchField" attribute - const findOptions: SavedObjectsFindOptions = { - search: queryText ? `${queryText}*` : undefined, - perPage, - page: page + 1, - fields: ['id'], - type: filteredTypes, - }; - - const availableNamespaces = namespaceRegistry.getAll()?.map((ns) => ns.id) || []; - if (availableNamespaces.length) { - const filteredNamespaces = filterQuery(availableNamespaces, visibleNamespaces); - findOptions.namespaces = filteredNamespaces; - } - - if (findOptions.type.length > 1) { - findOptions.sortField = 'type'; - } + const { activeQuery: query } = this.state; + const { notifications, http } = this.props; try { - const resp = await findObjects(http, findOptions); + const resp = await findObjects(http, this.findOptions); if (!this._isMounted) { return; } @@ -595,6 +615,117 @@ export class SavedObjectsTable extends Component { + this.setState({ isShowingDuplicateModal: false }); + }; + + onDuplicateAll = async () => { + const { notifications, http } = this.props; + let duplicateAllSavedObjects: SavedObjectWithMetadata[] = []; + const findOptions = this.findOptions; + findOptions.sortField = 'updated_at'; + findOptions.page = 1; + + while (duplicateAllSavedObjects.length < this.state.filteredItemCount) { + try { + const resp = await findObjects(http, findOptions); + const savedObjects = resp.savedObjects; + duplicateAllSavedObjects = duplicateAllSavedObjects.concat(savedObjects); + } catch (error) { + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage', + { defaultMessage: 'Unable find saved objects' } + ), + text: `${error}`, + }); + break; + } + findOptions.page++; + } + + this.setState({ + duplicateSelectedSavedObjects: duplicateAllSavedObjects, + isShowingDuplicateModal: true, + duplicateMode: DuplicateMode.All, + }); + }; + + onDuplicate = async ( + savedObjects: SavedObjectWithMetadata[], + includeReferencesDeep: boolean, + targetWorkspace: string + ) => { + const { http, notifications } = this.props; + const objectsToDuplicate = savedObjects.map((obj) => ({ id: obj.id, type: obj.type })); + let result; + try { + result = await duplicateSavedObjects( + http, + objectsToDuplicate, + includeReferencesDeep, + targetWorkspace + ); + if (result.success) { + notifications.toasts.addSuccess({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicate.successNotification', + { + defaultMessage: + 'Duplicate ' + savedObjects.length.toString() + ' saved objects successfully', + } + ), + }); + } else { + const errorIdMessages = result.errors + ? ' These objects cannot be duplicated:' + + result.errors.map((item: { id: string }) => item.id).join(', ') + : ''; + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicate.dangerNotification', + { + defaultMessage: + 'Unable to duplicate ' + + savedObjects.length.toString() + + ' saved objects.' + + errorIdMessages, + } + ), + }); + } + } 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(); + }; + + renderDuplicateModal() { + const { isShowingDuplicateModal, duplicateSelectedSavedObjects, duplicateMode } = this.state; + + if (!isShowingDuplicateModal) { + return null; + } + + return ( + + ); + } + renderRelationships() { if (!this.state.isShowingRelationships) { return null; @@ -887,11 +1018,15 @@ export class SavedObjectsTable extends Component this.setState({ isShowingExportAllOptionsModal: true })} onImport={this.showImportFlyout} + showDuplicateAll={this.state.workspaceEnabled} + onDuplicate={this.onDuplicateAll} onRefresh={this.refreshObjects} filteredCount={filteredItemCount} + objectCount={savedObjects.length} /> @@ -908,6 +1043,20 @@ export class SavedObjectsTable extends Component + this.setState({ + isShowingDuplicateModal: true, + duplicateMode: DuplicateMode.Selected, + duplicateSelectedSavedObjects: selectedSavedObjects, + }) + } + onDuplicateSingle={(object) => + this.setState({ + duplicateSelectedSavedObjects: [object], + isShowingDuplicateModal: true, + duplicateMode: DuplicateMode.Selected, + }) + } onActionRefresh={this.refreshObject} goInspectObject={this.props.goInspectObject} pageIndex={page} @@ -920,6 +1069,7 @@ export class SavedObjectsTable extends Component 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 42889d6d8095..b9781d353d39 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 @@ -37,6 +37,7 @@ import { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, SavedObjectsManagementColumnServiceStart, + SavedObjectsManagementNamespaceServiceStart, } from '../services'; import { SavedObjectsTable } from './objects_table'; diff --git a/src/plugins/saved_objects_management/public/management_section/types.ts b/src/plugins/saved_objects_management/public/management_section/types.ts index 77fcc824fef4..8b174a304f68 100644 --- a/src/plugins/saved_objects_management/public/management_section/types.ts +++ b/src/plugins/saved_objects_management/public/management_section/types.ts @@ -47,3 +47,8 @@ export interface SubmittedFormData { attributes: any; references: SavedObjectReference[]; } + +export enum DuplicateMode { + Selected = 'selected', + All = 'all', +} diff --git a/src/plugins/workspace/public/workspace_client.test.ts b/src/plugins/workspace/public/workspace_client.test.ts index c18ed3db64e7..0e295c367342 100644 --- a/src/plugins/workspace/public/workspace_client.test.ts +++ b/src/plugins/workspace/public/workspace_client.test.ts @@ -178,6 +178,7 @@ describe('#WorkspaceClient', () => { expect(workspaceMock.workspaceList$.getValue()).toEqual([ { id: 'foo', + libraryReadonly: false, }, ]); expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { @@ -209,4 +210,28 @@ describe('#WorkspaceClient', () => { }); expect(workspaceMock.workspaceList$.getValue()).toEqual([]); }); + + it('#init with resultWithWritePermission is not success ', async () => { + const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); + httpSetupMock.fetch + .mockResolvedValueOnce({ + success: true, + result: { + workspaces: [ + { + id: 'foo', + name: 'foo', + }, + ], + total: 1, + per_page: 999, + page: 1, + }, + }) + .mockResolvedValueOnce({ + success: false, + }); + await workspaceClient.init(); + expect(workspaceMock.workspaceList$.getValue()).toEqual([]); + }); }); diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts index 3e988f38b265..1e3236a8e3d2 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -11,6 +11,7 @@ import { WorkspaceAttribute, WorkspacesSetup, } from '../../../core/public'; +import { WorkspacePermissionMode } from '../common/constants'; const WORKSPACES_API_BASE_URL = '/api/workspaces'; @@ -37,6 +38,7 @@ interface WorkspaceFindOptions { searchFields?: string[]; sortField?: string; sortOrder?: string; + permissionModes?: WorkspacePermissionMode[]; } /** @@ -117,7 +119,20 @@ export class WorkspaceClient { }); if (result?.success) { - this.workspaces.workspaceList$.next(result.result.workspaces); + const resultWithWritePermission = await this.list({ + perPage: 999, + permissionModes: [WorkspacePermissionMode.LibraryWrite], + }); + if (resultWithWritePermission?.success) { + const workspaceIdsWithWritePermission = resultWithWritePermission.result.workspaces.map( + (workspace: WorkspaceAttribute) => workspace.id + ); + const workspaces = result.result.workspaces.map((workspace: WorkspaceAttribute) => ({ + ...workspace, + libraryReadonly: !workspaceIdsWithWritePermission.includes(workspace.id), + })); + this.workspaces.workspaceList$.next(workspaces); + } } else { this.workspaces.workspaceList$.next([]); } @@ -227,6 +242,7 @@ export class WorkspaceClient { * @property {integer} [options.page=1] * @property {integer} [options.perPage=20] * @property {array} options.fields + * @property {string array} permissionModes * @returns A find result with workspaces matching the specified search. */ public list( diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index 4d5d03641b5f..0539c4849576 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 @@ -32,6 +32,7 @@ import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, WorkspacePermissionMode, } from '../../common/constants'; +import { WorkspaceFindOptions } from '../types'; // Can't throw unauthorized for now, the page will be refreshed if unauthorized const generateWorkspacePermissionError = () => @@ -412,7 +413,7 @@ export class WorkspaceSavedObjectsClientWrapper { }; const findWithWorkspacePermissionControl = async ( - options: SavedObjectsFindOptions + options: SavedObjectsFindOptions & Pick ) => { const principals = this.permissionControl.getPrincipalsFromRequest(wrapperOptions.request); if (!options.ACLSearchParams) { diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index 2973ea4dbc31..12a38e015559 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -17,6 +17,7 @@ import { export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { permissions?: Permissions; } +import { WorkspacePermissionMode } from '../common/constants'; export interface WorkspaceFindOptions { page?: number; @@ -25,6 +26,7 @@ export interface WorkspaceFindOptions { searchFields?: string[]; sortField?: string; sortOrder?: string; + permissionModes?: WorkspacePermissionMode[]; } export interface IRequestDetail { From a47a1b11cff35d2b4fe0ac3d51ed35986d9b17c9 Mon Sep 17 00:00:00 2001 From: yubonluo Date: Tue, 16 Apr 2024 17:13:21 +0800 Subject: [PATCH 02/18] fix test error Signed-off-by: yubonluo --- .../__snapshots__/saved_objects_table.test.tsx.snap | 3 +++ .../objects_table/saved_objects_table.test.tsx | 3 +++ 2 files changed, 6 insertions(+) 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 f096f12efbd9..9983d219de64 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 @@ -838,6 +838,9 @@ exports[`SavedObjectsTable should render normally 1`] = ` "edit": false, "read": true, }, + "workspaces": Object { + "enabled": false, + }, }, "currentAppId$": Observable { "_isScalar": false, diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 2a4569d4a5d7..2b5e381b3b9a 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -137,6 +137,9 @@ describe('SavedObjectsTable', () => { edit: false, delete: false, }, + workspaces: { + enabled: false, + }, }; http.post.mockResolvedValue([]); From 549d24a142ae1e4b5fa0d48c3c42f3774e970bb3 Mon Sep 17 00:00:00 2001 From: yubonluo Date: Thu, 18 Apr 2024 10:48:28 +0800 Subject: [PATCH 03/18] optimize code transfer memory Signed-off-by: yubonluo --- .../components/duplicate_modal.tsx | 11 ++-- .../objects_table/saved_objects_table.tsx | 52 +++++++++---------- .../public/management_section/types.ts | 3 ++ 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx index 9ba53a1c6ac0..b1a57e8d8eff 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx @@ -27,15 +27,14 @@ import { } from '@elastic/eui'; import { HttpSetup, NotificationsStart, WorkspacesStart } from 'opensearch-dashboards/public'; import { i18n } from '@osd/i18n'; -import { SavedObjectWithMetadata } from '../../../../common'; import { getSavedObjectLabel } from '../../../../public'; import { WorkspaceOption, getTargetWorkspacesOptions, workspaceToOption } from './utils'; -import { DuplicateMode } from '../../types'; +import { DuplicateMode, DuplicateObject } from '../../types'; import RenderDuplicateObjectCategories from './duplicate_object_categories'; export interface ShowDuplicateModalProps { onDuplicate: ( - savedObjects: SavedObjectWithMetadata[], + savedObjects: DuplicateObject[], includeReferencesDeep: boolean, targetWorkspace: string ) => Promise; @@ -43,7 +42,7 @@ export interface ShowDuplicateModalProps { workspaces: WorkspacesStart; duplicateMode: DuplicateMode; notifications: NotificationsStart; - selectedSavedObjects: SavedObjectWithMetadata[]; + selectedSavedObjects: DuplicateObject[]; } interface Props extends ShowDuplicateModalProps { @@ -51,7 +50,7 @@ interface Props extends ShowDuplicateModalProps { } interface State { - allSelectedObjects: SavedObjectWithMetadata[]; + allSelectedObjects: DuplicateObject[]; workspaceOptions: WorkspaceOption[]; targetWorkspaceOption: WorkspaceOption[]; isLoading: boolean; @@ -98,7 +97,7 @@ export class SavedObjectsDuplicateModal extends React.Component { this.isMounted = false; } - duplicateSavedObjects = async (savedObjects: SavedObjectWithMetadata[]) => { + duplicateSavedObjects = async (savedObjects: DuplicateObject[]) => { this.setState({ isLoading: true, }); 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 ba7cf30d3c06..cf7503b13d4e 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 @@ -95,7 +95,7 @@ import { } from '../../services'; import { Header, Table, Flyout, Relationships, SavedObjectsDuplicateModal } from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; -import { DuplicateMode } from '../types'; +import { DuplicateMode, DuplicateObject } from '../types'; interface ExportAllOption { id: string; @@ -131,7 +131,7 @@ export interface SavedObjectsTableState { savedObjectCounts: Record; activeQuery: Query; selectedSavedObjects: SavedObjectWithMetadata[]; - duplicateSelectedSavedObjects: SavedObjectWithMetadata[]; + duplicateSelectedSavedObjects: DuplicateObject[]; isShowingImportFlyout: boolean; duplicateMode: DuplicateMode; isShowingDuplicateModal: boolean; @@ -621,38 +621,36 @@ export class SavedObjectsTable extends Component { const { notifications, http } = this.props; - let duplicateAllSavedObjects: SavedObjectWithMetadata[] = []; const findOptions = this.findOptions; - findOptions.sortField = 'updated_at'; + findOptions.perPage = 9999; findOptions.page = 1; - while (duplicateAllSavedObjects.length < this.state.filteredItemCount) { - try { - const resp = await findObjects(http, findOptions); - const savedObjects = resp.savedObjects; - duplicateAllSavedObjects = duplicateAllSavedObjects.concat(savedObjects); - } catch (error) { - notifications.toasts.addDanger({ - title: i18n.translate( - 'savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage', - { defaultMessage: 'Unable find saved objects' } - ), - text: `${error}`, - }); - break; - } - findOptions.page++; + try { + const resp = await findObjects(http, findOptions); + const duplicateObjects = resp.savedObjects.map((obj) => ({ + id: obj.id, + type: obj.type, + meta: obj.meta, + workspaces: obj.workspaces, + })); + this.setState({ + duplicateSelectedSavedObjects: duplicateObjects, + isShowingDuplicateModal: true, + duplicateMode: DuplicateMode.All, + }); + } catch (error) { + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage', + { defaultMessage: 'Unable find saved objects' } + ), + text: `${error}`, + }); } - - this.setState({ - duplicateSelectedSavedObjects: duplicateAllSavedObjects, - isShowingDuplicateModal: true, - duplicateMode: DuplicateMode.All, - }); }; onDuplicate = async ( - savedObjects: SavedObjectWithMetadata[], + savedObjects: DuplicateObject[], includeReferencesDeep: boolean, targetWorkspace: string ) => { diff --git a/src/plugins/saved_objects_management/public/management_section/types.ts b/src/plugins/saved_objects_management/public/management_section/types.ts index 8b174a304f68..a7c10eb1d14c 100644 --- a/src/plugins/saved_objects_management/public/management_section/types.ts +++ b/src/plugins/saved_objects_management/public/management_section/types.ts @@ -29,6 +29,7 @@ */ import { SavedObjectReference } from '../../../../core/types'; +import { SavedObjectWithMetadata } from '../../common'; export interface ObjectField { type: FieldType; @@ -52,3 +53,5 @@ export enum DuplicateMode { Selected = 'selected', All = 'all', } + +export type DuplicateObject = Pick; From 36427387b0aa9807b5e486772dd78cc2546ae85f Mon Sep 17 00:00:00 2001 From: yubonluo Date: Thu, 18 Apr 2024 11:53:23 +0800 Subject: [PATCH 04/18] revert useless modify Signed-off-by: yubonluo --- .../objects_table/saved_objects_table.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 b8fc95823080..f14b52fee4f8 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 @@ -282,7 +282,9 @@ export class SavedObjectsTable extends Component { const { allowedTypes, namespaceRegistry } = this.props; - const { queryText, visibleTypes, visibleWorkspaces } = parseQuery(this.state.activeQuery); + const { queryText, visibleTypes, visibleNamespaces, visibleWorkspaces } = parseQuery( + this.state.activeQuery + ); const filteredTypes = filterQuery(allowedTypes, visibleTypes); @@ -295,7 +297,7 @@ export class SavedObjectsTable extends Component Date: Tue, 23 Apr 2024 10:07:47 +0800 Subject: [PATCH 05/18] optimize the code Signed-off-by: yubonluo --- .../__snapshots__/duplicate_modal.test.tsx.snap | 4 ++-- .../objects_table/components/duplicate_modal.tsx | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap index 9ac13848a068..491b09399b28 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap @@ -92,7 +92,7 @@ HTMLCollection [
- Specify a workspace where the objects will be duplicated. + Specify a workspace where the objects will be duplicated to.

- The following saved objects will be copied: + The following saved objects will be duplicated:

{ {ignoredSelectedObjectsLength} saved object {ignoredSelectedObjectsLength === 1 ? ' will' : 's will'}{' '} - not be copied, because{' '} - {ignoredSelectedObjectsLength === 1 ? 'it has' : 'they have'} already existed in the - selected workspace. + not be duplicated as{' '} + {ignoredSelectedObjectsLength === 1 ? 'it' : 'they'}{' '} + already exist in the selected workspace. ); @@ -237,7 +237,7 @@ export class SavedObjectsDuplicateModal extends React.Component { {i18n.translate( 'savedObjectsManagement.objectsTable.duplicateModal.targetWorkspaceNotice', - { defaultMessage: 'Specify a workspace where the objects will be duplicated.' } + { defaultMessage: 'Specify a workspace where the objects will be duplicated to.' } )} @@ -295,7 +295,7 @@ export class SavedObjectsDuplicateModal extends React.Component {

From df96cc1a8fec9381160604f7bb1a5ea8d8590744 Mon Sep 17 00:00:00 2001 From: yubonluo Date: Wed, 24 Apr 2024 09:40:59 +0800 Subject: [PATCH 06/18] add test id Signed-off-by: yubonluo --- .../objects_table/components/duplicate_modal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx index 4e183a772e66..bcd415b7e38f 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx @@ -198,6 +198,7 @@ export class SavedObjectsDuplicateModal extends React.Component { color="warning" iconType="help" aria-disabled={ignoredSelectedObjectsLength === 0} + data-test-subj="ignoreSomeObjectsCallOut" > {warningMessage} From 4cb5808d2de4ea35b4dbe0fd7157f843f481fee5 Mon Sep 17 00:00:00 2001 From: "opensearch-changeset-bot[bot]" <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Date: Wed, 24 Apr 2024 02:33:07 +0000 Subject: [PATCH 07/18] Changeset file for PR #6478 created/updated --- changelogs/fragments/6478.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/fragments/6478.yml diff --git a/changelogs/fragments/6478.yml b/changelogs/fragments/6478.yml new file mode 100644 index 000000000000..d8009500a060 --- /dev/null +++ b/changelogs/fragments/6478.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace] Duplicate selected/all saved objects ([#6478](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6478)) \ No newline at end of file From 119bf3e4be0c9d45d8c9e869f7cca187507eed56 Mon Sep 17 00:00:00 2001 From: yubonluo Date: Wed, 8 May 2024 10:47:29 +0800 Subject: [PATCH 08/18] According the last mockup to optimize the code Signed-off-by: yubonluo --- .../saved_objects_management/public/index.ts | 2 +- .../public/management_section/index.ts | 2 +- .../saved_objects_table.test.tsx.snap | 3 - .../duplicate_modal.test.tsx.snap | 453 +----------------- .../duplicate_object_categories.test.tsx.snap | 36 -- .../__snapshots__/table.test.tsx.snap | 24 +- .../components/duplicate_modal.test.tsx | 191 ++++---- .../components/duplicate_modal.tsx | 179 +------ .../duplicate_object_categories.test.tsx | 23 - .../duplicate_object_categories.tsx | 56 --- .../objects_table/components/index.ts | 2 +- .../objects_table/components/table.tsx | 139 +++--- .../management_section/objects_table/index.ts | 2 +- .../saved_objects_table.test.tsx | 54 ++- .../objects_table/saved_objects_table.tsx | 45 +- .../public/management_section/types.ts | 5 - 16 files changed, 277 insertions(+), 939 deletions(-) delete mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_object_categories.test.tsx.snap delete mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.test.tsx delete mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.tsx diff --git a/src/plugins/saved_objects_management/public/index.ts b/src/plugins/saved_objects_management/public/index.ts index eb954bf80f7b..ba04384a28a2 100644 --- a/src/plugins/saved_objects_management/public/index.ts +++ b/src/plugins/saved_objects_management/public/index.ts @@ -56,7 +56,7 @@ export { export { SavedObjectRelation, SavedObjectWithMetadata, SavedObjectMetadata } from './types'; export { SAVED_OBJECT_DELETE_TRIGGER, savedObjectDeleteTrigger } from './triggers'; export { SavedObjectDeleteContext } from './ui_actions_bootstrap'; -export { SavedObjectsDuplicateModal, DuplicateMode } from './management_section'; +export { SavedObjectsDuplicateModal } from './management_section'; export function plugin(initializerContext: PluginInitializerContext) { return new SavedObjectsManagementPlugin(); } 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 3d0c166a4823..25488f636741 100644 --- a/src/plugins/saved_objects_management/public/management_section/index.ts +++ b/src/plugins/saved_objects_management/public/management_section/index.ts @@ -29,4 +29,4 @@ */ export { mountManagementSection } from './mount_section'; -export { SavedObjectsDuplicateModal, DuplicateMode } from './objects_table'; +export { SavedObjectsDuplicateModal } from './objects_table'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index 0c3af9448e0a..fe73f15d1066 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 @@ -76,7 +76,6 @@ exports[`SavedObjectsTable delete should show a confirm modal 1`] = ` exports[`SavedObjectsTable duplicate should allow the user to choose on header when duplicating all 1`] = ` - Duplicate all objects? + Duplicate 3 objects to?
@@ -80,7 +80,7 @@ HTMLCollection [ class="euiFormLabel euiFormRow__label" for="generated-id" > - Destination workspace + Workspace
- Specify a workspace where the objects will be duplicated to. + Select a workspace to where the object(s) will be duplicated
+

+ select a workspace +

-
- -
- -
-
- -
- -
-
- Related Objects + Options
- We recommended duplicating related objects to ensure your duplicated objects will continue to function. + Include related saved objects to ensure object(s) work as expected
- Duplicate related objects + Include related objects(recommended)
-
-

- - The following saved objects will be duplicated: - -

-
-
-
-
-
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - Type - - - - - - Id - - - - - - Title - - -
-
- Type -
-
- - - -
-
-
- Id -
-
- - 1 - -
-
-
- Title -
-
- - Dashboard_1 - -
-
-
- Type -
-
- - - -
-
-
- Id -
-
- - 2 - -
-
-
- Title -
-
- - Visualization - -
-
-
- Type -
-
- - - -
-
-
- Id -
-
- - 3 - -
-
-
- Title -
-
- - Dashboard_2 - -
-
-
-
-
-
-
-
-
- -
-
-
-
- -
-
-
-
- Duplicate(3) + Duplicate diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_object_categories.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_object_categories.test.tsx.snap deleted file mode 100644 index 3171694c6efd..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_object_categories.test.tsx.snap +++ /dev/null @@ -1,36 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RenderDuplicateObjectCategories renders checkboxes correctly 1`] = ` -Array [ - - } - onChange={[Function]} - />, - - } - onChange={[Function]} - />, -] -`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index 0c8a1d1c1bd4..43ffa04dafca 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -26,6 +26,7 @@ exports[`Table prevents saved objects from being deleted 1`] = ` onChange={[Function]} toolsRight={ Array [ + , + + + + , , - , , void; } describe('DuplicateModal', () => { - let selectedModeProps: Props; - let allModeProps: Props; + let duplicateProps: Props; let http: ReturnType; let notifications: ReturnType; let workspaces: ReturnType; @@ -61,7 +59,7 @@ describe('DuplicateModal', () => { }, }, ]; - const workspaceList: WorkspaceObject[] = [ + const workspaceList: WorkspaceAttribute[] = [ { id: 'workspace1', name: 'foo', @@ -76,21 +74,14 @@ describe('DuplicateModal', () => { notifications = notificationServiceMock.createStartContract(); workspaces = workspacesServiceMock.createStartContract(); - selectedModeProps = { + duplicateProps = { onDuplicate: jest.fn(), onClose: jest.fn(), http, workspaces, - duplicateMode: DuplicateMode.Selected, notifications, selectedSavedObjects, }; - - allModeProps = { - ...selectedModeProps, - duplicateMode: DuplicateMode.All, - selectedSavedObjects, - }; }); afterEach(() => { @@ -98,14 +89,12 @@ describe('DuplicateModal', () => { }); it('should render normally', async () => { - render(); + render(); expect(document.children).toMatchSnapshot(); }); it('should Unmount normally', async () => { - const component = shallowWithI18nProvider( - - ); + const component = shallowWithI18nProvider(); expect(component.unmount()).toMatchSnapshot(); }); @@ -113,10 +102,8 @@ describe('DuplicateModal', () => { workspaces.workspaceList$.next(workspaceList); workspaces.currentWorkspaceId$.next(''); workspaces.currentWorkspace$.next(null); - selectedModeProps = { ...selectedModeProps, workspaces }; - const component = shallowWithI18nProvider( - - ); + duplicateProps = { ...duplicateProps, workspaces }; + const component = shallowWithI18nProvider(); await new Promise((resolve) => process.nextTick(resolve)); component.update(); const options = component.find('EuiComboBox').prop('options') as WorkspaceOption[]; @@ -132,10 +119,8 @@ describe('DuplicateModal', () => { id: 'workspace1', name: 'foo', }); - selectedModeProps = { ...selectedModeProps, workspaces }; - const component = shallowWithI18nProvider( - - ); + duplicateProps = { ...duplicateProps, workspaces }; + const component = shallowWithI18nProvider(); await new Promise((resolve) => process.nextTick(resolve)); component.update(); const options = component.find('EuiComboBox').prop('options') as WorkspaceOption[]; @@ -144,77 +129,75 @@ describe('DuplicateModal', () => { expect(options[1].label).toEqual('bar'); }); - it('should only show saved objects belong to workspace1 when target workspace is workspace2', async () => { - workspaces.workspaceList$.next(workspaceList); - workspaces.currentWorkspaceId$.next(''); - workspaces.currentWorkspace$.next(null); - selectedModeProps = { ...selectedModeProps, workspaces }; - const component = shallowWithI18nProvider( - - ); - const selectedObjects = component - .find('EuiInMemoryTable') - .prop('items') as SavedObjectWithMetadata[]; - expect(selectedObjects.length).toEqual(3); - const comboBox = component.find('EuiComboBox'); - comboBox.simulate('change', [{ label: 'bar', key: 'workspace2', value: workspaceList[1] }]); - component.update(); - - const targetWorkspaceOption = component.state('targetWorkspaceOption') as WorkspaceOption[]; - expect(targetWorkspaceOption.length).toEqual(1); - expect(targetWorkspaceOption[0].key).toEqual('workspace2'); - - const includedSelectedObjects = component - .find('EuiInMemoryTable') - .prop('items') as SavedObjectWithMetadata[]; - expect(includedSelectedObjects.length).toEqual(1); - expect(includedSelectedObjects[0].workspaces).toEqual(['workspace1']); - expect(includedSelectedObjects[0].id).toEqual('1'); - - expect(component.find('EuiCallOut').prop('aria-disabled')).toEqual(false); - }); - - it('should ignore one saved object when target workspace is workspace1', async () => { - workspaces.workspaceList$.next(workspaceList); - workspaces.currentWorkspaceId$.next(''); - workspaces.currentWorkspace$.next(null); - selectedModeProps = { ...selectedModeProps, workspaces }; - const component = shallowWithI18nProvider( - - ); - const comboBox = component.find('EuiComboBox'); - comboBox.simulate('change', [{ label: 'foo', key: 'workspace1', value: workspaceList[0] }]); - - const includedSelectedObjects = component - .find('EuiInMemoryTable') - .prop('items') as SavedObjectWithMetadata[]; - expect(includedSelectedObjects.length).toEqual(2); - }); - - it('should show saved objects type when duplicate mode is all', async () => { - const component = shallowWithI18nProvider(); - const savedObjectTypeInfoMap = component.state('savedObjectTypeInfoMap') as Map< - string, - [number, boolean] - >; - expect(savedObjectTypeInfoMap.get('dashboard')).toEqual([2, true]); - - const euiCheckbox = component.find('EuiCheckbox').at(0); - expect(euiCheckbox.prop('checked')).toEqual(true); - expect(euiCheckbox.prop('id')).toEqual('includeSavedObjectType.dashboard'); - - euiCheckbox.simulate('change', { target: { checked: false } }); - const euiCheckboxUnCheced = component.find('EuiCheckbox').at(0); - expect(euiCheckboxUnCheced.prop('checked')).toEqual(false); - expect(savedObjectTypeInfoMap.get('dashboard')).toEqual([2, false]); - - (component.instance() as any).changeIncludeSavedObjectType('invalid'); - }); + // it('should only show saved objects belong to workspace1 when target workspace is workspace2', async () => { + // workspaces.workspaceList$.next(workspaceList); + // workspaces.currentWorkspaceId$.next(''); + // workspaces.currentWorkspace$.next(null); + // selectedModeProps = { ...selectedModeProps, workspaces }; + // const component = shallowWithI18nProvider( + // + // ); + // const selectedObjects = component + // .find('EuiInMemoryTable') + // .prop('items') as SavedObjectWithMetadata[]; + // expect(selectedObjects.length).toEqual(3); + // const comboBox = component.find('EuiComboBox'); + // comboBox.simulate('change', [{ label: 'bar', key: 'workspace2', value: workspaceList[1] }]); + // component.update(); + + // const targetWorkspaceOption = component.state('targetWorkspaceOption') as WorkspaceOption[]; + // expect(targetWorkspaceOption.length).toEqual(1); + // expect(targetWorkspaceOption[0].key).toEqual('workspace2'); + + // const includedSelectedObjects = component + // .find('EuiInMemoryTable') + // .prop('items') as SavedObjectWithMetadata[]; + // expect(includedSelectedObjects.length).toEqual(1); + // expect(includedSelectedObjects[0].workspaces).toEqual(['workspace1']); + // expect(includedSelectedObjects[0].id).toEqual('1'); + + // expect(component.find('EuiCallOut').prop('aria-disabled')).toEqual(false); + // }); + + // it('should ignore one saved object when target workspace is workspace1', async () => { + // workspaces.workspaceList$.next(workspaceList); + // workspaces.currentWorkspaceId$.next(''); + // workspaces.currentWorkspace$.next(null); + // selectedModeProps = { ...selectedModeProps, workspaces }; + // const component = shallowWithI18nProvider( + // + // ); + // const comboBox = component.find('EuiComboBox'); + // comboBox.simulate('change', [{ label: 'foo', key: 'workspace1', value: workspaceList[0] }]); + + // const includedSelectedObjects = component + // .find('EuiInMemoryTable') + // .prop('items') as SavedObjectWithMetadata[]; + // expect(includedSelectedObjects.length).toEqual(2); + // }); + + // it('should show saved objects type when duplicate mode is all', async () => { + // const component = shallowWithI18nProvider(); + // const savedObjectTypeInfoMap = component.state('savedObjectTypeInfoMap') as Map< + // string, + // [number, boolean] + // >; + // expect(savedObjectTypeInfoMap.get('dashboard')).toEqual([2, true]); + + // const euiCheckbox = component.find('EuiCheckbox').at(0); + // expect(euiCheckbox.prop('checked')).toEqual(true); + // expect(euiCheckbox.prop('id')).toEqual('includeSavedObjectType.dashboard'); + + // euiCheckbox.simulate('change', { target: { checked: false } }); + // const euiCheckboxUnCheced = component.find('EuiCheckbox').at(0); + // expect(euiCheckboxUnCheced.prop('checked')).toEqual(false); + // expect(savedObjectTypeInfoMap.get('dashboard')).toEqual([2, false]); + + // (component.instance() as any).changeIncludeSavedObjectType('invalid'); + // }); it('should uncheck duplicate related objects', async () => { - const component = shallowWithI18nProvider( - - ); + const component = shallowWithI18nProvider(); const euiCheckbox = component.find('EuiCheckbox').at(0); expect(euiCheckbox.prop('checked')).toEqual(true); @@ -226,40 +209,34 @@ describe('DuplicateModal', () => { }); it('should call onClose function when cancle button is clicked', () => { - const component = shallowWithI18nProvider( - - ); + const component = shallowWithI18nProvider(); component.find('[data-test-subj="duplicateCancelButton"]').simulate('click'); - expect(selectedModeProps.onClose).toHaveBeenCalled(); + expect(duplicateProps.onClose).toHaveBeenCalled(); }); it('should call onDuplicate function when confirm button is clicked', () => { workspaces.workspaceList$.next(workspaceList); workspaces.currentWorkspaceId$.next(''); workspaces.currentWorkspace$.next(null); - selectedModeProps = { ...selectedModeProps, workspaces }; - const component = shallowWithI18nProvider( - - ); + duplicateProps = { ...duplicateProps, workspaces }; + const component = shallowWithI18nProvider(); const comboBox = component.find('EuiComboBox'); comboBox.simulate('change', [{ label: 'bar', key: 'workspace2', value: workspaceList[1] }]); const confirmButton = component.find('[data-test-subj="duplicateConfirmButton"]'); expect(confirmButton.prop('isLoading')).toBe(false); expect(confirmButton.prop('disabled')).toBe(false); confirmButton.simulate('click'); - expect(selectedModeProps.onDuplicate).toHaveBeenCalled(); + expect(duplicateProps.onDuplicate).toHaveBeenCalled(); }); it('should not change isLoading when isMounted is false ', async () => { - const component = shallowWithI18nProvider( - - ); + const component = shallowWithI18nProvider(); const comboBox = component.find('EuiComboBox'); comboBox.simulate('change', [{ label: 'bar', key: 'workspace2', value: workspaceList[1] }]); const confirmButton = component.find('[data-test-subj="duplicateConfirmButton"]'); (component.instance() as any).isMounted = false; confirmButton.simulate('click'); - expect(selectedModeProps.onDuplicate).toHaveBeenCalled(); + expect(duplicateProps.onDuplicate).toHaveBeenCalled(); expect(component.state('isLoading')).toBe(true); }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx index bcd415b7e38f..2598be955ae4 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx @@ -5,7 +5,6 @@ import React from 'react'; import { FormattedMessage } from '@osd/i18n/react'; -import { groupBy } from 'lodash'; import { EuiButton, EuiButtonEmpty, @@ -18,29 +17,22 @@ import { EuiComboBox, EuiFormRow, EuiCheckbox, - EuiInMemoryTable, - EuiToolTip, - EuiIcon, - EuiCallOut, EuiText, - EuiTextColor, } from '@elastic/eui'; import { HttpSetup, NotificationsStart, WorkspacesStart } from 'opensearch-dashboards/public'; import { i18n } from '@osd/i18n'; -import { getSavedObjectLabel } from '../../../../public'; import { WorkspaceOption, getTargetWorkspacesOptions, workspaceToOption } from './utils'; -import { DuplicateMode, DuplicateObject } from '../../types'; -import RenderDuplicateObjectCategories from './duplicate_object_categories'; +import { DuplicateObject } from '../../types'; export interface ShowDuplicateModalProps { onDuplicate: ( savedObjects: DuplicateObject[], includeReferencesDeep: boolean, - targetWorkspace: string + targetWorkspace: string, + targetWorkspaceName: string ) => Promise; http: HttpSetup; workspaces: WorkspacesStart; - duplicateMode: DuplicateMode; notifications: NotificationsStart; selectedSavedObjects: DuplicateObject[]; } @@ -55,7 +47,6 @@ interface State { targetWorkspaceOption: WorkspaceOption[]; isLoading: boolean; isIncludeReferencesDeepChecked: boolean; - savedObjectTypeInfoMap: Map; } export class SavedObjectsDuplicateModal extends React.Component { @@ -64,20 +55,11 @@ export class SavedObjectsDuplicateModal extends React.Component { constructor(props: Props) { super(props); - const { workspaces, duplicateMode } = props; + const { workspaces } = props; const currentWorkspace = workspaces.currentWorkspace$.value; const currentWorkspaceId = currentWorkspace?.id; const targetWorkspacesOptions = getTargetWorkspacesOptions(workspaces, currentWorkspaceId); - // If user click 'Duplicate All' button, saved objects will be categoried by type. - const savedObjectTypeInfoMap = new Map(); - if (duplicateMode === DuplicateMode.All) { - const categorizedObjects = groupBy(props.selectedSavedObjects, (object) => object.type); - for (const [savedObjectType, savedObjects] of Object.entries(categorizedObjects)) { - savedObjectTypeInfoMap.set(savedObjectType, [savedObjects.length, true]); - } - } - this.state = { allSelectedObjects: props.selectedSavedObjects, // current workspace is the first option @@ -88,7 +70,6 @@ export class SavedObjectsDuplicateModal extends React.Component { targetWorkspaceOption: [], isLoading: false, isIncludeReferencesDeepChecked: true, - savedObjectTypeInfoMap, }; this.isMounted = true; } @@ -103,11 +84,13 @@ export class SavedObjectsDuplicateModal extends React.Component { }); const targetWorkspace = this.state.targetWorkspaceOption[0].key; + const targetWorkspaceName = this.state.targetWorkspaceOption[0].label; await this.props.onDuplicate( savedObjects, this.state.isIncludeReferencesDeepChecked, - targetWorkspace! + targetWorkspace!, + targetWorkspaceName ); if (this.isMounted) { @@ -129,37 +112,6 @@ export class SavedObjectsDuplicateModal extends React.Component { })); }; - // Choose whether to copy a certain type or not. - changeIncludeSavedObjectType = (savedObjectType: string) => { - const { savedObjectTypeInfoMap } = this.state; - const savedObjectTypeInfo = savedObjectTypeInfoMap.get(savedObjectType); - if (savedObjectTypeInfo) { - const [count, checked] = savedObjectTypeInfo; - savedObjectTypeInfoMap.set(savedObjectType, [count, !checked]); - this.setState({ savedObjectTypeInfoMap }); - } - }; - - isSavedObjectTypeIncluded = (savedObjectType: string) => { - const { savedObjectTypeInfoMap } = this.state; - const savedObjectTypeInfo = savedObjectTypeInfoMap.get(savedObjectType); - return savedObjectTypeInfo && savedObjectTypeInfo[1]; - }; - - getIncludeAndNotDuplicateObjects = (targetWorkspaceId: string | undefined) => { - let selectedObjects = this.state.allSelectedObjects; - if (this.props.duplicateMode === DuplicateMode.All) { - selectedObjects = selectedObjects.filter((item) => this.isSavedObjectTypeIncluded(item.type)); - } - // If the target workspace is not selected, all saved objects will be retained. - // If the target workspace has been selected, filter out the saved objects that belongs to the workspace. - const includedSelectedObjects = selectedObjects.filter((item) => - !!targetWorkspaceId && !!item.workspaces ? !item.workspaces.includes(targetWorkspaceId) : true - ); - const ignoredSelectedObjectsLength = selectedObjects.length - includedSelectedObjects.length; - return { includedSelectedObjects, ignoredSelectedObjectsLength }; - }; - render() { const { workspaceOptions, @@ -167,45 +119,9 @@ export class SavedObjectsDuplicateModal extends React.Component { isIncludeReferencesDeepChecked, allSelectedObjects, } = this.state; - const { duplicateMode, onClose } = this.props; + const { onClose } = this.props; const targetWorkspaceId = targetWorkspaceOption?.at(0)?.key; - const { - includedSelectedObjects, - ignoredSelectedObjectsLength, - } = this.getIncludeAndNotDuplicateObjects(targetWorkspaceId); - - let confirmDuplicateButtonEnabled = false; - if (!!targetWorkspaceId && includedSelectedObjects.length > 0) { - confirmDuplicateButtonEnabled = true; - } - - const warningMessage = ( - - {ignoredSelectedObjectsLength} saved object - {ignoredSelectedObjectsLength === 1 ? ' will' : 's will'}{' '} - not be duplicated as{' '} - {ignoredSelectedObjectsLength === 1 ? 'it' : 'they'}{' '} - already exist in the selected workspace. - - ); - - // Show the number and reason why some saved objects cannot be duplicated. - const ignoreSomeObjectsChildren: React.ReactChild = ( - <> - - {warningMessage} - - - - ); - return ( { { fullWidth label={i18n.translate( 'savedObjectsManagement.objectsTable.duplicateModal.targetWorkspacelabel', - { defaultMessage: 'Destination workspace' } + { defaultMessage: 'Workspace' } )} > <> {i18n.translate( 'savedObjectsManagement.objectsTable.duplicateModal.targetWorkspaceNotice', - { defaultMessage: 'Specify a workspace where the objects will be duplicated to.' } + { defaultMessage: 'Select a workspace to where the object(s) will be duplicated' } )} @@ -248,24 +163,19 @@ export class SavedObjectsDuplicateModal extends React.Component { selectedOptions={targetWorkspaceOption} singleSelection={{ asPlainText: true }} isClearable={false} - isInvalid={!confirmDuplicateButtonEnabled} + isInvalid={!targetWorkspaceId} + placeholder="select a workspace" /> - - {duplicateMode === DuplicateMode.All && - RenderDuplicateObjectCategories( - this.state.savedObjectTypeInfoMap, - this.changeIncludeSavedObjectType - )} - {duplicateMode === DuplicateMode.All && } + <> @@ -274,7 +184,7 @@ export class SavedObjectsDuplicateModal extends React.Component { 'savedObjectsManagement.objectsTable.duplicateModal.relatedObjectsNotice', { defaultMessage: - 'We recommended duplicating related objects to ensure your duplicated objects will continue to function.', + 'Include related saved objects to ensure object(s) work as expected', } )} @@ -283,59 +193,13 @@ export class SavedObjectsDuplicateModal extends React.Component { id={'includeReferencesDeep'} label={i18n.translate( 'savedObjectsManagement.objectsTable.duplicateModal.includeReferencesDeepLabel', - { defaultMessage: 'Duplicate related objects' } + { defaultMessage: 'Include related objects(recommended)' } )} checked={isIncludeReferencesDeepChecked} onChange={this.changeIncludeReferencesDeep} /> - - - {ignoredSelectedObjectsLength === 0 ? null : ignoreSomeObjectsChildren} -

- -

- - ( - - - - ), - }, - { - field: 'id', - name: i18n.translate( - 'savedObjectsManagement.objectsTable.duplicateModal.idColumnName', - { - defaultMessage: 'Id', - } - ), - }, - { - field: 'meta.title', - name: i18n.translate( - 'savedObjectsManagement.objectsTable.duplicateModal.titleColumnName', - { defaultMessage: 'Title' } - ), - }, - ]} - pagination={true} - sorting={false} - /> @@ -349,16 +213,15 @@ export class SavedObjectsDuplicateModal extends React.Component { this.duplicateSavedObjects(includedSelectedObjects)} + onClick={() => this.duplicateSavedObjects(allSelectedObjects)} isLoading={this.state.isLoading} - disabled={!confirmDuplicateButtonEnabled} + disabled={!targetWorkspaceId} > diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.test.tsx deleted file mode 100644 index c14757f16c2b..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import RenderDuplicateObjectCategories from './duplicate_object_categories'; - -describe('RenderDuplicateObjectCategories', () => { - test('renders checkboxes correctly', () => { - const savedObjectTypeInfoMap: Map = new Map([ - ['type1', [5, true]], - ['type2', [10, false]], - ]); - const changeIncludeSavedObjectTypeMock = jest.fn(); - - const renderDuplicateObjectCategories = RenderDuplicateObjectCategories( - savedObjectTypeInfoMap, - changeIncludeSavedObjectTypeMock - ); - - expect(renderDuplicateObjectCategories).toMatchSnapshot(); - }); -}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.tsx deleted file mode 100644 index 948c87de1f74..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_object_categories.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { EuiCheckbox } from '@elastic/eui'; -import React from 'react'; -import { FormattedMessage } from '@osd/i18n/react'; -import { capitalizeFirstLetter } from './utils'; - -// A checkbox showing the type and count of save objects. -function renderDuplicateObjectCategory( - savedObjectType: string, - savedObjectTypeCount: number, - savedObjectTypeChecked: boolean, - changeIncludeSavedObjectType: (savedObjectType: string) => void -) { - return ( - - } - checked={savedObjectTypeChecked} - onChange={() => changeIncludeSavedObjectType(savedObjectType)} - /> - ); -} - -// eslint-disable-next-line import/no-default-export -export default function RenderDuplicateObjectCategories( - savedObjectTypeInfoMap: Map, - changeIncludeSavedObjectType: (savedObjectType: string) => void -) { - const checkboxList: React.JSX.Element[] = []; - savedObjectTypeInfoMap.forEach( - ([savedObjectTypeCount, savedObjectTypeChecked], savedObjectType) => - checkboxList.push( - renderDuplicateObjectCategory( - savedObjectType, - savedObjectTypeCount, - savedObjectTypeChecked, - changeIncludeSavedObjectType - ) - ) - ); - return checkboxList; -} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts index 1a7353737963..16e8911da0ca 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts @@ -32,4 +32,4 @@ export { Header } from './header'; export { Table } from './table'; export { Flyout } from './flyout'; export { Relationships } from './relationships'; -export { SavedObjectsDuplicateModal, DuplicateMode } from './duplicate_modal'; +export { SavedObjectsDuplicateModal } from './duplicate_modal'; 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 798ad789b41d..3d8673161947 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 @@ -330,8 +330,9 @@ export class Table extends PureComponent { ), type: 'icon', icon: 'copyClipboard', - isPrimary: true, - onClick: (object: SavedObjectWithMetadata) => onDuplicateSingle(object), + // isPrimary: true, + onClick: (object: SavedObjectWithMetadata) => onDuplicateSingle(object), + available: (object: SavedObjectWithMetadata) => object.type !== 'config', 'data-test-subj': 'savedObjectsTableAction-duplicate', }, ] @@ -392,78 +393,21 @@ export class Table extends PureComponent { const activeActionContents = this.state.activeAction?.render() ?? null; - const tools = [ - - - , - - - } - > - - } - checked={this.state.isIncludeReferencesDeepChecked} - onChange={this.toggleIsIncludeReferencesDeepChecked} - /> - - - - - - - , - ]; - const duplicateButton = ( - + data-test-subj="savedObjectsManagementDuplicate" + > + +
); - if (showDuplicate) { - tools.splice(1, 0, duplicateButton); - } - return ( {activeActionContents} @@ -471,7 +415,64 @@ export class Table extends PureComponent { box={{ 'data-test-subj': 'savedObjectSearchBar' }} filters={filters as any} onChange={this.onChange} - toolsRight={tools} + toolsRight={[ + <>{showDuplicate && duplicateButton}, + + + , + + + } + > + + } + checked={this.state.isIncludeReferencesDeepChecked} + onChange={this.toggleIsIncludeReferencesDeepChecked} + /> + + + + + + + , + ]} /> {queryParseError} 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 e9bd1293def2..2375151af1db 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts @@ -29,4 +29,4 @@ */ export { SavedObjectsTable } from './saved_objects_table'; -export { SavedObjectsDuplicateModal, DuplicateMode } from './components'; +export { SavedObjectsDuplicateModal } from './components'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 1e39c6d0316c..3769106cef50 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -849,8 +849,8 @@ describe('SavedObjectsTable', () => { ]; const mockSelectedSavedObjects = [ - { id: '1', type: 'dashboard', references: [], attributes: [], meta: {} }, - { id: '2', type: 'dashboard', references: [], attributes: [], meta: {} }, + { id: '1', type: 'dashboard', references: [], attributes: [], meta: { title: 'object-1' } }, + { id: '2', type: 'dashboard', references: [], attributes: [], meta: { title: 'object-2' } }, ] as SavedObjectWithMetadata[]; beforeEach(() => { @@ -859,7 +859,7 @@ describe('SavedObjectsTable', () => { workspaces.currentWorkspace$.next(workspaceList[0]); }); - it('should duplicate selected object', async () => { + it('should duplicate selected objects', async () => { getDuplicateSavedObjectsMock.mockImplementation(() => ({ success: true })); const component = shallowRender({ applications, workspaces }); @@ -873,7 +873,7 @@ describe('SavedObjectsTable', () => { expect(component.state('isShowingDuplicateModal')).toEqual(true); expect(component.find('SavedObjectsDuplicateModal').length).toEqual(1); - await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2'); + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2', 'bar'); expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( http, @@ -887,13 +887,39 @@ describe('SavedObjectsTable', () => { component.update(); expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ - title: 'Duplicate 2 saved objects successfully', + title: 'Success - 2 saved objects were duplicated to bar', }); }); - it('should show error when duplicating selected object is fail', async () => { - getDuplicateSavedObjectsMock.mockImplementationOnce(() => ({ success: false })); + it('should duplicate single object', async () => { + getDuplicateSavedObjectsMock.mockImplementation(() => ({ success: true })); + + const component = shallowRender({ applications, workspaces }); + component.setState({ isShowingDuplicateModal: true }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + await component + .instance() + .onDuplicate([mockSelectedSavedObjects[0]], true, 'workspace2', 'bar'); + + expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( + http, + [{ id: '1', type: 'dashboard' }], + true, + 'workspace2' + ); + component.update(); + expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ + title: 'Success - object-1 and the related objects were duplicated to bar', + }); + }); + + it('should show error when duplicating selected object is fail', async () => { const component = shallowRender({ applications, workspaces }); component.setState({ isShowingDuplicateModal: true }); @@ -902,7 +928,7 @@ describe('SavedObjectsTable', () => { // Ensure the state changes are reflected component.update(); - await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2'); + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2', 'bar'); expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( http, @@ -915,18 +941,14 @@ describe('SavedObjectsTable', () => { ); component.update(); - expect(notifications.toasts.addDanger).toHaveBeenCalledWith({ - title: 'Unable to duplicate 2 saved objects.', - }); - getDuplicateSavedObjectsMock.mockImplementationOnce(() => ({ success: false, errors: [{ id: '1' }, { id: '2' }], })); - await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2'); + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2', 'bar'); expect(notifications.toasts.addDanger).toHaveBeenCalledWith({ - title: 'Unable to duplicate 2 saved objects. These objects cannot be duplicated:1, 2', + title: 'Warning - Unable to duplicate 2 saved object(s): 1, 2', }); }); @@ -941,7 +963,7 @@ describe('SavedObjectsTable', () => { // Ensure the state changes are reflected component.update(); - await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2'); + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2', 'bar'); expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( http, @@ -955,7 +977,7 @@ describe('SavedObjectsTable', () => { component.update(); expect(notifications.toasts.addDanger).toHaveBeenCalledWith({ - title: 'Unable to duplicate 2 saved objects', + title: 'Error - Unable to duplicate 2 saved object(s)', }); }); 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 7263e7867f14..3279c6abce02 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 @@ -97,7 +97,7 @@ import { } from '../../services'; import { Header, Table, Flyout, Relationships, SavedObjectsDuplicateModal } from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; -import { DuplicateMode, DuplicateObject } from '../types'; +import { DuplicateObject } from '../types'; import { formatWorkspaceIdParams } from '../../utils'; interface ExportAllOption { @@ -136,7 +136,6 @@ export interface SavedObjectsTableState { selectedSavedObjects: SavedObjectWithMetadata[]; duplicateSelectedSavedObjects: DuplicateObject[]; isShowingImportFlyout: boolean; - duplicateMode: DuplicateMode; isShowingDuplicateModal: boolean; isSearching: boolean; filteredItemCount: number; @@ -176,7 +175,6 @@ export class SavedObjectsTable extends Component { const { http, notifications } = this.props; const objectsToDuplicate = savedObjects.map((obj) => ({ id: obj.id, type: obj.type })); @@ -725,29 +723,49 @@ export class SavedObjectsTable extends Component item.id).join(', ') + ? result.errors.map((item: { id: string }) => item.id).join(', ') : ''; notifications.toasts.addDanger({ title: i18n.translate( 'savedObjectsManagement.objectsTable.duplicate.dangerNotification', { defaultMessage: + 'Warning - ' + + (successCount > 0 + ? successCount + + ' saved object(s)' + + refMessage + + ' were duplicated to ' + + targetWorkspaceName + + '. ' + : '') + 'Unable to duplicate ' + - savedObjects.length.toString() + - ' saved objects.' + + result.errors.length.toString() + + ' saved object(s): ' + errorIdMessages, } ), @@ -757,7 +775,7 @@ export class SavedObjectsTable extends Component @@ -1143,7 +1160,6 @@ export class SavedObjectsTable extends Component this.setState({ isShowingDuplicateModal: true, - duplicateMode: DuplicateMode.Selected, duplicateSelectedSavedObjects: selectedSavedObjects, }) } @@ -1151,7 +1167,6 @@ export class SavedObjectsTable extends Component; From c6081211a0e8fc68be8b1d473048131c06166719 Mon Sep 17 00:00:00 2001 From: yubonluo Date: Wed, 8 May 2024 10:51:58 +0800 Subject: [PATCH 09/18] Delete useless code Signed-off-by: yubonluo --- .../components/duplicate_modal.test.tsx | 67 ------------------- .../objects_table/components/table.tsx | 1 - 2 files changed, 68 deletions(-) diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.test.tsx index 850974a6e4d7..21b157012624 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.test.tsx @@ -129,73 +129,6 @@ describe('DuplicateModal', () => { expect(options[1].label).toEqual('bar'); }); - // it('should only show saved objects belong to workspace1 when target workspace is workspace2', async () => { - // workspaces.workspaceList$.next(workspaceList); - // workspaces.currentWorkspaceId$.next(''); - // workspaces.currentWorkspace$.next(null); - // selectedModeProps = { ...selectedModeProps, workspaces }; - // const component = shallowWithI18nProvider( - // - // ); - // const selectedObjects = component - // .find('EuiInMemoryTable') - // .prop('items') as SavedObjectWithMetadata[]; - // expect(selectedObjects.length).toEqual(3); - // const comboBox = component.find('EuiComboBox'); - // comboBox.simulate('change', [{ label: 'bar', key: 'workspace2', value: workspaceList[1] }]); - // component.update(); - - // const targetWorkspaceOption = component.state('targetWorkspaceOption') as WorkspaceOption[]; - // expect(targetWorkspaceOption.length).toEqual(1); - // expect(targetWorkspaceOption[0].key).toEqual('workspace2'); - - // const includedSelectedObjects = component - // .find('EuiInMemoryTable') - // .prop('items') as SavedObjectWithMetadata[]; - // expect(includedSelectedObjects.length).toEqual(1); - // expect(includedSelectedObjects[0].workspaces).toEqual(['workspace1']); - // expect(includedSelectedObjects[0].id).toEqual('1'); - - // expect(component.find('EuiCallOut').prop('aria-disabled')).toEqual(false); - // }); - - // it('should ignore one saved object when target workspace is workspace1', async () => { - // workspaces.workspaceList$.next(workspaceList); - // workspaces.currentWorkspaceId$.next(''); - // workspaces.currentWorkspace$.next(null); - // selectedModeProps = { ...selectedModeProps, workspaces }; - // const component = shallowWithI18nProvider( - // - // ); - // const comboBox = component.find('EuiComboBox'); - // comboBox.simulate('change', [{ label: 'foo', key: 'workspace1', value: workspaceList[0] }]); - - // const includedSelectedObjects = component - // .find('EuiInMemoryTable') - // .prop('items') as SavedObjectWithMetadata[]; - // expect(includedSelectedObjects.length).toEqual(2); - // }); - - // it('should show saved objects type when duplicate mode is all', async () => { - // const component = shallowWithI18nProvider(); - // const savedObjectTypeInfoMap = component.state('savedObjectTypeInfoMap') as Map< - // string, - // [number, boolean] - // >; - // expect(savedObjectTypeInfoMap.get('dashboard')).toEqual([2, true]); - - // const euiCheckbox = component.find('EuiCheckbox').at(0); - // expect(euiCheckbox.prop('checked')).toEqual(true); - // expect(euiCheckbox.prop('id')).toEqual('includeSavedObjectType.dashboard'); - - // euiCheckbox.simulate('change', { target: { checked: false } }); - // const euiCheckboxUnCheced = component.find('EuiCheckbox').at(0); - // expect(euiCheckboxUnCheced.prop('checked')).toEqual(false); - // expect(savedObjectTypeInfoMap.get('dashboard')).toEqual([2, false]); - - // (component.instance() as any).changeIncludeSavedObjectType('invalid'); - // }); - it('should uncheck duplicate related objects', async () => { const component = shallowWithI18nProvider(); 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 3d8673161947..2040b5d53f22 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 @@ -330,7 +330,6 @@ export class Table extends PureComponent { ), type: 'icon', icon: 'copyClipboard', - // isPrimary: true, onClick: (object: SavedObjectWithMetadata) => onDuplicateSingle(object), available: (object: SavedObjectWithMetadata) => object.type !== 'config', 'data-test-subj': 'savedObjectsTableAction-duplicate', From 164197e70f55964e4f013fbd02206fc8a87c5eb4 Mon Sep 17 00:00:00 2001 From: yubonluo Date: Fri, 10 May 2024 11:24:21 +0800 Subject: [PATCH 10/18] Optimize the code Signed-off-by: yubonluo --- .../lib/duplicate_saved_objects.test.ts | 21 ++++---- .../public/lib/duplicate_saved_objects.ts | 4 +- .../saved_objects_table.test.tsx.snap | 2 +- .../components/duplicate_modal.tsx | 5 +- .../objects_table/components/table.test.tsx | 2 +- .../objects_table/components/table.tsx | 9 ++-- .../objects_table/components/utils.ts | 2 +- .../saved_objects_table.test.tsx | 21 ++++---- .../objects_table/saved_objects_table.tsx | 48 +++++++------------ .../workspace/public/workspace_client.test.ts | 2 +- .../workspace/public/workspace_client.ts | 2 +- .../workspace_saved_objects_client_wrapper.ts | 3 +- .../workspace/server/workspace_client.ts | 1 + 13 files changed, 56 insertions(+), 66 deletions(-) diff --git a/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.test.ts b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.test.ts index 5bf84740db81..def28a431551 100644 --- a/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.test.ts +++ b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.test.ts @@ -7,15 +7,16 @@ import { httpServiceMock } from '../../../../core/public/mocks'; import { duplicateSavedObjects } from './duplicate_saved_objects'; describe('copy saved objects', () => { - it('make http call with body provided', async () => { - const httpClient = httpServiceMock.createStartContract(); - const objects = [ - { type: 'dashboard', id: '1' }, - { type: 'visualization', id: '2' }, - ]; + const httpClient = httpServiceMock.createStartContract(); + const objects = [ + { type: 'dashboard', id: '1' }, + { type: 'visualization', id: '2' }, + ]; + const targetWorkspace = '1'; + + it('make http call with all parameter provided', async () => { const includeReferencesDeep = true; - const targetWorkspace = '1'; - await duplicateSavedObjects(httpClient, objects, includeReferencesDeep, targetWorkspace); + await duplicateSavedObjects(httpClient, objects, targetWorkspace, includeReferencesDeep); expect(httpClient.post).toMatchInlineSnapshot(` [MockFunction] { "calls": Array [ @@ -34,8 +35,10 @@ describe('copy saved objects', () => { ], } `); + }); - await duplicateSavedObjects(httpClient, objects, undefined, targetWorkspace); + it('make http call without includeReferencesDeep parameter provided', async () => { + await duplicateSavedObjects(httpClient, objects, targetWorkspace); expect(httpClient.post).toMatchInlineSnapshot(` [MockFunction] { "calls": Array [ diff --git a/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts index bf7d209bca7a..560e0c1fddb3 100644 --- a/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts +++ b/src/plugins/saved_objects_management/public/lib/duplicate_saved_objects.ts @@ -8,8 +8,8 @@ import { HttpStart } from 'src/core/public'; export async function duplicateSavedObjects( http: HttpStart, objects: any[], - includeReferencesDeep: boolean = true, - targetWorkspace: string + targetWorkspace: string, + includeReferencesDeep: boolean = true ) { return await http.post('/api/workspaces/_duplicate_saved_objects', { body: JSON.stringify({ 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 fe73f15d1066..2688419c9a37 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 @@ -979,7 +979,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` } onActionRefresh={[Function]} onDelete={[Function]} - onDuplicateSelected={[Function]} + onDuplicate={[Function]} onDuplicateSingle={[Function]} onExport={[Function]} onQueryChange={[Function]} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx index 2598be955ae4..15420053745f 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx @@ -24,7 +24,7 @@ import { i18n } from '@osd/i18n'; import { WorkspaceOption, getTargetWorkspacesOptions, workspaceToOption } from './utils'; import { DuplicateObject } from '../../types'; -export interface ShowDuplicateModalProps { +export interface Props { onDuplicate: ( savedObjects: DuplicateObject[], includeReferencesDeep: boolean, @@ -35,9 +35,6 @@ export interface ShowDuplicateModalProps { workspaces: WorkspacesStart; notifications: NotificationsStart; selectedSavedObjects: DuplicateObject[]; -} - -interface Props extends ShowDuplicateModalProps { onClose: () => void; } diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx index aebff783ea0e..e9b5595dd45d 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx @@ -107,7 +107,7 @@ const defaultProps: TableProps = { isSearching: false, onShowRelationships: () => {}, canDelete: true, - onDuplicateSelected: () => {}, + onDuplicate: () => {}, onDuplicateSingle: () => {}, showDuplicate: false, }; 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 2040b5d53f22..5f813b483f70 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -46,7 +46,6 @@ import { EuiText, EuiTableFieldDataColumnType, EuiTableActionsColumnType, - EuiButtonIcon, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; @@ -63,6 +62,7 @@ export interface TableProps { basePath: IBasePath; actionRegistry: SavedObjectsManagementActionServiceStart; columnRegistry: SavedObjectsManagementColumnServiceStart; + namespaceRegistry: SavedObjectsManagementNamespaceServiceStart; selectedSavedObjects: SavedObjectWithMetadata[]; selectionConfig: { onSelectionChange: (selection: SavedObjectWithMetadata[]) => void; @@ -70,7 +70,7 @@ export interface TableProps { filters: any[]; canDelete: boolean; onDelete: () => void; - onDuplicateSelected: () => void; + onDuplicate: () => void; onDuplicateSingle: (object: SavedObjectWithMetadata) => void; onActionRefresh: (object: SavedObjectWithMetadata) => void; onExport: (includeReferencesDeep: boolean) => void; @@ -173,7 +173,7 @@ export class Table extends PureComponent { filters, selectionConfig: selection, onDelete, - onDuplicateSelected, + onDuplicate, onDuplicateSingle, onActionRefresh, selectedSavedObjects, @@ -183,6 +183,7 @@ export class Table extends PureComponent { basePath, actionRegistry, columnRegistry, + namespaceRegistry, dateFormat, availableWorkspaces, currentWorkspaceId, @@ -396,7 +397,7 @@ export class Table extends PureComponent { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts index 33b653e1d8ac..c6e7c9012a14 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts @@ -31,7 +31,7 @@ export function getTargetWorkspacesOptions( ): WorkspaceOption[] { const workspaceList = workspaces.workspaceList$.value; const targetWorkspaces = workspaceList.filter( - (workspace) => workspace.id !== currentWorkspaceId && !workspace.libraryReadonly + (workspace) => workspace.id !== currentWorkspaceId && !workspace.readonly ); return targetWorkspaces.map((workspace) => workspaceToOption(workspace, currentWorkspaceId)); } diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 3769106cef50..52c77584e382 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -881,8 +881,8 @@ describe('SavedObjectsTable', () => { { id: '1', type: 'dashboard' }, { id: '2', type: 'dashboard' }, ], - false, - 'workspace2' + 'workspace2', + false ); component.update(); @@ -909,8 +909,8 @@ describe('SavedObjectsTable', () => { expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( http, [{ id: '1', type: 'dashboard' }], - true, - 'workspace2' + 'workspace2', + true ); component.update(); @@ -936,8 +936,8 @@ describe('SavedObjectsTable', () => { { id: '1', type: 'dashboard' }, { id: '2', type: 'dashboard' }, ], - false, - 'workspace2' + 'workspace2', + false ); component.update(); @@ -948,7 +948,8 @@ describe('SavedObjectsTable', () => { await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2', 'bar'); expect(notifications.toasts.addDanger).toHaveBeenCalledWith({ - title: 'Warning - Unable to duplicate 2 saved object(s): 1, 2', + title: + 'Warning - 0 saved object(s) were duplicated to bar. Unable to duplicate 2 saved object(s): 1, 2', }); }); @@ -971,8 +972,8 @@ describe('SavedObjectsTable', () => { { id: '1', type: 'dashboard' }, { id: '2', type: 'dashboard' }, ], - false, - 'workspace2' + 'workspace2', + false ); component.update(); @@ -1009,7 +1010,7 @@ describe('SavedObjectsTable', () => { component.update(); const table = component.find('Table') as any; - table.prop('onDuplicateSelected')(); + table.prop('onDuplicate')(); component.update(); expect(component.state('isShowingDuplicateModal')).toEqual(true); 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 3279c6abce02..d3e46dd1994e 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 @@ -155,7 +155,6 @@ export class SavedObjectsTable extends Component { @@ -720,28 +716,25 @@ export class SavedObjectsTable extends Component 0 - ? successCount + - ' saved object(s)' + - refMessage + - ' were duplicated to ' + - targetWorkspaceName + - '. ' - : '') + - 'Unable to duplicate ' + - result.errors.length.toString() + - ' saved object(s): ' + + 'Warning - {successCount} saved object(s) {includeReferencesDeep, select, true {and the related objects were} false {were}} duplicated to {targetWorkspaceName}. Unable to duplicate {errorCount} saved object(s): {errorIdMessages}', + values: { + successCount, + includeReferencesDeep, + targetWorkspaceName, + errorCount: result.errors.length, errorIdMessages, + }, } ), }); @@ -774,8 +762,8 @@ export class SavedObjectsTable extends Component + onDuplicate={() => this.setState({ isShowingDuplicateModal: true, duplicateSelectedSavedObjects: selectedSavedObjects, diff --git a/src/plugins/workspace/public/workspace_client.test.ts b/src/plugins/workspace/public/workspace_client.test.ts index 0e295c367342..a36c68ff3bf3 100644 --- a/src/plugins/workspace/public/workspace_client.test.ts +++ b/src/plugins/workspace/public/workspace_client.test.ts @@ -178,7 +178,7 @@ describe('#WorkspaceClient', () => { expect(workspaceMock.workspaceList$.getValue()).toEqual([ { id: 'foo', - libraryReadonly: false, + readonly: false, }, ]); expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts index 98f8c3eef324..eb3cf25c0910 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -130,7 +130,7 @@ export class WorkspaceClient { ); const workspaces = result.result.workspaces.map((workspace: WorkspaceAttribute) => ({ ...workspace, - libraryReadonly: !workspaceIdsWithWritePermission.includes(workspace.id), + readonly: !workspaceIdsWithWritePermission.includes(workspace.id), })); this.workspaces.workspaceList$.next(workspaces); } 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 95a6573040fd..26910b67b35f 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 @@ -32,7 +32,6 @@ import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, WorkspacePermissionMode, } from '../../common/constants'; -import { WorkspaceFindOptions } from '../types'; // Can't throw unauthorized for now, the page will be refreshed if unauthorized const generateWorkspacePermissionError = () => @@ -413,7 +412,7 @@ export class WorkspaceSavedObjectsClientWrapper { }; const findWithWorkspacePermissionControl = async ( - options: SavedObjectsFindOptions & Pick + options: SavedObjectsFindOptions ) => { const principals = this.permissionControl.getPrincipalsFromRequest(wrapperOptions.request); if (!options.ACLSearchParams) { diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index 72609519d2f7..fd79b10c6fda 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -135,6 +135,7 @@ export class WorkspaceClient implements IWorkspaceClientImpl { { ...options, type: WORKSPACE_TYPE, + ACLSearchParams: { permissionModes: options.permissionModes }, } ); return { From 94e0767a58edad6ae99398e9d12831799dcb6642 Mon Sep 17 00:00:00 2001 From: yubonluo Date: Fri, 10 May 2024 14:32:25 +0800 Subject: [PATCH 11/18] optimize code Signed-off-by: yubonluo --- .../components/duplicate_modal.tsx | 17 +++----- .../objects_table/components/header.tsx | 1 + .../objects_table/components/utils.test.tsx | 43 +++++++++++++++---- .../objects_table/components/utils.ts | 11 +++-- 4 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx index 15420053745f..60d16c4b7c66 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx @@ -21,10 +21,10 @@ import { } from '@elastic/eui'; import { HttpSetup, NotificationsStart, WorkspacesStart } from 'opensearch-dashboards/public'; import { i18n } from '@osd/i18n'; -import { WorkspaceOption, getTargetWorkspacesOptions, workspaceToOption } from './utils'; +import { WorkspaceOption, getTargetWorkspacesOptions } from './utils'; import { DuplicateObject } from '../../types'; -export interface Props { +export interface ShowDuplicateModalProps { onDuplicate: ( savedObjects: DuplicateObject[], includeReferencesDeep: boolean, @@ -46,24 +46,19 @@ interface State { isIncludeReferencesDeepChecked: boolean; } -export class SavedObjectsDuplicateModal extends React.Component { +export class SavedObjectsDuplicateModal extends React.Component { private isMounted = false; - constructor(props: Props) { + constructor(props: ShowDuplicateModalProps) { super(props); const { workspaces } = props; const currentWorkspace = workspaces.currentWorkspace$.value; - const currentWorkspaceId = currentWorkspace?.id; - const targetWorkspacesOptions = getTargetWorkspacesOptions(workspaces, currentWorkspaceId); + const targetWorkspacesOptions = getTargetWorkspacesOptions(workspaces, currentWorkspace!); this.state = { allSelectedObjects: props.selectedSavedObjects, - // current workspace is the first option - workspaceOptions: [ - ...(currentWorkspace ? [workspaceToOption(currentWorkspace, currentWorkspaceId)] : []), - ...targetWorkspacesOptions, - ], + workspaceOptions: targetWorkspacesOptions, targetWorkspaceOption: [], isLoading: false, isIncludeReferencesDeepChecked: 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 47df820a9e8e..4fe3b6563fa3 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 @@ -79,6 +79,7 @@ export const Header = ({ data-test-subj="duplicateObjects" onClick={onDuplicate} disabled={objectCount === 0} + iconType="copyClipboard" > { expect(workspaceOption.value).toBe(workspace); }); - it('should get target workspace options', () => { + it('should get correct target workspace options in a workspace', () => { const workspaces: WorkspacesStart = { currentWorkspaceId$: new BehaviorSubject('1'), currentWorkspace$: new BehaviorSubject({ @@ -43,19 +43,46 @@ describe('duplicate mode utils', () => { name: 'Workspace 1', }), workspaceList$: new BehaviorSubject([ - { id: '1', name: 'Workspace 1' }, - { id: '2', name: 'Workspace 2', libraryReadonly: false }, - { id: '3', name: 'Workspace 3', libraryReadonly: true }, + { id: '1', name: 'Workspace 1', readonly: false }, + { id: '2', name: 'Workspace 2', readonly: false }, + { id: '3', name: 'Workspace 3', readonly: true }, + ]), + initialized$: new BehaviorSubject(true), + }; + const optionContainCurrent: WorkspaceOption[] = getTargetWorkspacesOptions(workspaces, { + id: '1', + name: 'Workspace 1', + readonly: false, + }); + expect(optionContainCurrent.length).toBe(2); + expect(optionContainCurrent[0].key).toBe('1'); + expect(optionContainCurrent[0].label).toBe('Workspace 1 (current)'); + + expect(optionContainCurrent[1].key).toBe('2'); + expect(optionContainCurrent[1].label).toBe('Workspace 2'); + }); + + it('should get correct target workspace options not in a workspace', () => { + const workspaces: WorkspacesStart = { + currentWorkspaceId$: new BehaviorSubject(''), + currentWorkspace$: new BehaviorSubject({ + id: '', + name: '', + }), + workspaceList$: new BehaviorSubject([ + { id: '1', name: 'Workspace 1', readonly: false }, + { id: '2', name: 'Workspace 2', readonly: false }, + { id: '3', name: 'Workspace 3', readonly: true }, ]), initialized$: new BehaviorSubject(true), }; - const optionContainCurrent: WorkspaceOption[] = getTargetWorkspacesOptions(workspaces, '1'); - expect(optionContainCurrent.length).toBe(1); - expect(optionContainCurrent[0].key).toBe('2'); - expect(optionContainCurrent[0].label).toBe('Workspace 2'); const workspaceOption: WorkspaceOption[] = getTargetWorkspacesOptions(workspaces); expect(workspaceOption.length).toBe(2); + expect(workspaceOption[0].key).toBe('1'); + expect(workspaceOption[0].label).toBe('Workspace 1'); + expect(workspaceOption[1].key).toBe('2'); + expect(workspaceOption[1].label).toBe('Workspace 2'); }); it('should capitalize first letter', () => { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts index c6e7c9012a14..13ca4d3cc82e 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts @@ -4,13 +4,13 @@ */ import { EuiComboBoxOptionOption } from '@elastic/eui'; -import { WorkspaceAttribute, WorkspacesStart } from 'opensearch-dashboards/public'; +import { WorkspaceObject, WorkspacesStart } from 'opensearch-dashboards/public'; -export type WorkspaceOption = EuiComboBoxOptionOption; +export type WorkspaceOption = EuiComboBoxOptionOption; // Convert workspace to option which can be displayed in the drop-down box. export function workspaceToOption( - workspace: WorkspaceAttribute, + workspace: WorkspaceObject, currentWorkspaceId?: string ): WorkspaceOption { // add (current) after current workspace name @@ -27,12 +27,15 @@ export function workspaceToOption( export function getTargetWorkspacesOptions( workspaces: WorkspacesStart, - currentWorkspaceId?: string + currentWorkspace?: WorkspaceObject ): WorkspaceOption[] { + const currentWorkspaceId = currentWorkspace?.id; const workspaceList = workspaces.workspaceList$.value; const targetWorkspaces = workspaceList.filter( (workspace) => workspace.id !== currentWorkspaceId && !workspace.readonly ); + // current workspace is the first option + if (currentWorkspace && !currentWorkspace.readonly) targetWorkspaces.unshift(currentWorkspace); return targetWorkspaces.map((workspace) => workspaceToOption(workspace, currentWorkspaceId)); } From c5685e14e87d724812deb1311d1820a7b1d2ea7e Mon Sep 17 00:00:00 2001 From: yubonluo Date: Fri, 10 May 2024 14:54:02 +0800 Subject: [PATCH 12/18] delete useless code Signed-off-by: yubonluo --- .../objects_table/components/utils.test.tsx | 13 +------------ .../objects_table/components/utils.ts | 4 ---- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.test.tsx index 79d6e5541888..d4c5490dcb32 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.test.tsx @@ -4,12 +4,7 @@ */ import { WorkspaceAttribute, WorkspaceObject, WorkspacesStart } from 'opensearch-dashboards/public'; -import { - WorkspaceOption, - capitalizeFirstLetter, - getTargetWorkspacesOptions, - workspaceToOption, -} from './utils'; +import { WorkspaceOption, getTargetWorkspacesOptions, workspaceToOption } from './utils'; import { BehaviorSubject } from 'rxjs'; describe('duplicate mode utils', () => { @@ -84,10 +79,4 @@ describe('duplicate mode utils', () => { expect(workspaceOption[1].key).toBe('2'); expect(workspaceOption[1].label).toBe('Workspace 2'); }); - - it('should capitalize first letter', () => { - const workspaceName: string = 'workspace'; - const capitalizedName: string = capitalizeFirstLetter(workspaceName); - expect(capitalizedName).toBe('Workspace'); - }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts index 13ca4d3cc82e..a7a5b8b77852 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/utils.ts @@ -38,7 +38,3 @@ export function getTargetWorkspacesOptions( if (currentWorkspace && !currentWorkspace.readonly) targetWorkspaces.unshift(currentWorkspace); return targetWorkspaces.map((workspace) => workspaceToOption(workspace, currentWorkspaceId)); } - -export function capitalizeFirstLetter(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1); -} From 5cf468f0acea6b2fa561c1dde413bf095ab56717 Mon Sep 17 00:00:00 2001 From: yubonluo Date: Tue, 14 May 2024 11:24:08 +0800 Subject: [PATCH 13/18] Optimize user experience Signed-off-by: yubonluo --- .../__snapshots__/saved_objects_table.test.tsx.snap | 1 + .../__snapshots__/duplicate_modal.test.tsx.snap | 4 ++-- .../components/__snapshots__/table.test.tsx.snap | 8 ++++---- .../objects_table/components/duplicate_modal.tsx | 4 ++-- .../objects_table/components/header.tsx | 7 +++++-- .../objects_table/components/table.tsx | 13 +++++++------ .../objects_table/saved_objects_table.tsx | 5 ++++- 7 files changed, 25 insertions(+), 17 deletions(-) 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 2688419c9a37..80e00f6d264d 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 @@ -914,6 +914,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` "view": "search (0)", }, ], + "searchThreshold": 1, "type": "field_value_selection", }, ] diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap index d024a2d7dfd2..ec3569e109f2 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_modal.test.tsx.snap @@ -59,7 +59,7 @@ HTMLCollection [ class="euiModalHeader__title" > - Duplicate 3 objects to? + Duplicate 3 objects?
@@ -205,7 +205,7 @@ HTMLCollection [ class="euiCheckbox__label" for="includeReferencesDeep" > - Include related objects(recommended) + Include related objects (recommended)
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index 43ffa04dafca..57f4cb7bbb75 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -256,12 +256,12 @@ exports[`Table should call onDuplicateSingle when show duplicate 1`] = ` @@ -415,8 +415,8 @@ exports[`Table should call onDuplicateSingle when show duplicate 1`] = ` "available": [Function], "data-test-subj": "savedObjectsTableAction-duplicate", "description": "Duplicate this saved object", - "icon": "copyClipboard", - "name": "Duplicate", + "icon": "copy", + "name": "Duplicate to...", "onClick": [Function], "type": "icon", }, diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx index 60d16c4b7c66..ae3d7f74b7e0 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx @@ -124,7 +124,7 @@ export class SavedObjectsDuplicateModal extends React.Component 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 5f813b483f70..cc0eafd24bb5 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -46,6 +46,7 @@ import { EuiText, EuiTableFieldDataColumnType, EuiTableActionsColumnType, + EuiSearchBarProps, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; @@ -67,7 +68,7 @@ export interface TableProps { selectionConfig: { onSelectionChange: (selection: SavedObjectWithMetadata[]) => void; }; - filters: any[]; + filters: EuiSearchBarProps['filters']; canDelete: boolean; onDelete: () => void; onDuplicate: () => void; @@ -323,14 +324,14 @@ export class Table extends PureComponent { { name: i18n.translate( 'savedObjectsManagement.objectsTable.table.columnActions.duplicateActionName', - { defaultMessage: 'Duplicate' } + { defaultMessage: 'Duplicate to...' } ), description: i18n.translate( 'savedObjectsManagement.objectsTable.table.columnActions.duplicateActionDescription', { defaultMessage: 'Duplicate this saved object' } ), type: 'icon', - icon: 'copyClipboard', + icon: 'copy', onClick: (object: SavedObjectWithMetadata) => onDuplicateSingle(object), available: (object: SavedObjectWithMetadata) => object.type !== 'config', 'data-test-subj': 'savedObjectsTableAction-duplicate', @@ -396,14 +397,14 @@ export class Table extends PureComponent { const duplicateButton = ( ); @@ -413,7 +414,7 @@ export class Table extends PureComponent { {activeActionContents} {showDuplicate && duplicateButton}, 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 d3e46dd1994e..7018f382e91a 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -55,6 +55,7 @@ import { EuiFormRow, EuiFlexGroup, EuiFlexItem, + EuiSearchBarProps, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; @@ -1041,7 +1042,7 @@ export class SavedObjectsTable extends Component Date: Tue, 4 Jun 2024 10:54:40 +0800 Subject: [PATCH 14/18] Solve the test snapshot error Signed-off-by: yubonluo --- .../saved_objects_table.test.tsx.snap | 278 +++++++++--------- 1 file changed, 140 insertions(+), 138 deletions(-) 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 26c63626fccb..2d8a4cb8bb25 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 @@ -74,6 +74,146 @@ exports[`SavedObjectsTable delete should show a confirm modal 1`] = ` `; +exports[`SavedObjectsTable delete should show error toast when failing to delete saved objects 1`] = ` + + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + onCancel={[Function]} + onConfirm={[Function]} + title={ + + } +> +

+ +

+ +
+`; + +exports[`SavedObjectsTable delete should show error toast when failing to delete saved objects 2`] = ` + + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + onCancel={[Function]} + onConfirm={[Function]} + title={ + + } +> +

+ +

+ +
+`; + exports[`SavedObjectsTable duplicate should allow the user to choose on header when duplicating all 1`] = ` -exports[`SavedObjectsTable delete should show error toast when failing to delete saved objects 1`] = ` - - } - confirmButtonText={ - - } - defaultFocusedButton="confirm" - onCancel={[Function]} - onConfirm={[Function]} - title={ - - } -> -

- -

- -
-`; - -exports[`SavedObjectsTable delete should show error toast when failing to delete saved objects 2`] = ` - - } - confirmButtonText={ - - } - defaultFocusedButton="confirm" - onCancel={[Function]} - onConfirm={[Function]} - title={ - - } -> -

- -

- -
`; exports[`SavedObjectsTable export should allow the user to choose when exporting all 1`] = ` From 483337eeb11143331e0acae01495e3b7fb6b3de0 Mon Sep 17 00:00:00 2001 From: yubonluo Date: Tue, 11 Jun 2024 14:27:40 +0800 Subject: [PATCH 15/18] modefy the ui text Signed-off-by: yubonluo --- .../saved_objects_table.test.tsx.snap | 1 - .../duplicate_modal.test.tsx.snap | 21 +++------- .../__snapshots__/header.test.tsx.snap | 16 ++------ .../__snapshots__/table.test.tsx.snap | 6 +-- .../components/duplicate_modal.tsx | 28 +++++++------ .../objects_table/components/header.tsx | 12 +----- .../objects_table/components/table.tsx | 6 +-- .../saved_objects_table.test.tsx | 14 +++---- .../objects_table/saved_objects_table.tsx | 39 +++++++++++++------ .../workspace_column/workspace_column.tsx | 2 +- 10 files changed, 66 insertions(+), 79 deletions(-) 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 2d8a4cb8bb25..f4a555ad07bf 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 @@ -947,7 +947,6 @@ exports[`SavedObjectsTable should render normally 1`] = ` horizontalPosition="center" >
- Duplicate 3 objects? + Copy 3 objects to another workspace?
@@ -92,7 +92,7 @@ HTMLCollection [
- Select a workspace to where the object(s) will be duplicated + Move copied saved objects to the selected workspace.
- select a workspace + Select a workspace

- Options + Copy related objects
-
-
- Include related saved objects to ensure object(s) work as expected -
-
@@ -205,7 +196,7 @@ HTMLCollection [ class="euiCheckbox__label" for="includeReferencesDeep" > - Include related objects (recommended) + Copy the selected object and any related objects (recommended).
@@ -245,7 +236,7 @@ HTMLCollection [ class="euiButton__text" > - Duplicate + Copy diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap index fc7c75d80cda..4121ada87b75 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap @@ -37,13 +37,9 @@ exports[`Header should render normally 1`] = ` size="s" > @@ -142,13 +138,9 @@ exports[`Header should render normally when showDuplicateAll is undefined 1`] = size="s" > diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index 57f4cb7bbb75..9b3f390a587c 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -261,7 +261,7 @@ exports[`Table should call onDuplicateSingle when show duplicate 1`] = ` onClick={[Function]} > @@ -414,9 +414,9 @@ exports[`Table should call onDuplicateSingle when show duplicate 1`] = ` Object { "available": [Function], "data-test-subj": "savedObjectsTableAction-duplicate", - "description": "Duplicate this saved object", + "description": "Copy this saved object", "icon": "copy", - "name": "Duplicate to...", + "name": "Copy to...", "onClick": [Function], "type": "icon", }, diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx index ae3d7f74b7e0..92c32615986d 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx @@ -124,7 +124,7 @@ export class SavedObjectsDuplicateModal extends React.Component {i18n.translate( 'savedObjectsManagement.objectsTable.duplicateModal.targetWorkspaceNotice', - { defaultMessage: 'Select a workspace to where the object(s) will be duplicated' } + { + defaultMessage: `Move copied saved object${ + allSelectedObjects.length > 1 ? `s` : `` + } to the selected workspace.`, + } )} @@ -156,7 +160,7 @@ export class SavedObjectsDuplicateModal extends React.Component @@ -167,25 +171,19 @@ export class SavedObjectsDuplicateModal extends React.Component <> - - {i18n.translate( - 'savedObjectsManagement.objectsTable.duplicateModal.relatedObjectsNotice', - { - defaultMessage: - 'Include related saved objects to ensure object(s) work as expected', - } - )} - void; onDuplicate: () => void; onRefresh: () => void; - filteredCount: number; objectCount: number; showDuplicateAll: boolean; }) => ( @@ -83,10 +81,7 @@ export const Header = ({ > @@ -100,10 +95,7 @@ export const Header = ({ > 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 cc0eafd24bb5..dc8251e7c619 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 @@ -324,11 +324,11 @@ export class Table extends PureComponent { { name: i18n.translate( 'savedObjectsManagement.objectsTable.table.columnActions.duplicateActionName', - { defaultMessage: 'Duplicate to...' } + { defaultMessage: 'Copy to...' } ), description: i18n.translate( 'savedObjectsManagement.objectsTable.table.columnActions.duplicateActionDescription', - { defaultMessage: 'Duplicate this saved object' } + { defaultMessage: 'Copy this saved object' } ), type: 'icon', icon: 'copy', @@ -404,7 +404,7 @@ export class Table extends PureComponent { > ); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 70b9b604175b..076cf47a43eb 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -937,7 +937,7 @@ describe('SavedObjectsTable', () => { component.update(); expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ - title: 'Success - 2 saved objects were duplicated to bar', + title: 'Successfully copied 2 saved objects to bar.', }); }); @@ -965,7 +965,7 @@ describe('SavedObjectsTable', () => { component.update(); expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ - title: 'Success - object-1 and the related objects were duplicated to bar', + title: 'Successfully copied object-1 and the related objects to bar.', }); }); @@ -997,10 +997,10 @@ describe('SavedObjectsTable', () => { })); await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2', 'bar'); - expect(notifications.toasts.addDanger).toHaveBeenCalledWith({ - title: - 'Warning - 0 saved object(s) were duplicated to bar. Unable to duplicate 2 saved object(s): 1, 2', - }); + expect(notifications.toasts.addDanger).toHaveBeenCalled(); + expect((notifications.toasts.addDanger as jest.Mock).mock.calls[0][0].title).toMatch( + 'Successfully copied 0 saved object to bar.' + ); }); it('should catch error when duplicating selected object is fail', async () => { @@ -1028,7 +1028,7 @@ describe('SavedObjectsTable', () => { component.update(); expect(notifications.toasts.addDanger).toHaveBeenCalledWith({ - title: 'Error - Unable to duplicate 2 saved object(s)', + title: 'Unable to copy 2 saved objects.', }); }); 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 cbb757d613cb..7bb048ef9ffc 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 @@ -72,7 +72,7 @@ import { } from 'src/core/public'; import { Subscription } from 'rxjs'; import { PUBLIC_WORKSPACE_ID, PUBLIC_WORKSPACE_NAME } from '../../../../../core/public'; -import { RedirectAppLinks } from '../../../../opensearch_dashboards_react/public'; +import { RedirectAppLinks, toMountPoint } from '../../../../opensearch_dashboards_react/public'; import { IndexPatternsContract } from '../../../../data/public'; import { parseQuery, @@ -737,7 +737,7 @@ export class SavedObjectsTable extends Component item.id).join(', ') - : ''; + const errorIds = result.errors ? result.errors.map((item: { id: string }) => item.id) : []; + const errorTitleMessages = savedObjects + .filter((obj) => errorIds.includes(obj.id)) + .map((obj) =>
  • {obj.meta.title}
  • ); + const errorText = i18n.translate( + 'savedObjectsManagement.objectsTable.duplicate.dangerNotificationText', + { + defaultMessage: + 'Unable to copy {errorCount, plural, one {# saved object} other {# saved objects}}:', + values: { + errorCount: result.errors.length, + }, + } + ); notifications.toasts.addDanger({ title: i18n.translate( 'savedObjectsManagement.objectsTable.duplicate.dangerNotification', { defaultMessage: - 'Warning - {successCount} saved object(s) {includeReferencesDeep, select, true {and the related objects were} false {were}} duplicated to {targetWorkspaceName}. Unable to duplicate {errorCount} saved object(s): {errorIdMessages}', + 'Successfully copied {successCount, plural,=0 {# saved object} one {# saved object} other {# saved objects}} {includeReferencesDeep, select, true {and the related objects } false { }}to {targetWorkspaceName}.', values: { successCount, includeReferencesDeep, targetWorkspaceName, - errorCount: result.errors.length, - errorIdMessages, }, } ), + text: toMountPoint( +
    + <> {errorText} + <> {errorTitleMessages} +
    + ), }); } } catch (e) { notifications.toasts.addDanger({ title: i18n.translate('savedObjectsManagement.objectsTable.duplicate.dangerNotification', { - defaultMessage: 'Error - Unable to duplicate {errorCount} saved object(s)', - values: { errorCount: savedObjects.length.toString() }, + defaultMessage: + 'Unable to copy {errorCount, plural, one {# saved object} other {# saved objects}}.', + values: { errorCount: savedObjects.length }, }), }); } @@ -1117,7 +1133,7 @@ export class SavedObjectsTable extends Component diff --git a/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx b/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx index 3d964009ee86..4391f8d2c858 100644 --- a/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx +++ b/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx @@ -36,7 +36,7 @@ export function getWorkspaceColumn( align: 'left', field: 'workspaces', name: i18n.translate('savedObjectsManagement.objectsTable.table.columnWorkspacesName', { - defaultMessage: 'Workspaces', + defaultMessage: 'Workspace', }), render: (workspaces: string[]) => { return ; From 68e9c871774a8ed2a1fd451a29552d231a4f8738 Mon Sep 17 00:00:00 2001 From: yubonluo Date: Fri, 21 Jun 2024 14:52:52 +0800 Subject: [PATCH 16/18] support copy result flyout Signed-off-by: yubonluo --- .../duplicate_result_flyout.test.tsx.snap | 361 ++++++++++++++++++ .../duplicate_result_flyout.test.tsx | 98 +++++ .../components/duplicate_result_flyout.tsx | 232 +++++++++++ .../objects_table/components/index.ts | 1 + .../saved_objects_table.test.tsx | 31 +- .../objects_table/saved_objects_table.tsx | 108 +++--- .../workspace/server/routes/duplicate.ts | 1 + 7 files changed, 759 insertions(+), 73 deletions(-) create mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_result_flyout.test.tsx.snap create mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.test.tsx create mode 100644 src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.tsx diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_result_flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_result_flyout.test.tsx.snap new file mode 100644 index 000000000000..598a382c8002 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_result_flyout.test.tsx.snap @@ -0,0 +1,361 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DuplicateResultFlyout copy count is null 1`] = ` +HTMLCollection [ + + + + + + + +
    +
    +
    + + , +] +`; + +exports[`DuplicateResultFlyout renders the flyout with correct title and result 1`] = ` +HTMLCollection [ + + + + + + + +
    +
    +
    + + , +] +`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.test.tsx new file mode 100644 index 000000000000..146a578ef03c --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.test.tsx @@ -0,0 +1,98 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsImportError, SavedObjectsImportSuccess } from 'opensearch-dashboards/public'; +import { DuplicateResultFlyout, DuplicateResultFlyoutProps } from './duplicate_result_flyout'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +describe('DuplicateResultFlyout', () => { + const failedCopies: SavedObjectsImportError[] = [ + { + type: 'config', + id: '1', + meta: { title: 'Failed Config Title' }, + error: { type: 'unknown', message: 'An error occurred', statusCode: 500 }, + }, + { + type: 'dashboard', + id: '2', + meta: {}, + error: { type: 'unsupported_type' }, + }, + ]; + const successfulCopies: SavedObjectsImportSuccess[] = [ + { + type: 'visualization', + id: '3', + meta: { title: 'Successful Visualization Title' }, + }, + { + type: 'search', + id: '4', + meta: {}, + }, + ]; + const workspaceName = 'targetWorkspace'; + const onCloseMock = jest.fn(); + const duplicateResultFlyoutProps: DuplicateResultFlyoutProps = { + workspaceName, + failedCopies, + successfulCopies, + onClose: onCloseMock, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the flyout with correct title and result', () => { + render(); + expect(document.children).toMatchSnapshot(); + + // Check title + expect(screen.getByText('Copy saved objects to targetWorkspace')).toBeInTheDocument(); + + // Check result counts + expect(screen.getByText('4 saved objects copied')).toBeInTheDocument(); + expect(screen.getByText('2 Successful')).toBeInTheDocument(); + expect(screen.getByText('2 Error copying file')).toBeInTheDocument(); + + // Check successful copy icon and message + expect(screen.getByLabelText('visualization')).toBeInTheDocument(); + expect(screen.getByText('Successful Visualization Title')).toBeInTheDocument(); + + expect(screen.getByLabelText('search')).toBeInTheDocument(); + expect(screen.getByText('search [id=4]')).toBeInTheDocument(); + + // Check failed copy icon and message + expect(screen.getByLabelText('dashboard')).toBeInTheDocument(); + expect(screen.getByText('dashboard [id=2]')).toBeInTheDocument(); + + expect(screen.getByLabelText('config')).toBeInTheDocument(); + expect(screen.getByText('Failed Config Title')).toBeInTheDocument(); + }); + + it('calls onClose when the close button is clicked', () => { + render(); + + const closeButton = screen.getByTestId('euiFlyoutCloseButton'); + fireEvent.click(closeButton); + + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); + + it('copy count is null', () => { + render( + + ); + expect(document.children).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.tsx new file mode 100644 index 000000000000..f1efcad4317d --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_result_flyout.tsx @@ -0,0 +1,232 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './import_summary.scss'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiHorizontalRule, + EuiIcon, + EuiIconTip, + EuiSpacer, + EuiText, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import _ from 'lodash'; +import React, { Fragment } from 'react'; +import { i18n } from '@osd/i18n'; +import { SavedObjectsImportError, SavedObjectsImportSuccess } from 'opensearch-dashboards/public'; +import { FormattedMessage } from '@osd/i18n/react'; +import { getSavedObjectLabel } from '../../..'; +import { getDefaultTitle } from '../../../lib'; + +interface CopyItem { + type: string; + id: string; + title: string; + icon: string; + outcome: 'copied' | 'error'; + errorMessage?: string; +} + +export interface CopyResultProps { + failedCopies: SavedObjectsImportError[]; + successfulCopies: SavedObjectsImportSuccess[]; +} + +export interface DuplicateResultFlyoutProps { + workspaceName: string; + failedCopies: SavedObjectsImportError[]; + successfulCopies: SavedObjectsImportSuccess[]; + onClose: () => void; +} + +interface State { + isLoading: boolean; +} + +const DEFAULT_ICON = 'apps'; + +const unsupportedTypeErrorMessage = i18n.translate( + 'savedObjectsManagement.objectsTable.copyResult.unsupportedTypeError', + { defaultMessage: 'Unsupported object type' } +); + +const getErrorMessage = ({ error }: SavedObjectsImportError) => { + if (error.type === 'unknown') { + return error.message; + } else if (error.type === 'unsupported_type') { + return unsupportedTypeErrorMessage; + } +}; + +export class DuplicateResultFlyout extends React.Component { + constructor(props: DuplicateResultFlyoutProps) { + super(props); + this.state = { isLoading: false }; + } + + getCountIndicators(copyItems: CopyItem[]) { + if (!copyItems.length) { + return null; + } + + const outcomeCounts = copyItems.reduce( + (acc, { outcome }) => acc.set(outcome, (acc.get(outcome) ?? 0) + 1), + new Map() + ); + const copiedCount = outcomeCounts.get('copied'); + const errorCount = outcomeCounts.get('error'); + + return ( + + {copiedCount && ( + + +

    + +

    +
    +
    + )} + {errorCount && ( + + +

    + +

    +
    +
    + )} +
    + ); + } + + getStatusIndicator({ outcome, errorMessage = 'Error' }: CopyItem) { + switch (outcome) { + case 'copied': + return ( + + ); + case 'error': + return ( + + ); + } + } + + mapFailedCopy(failure: SavedObjectsImportError): CopyItem { + const { type, id, meta } = failure; + const title = meta.title || getDefaultTitle({ type, id }); + const icon = meta.icon || DEFAULT_ICON; + const errorMessage = getErrorMessage(failure); + return { type, id, title, icon, outcome: 'error', errorMessage }; + } + + mapCopySuccess(obj: SavedObjectsImportSuccess): CopyItem { + const { type, id, meta } = obj; + const title = meta.title || getDefaultTitle(obj); + const icon = meta.icon || DEFAULT_ICON; + return { type, id, title, icon, outcome: 'copied' }; + } + + copyResult({ failedCopies, successfulCopies }: CopyResultProps) { + const copyItems: CopyItem[] = _.sortBy( + [ + ...failedCopies.map((object) => this.mapFailedCopy(object)), + ...successfulCopies.map((object) => this.mapCopySuccess(object)), + ], + ['type', 'title'] + ); + + return ( + + +

    + +

    +
    + + {this.getCountIndicators(copyItems)} + + {copyItems.map((item, index) => { + const { type, title, icon } = item; + return ( + + + + + + + + +

    + {title} +

    +
    +
    + +
    {this.getStatusIndicator(item)}
    +
    +
    + ); + })} +
    + ); + } + + render() { + const { onClose, failedCopies, successfulCopies, workspaceName } = this.props; + return ( + + + + + + + {this.copyResult({ failedCopies, successfulCopies })} + + ); + } +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts index 16e8911da0ca..766def0786e0 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts @@ -33,3 +33,4 @@ export { Table } from './table'; export { Flyout } from './flyout'; export { Relationships } from './relationships'; export { SavedObjectsDuplicateModal } from './duplicate_modal'; +export { DuplicateResultFlyout } from './duplicate_result_flyout'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 076cf47a43eb..c04725bf72f8 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -936,9 +936,8 @@ describe('SavedObjectsTable', () => { ); component.update(); - expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ - title: 'Successfully copied 2 saved objects to bar.', - }); + expect(component.state('isShowingDuplicateResultFlyout')).toEqual(true); + expect(component.find('DuplicateResultFlyout').length).toEqual(1); }); it('should duplicate single object', async () => { @@ -964,12 +963,11 @@ describe('SavedObjectsTable', () => { ); component.update(); - expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ - title: 'Successfully copied object-1 and the related objects to bar.', - }); + expect(component.state('isShowingDuplicateResultFlyout')).toEqual(true); + expect(component.find('DuplicateResultFlyout').length).toEqual(1); }); - it('should show error when duplicating selected object is fail', async () => { + it('should show result flyout when duplicating success and failure coexist', async () => { const component = shallowRender({ applications, workspaces }); component.setState({ isShowingDuplicateModal: true }); @@ -978,6 +976,13 @@ describe('SavedObjectsTable', () => { // Ensure the state changes are reflected component.update(); + getDuplicateSavedObjectsMock.mockImplementationOnce(() => ({ + success: false, + successCount: 1, + successResults: [{ id: '1' }], + errors: [{ id: '2' }], + })); + await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2', 'bar'); expect(getDuplicateSavedObjectsMock).toHaveBeenCalledWith( @@ -991,16 +996,8 @@ describe('SavedObjectsTable', () => { ); component.update(); - getDuplicateSavedObjectsMock.mockImplementationOnce(() => ({ - success: false, - errors: [{ id: '1' }, { id: '2' }], - })); - - await component.instance().onDuplicate(mockSelectedSavedObjects, false, 'workspace2', 'bar'); - expect(notifications.toasts.addDanger).toHaveBeenCalled(); - expect((notifications.toasts.addDanger as jest.Mock).mock.calls[0][0].title).toMatch( - 'Successfully copied 0 saved object to bar.' - ); + expect(component.state('isShowingDuplicateResultFlyout')).toEqual(true); + expect(component.find('DuplicateResultFlyout').length).toEqual(1); }); it('should catch error when duplicating selected object is fail', async () => { 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 7bb048ef9ffc..1998636bc54a 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 @@ -69,6 +69,8 @@ import { ApplicationStart, WorkspacesStart, WorkspaceAttribute, + SavedObjectsImportSuccess, + SavedObjectsImportError, } from 'src/core/public'; import { Subscription } from 'rxjs'; import { PUBLIC_WORKSPACE_ID, PUBLIC_WORKSPACE_NAME } from '../../../../../core/public'; @@ -96,7 +98,14 @@ import { SavedObjectsManagementColumnServiceStart, SavedObjectsManagementNamespaceServiceStart, } from '../../services'; -import { Header, Table, Flyout, Relationships, SavedObjectsDuplicateModal } from './components'; +import { + Header, + Table, + Flyout, + Relationships, + SavedObjectsDuplicateModal, + DuplicateResultFlyout, +} from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; import { DuplicateObject } from '../types'; import { formatWorkspaceIdParams } from '../../utils'; @@ -151,6 +160,10 @@ export interface SavedObjectsTableState { currentWorkspaceId?: string; workspaceEnabled: boolean; availableWorkspaces?: WorkspaceAttribute[]; + isShowingDuplicateResultFlyout: boolean; + failedCopies: SavedObjectsImportError[]; + successfulCopies: SavedObjectsImportSuccess[]; + targetWorkspaceName: string; } export class SavedObjectsTable extends Component { private _isMounted = false; @@ -189,6 +202,10 @@ export class SavedObjectsTable extends Component item.id) : []; - const errorTitleMessages = savedObjects - .filter((obj) => errorIds.includes(obj.id)) - .map((obj) =>
  • {obj.meta.title}
  • ); - const errorText = i18n.translate( - 'savedObjectsManagement.objectsTable.duplicate.dangerNotificationText', - { - defaultMessage: - 'Unable to copy {errorCount, plural, one {# saved object} other {# saved objects}}:', - values: { - errorCount: result.errors.length, - }, - } - ); - notifications.toasts.addDanger({ - title: i18n.translate( - 'savedObjectsManagement.objectsTable.duplicate.dangerNotification', - { - defaultMessage: - 'Successfully copied {successCount, plural,=0 {# saved object} one {# saved object} other {# saved objects}} {includeReferencesDeep, select, true {and the related objects } false { }}to {targetWorkspaceName}.', - values: { - successCount, - includeReferencesDeep, - targetWorkspaceName, - }, - } - ), - text: toMountPoint( -
    - <> {errorText} - <> {errorTitleMessages} -
    - ), - }); - } + + this.setState({ + isShowingDuplicateResultFlyout: true, + failedCopies: result.success ? [] : result.errors, + successfulCopies: result.successCount > 0 ? result.successResults : [], + targetWorkspaceName, + }); } catch (e) { notifications.toasts.addDanger({ title: i18n.translate('savedObjectsManagement.objectsTable.duplicate.dangerNotification', { @@ -815,6 +784,32 @@ export class SavedObjectsTable extends Component { + this.setState({ isShowingDuplicateResultFlyout: false }); + }; + + renderDuplicateResultFlyout() { + const { + isShowingDuplicateResultFlyout, + targetWorkspaceName, + failedCopies, + successfulCopies, + } = this.state; + + if (!isShowingDuplicateResultFlyout) { + return null; + } + + return ( + + ); + } + renderRelationships() { if (!this.state.isShowingRelationships) { return null; @@ -1148,6 +1143,7 @@ export class SavedObjectsTable extends Component this.setState({ isShowingExportAllOptionsModal: true })} onImport={this.showImportFlyout} diff --git a/src/plugins/workspace/server/routes/duplicate.ts b/src/plugins/workspace/server/routes/duplicate.ts index 001f924c31bb..dbd9a8d5c9c6 100644 --- a/src/plugins/workspace/server/routes/duplicate.ts +++ b/src/plugins/workspace/server/routes/duplicate.ts @@ -90,6 +90,7 @@ export const registerDuplicateRoute = ( overwrite: false, createNewCopies: true, workspaces: [targetWorkspace], + dataSourceEnabled: true, }); return res.ok({ body: result }); From 83c93e4e52c865469a56ba3d33ba1648b5c96c25 Mon Sep 17 00:00:00 2001 From: yubonluo Date: Fri, 19 Jul 2024 18:37:55 +0800 Subject: [PATCH 17/18] optimize the code Signed-off-by: yubonluo --- .../components/__snapshots__/header.test.tsx.snap | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap index 3774b4fe677f..cf2bac1ec93e 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap @@ -112,7 +112,9 @@ exports[`Header should render normally when showDuplicateAll is undefined 1`] = - +

    -
    +
    Date: Tue, 23 Jul 2024 18:34:15 +0800 Subject: [PATCH 18/18] optimize the code Signed-off-by: yubonluo --- .../duplicate_result_flyout.test.tsx.snap | 14 +++++++++++ .../components/duplicate_modal.test.tsx | 2 +- .../components/duplicate_modal.tsx | 23 ++++++++++--------- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_result_flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_result_flyout.test.tsx.snap index 598a382c8002..b82303876131 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_result_flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/duplicate_result_flyout.test.tsx.snap @@ -19,6 +19,13 @@ HTMLCollection [ + +