diff --git a/src/plugins/workspace/common/permission/constants.ts b/src/plugins/workspace/common/permission/constants.ts new file mode 100644 index 000000000000..8f7913853c5b --- /dev/null +++ b/src/plugins/workspace/common/permission/constants.ts @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspacePermissionMode } from '../../../../core/public'; + +export enum PermissionModeId { + Read = 'read', + ReadAndWrite = 'read+write', + Admin = 'admin', +} + +export const OptionIdToWorkspacePermissionModesMap: Record< + PermissionModeId, + WorkspacePermissionMode[] +> = { + [PermissionModeId.Read]: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + [PermissionModeId.ReadAndWrite]: [ + WorkspacePermissionMode.LibraryWrite, + WorkspacePermissionMode.Read, + ], + [PermissionModeId.Admin]: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], +}; diff --git a/src/plugins/workspace/public/components/workspace_creator/index.tsx b/src/plugins/workspace/public/components/workspace_creator/index.tsx index 83d87debad6e..ffdd51601704 100644 --- a/src/plugins/workspace/public/components/workspace_creator/index.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/index.tsx @@ -4,4 +4,5 @@ */ export { WorkspaceCreator } from './workspace_creator'; -export { WorkspacePermissionSetting } from './workspace_permission_setting_panel'; +export { WorkspaceForm } from './workspace_form'; +export { WorkspaceFormSubmitData, WorkspaceFormData } from './types'; diff --git a/src/plugins/workspace/public/components/workspace_creator/types.ts b/src/plugins/workspace/public/components/workspace_creator/types.ts new file mode 100644 index 000000000000..8d3406e42f50 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/types.ts @@ -0,0 +1,72 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { App, WorkspacePermissionMode } from '../../../../../core/public'; + +export interface WorkspaceFeature extends Pick { + id: string; + name: string; +} + +export interface WorkspaceFeatureGroup { + name: string; + features: WorkspaceFeature[]; +} +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 type WorkspaceFormErrors = Omit< + { [key in keyof WorkspaceFormData]?: string }, + 'permissions' +> & { + userPermissions?: string[]; + groupPermissions?: string[]; +}; + +export enum WorkspacePermissionItemType { + User = 'user', + Group = 'group', +} + +export interface PermissionFieldData { + id: string; + modes: WorkspacePermissionMode[]; +} + +interface UserPermissionSetting { + type: WorkspacePermissionItemType.User; + userId: string; + modes: WorkspacePermissionMode[]; +} + +interface GroupPermissionSetting { + type: WorkspacePermissionItemType.Group; + group: string; + modes: WorkspacePermissionMode[]; +} + +export type WorkspacePermissionSetting = UserPermissionSetting | GroupPermissionSetting; + +export type PermissionEditingData = Array>; + +// when editing, attributes could be undefined in workspace form +export type WorkspaceFormEditingData = Partial< + Omit & { + userPermissions: PermissionEditingData; + groupPermissions: PermissionEditingData; + } +>; diff --git a/src/plugins/workspace/public/components/workspace_creator/utils.test.ts b/src/plugins/workspace/public/components/workspace_creator/utils.test.ts new file mode 100644 index 000000000000..ec6e4e60e385 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/utils.test.ts @@ -0,0 +1,276 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + isValidWorkspacePermissionSetting, + getErrorsCount, + getUserAndGroupPermissions, + getUnsavedChangesCount, + getPermissionModeId, + getPermissionErrors, + formatPermissions, +} from './utils'; +import { + PermissionFieldData, + WorkspaceFormErrors, + WorkspacePermissionItemType, + WorkspacePermissionSetting, +} from './types'; +import { PublicAppInfo, WorkspacePermissionMode } from '../../../../../core/public'; +import { PermissionModeId } from '../../../common/permission/constants'; + +describe('isValidWorkspacePermissionSetting', () => { + it('should return true with valid user permission setting', () => { + expect( + isValidWorkspacePermissionSetting({ + type: WorkspacePermissionItemType.User, + userId: 'test user id', + modes: [WorkspacePermissionMode.Write, WorkspacePermissionMode.LibraryWrite], + }) + ).toBe(true); + }); + + it('should return true with valid group permission setting', () => { + expect( + isValidWorkspacePermissionSetting({ + type: WorkspacePermissionItemType.Group, + group: 'test group id', + modes: [WorkspacePermissionMode.Write, WorkspacePermissionMode.LibraryWrite], + }) + ).toBe(true); + }); + + it('should return false with empty permission modes', () => { + expect( + isValidWorkspacePermissionSetting({ + type: WorkspacePermissionItemType.User, + userId: 'test user id', + modes: [], + }) + ).toBe(false); + }); + + it('should return false with incorrect permission type (expect user)', () => { + expect( + isValidWorkspacePermissionSetting({ + type: WorkspacePermissionItemType.Group, + userId: 'test user id', + modes: [], + } as Partial) + ).toBe(false); + }); + + it('should return false with incorrect permission type 2 (expect group)', () => { + expect( + isValidWorkspacePermissionSetting({ + type: WorkspacePermissionItemType.User, + group: 'test user id', + modes: [], + } as Partial) + ).toBe(false); + }); +}); + +describe('getErrorsCount', () => { + it('should return error count in name, description and permissions', () => { + const workspaceFormErrors: WorkspaceFormErrors = { + name: 'test name error', + description: 'test description error', + userPermissions: ['test user permission error 1'], + groupPermissions: ['test group permission error 1', 'test group permission error 2'], + }; + expect(getErrorsCount(workspaceFormErrors)).toBe(5); + }); +}); + +describe('getUserAndGroupPermissions', () => { + it('should split user and group permissions from all permissions', () => { + const permissions = [ + { type: WorkspacePermissionItemType.User, userId: 'test user id 1', modes: [] }, + { type: WorkspacePermissionItemType.Group, group: 'test group id 1', modes: [] }, + { type: WorkspacePermissionItemType.Group, group: 'test group id 2', modes: [] }, + ] as WorkspacePermissionSetting[]; + expect(getUserAndGroupPermissions(permissions)).toEqual( + expect.arrayContaining([ + [{ id: 'test user id 1', modes: [] }], + [ + { id: 'test group id 1', modes: [] }, + { id: 'test group id 2', modes: [] }, + ], + ]) + ); + }); +}); + +describe('getUnsavedChangesCount', () => { + const allApplications = [ + { id: 'feature 1-1', category: { id: 'category 1' } }, + { id: 'feature 1-2', category: { id: 'category 1' } }, + { id: 'feature 2-1', category: { id: 'category 2' } }, + { id: 'feature 2-2', category: { id: 'category 2' } }, + ] as PublicAppInfo[]; + + it('should return number of unsaved changes in workspace metadata', () => { + const initialFormData = { + id: 'test workspace id', + name: 'test workspace name', + description: 'test workspace description', + permissions: [], + }; + const currentFormData = { + name: 'changed workspace name', + description: 'changed workspace description', + }; + expect(getUnsavedChangesCount(initialFormData, currentFormData, allApplications)).toBe(2); + }); + + it('should return number of unsaved changes in workspace features', () => { + const initialFormData = { + id: 'test workspace id', + name: 'test workspace name', + permissions: [], + features: ['feature 1-1', 'feature 1-2', 'feature 2-1'], + }; + const currentFormData = { + name: 'test workspace name', + features: ['feature 1-1', 'feature 1-2', 'feature 2-2'], + }; + // 1 deleted feature and 1 added feature + expect(getUnsavedChangesCount(initialFormData, currentFormData, allApplications)).toBe(2); + }); + + it('should return number of unsaved changes in workspace features with feature configs', () => { + const initialFormData = { + id: 'test workspace id', + name: 'test workspace name', + permissions: [], + features: ['@category 1', '@category 2'], + }; + const currentFormData = { + name: 'test workspace name', + features: ['@category 1', '!@category 2'], + }; + // 1 deleted feature and 1 added feature + expect(getUnsavedChangesCount(initialFormData, currentFormData, allApplications)).toBe(2); + }); + + it('should return number of unsaved changes in workspace permissions', () => { + const initialFormData = { + id: 'test workspace id', + name: 'test workspace name', + permissions: [ + { + userId: 'test user id 1', + type: WorkspacePermissionItemType.User, + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + { + group: 'test group id', + type: WorkspacePermissionItemType.Group, + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + ] as WorkspacePermissionSetting[], + }; + const currentFormData = { + name: 'test workspace name', + userPermissions: [ + { + id: 'test user id 1', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + }, + ], + groupPermissions: [], + }; + // 1 deleted permission and 1 edited permission + expect(getUnsavedChangesCount(initialFormData, currentFormData, allApplications)).toBe(2); + }); +}); + +describe('getPermissionModeId', () => { + it('should return Read with empty permission', () => { + expect(getPermissionModeId([])).toBe(PermissionModeId.Read); + }); + + it('should return Read with [LibraryRead, Read]', () => { + expect( + getPermissionModeId([WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read]) + ).toBe(PermissionModeId.Read); + }); + + it('should return ReadAndWrite with [LibraryWrite, Read],', () => { + expect( + getPermissionModeId([WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Read]) + ).toBe(PermissionModeId.ReadAndWrite); + }); + + it('should return ReadAndWrite with [LibraryWrite, Read]', () => { + expect( + getPermissionModeId([WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write]) + ).toBe(PermissionModeId.Admin); + }); +}); + +describe('getPermissionErrors', () => { + it('should get permission errors for both users and groups', () => { + const permissions = [ + {}, + { id: 'test permission id' }, + { id: 'test permission id', modes: [] }, + { + id: 'test permission id', + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + ]; + const expectedPermissionErrors = [ + 'Invalid id', + 'Invalid permission modes', + 'Invalid permission modes', + ]; + expect(getPermissionErrors(permissions)).toEqual( + expect.arrayContaining(expectedPermissionErrors) + ); + }); +}); + +describe('formatPermissions', () => { + it('should get permission errors for both users and groups', () => { + const userPermissions: PermissionFieldData[] = [ + { + id: 'read user', + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + { + id: 'admin user', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + }, + ]; + const groupPermissions: PermissionFieldData[] = [ + { + id: 'read group', + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + ]; + const expectedPermissions: WorkspacePermissionSetting[] = [ + { + userId: 'read user', + type: WorkspacePermissionItemType.User, + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + { + userId: 'admin user', + type: WorkspacePermissionItemType.User, + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + }, + { + group: 'read group', + type: WorkspacePermissionItemType.Group, + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + ]; + expect(formatPermissions(userPermissions, groupPermissions)).toEqual( + expect.arrayContaining(expectedPermissions) + ); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_creator/utils.ts b/src/plugins/workspace/public/components/workspace_creator/utils.ts new file mode 100644 index 000000000000..9e1a04a3c46c --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/utils.ts @@ -0,0 +1,211 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { PublicAppInfo, WorkspacePermissionMode } from '../../../../../core/public'; +import { featureMatchesConfig } from '../../utils'; +import { + OptionIdToWorkspacePermissionModesMap, + PermissionModeId, +} from '../../../common/permission/constants'; +import { + PermissionEditingData, + WorkspaceFormEditingData, + WorkspaceFormErrors, + WorkspacePermissionSetting, + WorkspacePermissionItemType, + WorkspaceFormData, + PermissionFieldData, +} from './types'; + +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 getErrorsCount = (formErrors: WorkspaceFormErrors) => { + let errorsCount = 0; + if (formErrors.name) { + errorsCount += 1; + } + if (formErrors.description) { + errorsCount += 1; + } + if (formErrors.userPermissions) { + errorsCount += formErrors.userPermissions.filter((permission) => !!permission).length; + } + if (formErrors.groupPermissions) { + errorsCount += formErrors.groupPermissions.filter((permission) => !!permission).length; + } + return errorsCount; +}; + +export const getUserAndGroupPermissions = ( + permissions: WorkspacePermissionSetting[] +): PermissionFieldData[][] => { + const userPermissions: PermissionFieldData[] = []; + const groupPermissions: PermissionFieldData[] = []; + for (const permission of permissions) { + if (permission.type === WorkspacePermissionItemType.User) { + userPermissions.push({ id: permission.userId, modes: permission.modes }); + } + if (permission.type === WorkspacePermissionItemType.Group) { + groupPermissions.push({ id: permission.group, modes: permission.modes }); + } + } + return [userPermissions, groupPermissions]; +}; + +const getUnsavedFeaturesCount = ( + initialFeatureConfig: string[], + currentFeatureConfig: string[], + allApplications: PublicAppInfo[] +) => { + // for features, unsaved changes is the sum of # deleted features and # added features + const initialFeatures = allApplications.filter(featureMatchesConfig(initialFeatureConfig)); + const currentFeatures = allApplications.filter(featureMatchesConfig(currentFeatureConfig)); + const featureIntersectionCount = ( + initialFeatures.filter((feature) => currentFeatures.includes(feature)) ?? [] + ).length; + return initialFeatures.length + currentFeatures.length - featureIntersectionCount * 2; +}; + +const getUnsavedPermissionsCount = ( + initialPermissions: PermissionEditingData, + currentPermissions: PermissionEditingData +) => { + // for permission data being edited, unsaved changes is the sum of + // # deleted permissions, # added permissions and # edited permissions + let addedPermissions = 0; + let editedPermissions = 0; + const initialPermissionMap = new Map(); + for (const permission of initialPermissions) { + if (permission.id) { + initialPermissionMap.set(permission.id, getPermissionModeId(permission.modes ?? [])); + } + } + + for (const permission of currentPermissions) { + if (!permission.id) { + addedPermissions += 1; // added permissions + } else { + const permissionMode = initialPermissionMap.get(permission.id); + if (!permissionMode) { + addedPermissions += 1; + } else if (permissionMode !== getPermissionModeId(permission.modes ?? [])) { + editedPermissions += 1; // added or edited permissions + } + } + } + + // currentPermissions.length = initialPermissions.length + # added permissions - # deleted permissions + const deletedPermissions = + addedPermissions + initialPermissions.length - currentPermissions.length; + + return addedPermissions + editedPermissions + deletedPermissions; +}; + +export const getUnsavedChangesCount = ( + initialFormData: WorkspaceFormData, + currentFormData: WorkspaceFormEditingData, + allApplications: PublicAppInfo[] +) => { + let unsavedChangesCount = 0; + if (initialFormData.name !== currentFormData.name) { + unsavedChangesCount += 1; + } + // initial and current description could be undefined + const initialDescription = initialFormData.description ?? ''; + const currentDescription = currentFormData.description ?? ''; + if (initialDescription !== currentDescription) { + unsavedChangesCount += 1; + } + if (initialFormData.color !== currentFormData.color) { + unsavedChangesCount += 1; + } + if (initialFormData.icon !== currentFormData.icon) { + unsavedChangesCount += 1; + } + if (initialFormData.defaultVISTheme !== currentFormData.defaultVISTheme) { + unsavedChangesCount += 1; + } + unsavedChangesCount += getUnsavedFeaturesCount( + initialFormData.features ?? [], + currentFormData.features ?? [], + allApplications + ); + // for permissions, unsaved changes is the sum of # unsaved user permissions and # unsaved group permissions + const [initialUserPermissions, initialGroupPermissions] = getUserAndGroupPermissions( + initialFormData.permissions ?? [] + ); + unsavedChangesCount += getUnsavedPermissionsCount( + initialUserPermissions, + currentFormData.userPermissions ?? [] + ); + unsavedChangesCount += getUnsavedPermissionsCount( + initialGroupPermissions, + currentFormData.groupPermissions ?? [] + ); + return unsavedChangesCount; +}; + +// default permission mode is read +export const getPermissionModeId = (modes: WorkspacePermissionMode[]): PermissionModeId => { + let key: PermissionModeId; + for (key in OptionIdToWorkspacePermissionModesMap) { + if (OptionIdToWorkspacePermissionModesMap[key].every((mode) => modes?.includes(mode))) { + return key; + } + } + return PermissionModeId.Read; +}; + +export const getPermissionErrors = (permissions: PermissionEditingData) => { + const permissionErrors: string[] = new Array(permissions.length); + for (let i = 0; i < permissions.length; i++) { + const permission = permissions[i]; + if (isValidWorkspacePermissionSetting(permission)) { + continue; + } + if (!permission.id) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.id', { + defaultMessage: 'Invalid id', + }); + continue; + } + if (!permission.modes || permission.modes.length === 0) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.modes', { + defaultMessage: 'Invalid permission modes', + }); + continue; // this line is need for more conditions + } + } + return permissionErrors; +}; + +export const formatPermissions = ( + userPermissions: PermissionFieldData[], + groupPermissions: PermissionFieldData[] +): WorkspacePermissionSetting[] => { + const permissions: WorkspacePermissionSetting[] = []; + for (const permission of userPermissions) { + permissions.push({ + userId: permission.id, + modes: permission.modes, + type: WorkspacePermissionItemType.User, + }); + } + for (const permission of groupPermissions) { + permissions.push({ + group: permission.id, + modes: permission.modes, + type: WorkspacePermissionItemType.Group, + }); + } + return permissions; +}; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_bottom_bar.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_bottom_bar.tsx index 79f1f92c8685..d889d844bce6 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_bottom_bar.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_bottom_bar.tsx @@ -21,7 +21,8 @@ import { WorkspaceCancelModal } from './workspace_cancel_modal'; interface WorkspaceBottomBarProps { formId: string; opType?: string; - numberOfErrors: number; + errorsCount: number; + unsavedChangesCount: number; application: ApplicationStart; } @@ -29,7 +30,8 @@ interface WorkspaceBottomBarProps { export const WorkspaceBottomBar = ({ formId, opType, - numberOfErrors, + errorsCount, + unsavedChangesCount, application, }: WorkspaceBottomBarProps) => { const [isCancelModalVisible, setIsCancelModalVisible] = useState(false); @@ -47,13 +49,13 @@ export const WorkspaceBottomBar = ({ {opType === WORKSPACE_OP_TYPE_UPDATE ? ( {i18n.translate('workspace.form.bottomBar.unsavedChanges', { - defaultMessage: '1 Unsaved change(s)', + defaultMessage: `${unsavedChangesCount} Unsaved change(s)`, })} ) : ( {i18n.translate('workspace.form.bottomBar.errors', { - defaultMessage: `${numberOfErrors} Error(s)`, + defaultMessage: `${errorsCount} Error(s)`, })} )} @@ -85,7 +87,13 @@ export const WorkspaceBottomBar = ({ )} {opType === WORKSPACE_OP_TYPE_UPDATE && ( - + {i18n.translate('workspace.form.bottomBar.saveChanges', { defaultMessage: 'Save changes', })} 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 index 9ba73b8e8b32..1bb4a3edb0b5 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -208,7 +208,7 @@ describe('WorkspaceCreator', () => { }); fireEvent.click(getByText('Users & Permissions')); fireEvent.click(getByTestId('workspaceForm-permissionSettingPanel-user-addNew')); - const userIdInput = getByTestId('workspaceForm-permissionSettingPanel-0-userId'); + const userIdInput = getByTestId('workspaceForm-permissionSettingPanel-user-0-id'); fireEvent.click(userIdInput); fireEvent.input(getByTestId('comboBoxSearchInput'), { target: { value: 'test user id' }, diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index 22663aad1dc0..df2c315f8772 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -7,7 +7,8 @@ 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 } from './workspace_form'; +import { WorkspaceForm } from './workspace_form'; +import { WorkspaceFormSubmitData } from './types'; import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_OP_TYPE_CREATE } from '../../../common/constants'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { WorkspaceClient } from '../../workspace_client'; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx index b2ff40855a9d..ecc80cf62d85 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -15,6 +15,7 @@ import { EuiSelect, EuiText, EuiFlexItem, + EuiFlexGrid, htmlIdGenerator, EuiCheckbox, EuiCheckboxGroup, @@ -23,21 +24,23 @@ import { EuiFieldTextProps, EuiColorPicker, EuiColorPickerProps, - EuiHorizontalRule, EuiFlexGroup, EuiTab, EuiTabs, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { - App, AppNavLinkStatus, ApplicationStart, DEFAULT_APP_CATEGORIES, MANAGEMENT_WORKSPACE_ID, } from '../../../../../core/public'; import { useApplications } from '../../hooks'; -import { DEFAULT_CHECKED_FEATURES_IDS } from '../../../common/constants'; +import { + DEFAULT_CHECKED_FEATURES_IDS, + WORKSPACE_OP_TYPE_CREATE, + WORKSPACE_OP_TYPE_UPDATE, +} from '../../../common/constants'; import { isFeatureDependBySelectedFeatures, getFinalFeatureIdsByDependency, @@ -45,60 +48,36 @@ import { } from '../utils/feature'; import { WorkspaceBottomBar } from './workspace_bottom_bar'; import { WorkspaceIconSelector } from './workspace_icon_selector'; -import { - WorkspacePermissionSetting, - WorkspacePermissionItemType, - WorkspacePermissionSettingPanel, -} from './workspace_permission_setting_panel'; +import { WorkspacePermissionSettingPanel } from './workspace_permission_setting_panel'; import { featureMatchesConfig } from '../../utils'; +import { + formatPermissions, + getErrorsCount, + getPermissionErrors, + getUnsavedChangesCount, + getUserAndGroupPermissions, +} from './utils'; +import { + WorkspaceFeature, + WorkspaceFeatureGroup, + WorkspaceFormData, + WorkspaceFormErrors, + WorkspaceFormSubmitData, + PermissionFieldData, + PermissionEditingData, +} from './types'; enum WorkspaceFormTabs { NotSelected, + WorkspaceSettings, FeatureVisibility, UsersAndPermissions, } -interface WorkspaceFeature extends Pick { - id: string; - name: string; -} - -interface WorkspaceFeatureGroup { - name: string; - features: WorkspaceFeature[]; -} - -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; -} - -type WorkspaceFormErrors = Omit<{ [key in keyof WorkspaceFormData]?: string }, 'permissions'> & { - permissions?: string[]; -}; - const isWorkspaceFeatureGroup = ( featureOrGroup: WorkspaceFeature | WorkspaceFeatureGroup ): featureOrGroup is WorkspaceFeatureGroup => 'features' in featureOrGroup; -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)); - const isDefaultCheckedFeatureId = (id: string) => { return DEFAULT_CHECKED_FEATURES_IDS.indexOf(id) > -1; }; @@ -116,34 +95,6 @@ const isValidNameOrDescription = (input?: string) => { return regex.test(input); }; -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; -}; - -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) - ); - const workspaceHtmlIdGenerator = htmlIdGenerator(); const defaultVISThemeOptions = [{ value: 'categorical', text: 'Categorical' }]; @@ -183,7 +134,6 @@ export const WorkspaceForm = ({ ? WorkspaceFormTabs.UsersAndPermissions : WorkspaceFormTabs.NotSelected ); - 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(() => { @@ -203,15 +153,32 @@ export const WorkspaceForm = ({ const [selectedFeatureIds, setSelectedFeatureIds] = useState( appendDefaultFeatureIds(defaultFeatures) ); - const [permissionSettings, setPermissionSettings] = useState< - Array> - >( + const [initialUserPermissions, initialGroupPermissions] = getUserAndGroupPermissions( defaultValues?.permissions && defaultValues.permissions.length > 0 ? defaultValues.permissions : [] ); + const [userPermissions, setUserPermissions] = useState( + initialUserPermissions + ); + const [groupPermissions, setGroupPermissions] = useState( + initialGroupPermissions + ); + + const libraryCategoryLabel = i18n.translate('core.ui.libraryNavList.label', { + defaultMessage: 'Library', + }); + const categoryToDescription: { [key: string]: string } = { + [libraryCategoryLabel]: i18n.translate( + 'workspace.form.featureVisibility.libraryCategory.Description', + { + defaultMessage: 'Workspace-owned library items', + } + ), + }; const [formErrors, setFormErrors] = useState({}); + const errorsCount = useMemo(() => getErrorsCount(formErrors), [formErrors]); const formIdRef = useRef(); const getFormData = () => ({ name, @@ -220,11 +187,41 @@ export const WorkspaceForm = ({ color, icon, defaultVISTheme, - permissions: permissionSettings, + userPermissions, + groupPermissions, }); const getFormDataRef = useRef(getFormData); getFormDataRef.current = getFormData; + const unsavedChangesCount = useMemo(() => { + const currentFormData = { + name, + description, + features: selectedFeatureIds, + color, + icon, + defaultVISTheme, + userPermissions, + groupPermissions, + }; + return getUnsavedChangesCount( + defaultValues ?? ({} as WorkspaceFormData), + currentFormData, + applications + ); + }, [ + name, + description, + selectedFeatureIds, + color, + icon, + defaultVISTheme, + userPermissions, + groupPermissions, + defaultValues, + applications, + ]); + const featureOrGroups = useMemo(() => { const transformedApplications = applications.map((app) => { if (app.category?.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id) { @@ -232,9 +229,7 @@ export const WorkspaceForm = ({ ...app, category: { ...app.category, - label: i18n.translate('core.ui.libraryNavList.label', { - defaultMessage: 'Library', - }), + label: libraryCategoryLabel, }, }; } @@ -271,7 +266,7 @@ export const WorkspaceForm = ({ }, ]; }, []); - }, [applications]); + }, [applications, libraryCategoryLabel]); const allFeatures = useMemo( () => @@ -387,55 +382,23 @@ export const WorkspaceForm = ({ }), }; } - 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 - } + const userPermissionErrors = getPermissionErrors(formData.userPermissions); + const groupPermissionErrors = getPermissionErrors(formData.groupPermissions); + if (userPermissionErrors.some((error) => !!error)) { + currentFormErrors = { + ...currentFormErrors, + userPermissions: userPermissionErrors, + }; } - if (permissionErrors.some((error) => !!error)) { + if (groupPermissionErrors.some((error) => !!error)) { currentFormErrors = { ...currentFormErrors, - permissions: permissionErrors, + groupPermissions: groupPermissionErrors, }; } - const currentNumberOfErrors = getNumberOfErrors(currentFormErrors); + const currentErrorsCount = getErrorsCount(currentFormErrors); setFormErrors(currentFormErrors); - setNumberOfErrors(currentNumberOfErrors); - if (currentNumberOfErrors > 0) { + if (currentErrorsCount > 0) { return; } @@ -449,12 +412,25 @@ export const WorkspaceForm = ({ // 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 + // be expanded to feature ids formData.features = defaultValues?.features ?? []; } - const permissions = formData.permissions.filter(isValidWorkspacePermissionSetting); - onSubmit?.({ ...formData, name: formData.name!, permissions }); + // Create a new object without the specified properties + // If there are no form errors, attributes are available in TypelessPermissionSetting + const { + ['userPermissions']: formDataUserPermissions, + ['groupPermissions']: formDataGroupPermissions, + ...formDataWithoutPermissions + } = formData; + onSubmit?.({ + ...formDataWithoutPermissions, + name: formData.name!, + permissions: formatPermissions( + formDataUserPermissions as PermissionFieldData[], + formDataGroupPermissions as PermissionFieldData[] + ), + }); }, [defaultFeatures, onSubmit, defaultValues?.features] ); @@ -483,150 +459,227 @@ export const WorkspaceForm = ({ setSelectedTab(WorkspaceFormTabs.UsersAndPermissions); }, []); + const handleTabWorkspaceSettingsClick = useCallback(() => { + setSelectedTab(WorkspaceFormTabs.WorkspaceSettings); + }, []); + const onDefaultVISThemeChange = (e: React.ChangeEvent) => { setDefaultVISTheme(e.target.value); }; + const workspaceOverviewTitle = i18n.translate('workspace.form.overview.title', { + defaultMessage: 'Overview', + }); const workspaceDetailsTitle = i18n.translate('workspace.form.workspaceDetails.title', { defaultMessage: 'Workspace Details', }); const featureVisibilityTitle = i18n.translate('workspace.form.featureVisibility.title', { defaultMessage: 'Feature Visibility', }); + const workspaceSettingsTitle = i18n.translate('workspace.form.workspaceSettings.title', { + defaultMessage: 'Workspace Settings', + }); const usersAndPermissionsTitle = i18n.translate('workspace.form.usersAndPermissions.title', { defaultMessage: 'Users & Permissions', }); - const libraryCategoryLabel = i18n.translate('core.ui.libraryNavList.label', { - defaultMessage: 'Library', - }); - const categoryToDescription: { [key: string]: string } = { - [libraryCategoryLabel]: i18n.translate( - 'workspace.form.featureVisibility.libraryCategory.Description', - { - defaultMessage: 'Workspace-owned library items', - } - ), - }; - 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', - })} + const workspaceOverviewSection = ( + + +

