From 529fc0137cc014217952ab7f924c6043b7f4d2fa Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Fri, 15 Mar 2024 11:37:45 +0800 Subject: [PATCH] Add workspace create page (#284) * Add workspace create page Signed-off-by: Lin Wang * Address PR comments Signed-off-by: Lin Wang * Add more comments Signed-off-by: Lin Wang * Add example for dependencies field in App Signed-off-by: Lin Wang * Separate workspace feature selector Signed-off-by: Lin Wang * Correct example for dependencies Signed-off-by: Lin Wang * Remove unclear icon and defaultVISTheme input Signed-off-by: Lin Wang * Remove unclear dependencies feature Signed-off-by: Lin Wang * Remove states and fix onChange fired after mount Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang --- src/plugins/workspace/common/constants.ts | 2 + src/plugins/workspace/public/application.tsx | 14 + .../components/workspace_creator/index.tsx | 6 + .../workspace_creator.test.tsx | 226 ++++++++++++++++ .../workspace_creator/workspace_creator.tsx | 97 +++++++ .../components/workspace_creator_app.tsx | 35 +++ .../components/workspace_form/constants.ts | 64 +++++ .../public/components/workspace_form/index.ts | 8 + .../public/components/workspace_form/types.ts | 53 ++++ .../workspace_form/use_workspace_form.ts | 216 +++++++++++++++ .../public/components/workspace_form/utils.ts | 97 +++++++ .../workspace_form/workspace_bottom_bar.tsx | 112 ++++++++ .../workspace_form/workspace_cancel_modal.tsx | 49 ++++ .../workspace_feature_selector.tsx | 212 +++++++++++++++ .../workspace_form/workspace_form.tsx | 188 +++++++++++++ .../workspace_icon_selector.tsx | 46 ++++ .../workspace_permission_setting_input.tsx | 129 +++++++++ .../workspace_permission_setting_panel.tsx | 246 ++++++++++++++++++ src/plugins/workspace/public/hooks.ts | 19 ++ src/plugins/workspace/public/plugin.test.ts | 4 +- src/plugins/workspace/public/plugin.ts | 15 ++ 21 files changed, 1836 insertions(+), 2 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_creator/index.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator_app.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/constants.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/index.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/types.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/utils.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_form.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_icon_selector.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx create mode 100644 src/plugins/workspace/public/hooks.ts diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 5bd8ab34c313..2be4a9d121db 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -7,6 +7,8 @@ export const WORKSPACE_CREATE_APP_ID = 'workspace_create'; export const WORKSPACE_LIST_APP_ID = 'workspace_list'; export const WORKSPACE_UPDATE_APP_ID = 'workspace_update'; export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; +// These features will be checked and disabled in checkbox on default. +export const DEFAULT_CHECKED_FEATURES_IDS = [WORKSPACE_UPDATE_APP_ID, WORKSPACE_OVERVIEW_APP_ID]; export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index ad943786d0b6..f01e788f3cc9 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -9,8 +9,22 @@ import { AppMountParameters, ScopedHistory } from '../../../core/public'; import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; import { WorkspaceListApp } from './components/workspace_list_app'; import { WorkspaceFatalError } from './components/workspace_fatal_error'; +import { WorkspaceCreatorApp } from './components/workspace_creator_app'; import { Services } from './types'; +export const renderCreatorApp = ({ element }: AppMountParameters, services: Services) => { + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; + export const renderFatalErrorApp = (params: AppMountParameters, services: Services) => { const { element } = params; const history = params.history as ScopedHistory<{ error?: string }>; diff --git a/src/plugins/workspace/public/components/workspace_creator/index.tsx b/src/plugins/workspace/public/components/workspace_creator/index.tsx new file mode 100644 index 000000000000..c8cdbfab65be --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/index.tsx @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceCreator } from './workspace_creator'; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx new file mode 100644 index 000000000000..f10fd39cfe9d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -0,0 +1,226 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { PublicAppInfo } from 'opensearch-dashboards/public'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { BehaviorSubject } from 'rxjs'; +import { WorkspaceCreator as WorkspaceCreatorComponent } from './workspace_creator'; +import { coreMock } from '../../../../../core/public/mocks'; +import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; + +const workspaceClientCreate = jest + .fn() + .mockReturnValue({ result: { id: 'successResult' }, success: true }); + +const navigateToApp = jest.fn(); +const notificationToastsAddSuccess = jest.fn(); +const notificationToastsAddDanger = jest.fn(); +const PublicAPPInfoMap = new Map([ + ['app1', { id: 'app1', title: 'app1' }], + ['app2', { id: 'app2', title: 'app2', category: { id: 'category1', label: 'category1' } }], + ['app3', { id: 'app3', category: { id: 'category1', label: 'category1' } }], + ['app4', { id: 'app4', category: { id: 'category2', label: 'category2' } }], + ['app5', { id: 'app5', category: { id: 'category2', label: 'category2' } }], +]); + +const mockCoreStart = coreMock.createStart(); + +const WorkspaceCreator = (props: any) => { + const { Provider } = createOpenSearchDashboardsReactContext({ + ...mockCoreStart, + ...{ + application: { + ...mockCoreStart.application, + capabilities: { + ...mockCoreStart.application.capabilities, + workspaces: { + permissionEnabled: true, + }, + }, + navigateToApp, + getUrlForApp: jest.fn(), + applications$: new BehaviorSubject>(PublicAPPInfoMap as any), + }, + notifications: { + ...mockCoreStart.notifications, + toasts: { + ...mockCoreStart.notifications.toasts, + addDanger: notificationToastsAddDanger, + addSuccess: notificationToastsAddSuccess, + }, + }, + workspaceClient: { + ...mockCoreStart.workspaces, + create: workspaceClientCreate, + }, + }, + }); + + return ( + + + + ); +}; + +function clearMockedFunctions() { + workspaceClientCreate.mockClear(); + notificationToastsAddDanger.mockClear(); + notificationToastsAddSuccess.mockClear(); +} + +describe('WorkspaceCreator', () => { + beforeEach(() => clearMockedFunctions()); + const { location } = window; + const setHrefSpy = jest.fn((href) => href); + + beforeAll(() => { + if (window.location) { + // @ts-ignore + delete window.location; + } + window.location = {} as Location; + Object.defineProperty(window.location, 'href', { + get: () => 'http://localhost/', + set: setHrefSpy, + }); + }); + + afterAll(() => { + window.location = location; + }); + + it('cannot create workspace when name empty', async () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).not.toHaveBeenCalled(); + }); + + it('cannot create workspace with invalid name', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: '~' }, + }); + expect(workspaceClientCreate).not.toHaveBeenCalled(); + }); + + it('cannot create workspace with invalid description', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + const descriptionInput = getByTestId('workspaceForm-workspaceDetails-descriptionInputText'); + fireEvent.input(descriptionInput, { + target: { value: '~' }, + }); + expect(workspaceClientCreate).not.toHaveBeenCalled(); + }); + + it('cancel create workspace', async () => { + const { findByText, getByTestId } = render(); + fireEvent.click(getByTestId('workspaceForm-bottomBar-cancelButton')); + await findByText('Discard changes?'); + fireEvent.click(getByTestId('confirmModalConfirmButton')); + expect(navigateToApp).toHaveBeenCalled(); + }); + + it('create workspace with detailed information', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + const descriptionInput = getByTestId('workspaceForm-workspaceDetails-descriptionInputText'); + fireEvent.input(descriptionInput, { + target: { value: 'test workspace description' }, + }); + const colorSelector = getByTestId( + 'euiColorPickerAnchor workspaceForm-workspaceDetails-colorPicker' + ); + fireEvent.input(colorSelector, { + target: { value: '#000000' }, + }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + color: '#000000', + description: 'test workspace description', + }), + expect.any(Array) + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); + + it('create workspace with customized features', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-app1')); + fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-category1')); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + features: expect.arrayContaining(['app1', 'app2', 'app3']), + }), + expect.any(Array) + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); + + it('create workspace with customized permissions', async () => { + const { getByTestId, getByText } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByText('Users & Permissions')); + fireEvent.click(getByTestId('workspaceForm-permissionSettingPanel-user-addNew')); + const userIdInput = getByTestId('workspaceForm-permissionSettingPanel-0-userId'); + fireEvent.click(userIdInput); + fireEvent.input(getByTestId('comboBoxSearchInput'), { + target: { value: 'test user id' }, + }); + fireEvent.blur(getByTestId('comboBoxSearchInput')); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + }), + expect.arrayContaining([expect.objectContaining({ type: 'user', userId: 'test user id' })]) + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); + + it('should show danger toasts after create workspace failed', async () => { + workspaceClientCreate.mockReturnValue({ result: { id: 'failResult' }, success: false }); + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalled(); + await waitFor(() => { + expect(notificationToastsAddDanger).toHaveBeenCalled(); + }); + expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx new file mode 100644 index 000000000000..2b3511f18b8b --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -0,0 +1,97 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceForm, WorkspaceFormSubmitData, WorkspaceOperationType } from '../workspace_form'; +import { WORKSPACE_OVERVIEW_APP_ID } from '../../../common/constants'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { WorkspaceClient } from '../../workspace_client'; + +export const WorkspaceCreator = () => { + const { + services: { application, notifications, http, workspaceClient }, + } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>(); + + const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; + const handleWorkspaceFormSubmit = useCallback( + async (data: WorkspaceFormSubmitData) => { + let result; + try { + const { permissions, ...attributes } = data; + result = await workspaceClient.create(attributes, permissions); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.create.failed', { + defaultMessage: 'Failed to create workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.create.success', { + defaultMessage: 'Create workspace successfully', + }), + }); + if (application && http) { + const newWorkspaceId = result.result.id; + // Redirect page after one second, leave one second time to show create successful toast. + window.setTimeout(() => { + window.location.href = formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: true, + }), + newWorkspaceId, + http.basePath + ); + }, 1000); + } + return; + } + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.create.failed', { + defaultMessage: 'Failed to create workspace', + }), + text: result?.error, + }); + }, + [notifications?.toasts, http, application, workspaceClient] + ); + + return ( + + + + + + {application && ( + + )} + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_creator_app.tsx b/src/plugins/workspace/public/components/workspace_creator_app.tsx new file mode 100644 index 000000000000..b74359929352 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator_app.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { WorkspaceCreator } from './workspace_creator'; + +export const WorkspaceCreatorApp = () => { + const { + services: { chrome }, + } = useOpenSearchDashboards(); + + /** + * set breadcrumbs to chrome + */ + useEffect(() => { + chrome?.setBreadcrumbs([ + { + text: i18n.translate('workspace.workspaceCreateTitle', { + defaultMessage: 'Create workspace', + }), + }, + ]); + }, [chrome]); + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/constants.ts b/src/plugins/workspace/public/components/workspace_form/constants.ts new file mode 100644 index 000000000000..3af7f5c743e9 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/constants.ts @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { WorkspacePermissionMode } from '../../../common/constants'; + +export enum WorkspacePermissionItemType { + User = 'user', + Group = 'group', +} + +export enum PermissionModeId { + Read = 'read', + ReadAndWrite = 'read+write', + Admin = 'admin', +} + +export const permissionModeOptions = [ + { + id: PermissionModeId.Read, + label: i18n.translate('workspace.form.permissionSettingPanel.permissionModeOptions.read', { + defaultMessage: 'Read', + }), + }, + { + id: PermissionModeId.ReadAndWrite, + label: i18n.translate( + 'workspace.form.permissionSettingPanel.permissionModeOptions.readAndWrite', + { + defaultMessage: 'Read & Write', + } + ), + }, + { + id: PermissionModeId.Admin, + label: i18n.translate('workspace.form.permissionSettingPanel.permissionModeOptions.admin', { + defaultMessage: 'Admin', + }), + }, +]; + +export const optionIdToWorkspacePermissionModesMap: { + [key: string]: WorkspacePermissionMode[]; +} = { + [PermissionModeId.Read]: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + [PermissionModeId.ReadAndWrite]: [ + WorkspacePermissionMode.LibraryWrite, + WorkspacePermissionMode.Read, + ], + [PermissionModeId.Admin]: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], +}; + +export enum WorkspaceOperationType { + Create = 'create', + Update = 'update', +} + +export enum WorkspaceFormTabs { + NotSelected, + FeatureVisibility, + UsersAndPermissions, +} diff --git a/src/plugins/workspace/public/components/workspace_form/index.ts b/src/plugins/workspace/public/components/workspace_form/index.ts new file mode 100644 index 000000000000..6531d4a1c6f7 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceForm } from './workspace_form'; +export { WorkspaceFormSubmitData } from './types'; +export { WorkspaceOperationType } from './constants'; diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts new file mode 100644 index 000000000000..15af85965943 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WorkspacePermissionItemType, WorkspaceOperationType } from './constants'; +import type { WorkspacePermissionMode } from '../../../common/constants'; +import type { App, ApplicationStart } from '../../../../../core/public'; + +export type WorkspacePermissionSetting = + | { type: WorkspacePermissionItemType.User; userId: string; modes: WorkspacePermissionMode[] } + | { type: WorkspacePermissionItemType.Group; group: string; modes: WorkspacePermissionMode[] }; + +export interface WorkspaceFormSubmitData { + name: string; + description?: string; + features?: string[]; + color?: string; + icon?: string; + defaultVISTheme?: string; + permissions: WorkspacePermissionSetting[]; +} + +export interface WorkspaceFormData extends WorkspaceFormSubmitData { + id: string; + reserved?: boolean; +} + +export interface WorkspaceFeature { + id: string; + name: string; +} + +export interface WorkspaceFeatureGroup { + name: string; + features: WorkspaceFeature[]; +} + +export type WorkspaceFormErrors = Omit< + { [key in keyof WorkspaceFormData]?: string }, + 'permissions' +> & { + permissions?: string[]; +}; + +export interface WorkspaceFormProps { + application: ApplicationStart; + onSubmit?: (formData: WorkspaceFormSubmitData) => void; + defaultValues?: WorkspaceFormData; + operationType?: WorkspaceOperationType; + permissionEnabled?: boolean; + permissionLastAdminItemDeletable?: boolean; +} diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts new file mode 100644 index 000000000000..7158693aedff --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts @@ -0,0 +1,216 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useState, FormEventHandler, useRef, useMemo, useEffect } from 'react'; +import { htmlIdGenerator, EuiFieldTextProps, EuiColorPickerProps } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { useApplications } from '../../hooks'; +import { featureMatchesConfig } from '../../utils'; + +import { WorkspacePermissionItemType, WorkspaceFormTabs } from './constants'; +import { WorkspacePermissionSetting, WorkspaceFormProps, WorkspaceFormErrors } from './types'; +import { + appendDefaultFeatureIds, + getNumberOfErrors, + isUserOrGroupPermissionSettingDuplicated, + isValidNameOrDescription, + isValidWorkspacePermissionSetting, +} from './utils'; + +const workspaceHtmlIdGenerator = htmlIdGenerator(); + +export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: WorkspaceFormProps) => { + const applications = useApplications(application); + const [name, setName] = useState(defaultValues?.name); + const [description, setDescription] = useState(defaultValues?.description); + const [color, setColor] = useState(defaultValues?.color); + + const [selectedTab, setSelectedTab] = useState(WorkspaceFormTabs.FeatureVisibility); + const [numberOfErrors, setNumberOfErrors] = useState(0); + // The matched feature id list based on original feature config, + // the feature category will be expanded to list of feature ids + const defaultFeatures = useMemo(() => { + // The original feature list, may contain feature id and category wildcard like @management, etc. + const defaultOriginalFeatures = defaultValues?.features ?? []; + return applications.filter(featureMatchesConfig(defaultOriginalFeatures)).map((app) => app.id); + }, [defaultValues?.features, applications]); + + const defaultFeaturesRef = useRef(defaultFeatures); + defaultFeaturesRef.current = defaultFeatures; + + const [selectedFeatureIds, setSelectedFeatureIds] = useState( + appendDefaultFeatureIds(defaultFeatures) + ); + const [permissionSettings, setPermissionSettings] = useState< + Array> + >( + defaultValues?.permissions && defaultValues.permissions.length > 0 + ? defaultValues.permissions + : [] + ); + + const [formErrors, setFormErrors] = useState({}); + const formIdRef = useRef(); + const getFormData = () => ({ + name, + description, + features: selectedFeatureIds, + color, + permissions: permissionSettings, + }); + const getFormDataRef = useRef(getFormData); + getFormDataRef.current = getFormData; + + if (!formIdRef.current) { + formIdRef.current = workspaceHtmlIdGenerator(); + } + + const handleFormSubmit = useCallback( + (e) => { + e.preventDefault(); + let currentFormErrors: WorkspaceFormErrors = {}; + const formData = getFormDataRef.current(); + if (!formData.name) { + currentFormErrors = { + ...currentFormErrors, + name: i18n.translate('workspace.form.detail.name.empty', { + defaultMessage: "Name can't be empty.", + }), + }; + } + if (!isValidNameOrDescription(formData.name)) { + currentFormErrors = { + ...currentFormErrors, + name: i18n.translate('workspace.form.detail.name.invalid', { + defaultMessage: 'Invalid workspace name', + }), + }; + } + if (!isValidNameOrDescription(formData.description)) { + currentFormErrors = { + ...currentFormErrors, + description: i18n.translate('workspace.form.detail.description.invalid', { + defaultMessage: 'Invalid workspace description', + }), + }; + } + const permissionErrors: string[] = new Array(formData.permissions.length); + for (let i = 0; i < formData.permissions.length; i++) { + const permission = formData.permissions[i]; + if (isValidWorkspacePermissionSetting(permission)) { + if ( + isUserOrGroupPermissionSettingDuplicated(formData.permissions.slice(0, i), permission) + ) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.group', { + defaultMessage: 'Duplicate permission setting', + }); + continue; + } + continue; + } + if (!permission.type) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.type', { + defaultMessage: 'Invalid type', + }); + continue; + } + if (!permission.modes || permission.modes.length === 0) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.modes', { + defaultMessage: 'Invalid permission modes', + }); + continue; + } + if (permission.type === WorkspacePermissionItemType.User && !permission.userId) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.userId', { + defaultMessage: 'Invalid userId', + }); + continue; + } + if (permission.type === WorkspacePermissionItemType.Group && !permission.group) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.group', { + defaultMessage: 'Invalid user group', + }); + continue; // this line is need for more conditions + } + } + if (permissionErrors.some((error) => !!error)) { + currentFormErrors = { + ...currentFormErrors, + permissions: permissionErrors, + }; + } + const currentNumberOfErrors = getNumberOfErrors(currentFormErrors); + setFormErrors(currentFormErrors); + setNumberOfErrors(currentNumberOfErrors); + if (currentNumberOfErrors > 0) { + return; + } + + const featureConfigChanged = + formData.features.length !== defaultFeatures.length || + formData.features.some((feat) => !defaultFeatures.includes(feat)); + + if (!featureConfigChanged) { + // If feature config not changed, set workspace feature config to the original value. + // The reason why we do this is when a workspace feature is configured by wildcard, + // such as `['@management']` or `['*']`. The form value `formData.features` will be + // expanded to array of individual feature id, if the feature hasn't changed, we will + // set the feature config back to the original value so that category wildcard won't + // expanded to feature ids + formData.features = defaultValues?.features ?? []; + } + + const permissions = formData.permissions.filter(isValidWorkspacePermissionSetting); + onSubmit?.({ ...formData, name: formData.name!, permissions }); + }, + [defaultFeatures, onSubmit, defaultValues?.features] + ); + + const handleNameInputChange = useCallback['onChange']>((e) => { + setName(e.target.value); + }, []); + + const handleDescriptionInputChange = useCallback['onChange']>((e) => { + setDescription(e.target.value); + }, []); + + const handleColorChange = useCallback['onChange']>((text) => { + setColor(text); + }, []); + + const handleTabFeatureClick = useCallback(() => { + setSelectedTab(WorkspaceFormTabs.FeatureVisibility); + }, []); + + const handleTabPermissionClick = useCallback(() => { + setSelectedTab(WorkspaceFormTabs.UsersAndPermissions); + }, []); + + const handleFeaturesChange = useCallback((featureIds: string[]) => { + setSelectedFeatureIds(featureIds); + }, []); + + useEffect(() => { + // When applications changed, reset form feature selection to original value + setSelectedFeatureIds(appendDefaultFeatureIds(defaultFeaturesRef.current)); + }, [applications]); + + return { + formId: formIdRef.current, + formData: getFormData(), + formErrors, + selectedTab, + applications, + numberOfErrors, + handleFormSubmit, + handleColorChange, + handleFeaturesChange, + handleNameInputChange, + handleTabFeatureClick, + setPermissionSettings, + handleTabPermissionClick, + handleDescriptionInputChange, + }; +}; diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts new file mode 100644 index 000000000000..133a3bc563de --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -0,0 +1,97 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspacePermissionMode, DEFAULT_CHECKED_FEATURES_IDS } from '../../../common/constants'; + +import { + WorkspaceFeature, + WorkspaceFeatureGroup, + WorkspacePermissionSetting, + WorkspaceFormErrors, +} from './types'; +import { + WorkspacePermissionItemType, + optionIdToWorkspacePermissionModesMap, + PermissionModeId, +} from './constants'; + +export const isWorkspaceFeatureGroup = ( + featureOrGroup: WorkspaceFeature | WorkspaceFeatureGroup +): featureOrGroup is WorkspaceFeatureGroup => 'features' in featureOrGroup; + +export const isValidWorkspacePermissionSetting = ( + setting: Partial +): setting is WorkspacePermissionSetting => + !!setting.modes && + setting.modes.length > 0 && + ((setting.type === WorkspacePermissionItemType.User && !!setting.userId) || + (setting.type === WorkspacePermissionItemType.Group && !!setting.group)); + +export const isDefaultCheckedFeatureId = (id: string) => { + return DEFAULT_CHECKED_FEATURES_IDS.indexOf(id) > -1; +}; + +export const appendDefaultFeatureIds = (ids: string[]) => { + // concat default checked ids and unique the result + return Array.from(new Set(ids.concat(DEFAULT_CHECKED_FEATURES_IDS))); +}; + +export const isValidNameOrDescription = (input?: string) => { + if (!input) { + return true; + } + const regex = /^[0-9a-zA-Z()_\[\]\-\s]+$/; + return regex.test(input); +}; + +export const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => { + let numberOfErrors = 0; + if (formErrors.name) { + numberOfErrors += 1; + } + if (formErrors.description) { + numberOfErrors += 1; + } + if (formErrors.permissions) { + numberOfErrors += formErrors.permissions.length; + } + return numberOfErrors; +}; + +export const isUserOrGroupPermissionSettingDuplicated = ( + permissionSettings: Array>, + permissionSettingToCheck: WorkspacePermissionSetting +) => + permissionSettings.some( + (permissionSetting) => + (permissionSettingToCheck.type === WorkspacePermissionItemType.User && + permissionSetting.type === WorkspacePermissionItemType.User && + permissionSettingToCheck.userId === permissionSetting.userId) || + (permissionSettingToCheck.type === WorkspacePermissionItemType.Group && + permissionSetting.type === WorkspacePermissionItemType.Group && + permissionSettingToCheck.group === permissionSetting.group) + ); + +export const generateWorkspacePermissionItemKey = ( + item: Partial, + index?: number +) => + [ + ...(item.type ?? []), + ...(item.type === WorkspacePermissionItemType.User ? [item.userId] : []), + ...(item.type === WorkspacePermissionItemType.Group ? [item.group] : []), + ...(item.modes ?? []), + index, + ].join('-'); + +// default permission mode is read +export const getPermissionModeId = (modes: WorkspacePermissionMode[]) => { + for (const key in optionIdToWorkspacePermissionModesMap) { + if (optionIdToWorkspacePermissionModesMap[key].every((mode) => modes?.includes(mode))) { + return key; + } + } + return PermissionModeId.Read; +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx new file mode 100644 index 000000000000..c55501725a52 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx @@ -0,0 +1,112 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiBottomBar, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React, { useState } from 'react'; +import { ApplicationStart } from 'opensearch-dashboards/public'; +import { WorkspaceOperationType } from '../workspace_form'; +import { WorkspaceCancelModal } from './workspace_cancel_modal'; + +interface WorkspaceBottomBarProps { + formId: string; + operationType?: WorkspaceOperationType; + numberOfErrors: number; + application: ApplicationStart; + numberOfUnSavedChanges?: number; +} + +export const WorkspaceBottomBar = ({ + formId, + operationType, + numberOfErrors, + numberOfUnSavedChanges, + application, +}: WorkspaceBottomBarProps) => { + const [isCancelModalVisible, setIsCancelModalVisible] = useState(false); + const closeCancelModal = () => setIsCancelModalVisible(false); + const showCancelModal = () => setIsCancelModalVisible(true); + + return ( +
+ + + + + + + {operationType === WorkspaceOperationType.Update ? ( + + {i18n.translate('workspace.form.bottomBar.unsavedChanges', { + defaultMessage: '{numberOfUnSavedChanges} Unsaved change(s)', + values: { + numberOfUnSavedChanges, + }, + })} + + ) : ( + + {i18n.translate('workspace.form.bottomBar.errors', { + defaultMessage: '{numberOfErrors} Error(s)', + values: { + numberOfErrors, + }, + })} + + )} + + + + + + {i18n.translate('workspace.form.bottomBar.cancel', { + defaultMessage: 'Cancel', + })} + + + {operationType === WorkspaceOperationType.Create && ( + + {i18n.translate('workspace.form.bottomBar.createWorkspace', { + defaultMessage: 'Create workspace', + })} + + )} + {operationType === WorkspaceOperationType.Update && ( + + {i18n.translate('workspace.form.bottomBar.saveChanges', { + defaultMessage: 'Save changes', + })} + + )} + + + + + +
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx new file mode 100644 index 000000000000..040e46f9ddfc --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx @@ -0,0 +1,49 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiConfirmModal } from '@elastic/eui'; +import { ApplicationStart } from 'opensearch-dashboards/public'; +import { WORKSPACE_LIST_APP_ID } from '../../../common/constants'; + +interface WorkspaceCancelModalProps { + visible: boolean; + application: ApplicationStart; + closeCancelModal: () => void; +} + +export const WorkspaceCancelModal = ({ + application, + visible, + closeCancelModal, +}: WorkspaceCancelModalProps) => { + if (!visible) { + return null; + } + + return ( + application?.navigateToApp(WORKSPACE_LIST_APP_ID)} + cancelButtonText={i18n.translate('workspace.form.cancelButtonText.', { + defaultMessage: 'Continue editing', + })} + confirmButtonText={i18n.translate('workspace.form.confirmButtonText.', { + defaultMessage: 'Discard changes', + })} + buttonColor="danger" + defaultFocusedButton="confirm" + > + {i18n.translate('workspace.form.cancelModal.body', { + defaultMessage: 'This will discard all changes. Are you sure?', + })} + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx new file mode 100644 index 000000000000..61181a7a749e --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx @@ -0,0 +1,212 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo } from 'react'; +import { + EuiText, + EuiFlexItem, + EuiCheckbox, + EuiCheckboxGroup, + EuiFlexGroup, + EuiCheckboxGroupProps, + EuiCheckboxProps, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { groupBy } from 'lodash'; + +import { + AppNavLinkStatus, + DEFAULT_APP_CATEGORIES, + PublicAppInfo, +} from '../../../../../core/public'; + +import { WorkspaceFeature, WorkspaceFeatureGroup } from './types'; +import { isDefaultCheckedFeatureId, isWorkspaceFeatureGroup } from './utils'; + +const libraryCategoryLabel = i18n.translate('core.ui.libraryNavList.label', { + defaultMessage: 'Library', +}); + +interface WorkspaceFeatureSelectorProps { + applications: PublicAppInfo[]; + selectedFeatures: string[]; + onChange: (newFeatures: string[]) => void; +} + +export const WorkspaceFeatureSelector = ({ + applications, + selectedFeatures, + onChange, +}: WorkspaceFeatureSelectorProps) => { + const featureOrGroups = useMemo(() => { + const transformedApplications = applications.map((app) => { + if (app.category?.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id) { + return { + ...app, + category: { + ...app.category, + label: libraryCategoryLabel, + }, + }; + } + return app; + }); + const category2Applications = groupBy(transformedApplications, 'category.label'); + return Object.keys(category2Applications).reduce< + Array + >((previousValue, currentKey) => { + const apps = category2Applications[currentKey]; + const features = apps + .filter( + ({ navLinkStatus, chromeless, category }) => + navLinkStatus !== AppNavLinkStatus.hidden && + !chromeless && + category?.id !== DEFAULT_APP_CATEGORIES.management.id + ) + .map(({ id, title }) => ({ + id, + name: title, + })); + if (features.length === 0) { + return previousValue; + } + if (currentKey === 'undefined') { + return [...previousValue, ...features]; + } + return [ + ...previousValue, + { + name: apps[0].category?.label || '', + features, + }, + ]; + }, []); + }, [applications]); + + const handleFeatureChange = useCallback( + (featureId) => { + if (!selectedFeatures.includes(featureId)) { + onChange([...selectedFeatures, featureId]); + return; + } + onChange(selectedFeatures.filter((selectedId) => selectedId !== featureId)); + }, + [selectedFeatures, onChange] + ); + + const handleFeatureCheckboxChange = useCallback( + (e) => { + handleFeatureChange(e.target.id); + }, + [handleFeatureChange] + ); + + const handleFeatureGroupChange = useCallback( + (e) => { + const featureOrGroup = featureOrGroups.find( + (item) => isWorkspaceFeatureGroup(item) && item.name === e.target.id + ); + if (!featureOrGroup || !isWorkspaceFeatureGroup(featureOrGroup)) { + return; + } + const groupFeatureIds = featureOrGroup.features.map((feature) => feature.id); + // setSelectedFeatureIds((previousData) => { + const notExistsIds = groupFeatureIds.filter((id) => !selectedFeatures.includes(id)); + // Check all not selected features if not been selected in current group. + if (notExistsIds.length > 0) { + onChange([...selectedFeatures, ...notExistsIds]); + return; + } + // Need to un-check these features, if all features in group has been selected + onChange(selectedFeatures.filter((featureId) => !groupFeatureIds.includes(featureId))); + }, + [featureOrGroups, selectedFeatures, onChange] + ); + + return ( + <> + {featureOrGroups.map((featureOrGroup) => { + const features = isWorkspaceFeatureGroup(featureOrGroup) ? featureOrGroup.features : []; + const selectedIds = selectedFeatures.filter((id) => + (isWorkspaceFeatureGroup(featureOrGroup) + ? featureOrGroup.features + : [featureOrGroup] + ).find((item) => item.id === id) + ); + const featureOrGroupId = isWorkspaceFeatureGroup(featureOrGroup) + ? featureOrGroup.name + : featureOrGroup.id; + + const categoryToDescription: { [key: string]: string } = { + [libraryCategoryLabel]: i18n.translate( + 'workspace.form.featureVisibility.libraryCategory.Description', + { + defaultMessage: 'Workspace-owned library items', + } + ), + }; + + return ( + + +
+ + {featureOrGroup.name} + + {isWorkspaceFeatureGroup(featureOrGroup) && + categoryToDescription[featureOrGroup.name] && ( + {categoryToDescription[featureOrGroup.name]} + )} +
+
+ + 0 ? ` (${selectedIds.length}/${features.length})` : '' + }`} + checked={selectedIds.length > 0} + disabled={ + !isWorkspaceFeatureGroup(featureOrGroup) && + isDefaultCheckedFeatureId(featureOrGroup.id) + } + indeterminate={ + isWorkspaceFeatureGroup(featureOrGroup) && + selectedIds.length > 0 && + selectedIds.length < features.length + } + data-test-subj={`workspaceForm-workspaceFeatureVisibility-${featureOrGroupId}`} + /> + {isWorkspaceFeatureGroup(featureOrGroup) && ( + ({ + id: item.id, + label: item.name, + disabled: isDefaultCheckedFeatureId(item.id), + }))} + idToSelectedMap={selectedIds.reduce( + (previousValue, currentValue) => ({ + ...previousValue, + [currentValue]: true, + }), + {} + )} + onChange={handleFeatureChange} + style={{ marginLeft: 40 }} + data-test-subj={`workspaceForm-workspaceFeatureVisibility-featureWithCategory-${featureOrGroupId}`} + /> + )} + +
+ ); + })} + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx new file mode 100644 index 000000000000..ec4f2bfed3e0 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -0,0 +1,188 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiPanel, + EuiSpacer, + EuiTitle, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiText, + EuiColorPicker, + EuiHorizontalRule, + EuiTab, + EuiTabs, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +import { WorkspaceBottomBar } from './workspace_bottom_bar'; +import { WorkspacePermissionSettingPanel } from './workspace_permission_setting_panel'; +import { WorkspaceFormProps } from './types'; +import { WorkspaceFormTabs } from './constants'; +import { useWorkspaceForm } from './use_workspace_form'; +import { WorkspaceFeatureSelector } from './workspace_feature_selector'; + +export const WorkspaceForm = (props: WorkspaceFormProps) => { + const { + application, + defaultValues, + operationType, + permissionEnabled, + permissionLastAdminItemDeletable, + } = props; + const { + formId, + formData, + formErrors, + selectedTab, + applications, + numberOfErrors, + handleFormSubmit, + handleColorChange, + handleFeaturesChange, + handleNameInputChange, + handleTabFeatureClick, + setPermissionSettings, + handleTabPermissionClick, + handleDescriptionInputChange, + } = useWorkspaceForm(props); + const workspaceDetailsTitle = i18n.translate('workspace.form.workspaceDetails.title', { + defaultMessage: 'Workspace Details', + }); + const featureVisibilityTitle = i18n.translate('workspace.form.featureVisibility.title', { + defaultMessage: 'Feature Visibility', + }); + const usersAndPermissionsTitle = i18n.translate('workspace.form.usersAndPermissions.title', { + defaultMessage: 'Users & Permissions', + }); + + return ( + + + +

{workspaceDetailsTitle}

+
+ + + + + + + Description - optional + + } + helpText={i18n.translate('workspace.form.workspaceDetails.description.helpText', { + defaultMessage: + 'Valid characters are a-z, A-Z, 0-9, (), [], _ (underscore), - (hyphen) and (space).', + })} + isInvalid={!!formErrors.description} + error={formErrors.description} + > + + + +
+ + {i18n.translate('workspace.form.workspaceDetails.color.helpText', { + defaultMessage: 'Accent color for your workspace', + })} + + + +
+
+
+ + + + + {featureVisibilityTitle} + + {permissionEnabled && ( + + {usersAndPermissionsTitle} + + )} + + + {selectedTab === WorkspaceFormTabs.FeatureVisibility && ( + + +

{featureVisibilityTitle}

+
+ + + +
+ )} + + {selectedTab === WorkspaceFormTabs.UsersAndPermissions && ( + + +

{usersAndPermissionsTitle}

+
+ + +
+ )} + + +
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_icon_selector.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_icon_selector.tsx new file mode 100644 index 000000000000..06b0a224a258 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_icon_selector.tsx @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect, EuiText } from '@elastic/eui'; + +const icons = ['Glasses', 'Search', 'Bell', 'Package']; + +export const WorkspaceIconSelector = ({ + color, + value, + onChange, +}: { + color?: string; + value?: string; + onChange: (value: string) => void; +}) => { + const options = icons.map((item) => ({ + value: item, + inputDisplay: ( + + + + + + {item} + + + ), + })); + return ( + onChange(icon)} + /> + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx new file mode 100644 index 000000000000..e17f99b0d15b --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx @@ -0,0 +1,129 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo } from 'react'; +import { + EuiFlexGroup, + EuiComboBox, + EuiFlexItem, + EuiButtonIcon, + EuiButtonGroup, +} from '@elastic/eui'; +import { WorkspacePermissionMode } from '../../../common/constants'; +import { + WorkspacePermissionItemType, + optionIdToWorkspacePermissionModesMap, + permissionModeOptions, +} from './constants'; +import { getPermissionModeId } from './utils'; + +export interface WorkspacePermissionSettingInputProps { + index: number; + deletable: boolean; + type: WorkspacePermissionItemType; + userId?: string; + group?: string; + modes?: WorkspacePermissionMode[]; + onGroupOrUserIdChange: ( + groupOrUserId: + | { type: WorkspacePermissionItemType.User; userId?: string } + | { type: WorkspacePermissionItemType.Group; group?: string }, + index: number + ) => void; + onPermissionModesChange: ( + WorkspacePermissionMode: WorkspacePermissionMode[], + index: number + ) => void; + onDelete: (index: number) => void; +} + +export const WorkspacePermissionSettingInput = ({ + index, + type, + userId, + group, + modes, + deletable, + onDelete, + onGroupOrUserIdChange, + onPermissionModesChange, +}: WorkspacePermissionSettingInputProps) => { + const groupOrUserIdSelectedOptions = useMemo( + () => (group || userId ? [{ label: (group || userId) as string }] : []), + [group, userId] + ); + + const permissionModesSelectedId = useMemo(() => getPermissionModeId(modes ?? []), [modes]); + const handleGroupOrUserIdCreate = useCallback( + (groupOrUserId) => { + onGroupOrUserIdChange( + type === WorkspacePermissionItemType.Group + ? { type, group: groupOrUserId } + : { type, userId: groupOrUserId }, + index + ); + }, + [index, type, onGroupOrUserIdChange] + ); + + const handleGroupOrUserIdChange = useCallback( + (options) => { + if (options.length === 0) { + onGroupOrUserIdChange({ type }, index); + } + }, + [index, type, onGroupOrUserIdChange] + ); + + const handlePermissionModeOptionChange = useCallback( + (id: string) => { + if (optionIdToWorkspacePermissionModesMap[id]) { + onPermissionModesChange([...optionIdToWorkspacePermissionModesMap[id]], index); + } + }, + [index, onPermissionModesChange] + ); + + const handleDelete = useCallback(() => { + onDelete(index); + }, [index, onDelete]); + + return ( + + + + + + + + + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx new file mode 100644 index 000000000000..8d2dacc4165e --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx @@ -0,0 +1,246 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import { EuiButton, EuiFormRow, EuiText, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { WorkspacePermissionSetting } from './types'; +import { + WorkspacePermissionItemType, + optionIdToWorkspacePermissionModesMap, + PermissionModeId, +} from './constants'; +import { + WorkspacePermissionSettingInput, + WorkspacePermissionSettingInputProps, +} from './workspace_permission_setting_input'; +import { generateWorkspacePermissionItemKey, getPermissionModeId } from './utils'; + +interface WorkspacePermissionSettingPanelProps { + errors?: string[]; + lastAdminItemDeletable: boolean; + permissionSettings: Array>; + onChange?: (value: Array>) => void; +} + +interface UserOrGroupSectionProps + extends Omit { + title: string; + nonDeletableIndex: number; + type: WorkspacePermissionItemType; +} + +const UserOrGroupSection = ({ + type, + title, + errors, + onChange, + permissionSettings, + nonDeletableIndex, +}: UserOrGroupSectionProps) => { + const transformedValue = useMemo(() => { + if (!permissionSettings) { + return []; + } + const result: Array> = []; + /** + * One workspace permission setting may include multi setting options, + * for loop the workspace permission setting array to separate it to multi rows. + **/ + for (let i = 0; i < permissionSettings.length; i++) { + const valueItem = permissionSettings[i]; + // Incomplete workspace permission setting don't need to separate to multi rows + if ( + !valueItem.modes || + !valueItem.type || + (valueItem.type === 'user' && !valueItem.userId) || + (valueItem.type === 'group' && !valueItem.group) + ) { + result.push(valueItem); + continue; + } + /** + * For loop the option id to workspace permission modes map, + * if one settings includes all permission modes in a specific option, + * add these permission modes to the result array. + */ + for (const key in optionIdToWorkspacePermissionModesMap) { + if (!Object.prototype.hasOwnProperty.call(optionIdToWorkspacePermissionModesMap, key)) { + continue; + } + const modesForCertainPermissionId = optionIdToWorkspacePermissionModesMap[key]; + if (modesForCertainPermissionId.every((mode) => valueItem.modes?.includes(mode))) { + result.push({ ...valueItem, modes: modesForCertainPermissionId }); + } + } + } + return result; + }, [permissionSettings]); + + // default permission mode is read + const handleAddNewOne = useCallback(() => { + onChange?.([ + ...(transformedValue ?? []), + { type, modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Read] }, + ]); + }, [onChange, type, transformedValue]); + + const handleDelete = useCallback( + (index: number) => { + onChange?.((transformedValue ?? []).filter((_item, itemIndex) => itemIndex !== index)); + }, + [onChange, transformedValue] + ); + + const handlePermissionModesChange = useCallback< + WorkspacePermissionSettingInputProps['onPermissionModesChange'] + >( + (modes, index) => { + onChange?.( + (transformedValue ?? []).map((item, itemIndex) => + index === itemIndex ? { ...item, modes } : item + ) + ); + }, + [onChange, transformedValue] + ); + + const handleGroupOrUserIdChange = useCallback< + WorkspacePermissionSettingInputProps['onGroupOrUserIdChange'] + >( + (userOrGroupIdWithType, index) => { + onChange?.( + (transformedValue ?? []).map((item, itemIndex) => + index === itemIndex + ? { ...userOrGroupIdWithType, ...(item.modes ? { modes: item.modes } : {}) } + : item + ) + ); + }, + [onChange, transformedValue] + ); + + // assume that group items are always deletable + return ( +
+ + {title} + + + {transformedValue?.map((item, index) => ( + + + + + + ))} + + {i18n.translate('workspace.form.permissionSettingPanel.addNew', { + defaultMessage: 'Add New', + })} + +
+ ); +}; + +export const WorkspacePermissionSettingPanel = ({ + errors, + onChange, + permissionSettings, + lastAdminItemDeletable, +}: WorkspacePermissionSettingPanelProps) => { + const userPermissionSettings = useMemo( + () => + permissionSettings?.filter( + (permissionSettingItem) => permissionSettingItem.type === WorkspacePermissionItemType.User + ) ?? [], + [permissionSettings] + ); + const groupPermissionSettings = useMemo( + () => + permissionSettings?.filter( + (permissionSettingItem) => permissionSettingItem.type === WorkspacePermissionItemType.Group + ) ?? [], + [permissionSettings] + ); + + const handleUserPermissionSettingsChange = useCallback( + (newSettings) => { + onChange?.([...newSettings, ...groupPermissionSettings]); + }, + [groupPermissionSettings, onChange] + ); + + const handleGroupPermissionSettingsChange = useCallback( + (newSettings) => { + onChange?.([...userPermissionSettings, ...newSettings]); + }, + [userPermissionSettings, onChange] + ); + + const nonDeletableIndex = useMemo(() => { + let userNonDeletableIndex = -1; + let groupNonDeletableIndex = -1; + const newPermissionSettings = [...userPermissionSettings, ...groupPermissionSettings]; + if (!lastAdminItemDeletable) { + const adminPermissionSettings = newPermissionSettings.filter( + (permission) => getPermissionModeId(permission.modes ?? []) === PermissionModeId.Admin + ); + if (adminPermissionSettings.length === 1) { + if (adminPermissionSettings[0].type === WorkspacePermissionItemType.User) { + userNonDeletableIndex = userPermissionSettings.findIndex( + (permission) => getPermissionModeId(permission.modes ?? []) === PermissionModeId.Admin + ); + } else { + groupNonDeletableIndex = groupPermissionSettings.findIndex( + (permission) => getPermissionModeId(permission.modes ?? []) === PermissionModeId.Admin + ); + } + } + } + return { userNonDeletableIndex, groupNonDeletableIndex }; + }, [userPermissionSettings, groupPermissionSettings, lastAdminItemDeletable]); + + const { userNonDeletableIndex, groupNonDeletableIndex } = nonDeletableIndex; + + return ( +
+ + + +
+ ); +}; diff --git a/src/plugins/workspace/public/hooks.ts b/src/plugins/workspace/public/hooks.ts new file mode 100644 index 000000000000..e84ee46507ef --- /dev/null +++ b/src/plugins/workspace/public/hooks.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApplicationStart, PublicAppInfo } from 'opensearch-dashboards/public'; +import { useObservable } from 'react-use'; +import { useMemo } from 'react'; + +export function useApplications(application: ApplicationStart) { + const applications = useObservable(application.applications$); + return useMemo(() => { + const apps: PublicAppInfo[] = []; + applications?.forEach((app) => { + apps.push(app); + }); + return apps; + }, [applications]); +} diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index f0050879074a..5ecdc219fe96 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -23,7 +23,7 @@ describe('Workspace plugin', () => { await workspacePlugin.setup(setupMock, { savedObjectsManagement: savedObjectManagementSetupMock, }); - expect(setupMock.application.register).toBeCalledTimes(2); + expect(setupMock.application.register).toBeCalledTimes(3); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); expect(savedObjectManagementSetupMock.columns.register).toBeCalledTimes(1); @@ -70,7 +70,7 @@ describe('Workspace plugin', () => { await workspacePlugin.setup(setupMock, { savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract(), }); - expect(setupMock.application.register).toBeCalledTimes(2); + expect(setupMock.application.register).toBeCalledTimes(3); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); expect(setupMock.getStartServices).toBeCalledTimes(1); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index d430caabdd5c..24fb61741cc7 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,6 +4,7 @@ */ import type { Subscription } from 'rxjs'; +import { i18n } from '@osd/i18n'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; import { featureMatchesConfig } from './utils'; import { @@ -19,6 +20,7 @@ import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_LIST_APP_ID, + WORKSPACE_CREATE_APP_ID, } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { renderWorkspaceMenu } from './render_workspace_menu'; @@ -161,6 +163,19 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { }, }); + // create + core.application.register({ + id: WORKSPACE_CREATE_APP_ID, + title: i18n.translate('workspace.settings.workspaceCreate', { + defaultMessage: 'Create Workspace', + }), + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderCreatorApp } = await import('./application'); + return mountWorkspaceApp(params, renderCreatorApp); + }, + }); + // workspace fatal error core.application.register({ id: WORKSPACE_FATAL_ERROR_APP_ID,