From 3206f82ee0b288e1a64bb874ffc8c406f7dc185f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 5 Oct 2024 03:14:42 +0000 Subject: [PATCH] [Workspace]Add WorkspaceCollaboratorTypesService and AddCollaboratorsModal (#8486) (cherry picked from commit d3ddf9176ac9b83dd23ea2e062bd6ea5147df08c) Signed-off-by: github-actions[bot] --- changelogs/fragments/8486.yml | 2 + .../add_collaborators_modal.test.tsx | 77 +++++++++++ .../add_collaborators_modal.tsx | 128 +++++++++++++++++ .../add_collaborators_modal/index.ts | 6 + .../workspace_collaborator_input.test.tsx | 44 ++++++ .../workspace_collaborator_input.tsx | 108 +++++++++++++++ .../workspace_collaborators_panel.test.tsx | 94 +++++++++++++ .../workspace_collaborators_panel.tsx | 119 ++++++++++++++++ src/plugins/workspace/public/constants.ts | 20 +++ src/plugins/workspace/public/index.ts | 3 + src/plugins/workspace/public/plugin.test.ts | 46 ++++++- src/plugins/workspace/public/plugin.ts | 30 +++- ...gister_default_collaborator_types.test.tsx | 130 ++++++++++++++++++ .../register_default_collaborator_types.tsx | 113 +++++++++++++++ .../workspace/public/services/index.ts | 10 ++ ...rkspace_collaborator_types_service.test.ts | 123 +++++++++++++++++ .../workspace_collaborator_types_service.ts | 35 +++++ src/plugins/workspace/public/types.ts | 19 +++ 18 files changed, 1102 insertions(+), 5 deletions(-) create mode 100644 changelogs/fragments/8486.yml create mode 100644 src/plugins/workspace/public/components/add_collaborators_modal/add_collaborators_modal.test.tsx create mode 100644 src/plugins/workspace/public/components/add_collaborators_modal/add_collaborators_modal.tsx create mode 100644 src/plugins/workspace/public/components/add_collaborators_modal/index.ts create mode 100644 src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborator_input.test.tsx create mode 100644 src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborator_input.tsx create mode 100644 src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborators_panel.test.tsx create mode 100644 src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborators_panel.tsx create mode 100644 src/plugins/workspace/public/constants.ts create mode 100644 src/plugins/workspace/public/register_default_collaborator_types.test.tsx create mode 100644 src/plugins/workspace/public/register_default_collaborator_types.tsx create mode 100644 src/plugins/workspace/public/services/index.ts create mode 100644 src/plugins/workspace/public/services/workspace_collaborator_types_service.test.ts create mode 100644 src/plugins/workspace/public/services/workspace_collaborator_types_service.ts diff --git a/changelogs/fragments/8486.yml b/changelogs/fragments/8486.yml new file mode 100644 index 000000000000..be90a964870b --- /dev/null +++ b/changelogs/fragments/8486.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace]Add WorkspaceCollaboratorTypesService and AddCollaboratorsModal ([#8486](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8486)) \ No newline at end of file diff --git a/src/plugins/workspace/public/components/add_collaborators_modal/add_collaborators_modal.test.tsx b/src/plugins/workspace/public/components/add_collaborators_modal/add_collaborators_modal.test.tsx new file mode 100644 index 000000000000..17d0818f37b1 --- /dev/null +++ b/src/plugins/workspace/public/components/add_collaborators_modal/add_collaborators_modal.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { AddCollaboratorsModal } from './add_collaborators_modal'; + +describe('AddCollaboratorsModal', () => { + const defaultProps = { + title: 'Add Collaborators', + inputLabel: 'Collaborator ID', + addAnotherButtonLabel: 'Add Another', + permissionType: 'readOnly', + onClose: jest.fn(), + onAddCollaborators: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the modal with the correct title', () => { + render(); + expect(screen.getByText(defaultProps.title)).toBeInTheDocument(); + }); + + it('renders the collaborator input field with the correct label', () => { + render(); + expect(screen.getByLabelText(defaultProps.inputLabel)).toBeInTheDocument(); + }); + + it('renders the "Add Another" button with the correct label', () => { + render(); + expect( + screen.getByRole('button', { name: defaultProps.addAnotherButtonLabel }) + ).toBeInTheDocument(); + }); + + it('calls onAddCollaborators with valid collaborators when clicking the "Add collaborators" button', async () => { + render(); + const collaboratorInput = screen.getByLabelText(defaultProps.inputLabel); + fireEvent.change(collaboratorInput, { target: { value: 'user1' } }); + const addCollaboratorsButton = screen.getByRole('button', { name: 'Add collaborators' }); + fireEvent.click(addCollaboratorsButton); + await waitFor(() => { + expect(defaultProps.onAddCollaborators).toHaveBeenCalledWith([ + { collaboratorId: 'user1', accessLevel: 'readOnly', permissionType: 'readOnly' }, + ]); + }); + }); + + it('calls onClose when clicking the "Cancel" button', () => { + render(); + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + fireEvent.click(cancelButton); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('renders the description if provided', () => { + const props = { ...defaultProps, description: 'Add collaborators to your workspace' }; + render(); + expect(screen.getByText(props.description)).toBeInTheDocument(); + }); + + it('renders the instruction if provided', () => { + const instruction = { + title: 'Instructions', + detail: 'Follow these instructions to add collaborators', + }; + const props = { ...defaultProps, instruction }; + render(); + expect(screen.getByText(instruction.title)).toBeInTheDocument(); + expect(screen.getByText(instruction.detail)).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/workspace/public/components/add_collaborators_modal/add_collaborators_modal.tsx b/src/plugins/workspace/public/components/add_collaborators_modal/add_collaborators_modal.tsx new file mode 100644 index 000000000000..6c69d17886a4 --- /dev/null +++ b/src/plugins/workspace/public/components/add_collaborators_modal/add_collaborators_modal.tsx @@ -0,0 +1,128 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiAccordion, + EuiHorizontalRule, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSmallButton, + EuiSmallButtonEmpty, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { i18n } from '@osd/i18n'; + +import { WorkspaceCollaboratorPermissionType, WorkspaceCollaborator } from '../../types'; +import { + WorkspaceCollaboratorsPanel, + WorkspaceCollaboratorInner, +} from './workspace_collaborators_panel'; + +export interface AddCollaboratorsModalProps { + title: string; + description?: string; + inputLabel: string; + addAnotherButtonLabel: string; + inputDescription?: string; + inputPlaceholder?: string; + instruction?: { + title: string; + detail: string; + link?: string; + }; + permissionType: WorkspaceCollaboratorPermissionType; + onClose: () => void; + onAddCollaborators: (collaborators: WorkspaceCollaborator[]) => Promise; +} + +export const AddCollaboratorsModal = ({ + title, + inputLabel, + instruction, + description, + permissionType, + inputDescription, + inputPlaceholder, + addAnotherButtonLabel, + onClose, + onAddCollaborators, +}: AddCollaboratorsModalProps) => { + const [collaborators, setCollaborators] = useState([ + { id: 0, accessLevel: 'readOnly', collaboratorId: '' }, + ]); + const validCollaborators = collaborators.flatMap(({ collaboratorId, accessLevel }) => { + if (!collaboratorId) { + return []; + } + return { collaboratorId, accessLevel, permissionType }; + }); + + const handleAddCollaborators = () => { + onAddCollaborators(validCollaborators); + }; + + return ( + + + +

{title}

+
+
+ + {description && ( + <> + {description} + + + )} + {instruction && ( + <> + {instruction.title}} + > + + + {instruction.detail} + + + + + )} + + + + + + {i18n.translate('workspace.addCollaboratorsModal.cancelButton', { + defaultMessage: 'Cancel', + })} + + + {i18n.translate('workspace.addCollaboratorsModal.addCollaboratorsButton', { + defaultMessage: 'Add collaborators', + })} + + +
+ ); +}; diff --git a/src/plugins/workspace/public/components/add_collaborators_modal/index.ts b/src/plugins/workspace/public/components/add_collaborators_modal/index.ts new file mode 100644 index 000000000000..e0d031c693ef --- /dev/null +++ b/src/plugins/workspace/public/components/add_collaborators_modal/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { AddCollaboratorsModal, AddCollaboratorsModalProps } from './add_collaborators_modal'; diff --git a/src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborator_input.test.tsx b/src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborator_input.test.tsx new file mode 100644 index 000000000000..e20d28b8d5d1 --- /dev/null +++ b/src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborator_input.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { WorkspaceCollaboratorInput } from './workspace_collaborator_input'; + +describe('WorkspaceCollaboratorInput', () => { + const defaultProps = { + index: 0, + collaboratorId: '', + accessLevel: 'readOnly' as const, + onCollaboratorIdChange: jest.fn(), + onAccessLevelChange: jest.fn(), + onDelete: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('calls onCollaboratorIdChange when input value changes', () => { + render(); + const input = screen.getByTestId('workspaceCollaboratorIdInput-0'); + fireEvent.change(input, { target: { value: 'test' } }); + expect(defaultProps.onCollaboratorIdChange).toHaveBeenCalledWith('test', 0); + }); + + it('calls onAccessLevelChange when access level changes', () => { + render(); + const readButton = screen.getByText('Admin'); + fireEvent.click(readButton); + expect(defaultProps.onAccessLevelChange).toHaveBeenCalledWith('admin', 0); + }); + + it('calls onDelete when delete button is clicked', () => { + render(); + const deleteButton = screen.getByRole('button', { name: 'Delete collaborator 0' }); + fireEvent.click(deleteButton); + expect(defaultProps.onDelete).toHaveBeenCalledWith(0); + }); +}); diff --git a/src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborator_input.tsx b/src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborator_input.tsx new file mode 100644 index 000000000000..93a53c3fbcc8 --- /dev/null +++ b/src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborator_input.tsx @@ -0,0 +1,108 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiFieldText, + EuiButtonGroup, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { WorkspaceCollaboratorAccessLevel } from '../../types'; +import { WORKSPACE_ACCESS_LEVEL_NAMES } from '../../constants'; + +export const COLLABORATOR_ID_INPUT_LABEL_ID = 'collaborator_id_input_label'; + +export interface WorkspaceCollaboratorInputProps { + index: number; + collaboratorId?: string; + accessLevel: WorkspaceCollaboratorAccessLevel; + collaboratorIdInputPlaceholder?: string; + onCollaboratorIdChange: (id: string, index: number) => void; + onAccessLevelChange: (accessLevel: WorkspaceCollaboratorAccessLevel, index: number) => void; + onDelete: (index: number) => void; +} + +const accessLevelKeys = Object.keys( + WORKSPACE_ACCESS_LEVEL_NAMES +) as WorkspaceCollaboratorAccessLevel[]; + +const accessLevelButtonGroupOptions = accessLevelKeys.map((id) => ({ + id, + label: {WORKSPACE_ACCESS_LEVEL_NAMES[id]}, +})); + +const isAccessLevelKey = (test: string): test is WorkspaceCollaboratorAccessLevel => + (accessLevelKeys as string[]).includes(test); + +export const WorkspaceCollaboratorInput = ({ + index, + accessLevel, + collaboratorId, + onDelete, + onAccessLevelChange, + onCollaboratorIdChange, + collaboratorIdInputPlaceholder, +}: WorkspaceCollaboratorInputProps) => { + const handleCollaboratorIdChange = useCallback( + (e) => { + onCollaboratorIdChange(e.target.value, index); + }, + [index, onCollaboratorIdChange] + ); + + const handlePermissionModeOptionChange = useCallback( + (newAccessLevel: string) => { + if (isAccessLevelKey(newAccessLevel)) { + onAccessLevelChange(newAccessLevel, index); + } + }, + [index, onAccessLevelChange] + ); + + const handleDelete = useCallback(() => { + onDelete(index); + }, [index, onDelete]); + + return ( + + + + + + + + + + + + ); +}; diff --git a/src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborators_panel.test.tsx b/src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborators_panel.test.tsx new file mode 100644 index 000000000000..f5e695cc59a7 --- /dev/null +++ b/src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborators_panel.test.tsx @@ -0,0 +1,94 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { WorkspaceCollaboratorsPanel } from './workspace_collaborators_panel'; + +describe('WorkspaceCollaboratorsPanel', () => { + const defaultProps = { + label: 'Collaborators', + collaborators: [ + { id: 1, collaboratorId: 'user1', accessLevel: 'readOnly' as const }, + { id: 2, collaboratorId: 'user2', accessLevel: 'readAndWrite' as const }, + ], + onChange: jest.fn(), + addAnotherButtonLabel: 'Add Another', + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the component', () => { + render(); + expect(screen.getByText(defaultProps.label)).toBeInTheDocument(); + }); + + it('renders collaborator ID inputs', () => { + render(); + expect(screen.getAllByTestId(/workspaceCollaboratorIdInput-\d/)).toHaveLength(2); + }); + + it('calls onChange when collaborator ID changes', () => { + render(); + const input = screen.getByTestId('workspaceCollaboratorIdInput-0'); + fireEvent.change(input, { target: { value: 'newUser' } }); + expect(defaultProps.onChange).toHaveBeenCalledWith([ + { id: 1, collaboratorId: 'newUser', accessLevel: 'readOnly' }, + { id: 2, collaboratorId: 'user2', accessLevel: 'readAndWrite' }, + ]); + }); + + it('calls onChange when access level changes to admin', () => { + render(); + const adminButton = screen.getAllByText('Admin')[0]; + fireEvent.click(adminButton); + expect(defaultProps.onChange).toHaveBeenCalledWith([ + { id: 1, collaboratorId: 'user1', accessLevel: 'admin' }, + { id: 2, collaboratorId: 'user2', accessLevel: 'readAndWrite' }, + ]); + }); + + it('calls onChange when access level changes to readOnly', () => { + render(); + const readOnlyButton = screen.getAllByText('Read only')[1]; + fireEvent.click(readOnlyButton); + expect(defaultProps.onChange).toHaveBeenCalledWith([ + { id: 1, collaboratorId: 'user1', accessLevel: 'readOnly' }, + { id: 2, collaboratorId: 'user2', accessLevel: 'readOnly' }, + ]); + }); + + it('calls onChange when access level changes to readAndWrite', () => { + render(); + const readAndWriteButton = screen.getAllByText('Read and write')[0]; + fireEvent.click(readAndWriteButton); + expect(defaultProps.onChange).toHaveBeenCalledWith([ + { id: 1, collaboratorId: 'user1', accessLevel: 'readAndWrite' }, + { id: 2, collaboratorId: 'user2', accessLevel: 'readAndWrite' }, + ]); + }); + + it('calls onChange when collaborator is deleted', () => { + render(); + const deleteButton = screen.getAllByRole('button', { name: /Delete collaborator/ })[0]; + fireEvent.click(deleteButton); + expect(defaultProps.onChange).toHaveBeenCalledWith([ + { id: 2, collaboratorId: 'user2', accessLevel: 'readAndWrite' }, + ]); + }); + + it('calls onChange when adding a new collaborator', () => { + render(); + const addButton = screen.getByRole('button', { name: defaultProps.addAnotherButtonLabel }); + fireEvent.click(addButton); + expect(defaultProps.onChange).toHaveBeenCalledWith([ + { id: 1, collaboratorId: 'user1', accessLevel: 'readOnly' }, + { id: 2, collaboratorId: 'user2', accessLevel: 'readAndWrite' }, + { id: 3, collaboratorId: '', accessLevel: 'readOnly' }, + ]); + }); +}); diff --git a/src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborators_panel.tsx b/src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborators_panel.tsx new file mode 100644 index 000000000000..4c71d1c6be0e --- /dev/null +++ b/src/plugins/workspace/public/components/add_collaborators_modal/workspace_collaborators_panel.tsx @@ -0,0 +1,119 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiSmallButton, + EuiCompressedFormRow, + EuiFormLabel, + EuiText, + EuiSpacer, +} from '@elastic/eui'; +import { WorkspaceCollaborator, WorkspaceCollaboratorAccessLevel } from '../../types'; +import { + WorkspaceCollaboratorInput, + COLLABORATOR_ID_INPUT_LABEL_ID, +} from './workspace_collaborator_input'; + +export interface WorkspaceCollaboratorInner + extends Pick { + id: number; +} + +export interface WorkspaceCollaboratorsPanelProps { + label: string; + description?: string; + collaborators: WorkspaceCollaboratorInner[]; + onChange: (value: WorkspaceCollaboratorInner[]) => void; + collaboratorIdInputPlaceholder?: string; + addAnotherButtonLabel: string; +} + +export const WorkspaceCollaboratorsPanel = ({ + label, + description, + collaborators, + addAnotherButtonLabel, + collaboratorIdInputPlaceholder, + onChange, +}: WorkspaceCollaboratorsPanelProps) => { + const handleAddNewOne = () => { + const nextId = Math.max(...[0, ...collaborators.map(({ id }) => id)]) + 1; + onChange([ + ...collaborators, + { + id: nextId, + accessLevel: 'readOnly', + collaboratorId: '', + }, + ]); + }; + + const handleCollaboratorIdChange = (collaboratorId: string, passedIndex: number) => { + onChange([ + ...collaborators.map((collaborator, index) => + index === passedIndex ? { ...collaborator, collaboratorId } : collaborator + ), + ]); + }; + + const handleAccessLevelChange = ( + accessLevel: WorkspaceCollaboratorAccessLevel, + passedIndex: number + ) => { + onChange([ + ...collaborators.map((collaborator, index) => + index === passedIndex ? { ...collaborator, accessLevel } : collaborator + ), + ]); + }; + + const handleDelete = (index: number) => { + onChange([...collaborators.slice(0, index), ...collaborators.slice(index + 1)]); + }; + + return ( + <> + {collaborators.length > 0 && ( + <> + {label} + + {description && ( + <> + + {description} + + + + )} + + )} + {collaborators.map((item, index) => ( + + + + ))} + + + {addAnotherButtonLabel} + + + + ); +}; diff --git a/src/plugins/workspace/public/constants.ts b/src/plugins/workspace/public/constants.ts new file mode 100644 index 000000000000..e81ac8ddf20c --- /dev/null +++ b/src/plugins/workspace/public/constants.ts @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; + +import { WorkspaceCollaboratorAccessLevel } from './types'; + +export const WORKSPACE_ACCESS_LEVEL_NAMES: { [key in WorkspaceCollaboratorAccessLevel]: string } = { + readOnly: i18n.translate('workspace.accessLevel.readOnlyName', { + defaultMessage: 'Read only', + }), + readAndWrite: i18n.translate('workspace.accessLevel.readAndWriteName', { + defaultMessage: 'Read and write', + }), + admin: i18n.translate('workspace.accessLevel.AdminName', { + defaultMessage: 'Admin', + }), +}; diff --git a/src/plugins/workspace/public/index.ts b/src/plugins/workspace/public/index.ts index 99161a7edbd7..b3e313e03b5a 100644 --- a/src/plugins/workspace/public/index.ts +++ b/src/plugins/workspace/public/index.ts @@ -8,3 +8,6 @@ import { WorkspacePlugin } from './plugin'; export function plugin() { return new WorkspacePlugin(); } + +export { WorkspacePluginSetup, WorkspaceCollaborator } from './types'; +export { WorkspaceCollaboratorType } from './services'; diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index 2fba6a457322..beea5e4fb341 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -17,11 +17,13 @@ import { import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_DETAIL_APP_ID } from '../common/constants'; import { savedObjectsManagementPluginMock } from '../../saved_objects_management/public/mocks'; import { managementPluginMock } from '../../management/public/mocks'; -import { UseCaseService } from './services/use_case_service'; +import { UseCaseService, WorkspaceCollaboratorTypesService } from './services'; import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; import { WorkspacePlugin } from './plugin'; import { contentManagementPluginMocks } from '../../content_management/public'; import { navigationPluginMock } from '../../navigation/public/mocks'; +import * as registerDefaultCollaboratorTypesExports from './register_default_collaborator_types'; +import { AddCollaboratorsModal } from './components/add_collaborators_modal'; // Expect 6 app registrations: create, fatal error, detail, initial, navigation, and list apps. const registrationAppNumber = 6; @@ -336,6 +338,34 @@ describe('Workspace plugin', () => { ); }); + it('#setup should call registerDefaultCollaboratorTypes', async () => { + const registerDefaultCollaboratorTypesMock = jest.fn(); + jest + .spyOn(registerDefaultCollaboratorTypesExports, 'registerDefaultCollaboratorTypes') + .mockImplementationOnce(registerDefaultCollaboratorTypesMock); + const setupMock = coreMock.createSetup(); + const workspacePlugin = new WorkspacePlugin(); + expect(registerDefaultCollaboratorTypesMock).not.toHaveBeenCalled(); + await workspacePlugin.setup(setupMock, {}); + expect(registerDefaultCollaboratorTypesMock).toHaveBeenCalled(); + }); + + it('#setup should return WorkspaceCollaboratorTypesService', async () => { + const setupMock = coreMock.createSetup(); + const workspacePlugin = new WorkspacePlugin(); + const result = await workspacePlugin.setup(setupMock, {}); + + expect(result.collaboratorTypes).toBeInstanceOf(WorkspaceCollaboratorTypesService); + }); + + it('#setup should return getAddCollaboratorsModal method', async () => { + const setupMock = coreMock.createSetup(); + const workspacePlugin = new WorkspacePlugin(); + const result = await workspacePlugin.setup(setupMock, {}); + + expect(result.ui.AddCollaboratorsModal).toBe(AddCollaboratorsModal); + }); + it('#start add workspace detail page to breadcrumbs when start', async () => { const startMock = coreMock.createStart(); const workspaceObject = { @@ -580,4 +610,18 @@ describe('Workspace plugin', () => { registeredUseCases$.next([]); expect(appUpdaterChangeMock).toHaveBeenCalledTimes(2); }); + + it('#stop should call collaboratorTypesService.stop', async () => { + const workspacePlugin = new WorkspacePlugin(); + const setupMock = getSetupMock(); + const stopMock = jest.fn(); + jest + .spyOn(WorkspaceCollaboratorTypesService.prototype, 'stop') + .mockImplementationOnce(stopMock); + await workspacePlugin.setup(setupMock, {}); + + expect(stopMock).not.toHaveBeenCalled(); + workspacePlugin.stop(); + expect(stopMock).toHaveBeenCalled(); + }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index aea0136023a0..406a01643d03 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -32,7 +32,7 @@ import { WORKSPACE_NAVIGATION_APP_ID, } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; -import { Services, WorkspaceUseCase } from './types'; +import { Services, WorkspaceUseCase, WorkspacePluginSetup } from './types'; import { WorkspaceClient } from './workspace_client'; import { SavedObjectsManagementPluginSetup } from '../../../plugins/saved_objects_management/public'; import { ManagementSetup } from '../../../plugins/management/public'; @@ -55,7 +55,6 @@ import { } from './utils'; import { recentWorkspaceManager } from './recent_workspace_manager'; import { toMountPoint } from '../../opensearch_dashboards_react/public'; -import { UseCaseService } from './services/use_case_service'; import { WorkspaceListCard } from './components/service_card'; import { NavigationPublicPluginStart } from '../../../plugins/navigation/public'; import { WorkspaceSelector } from './components/workspace_selector/workspace_selector'; @@ -70,6 +69,13 @@ import { } from './components/use_case_overview/setup_overview'; import { UserDefaultWorkspace } from './components/workspace_list/default_workspace'; import { registerGetStartedCardToNewHome } from './components/home_get_start_card'; +import { + WorkspaceCollaboratorTypesService, + UseCaseService, + WorkspaceCollaboratorType, +} from './services'; +import { AddCollaboratorsModal } from './components/add_collaborators_modal'; +import { registerDefaultCollaboratorTypes } from './register_default_collaborator_types'; type WorkspaceAppType = ( params: AppMountParameters, @@ -90,7 +96,7 @@ export interface WorkspacePluginStartDeps { } export class WorkspacePlugin - implements Plugin<{}, {}, WorkspacePluginSetupDeps, WorkspacePluginStartDeps> { + implements Plugin { private coreStart?: CoreStart; private currentWorkspaceSubscription?: Subscription; private breadcrumbsSubscription?: Subscription; @@ -104,6 +110,7 @@ export class WorkspacePlugin private workspaceAndUseCasesCombineSubscription?: Subscription; private useCase = new UseCaseService(); private workspaceClient?: WorkspaceClient; + private collaboratorTypes = new WorkspaceCollaboratorTypesService(); private _changeSavedObjectCurrentWorkspace() { if (this.coreStart) { @@ -330,6 +337,7 @@ export class WorkspacePlugin ...coreStart, workspaceClient, dataSourceManagement, + collaboratorTypes: this.collaboratorTypes, navigationUI: navigation.ui, }; @@ -458,6 +466,7 @@ export class WorkspacePlugin workspaceClient, dataSourceManagement, contentManagement: contentManagementStart, + collaboratorTypes: this.collaboratorTypes, }; return renderUseCaseOverviewApp(params, services, ESSENTIAL_OVERVIEW_PAGE_ID); @@ -493,6 +502,7 @@ export class WorkspacePlugin workspaceClient, dataSourceManagement, contentManagement: contentManagementStart, + collaboratorTypes: this.collaboratorTypes, }; return renderUseCaseOverviewApp(params, services, ANALYTICS_ALL_OVERVIEW_PAGE_ID); @@ -547,7 +557,17 @@ export class WorkspacePlugin }); } - return {}; + registerDefaultCollaboratorTypes({ + getStartServices: core.getStartServices, + collaboratorTypesService: this.collaboratorTypes, + }); + + return { + collaboratorTypes: this.collaboratorTypes, + ui: { + AddCollaboratorsModal, + }, + }; } public start(core: CoreStart, { contentManagement, navigation }: WorkspacePluginStartDeps) { @@ -634,6 +654,7 @@ export class WorkspacePlugin ...coreStart, workspaceClient: this.workspaceClient!, navigationUI: navigation.ui, + collaboratorTypes: this.collaboratorTypes, }; contentManagement.registerContentProvider({ id: 'default_workspace_list', @@ -661,5 +682,6 @@ export class WorkspacePlugin this.registeredUseCasesUpdaterSubscription?.unsubscribe(); this.workspaceAndUseCasesCombineSubscription?.unsubscribe(); this.useCase.stop(); + this.collaboratorTypes.stop(); } } diff --git a/src/plugins/workspace/public/register_default_collaborator_types.test.tsx b/src/plugins/workspace/public/register_default_collaborator_types.test.tsx new file mode 100644 index 000000000000..3a711dc87a7e --- /dev/null +++ b/src/plugins/workspace/public/register_default_collaborator_types.test.tsx @@ -0,0 +1,130 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { coreMock } from '../../../core/public/mocks'; +import * as opensearchDashboardsReactExports from '../../../plugins/opensearch_dashboards_react/public'; +import { + generateOnAddCallback, + registerDefaultCollaboratorTypes, +} from './register_default_collaborator_types'; +import { WorkspaceCollaboratorTypesService } from './services'; +import { fireEvent, render } from '@testing-library/react'; + +jest.mock('../../../plugins/opensearch_dashboards_react/public', () => ({ + toMountPoint: jest.fn(), +})); + +jest.mock('./components/add_collaborators_modal', () => ({ + AddCollaboratorsModal: ({ onClose, onAddCollaborators }) => ( +
+ + +
+ ), +})); + +const toMountPointMock = jest.fn(); +jest.spyOn(opensearchDashboardsReactExports, 'toMountPoint').mockImplementation(toMountPointMock); + +describe('generateOnAddCallback', () => { + const getStartServices = coreMock.createSetup().getStartServices; + + const props = { + getStartServices, + title: 'Test Title', + inputLabel: 'Test Input Label', + addAnotherButtonLabel: 'Test Add Another Button Label', + permissionType: 'test', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should open the AddCollaboratorsModal when onAdd is called', async () => { + const onAddCollaborators = jest.fn(); + const onAdd = generateOnAddCallback(props); + + await onAdd({ onAddCollaborators }); + + expect(toMountPointMock).toHaveBeenCalled(); + expect(getStartServices).toHaveBeenCalled(); + }); + + it('should close the modal when onClose is called', async () => { + const onAddCollaborators = jest.fn(); + const onAdd = generateOnAddCallback(props); + + await onAdd({ onAddCollaborators }); + + const modalElement = toMountPointMock.mock.calls[0][0]; + + const { getByText } = render(modalElement); + + fireEvent.click(getByText('Close')); + expect( + (await getStartServices.mock.results[0].value)[0].overlays.openModal.mock.results[0].value + .close + ).toHaveBeenCalled(); + }); + + it('should call onAddCollaborators and close the modal when collaborators are added', async () => { + const onAddCollaborators = jest.fn(); + const onAdd = generateOnAddCallback(props); + + await onAdd({ onAddCollaborators }); + + const element = toMountPointMock.mock.calls[0][0]; + + const { getByText } = render(element); + + fireEvent.click(getByText('Add collaborators')); + expect(onAddCollaborators).toHaveBeenCalledWith([]); + expect( + (await getStartServices.mock.results[0].value)[0].overlays.openModal.mock.results[0].value + .close + ).toHaveBeenCalled(); + }); +}); + +describe('registerDefaultCollaboratorTypes', () => { + const collaboratorTypesService = new WorkspaceCollaboratorTypesService(); + const getStartServices = coreMock.createSetup().getStartServices; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should register the default collaborator types', () => { + registerDefaultCollaboratorTypes({ collaboratorTypesService, getStartServices }); + + const registeredTypes = collaboratorTypesService.getTypes$().getValue(); + expect(registeredTypes).toHaveLength(2); + + const userType = registeredTypes.find((type) => type.id === 'user'); + const groupType = registeredTypes.find((type) => type.id === 'group'); + + expect(userType).toBeDefined(); + expect(groupType).toBeDefined(); + + expect(userType?.name).toBe('User'); + expect(userType?.buttonLabel).toBe('Add Users'); + + expect(groupType?.name).toBe('Group'); + expect(groupType?.buttonLabel).toBe('Add Groups'); + }); +}); diff --git a/src/plugins/workspace/public/register_default_collaborator_types.tsx b/src/plugins/workspace/public/register_default_collaborator_types.tsx new file mode 100644 index 000000000000..09fe96ae4db4 --- /dev/null +++ b/src/plugins/workspace/public/register_default_collaborator_types.tsx @@ -0,0 +1,113 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { CoreSetup, OverlayRef } from '../../../core/public'; +import { toMountPoint } from '../../../plugins/opensearch_dashboards_react/public'; + +import { WorkspaceCollaboratorTypesService, WorkspaceCollaboratorType } from './services'; +import { + AddCollaboratorsModal, + AddCollaboratorsModalProps, +} from './components/add_collaborators_modal'; + +export const generateOnAddCallback: ( + options: Omit & { + getStartServices: CoreSetup['getStartServices']; + } +) => WorkspaceCollaboratorType['onAdd'] = ({ getStartServices, ...props }) => async ({ + onAddCollaborators, +}) => { + let overlayRef: OverlayRef | null = null; + const [coreStart] = await getStartServices(); + overlayRef = coreStart.overlays.openModal( + toMountPoint( + { + overlayRef?.close(); + }} + onAddCollaborators={async (collaborators) => { + await onAddCollaborators(collaborators); + overlayRef?.close(); + }} + /> + ) + ); +}; + +export const registerDefaultCollaboratorTypes = ({ + getStartServices, + collaboratorTypesService, +}: { + collaboratorTypesService: WorkspaceCollaboratorTypesService; + getStartServices: CoreSetup['getStartServices']; +}) => { + collaboratorTypesService.setTypes([ + { + id: 'user', + name: i18n.translate('workspace.collaboratorType.defaultUser.name', { + defaultMessage: 'User', + }), + buttonLabel: i18n.translate('workspace.collaboratorType.defaultUser.buttonLabel', { + defaultMessage: 'Add Users', + }), + onAdd: generateOnAddCallback({ + getStartServices, + title: i18n.translate('workspace.collaboratorType.defaultUser.modalTitle', { + defaultMessage: 'Add Users', + }), + inputLabel: i18n.translate('workspace.collaboratorType.defaultUser.inputLabel', { + defaultMessage: 'User ID', + }), + inputPlaceholder: i18n.translate( + 'workspace.collaboratorType.defaultUser.inputPlaceholder', + { + defaultMessage: 'Enter User ID', + } + ), + addAnotherButtonLabel: i18n.translate( + 'workspace.collaboratorType.defaultUser.addAnotherButtonLabel', + { + defaultMessage: 'Add another User', + } + ), + permissionType: 'user', + }), + }, + { + id: 'group', + name: i18n.translate('workspace.collaboratorType.defaultGroup.name', { + defaultMessage: 'Group', + }), + buttonLabel: i18n.translate('workspace.collaboratorType.defaultGroup.buttonLabel', { + defaultMessage: 'Add Groups', + }), + onAdd: generateOnAddCallback({ + getStartServices, + title: i18n.translate('workspace.collaboratorType.defaultGroup.modalTitle', { + defaultMessage: 'Add Groups', + }), + inputLabel: i18n.translate('workspace.collaboratorType.defaultGroup.inputLabel', { + defaultMessage: 'Group ID', + }), + inputPlaceholder: i18n.translate( + 'workspace.collaboratorType.defaultGroup.inputPlaceholder', + { + defaultMessage: 'Enter Group ID', + } + ), + addAnotherButtonLabel: i18n.translate( + 'workspace.collaboratorType.defaultGroup.addAnotherButtonLabel', + { + defaultMessage: 'Add another Group', + } + ), + permissionType: 'group', + }), + }, + ]); +}; diff --git a/src/plugins/workspace/public/services/index.ts b/src/plugins/workspace/public/services/index.ts new file mode 100644 index 000000000000..4e966f0070fd --- /dev/null +++ b/src/plugins/workspace/public/services/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { UseCaseService } from './use_case_service'; +export { + WorkspaceCollaboratorType, + WorkspaceCollaboratorTypesService, +} from './workspace_collaborator_types_service'; diff --git a/src/plugins/workspace/public/services/workspace_collaborator_types_service.test.ts b/src/plugins/workspace/public/services/workspace_collaborator_types_service.test.ts new file mode 100644 index 000000000000..9c0fd6a130f5 --- /dev/null +++ b/src/plugins/workspace/public/services/workspace_collaborator_types_service.test.ts @@ -0,0 +1,123 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + WorkspaceCollaboratorType, + WorkspaceCollaboratorTypesService, +} from './workspace_collaborator_types_service'; + +describe('WorkspaceCollaboratorTypesService', () => { + let service: WorkspaceCollaboratorTypesService; + + beforeEach(() => { + service = new WorkspaceCollaboratorTypesService(); + }); + + afterEach(() => { + service.stop(); + }); + + describe('getTypes$', () => { + it('should return an observable of collaborator types', () => { + const collaboratorTypes: WorkspaceCollaboratorType[] = [ + { + id: 'type1', + name: 'Type 1', + buttonLabel: 'Button Label 1', + onAdd: jest.fn(), + }, + { + id: 'type2', + name: 'Type 2', + buttonLabel: 'Button Label 2', + onAdd: jest.fn(), + }, + ]; + + service.setTypes(collaboratorTypes); + + const subscription = service.getTypes$().subscribe((types) => { + expect(types).toEqual(collaboratorTypes); + }); + + subscription.unsubscribe(); + }); + }); + + describe('setTypes', () => { + it('should update the collaborator types', () => { + const initialTypes: WorkspaceCollaboratorType[] = [ + { + id: 'type1', + name: 'Type 1', + buttonLabel: 'Button Label 1', + onAdd: jest.fn(), + }, + ]; + + const updatedTypes: WorkspaceCollaboratorType[] = [ + { + id: 'type2', + name: 'Type 2', + buttonLabel: 'Button Label 2', + onAdd: jest.fn(), + }, + ]; + + service.setTypes(initialTypes); + + const subscription = service.getTypes$().subscribe((types) => { + expect(types).toEqual(initialTypes); + service.setTypes(updatedTypes); + }); + + const secondSubscription = service.getTypes$().subscribe((types) => { + expect(types).toEqual(updatedTypes); + subscription.unsubscribe(); + secondSubscription.unsubscribe(); + }); + }); + }); + + describe('stop', () => { + it('should complete the observable and prevent further emissions', () => { + const collaboratorTypes: WorkspaceCollaboratorType[] = [ + { + id: 'type1', + name: 'Type 1', + buttonLabel: 'Button Label 1', + onAdd: jest.fn(), + }, + ]; + + service.setTypes(collaboratorTypes); + + const subscription = service.getTypes$().subscribe({ + next: () => { + // This should not be called after stop() + expect(true).toBe(false); + }, + complete: () => { + // The observable should complete + expect(true).toBe(true); + }, + }); + + service.stop(); + + // Trying to update collaborator types after stop() should not emit any values + service.setTypes([ + { + id: 'type2', + name: 'Type 2', + buttonLabel: 'Button Label 2', + onAdd: jest.fn(), + }, + ]); + + subscription.unsubscribe(); + }); + }); +}); diff --git a/src/plugins/workspace/public/services/workspace_collaborator_types_service.ts b/src/plugins/workspace/public/services/workspace_collaborator_types_service.ts new file mode 100644 index 000000000000..a8cee717883a --- /dev/null +++ b/src/plugins/workspace/public/services/workspace_collaborator_types_service.ts @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { WorkspaceCollaborator } from '../types'; + +interface OnAddOptions { + onAddCollaborators: (collaborators: WorkspaceCollaborator[]) => Promise; +} + +export interface WorkspaceCollaboratorType { + id: string; + name: string; + buttonLabel: string; + getDisplayedType?: (collaborator: WorkspaceCollaborator) => string | void; + onAdd: ({ onAddCollaborators }: OnAddOptions) => Promise; +} + +export class WorkspaceCollaboratorTypesService { + private _collaboratorTypes$ = new BehaviorSubject([]); + + public getTypes$() { + return this._collaboratorTypes$; + } + + public setTypes(types: WorkspaceCollaboratorType[]) { + this._collaboratorTypes$.next(types); + } + + public stop() { + this._collaboratorTypes$.complete(); + } +} diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts index 53f2be4dfb36..8572c9556141 100644 --- a/src/plugins/workspace/public/types.ts +++ b/src/plugins/workspace/public/types.ts @@ -9,12 +9,15 @@ import { DataSourceManagementPluginSetup } from '../../../plugins/data_source_ma import { NavigationPublicPluginStart } from '../../../plugins/navigation/public'; import { ContentManagementPluginStart } from '../../../plugins/content_management/public'; import { DataSourceAttributes } from '../../../plugins/data_source/common/data_sources'; +import type { AddCollaboratorsModal } from './components/add_collaborators_modal'; +import { WorkspaceCollaboratorTypesService } from './services'; export type Services = CoreStart & { workspaceClient: WorkspaceClient; dataSourceManagement?: DataSourceManagementPluginSetup; navigationUI?: NavigationPublicPluginStart['ui']; contentManagement?: ContentManagementPluginStart; + collaboratorTypes: WorkspaceCollaboratorTypesService; }; export interface WorkspaceUseCaseFeature { @@ -35,3 +38,19 @@ export interface WorkspaceUseCase { export interface DataSourceAttributesWithWorkspaces extends Omit { workspaces?: string[]; } + +export type WorkspaceCollaboratorPermissionType = 'user' | 'group'; +export type WorkspaceCollaboratorAccessLevel = 'readOnly' | 'readAndWrite' | 'admin'; + +export interface WorkspaceCollaborator { + collaboratorId: string; + permissionType: WorkspaceCollaboratorPermissionType; + accessLevel: WorkspaceCollaboratorAccessLevel; +} + +export interface WorkspacePluginSetup { + collaboratorTypes: WorkspaceCollaboratorTypesService; + ui: { + AddCollaboratorsModal: typeof AddCollaboratorsModal; + }; +}