{workspaceOverviewTitle}

+
+ + + + <> + + + {i18n.translate('workspace.form.overview.workspaceNameTitle', { + defaultMessage: 'Name', + })} + - - -
-
- - - - - {defaultValues?.name} + + + + <> + + + {i18n.translate('workspace.form.overview.lastUpdatedTimeTitle', { + defaultMessage: 'Last Updated', + })} + + + {defaultValues?.name} + + + + <> + + + {i18n.translate('workspace.form.overview.createdTimeTitle', { + defaultMessage: 'Created', + })} + + + {defaultValues?.name} + + + + <> + + + {i18n.translate('workspace.form.overview.workspaceDescriptionTitle', { + defaultMessage: 'Workspace Description', + })} + + + {defaultValues?.description} + + + +
+ ); + + const workspaceInfoSection = ( + + +

+ {opType === WORKSPACE_OP_TYPE_UPDATE ? workspaceSettingsTitle : 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', + })} + + + - - - +
+
+ + + + + + +
+ ); + return ( + + {opType === WORKSPACE_OP_TYPE_UPDATE && workspaceOverviewSection} + {opType === WORKSPACE_OP_TYPE_CREATE && workspaceInfoSection} + {!isEditingManagementWorkspace && ( {featureVisibilityTitle} )} + {opType === WORKSPACE_OP_TYPE_UPDATE && ( + + {workspaceSettingsTitle} + + )} {permissionEnabled && ( {usersAndPermissionsTitle} )} + {opType === WORKSPACE_OP_TYPE_UPDATE && + selectedTab === WorkspaceFormTabs.WorkspaceSettings && + workspaceInfoSection} + {selectedTab === WorkspaceFormTabs.FeatureVisibility && (

{featureVisibilityTitle}

- - + {featureOrGroups.map((featureOrGroup) => { const features = isWorkspaceFeatureGroup(featureOrGroup) ? featureOrGroup.features : []; const selectedIds = selectedFeatureIds.filter((id) => @@ -704,11 +757,14 @@ export const WorkspaceForm = ({

{usersAndPermissionsTitle}

- + @@ -719,7 +775,8 @@ export const WorkspaceForm = ({ opType={opType} formId={formIdRef.current} application={application} - numberOfErrors={numberOfErrors} + errorsCount={errorsCount} + unsavedChangesCount={unsavedChangesCount} />
); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_permission_setting_panel.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_permission_setting_panel.tsx index d95b062db3c4..eaaaec7f1ba6 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_permission_setting_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_permission_setting_panel.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiFlexGroup, EuiComboBox, @@ -17,21 +17,12 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { WorkspacePermissionMode } from '../../../../../core/public'; - -export enum WorkspacePermissionItemType { - User = 'user', - Group = 'group', -} - -export type WorkspacePermissionSetting = - | { type: WorkspacePermissionItemType.User; userId: string; modes: WorkspacePermissionMode[] } - | { type: WorkspacePermissionItemType.Group; group: string; modes: WorkspacePermissionMode[] }; - -enum PermissionModeId { - Read = 'read', - ReadAndWrite = 'read+write', - Admin = 'admin', -} +import { + PermissionModeId, + OptionIdToWorkspacePermissionModesMap, +} from '../../../common/permission/constants'; +import { WorkspacePermissionItemType, PermissionEditingData } from './types'; +import { getPermissionModeId } from './utils'; const permissionModeOptions = [ { @@ -57,52 +48,15 @@ const permissionModeOptions = [ }, ]; -const optionIdToWorkspacePermissionModesMap: { - [key: string]: WorkspacePermissionMode[]; -} = { - [PermissionModeId.Read]: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], - [PermissionModeId.ReadAndWrite]: [ - WorkspacePermissionMode.LibraryWrite, - WorkspacePermissionMode.Read, - ], - [PermissionModeId.Admin]: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], -}; - -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 -const getPermissionModeId = (modes: WorkspacePermissionMode[]) => { - for (const key in optionIdToWorkspacePermissionModesMap) { - if (optionIdToWorkspacePermissionModesMap[key].every((mode) => modes?.includes(mode))) { - return key; - } - } - return PermissionModeId.Read; -}; +const generateWorkspacePermissionItemKey = (type: string, index: number) => [type, index].join('-'); 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; + permissionId?: string; + permissionModes?: WorkspacePermissionMode[]; + onIdChange: (index: number, id?: string) => void; onPermissionModesChange: ( WorkspacePermissionMode: WorkspacePermissionMode[], index: number @@ -113,45 +67,45 @@ interface WorkspacePermissionSettingInputProps { const WorkspacePermissionSettingInput = ({ index, type, - userId, - group, - modes, + permissionModes, deletable, onDelete, - onGroupOrUserIdChange, + onIdChange, + permissionId, onPermissionModesChange, }: WorkspacePermissionSettingInputProps) => { - const groupOrUserIdSelectedOptions = useMemo( - () => (group || userId ? [{ label: (group || userId) as string }] : []), - [group, userId] + const idSelectedOptions = useMemo( + () => (permissionId ? [{ label: permissionId as string }] : []), + [permissionId] ); - const permissionModesSelectedId = useMemo(() => getPermissionModeId(modes ?? []), [modes]); - const handleGroupOrUserIdCreate = useCallback( - (groupOrUserId) => { - onGroupOrUserIdChange( - type === WorkspacePermissionItemType.Group - ? { type, group: groupOrUserId } - : { type, userId: groupOrUserId }, - index - ); + const permissionModesSelectedId = useMemo(() => getPermissionModeId(permissionModes ?? []), [ + permissionModes, + ]); + + const handleIdCreate = useCallback( + (createdId) => { + onIdChange(index, createdId); }, - [index, type, onGroupOrUserIdChange] + [index, onIdChange] ); - const handleGroupOrUserIdChange = useCallback( + const handleIdChange = useCallback( (options) => { if (options.length === 0) { - onGroupOrUserIdChange({ type }, index); + onIdChange(index); } }, - [index, type, onGroupOrUserIdChange] + [index, onIdChange] ); const handlePermissionModeOptionChange = useCallback( - (id: string) => { - if (optionIdToWorkspacePermissionModesMap[id]) { - onPermissionModesChange([...optionIdToWorkspacePermissionModesMap[id]], index); + (changedId: string) => { + if (OptionIdToWorkspacePermissionModesMap[changedId as PermissionModeId]) { + onPermissionModesChange( + [...OptionIdToWorkspacePermissionModesMap[changedId as PermissionModeId]], + index + ); } }, [index, onPermissionModesChange] @@ -166,12 +120,12 @@ const WorkspacePermissionSettingInput = ({ @@ -200,17 +154,22 @@ const WorkspacePermissionSettingInput = ({ }; interface WorkspacePermissionSettingPanelProps { - errors?: string[]; - lastAdminItemDeletable: boolean; - permissionSettings: Array>; - onChange?: (value: Array>) => void; + userErrors?: string[]; + groupErrors?: string[]; + lastAdminItemDeletable?: boolean; + userPermissionSettings: PermissionEditingData; + groupPermissionSettings: PermissionEditingData; + onUserPermissionChange: (value: PermissionEditingData) => void; + onGroupPermissionChange: (value: PermissionEditingData) => void; } -interface UserOrGroupSectionProps - extends Omit { +interface UserOrGroupSectionProps { title: string; + errors?: string[]; nonDeletableIndex: number; type: WorkspacePermissionItemType; + permissionSettings: PermissionEditingData; + onChange: (value: PermissionEditingData) => void; } const UserOrGroupSection = ({ @@ -221,58 +180,19 @@ const UserOrGroupSection = ({ 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] }, + ...(permissionSettings ?? []), + { modes: OptionIdToWorkspacePermissionModesMap[PermissionModeId.Read] }, ]); - }, [onChange, type, transformedValue]); + }, [onChange, permissionSettings]); const handleDelete = useCallback( (index: number) => { - onChange?.((transformedValue ?? []).filter((_item, itemIndex) => itemIndex !== index)); + onChange?.((permissionSettings ?? []).filter((_item, itemIndex) => itemIndex !== index)); }, - [onChange, transformedValue] + [onChange, permissionSettings] ); const handlePermissionModesChange = useCallback< @@ -280,27 +200,23 @@ const UserOrGroupSection = ({ >( (modes, index) => { onChange?.( - (transformedValue ?? []).map((item, itemIndex) => + (permissionSettings ?? []).map((item, itemIndex) => index === itemIndex ? { ...item, modes } : item ) ); }, - [onChange, transformedValue] + [onChange, permissionSettings] ); - const handleGroupOrUserIdChange = useCallback< - WorkspacePermissionSettingInputProps['onGroupOrUserIdChange'] - >( - (userOrGroupIdWithType, index) => { + const handleIdChange = useCallback( + (index: number, id?: string) => { onChange?.( - (transformedValue ?? []).map((item, itemIndex) => - index === itemIndex - ? { ...userOrGroupIdWithType, ...(item.modes ? { modes: item.modes } : {}) } - : item + (permissionSettings ?? []).map((item, itemIndex) => + index === itemIndex ? { id, ...(item.modes ? { modes: item.modes } : {}) } : item ) ); }, - [onChange, transformedValue] + [onChange, permissionSettings] ); // assume that group items are always deletable @@ -310,16 +226,17 @@ const UserOrGroupSection = ({ {title} - {transformedValue?.map((item, index) => ( - + {permissionSettings?.map((permissionItem, index) => ( + @@ -340,30 +257,14 @@ const UserOrGroupSection = ({ }; export const WorkspacePermissionSettingPanel = ({ - errors, - onChange, - permissionSettings, + userErrors, + groupErrors, + onUserPermissionChange, + onGroupPermissionChange, + userPermissionSettings, + groupPermissionSettings, lastAdminItemDeletable, }: WorkspacePermissionSettingPanelProps) => { - const [userPermissionSettings, setUserPermissionSettings] = useState< - Array> - >( - permissionSettings?.filter( - (permissionSettingItem) => permissionSettingItem.type === WorkspacePermissionItemType.User - ) ?? [] - ); - const [groupPermissionSettings, setGroupPermissionSettings] = useState< - Array> - >( - permissionSettings?.filter( - (permissionSettingItem) => permissionSettingItem.type === WorkspacePermissionItemType.Group - ) ?? [] - ); - - useEffect(() => { - onChange?.([...userPermissionSettings, ...groupPermissionSettings]); - }, [onChange, userPermissionSettings, groupPermissionSettings]); - const nonDeletableIndex = useMemo(() => { let userNonDeletableIndex = -1; let groupNonDeletableIndex = -1; @@ -373,15 +274,12 @@ export const WorkspacePermissionSettingPanel = ({ (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 - ); - } + userNonDeletableIndex = userPermissionSettings.findIndex( + (permission) => getPermissionModeId(permission.modes ?? []) === PermissionModeId.Admin + ); + groupNonDeletableIndex = groupPermissionSettings.findIndex( + (permission) => getPermissionModeId(permission.modes ?? []) === PermissionModeId.Admin + ); } } return { userNonDeletableIndex, groupNonDeletableIndex }; @@ -395,8 +293,8 @@ export const WorkspacePermissionSettingPanel = ({ title={i18n.translate('workspace.form.permissionSettingPanel.userTitle', { defaultMessage: 'User', })} - errors={errors} - onChange={setUserPermissionSettings} + errors={userErrors} + onChange={onUserPermissionChange} nonDeletableIndex={userNonDeletableIndex} permissionSettings={userPermissionSettings} type={WorkspacePermissionItemType.User} @@ -406,8 +304,8 @@ export const WorkspacePermissionSettingPanel = ({ title={i18n.translate('workspace.form.permissionSettingPanel.userGroupTitle', { defaultMessage: 'User Groups', })} - errors={errors} - onChange={setGroupPermissionSettings} + errors={groupErrors} + onChange={onGroupPermissionChange} nonDeletableIndex={groupNonDeletableIndex} permissionSettings={groupPermissionSettings} type={WorkspacePermissionItemType.Group} diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx new file mode 100644 index 000000000000..b08809a792a9 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx @@ -0,0 +1,158 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { PublicAppInfo, WorkspaceObject } from 'opensearch-dashboards/public'; +import { fireEvent, render } from '@testing-library/react'; +import { BehaviorSubject } from 'rxjs'; +import { WorkspaceUpdater as WorkspaceUpdaterComponent } from './workspace_updater'; +import { coreMock } from '../../../../../core/public/mocks'; +import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; +import { + WORKSPACE_UPDATE_APP_ID, + WORKSPACE_OVERVIEW_APP_ID, + DEFAULT_CHECKED_FEATURES_IDS, +} from '../../../common/constants'; + +const workspaceClientUpdate = jest + .fn() + .mockReturnValue({ result: { id: 'successResult' }, success: true }); + +const navigateToApp = jest.fn(); +const notificationToastsAddSuccess = jest.fn(); +const notificationToastsAddDanger = jest.fn(); + +// upon initialization on workspace settings page, two features (workspace overview and workspace settings) are automatically checked +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' } }], + [WORKSPACE_UPDATE_APP_ID, { id: WORKSPACE_UPDATE_APP_ID, title: 'Workspace Settings' }], + [WORKSPACE_OVERVIEW_APP_ID, { id: WORKSPACE_OVERVIEW_APP_ID, title: 'Overview' }], +]); + +const mockCoreStart = coreMock.createStart(); + +const WorkspaceUpdater = (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), + }, + http: { + ...mockCoreStart.http, + basePath: { + ...mockCoreStart.http.basePath, + remove: jest.fn(), + prepend: jest.fn(), + }, + }, + notifications: { + ...mockCoreStart.notifications, + toasts: { + ...mockCoreStart.notifications.toasts, + addDanger: notificationToastsAddDanger, + addSuccess: notificationToastsAddSuccess, + }, + }, + workspaceClient: { + update: workspaceClientUpdate, + }, + workspaces: { + ...mockCoreStart.workspaces, + currentWorkspace$: new BehaviorSubject({ + id: 'test workspace id', + name: 'test workspace name', + features: DEFAULT_CHECKED_FEATURES_IDS, + }), + }, + }, + }); + + return ( + + + + ); +}; + +function clearMockedFunctions() { + workspaceClientUpdate.mockClear(); + notificationToastsAddDanger.mockClear(); + notificationToastsAddSuccess.mockClear(); +} + +describe('WorkspaceUpdater', () => { + 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 update workspace when name empty', async () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('workspaceForm-tabSelection-workspaceSettings')); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: '' }, + }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); + expect(workspaceClientUpdate).not.toHaveBeenCalled(); + }); + + it('cannot update workspace with invalid name', async () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('workspaceForm-tabSelection-workspaceSettings')); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: '~' }, + }); + expect(workspaceClientUpdate).not.toHaveBeenCalled(); + }); + + it('cannot create workspace with invalid description', async () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('workspaceForm-tabSelection-workspaceSettings')); + const descriptionInput = getByTestId('workspaceForm-workspaceDetails-descriptionInputText'); + fireEvent.input(descriptionInput, { + target: { value: '~' }, + }); + expect(workspaceClientUpdate).not.toHaveBeenCalled(); + }); + + it('cancel update workspace', async () => { + const { findByText, getByTestId } = render(); + fireEvent.click(getByTestId('workspaceForm-bottomBar-cancelButton')); + await findByText('Discard changes?'); + fireEvent.click(getByTestId('confirmModalConfirmButton')); + expect(navigateToApp).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx index 59268fa7d917..f2ae052a59dc 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -16,25 +16,21 @@ import { import { useObservable } from 'react-use'; import { i18n } from '@osd/i18n'; import { of } from 'rxjs'; -import { WorkspaceAttribute } from 'opensearch-dashboards/public'; +import { WorkspaceObject } from 'opensearch-dashboards/public'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; -import { - WorkspaceForm, - WorkspaceFormSubmitData, - WorkspaceFormData, -} from '../workspace_creator/workspace_form'; +import { WorkspaceForm, WorkspaceFormSubmitData, WorkspaceFormData } from '../workspace_creator/'; import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { WorkspaceClient } from '../../workspace_client'; import { WorkspacePermissionSetting } from '../'; -interface WorkspaceWithPermission extends WorkspaceAttribute { +interface WorkspaceWithPermission extends WorkspaceObject { permissions?: WorkspacePermissionSetting[]; } function getFormDataFromWorkspace( - currentWorkspace: WorkspaceAttribute | null | undefined + currentWorkspace: WorkspaceObject | null | undefined ): WorkspaceFormData { const currentWorkspaceWithPermission = (currentWorkspace || {}) as WorkspaceWithPermission; return {