diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 036aa4ef7ce6..ef0821c88619 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,9 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { i18n } from '@osd/i18n'; -import { AppCategory } from '../../../core/types'; - export const WORKSPACE_CREATE_APP_ID = 'workspace_create'; export const WORKSPACE_LIST_APP_ID = 'workspace_list'; export const WORKSPACE_UPDATE_APP_ID = 'workspace_update'; @@ -22,12 +19,3 @@ export const PATHS = { export const WORKSPACE_OP_TYPE_CREATE = 'create'; export const WORKSPACE_OP_TYPE_UPDATE = 'update'; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; - -export const WORKSPACE_NAV_CATEGORY: AppCategory = { - id: 'workspace', - label: i18n.translate('core.ui.workspaceNavList.label', { - defaultMessage: 'Workspaces', - }), - euiIconType: 'folderClosed', - order: 2000, -}; 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 new file mode 100644 index 000000000000..79f1f92c8685 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_bottom_bar.tsx @@ -0,0 +1,105 @@ +/* + * 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 { WORKSPACE_OP_TYPE_CREATE, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; +import { WorkspaceCancelModal } from './workspace_cancel_modal'; + +interface WorkspaceBottomBarProps { + formId: string; + opType?: string; + numberOfErrors: number; + application: ApplicationStart; +} + +// Number of saved changes will be implemented in workspace update page PR +export const WorkspaceBottomBar = ({ + formId, + opType, + numberOfErrors, + application, +}: WorkspaceBottomBarProps) => { + const [isCancelModalVisible, setIsCancelModalVisible] = useState(false); + const closeCancelModal = () => setIsCancelModalVisible(false); + const showCancelModal = () => setIsCancelModalVisible(true); + + return ( +
+ + + + + + + {opType === WORKSPACE_OP_TYPE_UPDATE ? ( + + {i18n.translate('workspace.form.bottomBar.unsavedChanges', { + defaultMessage: '1 Unsaved change(s)', + })} + + ) : ( + + {i18n.translate('workspace.form.bottomBar.errors', { + defaultMessage: `${numberOfErrors} Error(s)`, + })} + + )} + + + + + + {i18n.translate('workspace.form.bottomBar.cancel', { + defaultMessage: 'Cancel', + })} + + + {opType === WORKSPACE_OP_TYPE_CREATE && ( + + {i18n.translate('workspace.form.bottomBar.createWorkspace', { + defaultMessage: 'Create workspace', + })} + + )} + {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_cancel_modal.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_cancel_modal.tsx new file mode 100644 index 000000000000..040e46f9ddfc --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/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_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx new file mode 100644 index 000000000000..73633950d6b0 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -0,0 +1,244 @@ +/* + * 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), + }, + 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: { + ...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' }, + }); + const iconSelector = getByTestId('workspaceForm-workspaceDetails-iconSelector'); + fireEvent.click(iconSelector); + fireEvent.click(getByTestId('workspaceForm-workspaceDetails-iconSelector-Glasses')); + const defaultVISThemeSelector = getByTestId( + 'workspaceForm-workspaceDetails-defaultVISThemeSelector' + ); + fireEvent.click(defaultVISThemeSelector); + fireEvent.change(defaultVISThemeSelector, { target: { value: 'categorical' } }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + icon: 'Glasses', + color: '#000000', + description: 'test workspace description', + defaultVISTheme: 'categorical', + }) + ); + 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']), + }) + ); + 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', + permissions: 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 index 85a383781bff..d19f68b9ead7 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -4,7 +4,7 @@ */ import React, { useCallback } from 'react'; -import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent } from '@elastic/eui'; +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'; @@ -61,8 +61,9 @@ export const WorkspaceCreator = () => { return ( - + + { application={application} onSubmit={handleWorkspaceFormSubmit} opType={WORKSPACE_OP_TYPE_CREATE} - permissionFirstRowDeletable permissionEnabled={isPermissionEnabled} + permissionFirstUserDeletable /> )} 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 0d6438aaf2b5..6316218f2e8c 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -12,11 +12,10 @@ import { EuiForm, EuiFormRow, EuiFieldText, + EuiSelect, EuiText, - EuiButton, EuiFlexItem, htmlIdGenerator, - EuiFlexGrid, EuiCheckbox, EuiCheckboxGroup, EuiCheckboxGroupProps, @@ -24,8 +23,10 @@ import { EuiFieldTextProps, EuiColorPicker, EuiColorPickerProps, - EuiComboBox, - EuiComboBoxProps, + EuiHorizontalRule, + EuiFlexGroup, + EuiTab, + EuiTabs, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { @@ -36,24 +37,27 @@ import { MANAGEMENT_WORKSPACE_ID, } from '../../../../../core/public'; import { useApplications } from '../../hooks'; -import { - WORKSPACE_OP_TYPE_CREATE, - WORKSPACE_OP_TYPE_UPDATE, - DEFAULT_CHECKED_FEATURES_IDS, -} from '../../../common/constants'; +import { DEFAULT_CHECKED_FEATURES_IDS } from '../../../common/constants'; import { isFeatureDependBySelectedFeatures, getFinalFeatureIdsByDependency, generateFeatureDependencyMap, } 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 { featureMatchesConfig } from '../../utils'; +enum WorkspaceFormTabs { + NotSelected, + FeatureVisibility, + UsersAndPermissions, +} + interface WorkspaceFeature extends Pick { id: string; name: string; @@ -92,7 +96,8 @@ const isValidWorkspacePermissionSetting = ( ): setting is WorkspacePermissionSetting => !!setting.modes && setting.modes.length > 0 && - ((setting.type === 'user' && !!setting.userId) || (setting.type === 'group' && !!setting.group)); + ((setting.type === WorkspacePermissionItemType.User && !!setting.userId) || + (setting.type === WorkspacePermissionItemType.Group && !!setting.group)); const isDefaultCheckedFeatureId = (id: string) => { return DEFAULT_CHECKED_FEATURES_IDS.indexOf(id) > -1; @@ -103,17 +108,39 @@ const appendDefaultFeatureIds = (ids: string[]) => { return Array.from(new Set(ids.concat(DEFAULT_CHECKED_FEATURES_IDS))); }; +const isValidNameOrDescription = (input?: string) => { + if (!input) { + return true; + } + const regex = /^[0-9a-zA-Z()_\[\]\-\s]+$/; + 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 workspaceHtmlIdGenerator = htmlIdGenerator(); -const defaultVISThemeOptions = [{ label: 'Categorical', value: 'categorical' }]; +const defaultVISThemeOptions = [{ value: 'categorical', text: 'Categorical' }]; interface WorkspaceFormProps { application: ApplicationStart; onSubmit?: (formData: WorkspaceFormSubmitData) => void; defaultValues?: WorkspaceFormData; opType?: string; - permissionFirstRowDeletable?: boolean; permissionEnabled?: boolean; + permissionFirstUserDeletable?: boolean; } export const WorkspaceForm = ({ @@ -121,8 +148,8 @@ export const WorkspaceForm = ({ onSubmit, defaultValues, opType, - permissionFirstRowDeletable, permissionEnabled, + permissionFirstUserDeletable, }: WorkspaceFormProps) => { const applications = useApplications(application); const workspaceNameReadOnly = defaultValues?.reserved; @@ -131,9 +158,18 @@ export const WorkspaceForm = ({ const [color, setColor] = useState(defaultValues?.color); const [icon, setIcon] = useState(defaultValues?.icon); const [defaultVISTheme, setDefaultVISTheme] = useState(defaultValues?.defaultVISTheme); - const isEditingManagementWorkspace = defaultValues?.id === MANAGEMENT_WORKSPACE_ID; + // feature visibility section will be hidden in management workspace + // permission section will be hidden when permission is not enabled + const [selectedTab, setSelectedTab] = useState( + !isEditingManagementWorkspace + ? WorkspaceFormTabs.FeatureVisibility + : permissionEnabled + ? 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(() => { @@ -176,7 +212,21 @@ export const WorkspaceForm = ({ getFormDataRef.current = getFormData; const featureOrGroups = useMemo(() => { - const category2Applications = groupBy(applications, 'category.label'); + const transformedApplications = applications.map((app) => { + if (app.category?.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id) { + return { + ...app, + category: { + ...app.category, + label: i18n.translate('core.ui.libraryNavList.label', { + defaultMessage: 'Library', + }), + }, + }; + } + return app; + }); + const category2Applications = groupBy(transformedApplications, 'category.label'); return Object.keys(category2Applications).reduce< Array >((previousValue, currentKey) => { @@ -196,7 +246,7 @@ export const WorkspaceForm = ({ if (features.length === 0) { return previousValue; } - if (features.length === 1 || currentKey === 'undefined') { + if (currentKey === 'undefined') { return [...previousValue, ...features]; } return [ @@ -209,11 +259,6 @@ export const WorkspaceForm = ({ }, []); }, [applications]); - const selectedDefaultVISThemeOptions = useMemo( - () => defaultVISThemeOptions.filter((item) => item.value === defaultVISTheme), - [defaultVISTheme] - ); - const allFeatures = useMemo( () => featureOrGroups.reduce( @@ -302,14 +347,31 @@ export const WorkspaceForm = ({ const handleFormSubmit = useCallback( (e) => { e.preventDefault(); + let currentFormErrors: WorkspaceFormErrors = {}; const formData = getFormDataRef.current(); if (!formData.name) { - setFormErrors({ - name: i18n.translate('workspace.form.name.empty', { + currentFormErrors = { + ...currentFormErrors, + name: i18n.translate('workspace.form.detail.name.empty', { defaultMessage: "Name can't be empty.", }), - }); - return; + }; + } + 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++) { @@ -329,13 +391,13 @@ export const WorkspaceForm = ({ }); continue; } - if (permission.type === 'user' && !permission.userId) { + if (permission.type === WorkspacePermissionItemType.User && !permission.userId) { permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.userId', { defaultMessage: 'Invalid userId', }); continue; } - if (permission.type === 'group' && !permission.group) { + if (permission.type === WorkspacePermissionItemType.Group && !permission.group) { permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.group', { defaultMessage: 'Invalid user group', }); @@ -343,7 +405,15 @@ export const WorkspaceForm = ({ } } if (permissionErrors.some((error) => !!error)) { - setFormErrors({ permissions: permissionErrors }); + currentFormErrors = { + ...currentFormErrors, + permissions: permissionErrors, + }; + } + const currentNumberOfErrors = getNumberOfErrors(currentFormErrors); + setFormErrors(currentFormErrors); + setNumberOfErrors(currentNumberOfErrors); + if (currentNumberOfErrors > 0) { return; } @@ -362,10 +432,9 @@ export const WorkspaceForm = ({ } const permissions = formData.permissions.filter(isValidWorkspacePermissionSetting); - setFormErrors({}); - onSubmit?.({ ...formData, name: formData.name, permissions }); + onSubmit?.({ ...formData, name: formData.name!, permissions }); }, - [onSubmit, defaultFeatures, defaultValues?.features] + [defaultFeatures, onSubmit, defaultValues?.features] ); const handleNameInputChange = useCallback['onChange']>((e) => { @@ -384,24 +453,63 @@ export const WorkspaceForm = ({ setIcon(newIcon); }, []); - const handleDefaultVISThemeInputChange = useCallback< - Required>['onChange'] - >((options) => { - setDefaultVISTheme(options[0]?.value); + const handleTabFeatureClick = useCallback(() => { + setSelectedTab(WorkspaceFormTabs.FeatureVisibility); + }, []); + + const handleTabPermissionClick = useCallback(() => { + setSelectedTab(WorkspaceFormTabs.UsersAndPermissions); }, []); + const onDefaultVISThemeChange = (e: React.ChangeEvent) => { + setDefaultVISTheme(e.target.value); + }; + + 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', + }); + 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 ( -

Workspace details

+

{workspaceDetailsTitle}

- - + + + 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', + })} + + + +
- + -
- {!isEditingManagementWorkspace && ( + + + {!isEditingManagementWorkspace && ( + + {featureVisibilityTitle} + + )} + {permissionEnabled && ( + + {usersAndPermissionsTitle} + + )} + + + {selectedTab === WorkspaceFormTabs.FeatureVisibility && ( -

Workspace features

+

{featureVisibilityTitle}

- - {featureOrGroups.map((featureOrGroup) => { - const features = isWorkspaceFeatureGroup(featureOrGroup) + + + {featureOrGroups.map((featureOrGroup) => { + const features = isWorkspaceFeatureGroup(featureOrGroup) ? featureOrGroup.features : []; + const selectedIds = selectedFeatureIds.filter((id) => + (isWorkspaceFeatureGroup(featureOrGroup) ? featureOrGroup.features - : []; - const selectedIds = selectedFeatureIds.filter((id) => - (isWorkspaceFeatureGroup(featureOrGroup) - ? featureOrGroup.features - : [featureOrGroup] - ).find((item) => item.id === id) - ); - return ( - + : [featureOrGroup] + ).find((item) => item.id === id) + ); + const featureOrGroupId = isWorkspaceFeatureGroup(featureOrGroup) + ? featureOrGroup.name + : featureOrGroup.id; + return ( + + +
+ + {featureOrGroup.name} + + {isWorkspaceFeatureGroup(featureOrGroup) && ( + {categoryToDescription[featureOrGroup.name] ?? ''} + )} +
+
+ 0 ? `(${selectedIds.length}/${features.length})` : '' + features.length > 0 ? ` (${selectedIds.length}/${features.length})` : '' }`} checked={selectedIds.length > 0} disabled={ @@ -476,6 +649,7 @@ export const WorkspaceForm = ({ selectedIds.length > 0 && selectedIds.length < features.length } + data-test-subj={`workspaceForm-workspaceFeatureVisibility-${featureOrGroupId}`} /> {isWorkspaceFeatureGroup(featureOrGroup) && ( )} - ); - })} -
+ + ); + })}
)} - - {permissionEnabled && ( + + {selectedTab === WorkspaceFormTabs.UsersAndPermissions && ( -

Members & permissions

+

{usersAndPermissionsTitle}

+
)} - - {opType === WORKSPACE_OP_TYPE_CREATE && ( - - Create workspace - - )} - {opType === WORKSPACE_OP_TYPE_UPDATE && ( - - Update workspace - - )} - +
); }; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_icon_selector.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_icon_selector.tsx index 80e08d8e2e98..06b0a224a258 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_icon_selector.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_icon_selector.tsx @@ -5,9 +5,9 @@ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect, EuiText } from '@elastic/eui'; -const icons = ['glasses', 'search', 'bell']; +const icons = ['Glasses', 'Search', 'Bell', 'Package']; export const WorkspaceIconSelector = ({ color, @@ -18,19 +18,29 @@ export const WorkspaceIconSelector = ({ value?: string; onChange: (value: string) => void; }) => { - return ( - - {icons.map((item) => ( - { - onChange(item); - }} - grow={false} - > - + const options = icons.map((item) => ({ + value: item, + inputDisplay: ( + + + + + + {item} - ))} - + + ), + })); + return ( + onChange(icon)} + /> ); }; 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 3e685d968f86..12ff0d21fba1 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,30 +3,29 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { - EuiDescribedFormGroup, EuiFlexGroup, - EuiSuperSelect, EuiComboBox, EuiFlexItem, EuiButton, EuiButtonIcon, EuiButtonGroup, EuiFormRow, + EuiText, + EuiSpacer, } from '@elastic/eui'; - +import { i18n } from '@osd/i18n'; import { WorkspacePermissionMode } from '../../../../../core/public'; -export type WorkspacePermissionSetting = ( - | { type: 'user'; userId: string } - | { type: 'group'; group: string } -) & { - type: 'user' | 'group'; - userId?: string; - group?: string; - modes: WorkspacePermissionMode[]; -}; +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', @@ -37,18 +36,24 @@ enum PermissionModeId { const permissionModeOptions = [ { id: PermissionModeId.Read, - label: 'Read', - iconType: 'eye', + label: i18n.translate('workspace.form.permissionSettingPanel.permissionModeOptions.read', { + defaultMessage: 'Read', + }), }, { id: PermissionModeId.ReadAndWrite, - label: 'Read + Write', - iconType: 'pencil', + label: i18n.translate( + 'workspace.form.permissionSettingPanel.permissionModeOptions.readAndWrite', + { + defaultMessage: 'Read & Write', + } + ), }, { id: PermissionModeId.Admin, - label: 'Management', - iconType: 'visTimelion', + label: i18n.translate('workspace.form.permissionSettingPanel.permissionModeOptions.admin', { + defaultMessage: 'Admin', + }), }, ]; @@ -63,29 +68,39 @@ const optionIdToWorkspacePermissionModesMap: { [PermissionModeId.Admin]: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], }; -const permissionTypeOptions = [ - { value: 'user' as const, inputDisplay: 'User' }, - { value: 'group' as const, inputDisplay: 'Group' }, -]; - const generateWorkspacePermissionItemKey = ( item: Partial, index?: number -) => [item.type, item.userId, item.group, ...(item.modes ?? []), index].filter(Boolean).join('-'); +) => + [ + ...(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; +}; interface WorkspacePermissionSettingInputProps { index: number; deletable: boolean; - type?: 'user' | 'group'; + type: WorkspacePermissionItemType; userId?: string; group?: string; modes?: WorkspacePermissionMode[]; - onTypeChange: (type: 'user' | 'group', index: number) => void; onGroupOrUserIdChange: ( groupOrUserId: - | { type: 'user'; userId: string } - | { type: 'group'; group: string } - | { type: 'user' | 'group' }, + | { type: WorkspacePermissionItemType.User; userId?: string } + | { type: WorkspacePermissionItemType.Group; group?: string }, index: number ) => void; onPermissionModesChange: ( @@ -103,7 +118,6 @@ const WorkspacePermissionSettingInput = ({ group, modes, onDelete, - onTypeChange, onGroupOrUserIdChange, onPermissionModesChange, }: WorkspacePermissionSettingInputProps) => { @@ -111,31 +125,14 @@ const WorkspacePermissionSettingInput = ({ () => (group || userId ? [{ label: (group || userId) as string }] : []), [group, userId] ); - const permissionModesSelectedId = useMemo(() => { - if (!modes) { - return undefined; - } - for (const key in optionIdToWorkspacePermissionModesMap) { - if (optionIdToWorkspacePermissionModesMap[key].every((mode) => modes?.includes(mode))) { - return key; - } - } - }, [modes]); - - const handleTypeChange = useCallback( - (newType: 'user' | 'group') => { - onTypeChange(newType, index); - }, - [onTypeChange, index] - ); + const permissionModesSelectedId = useMemo(() => getPermissionModeId(modes ?? []), [modes]); const handleGroupOrUserIdCreate = useCallback( (groupOrUserId) => { - if (!type) { - return; - } onGroupOrUserIdChange( - type === 'group' ? { type, group: groupOrUserId } : { type, userId: groupOrUserId }, + type === WorkspacePermissionItemType.Group + ? { type, group: groupOrUserId } + : { type, userId: groupOrUserId }, index ); }, @@ -144,9 +141,6 @@ const WorkspacePermissionSettingInput = ({ const handleGroupOrUserIdChange = useCallback( (options) => { - if (!type) { - return; - } if (options.length === 0) { onGroupOrUserIdChange({ type }, index); } @@ -168,41 +162,35 @@ const WorkspacePermissionSettingInput = ({ }, [index, onDelete]); return ( - - - - + @@ -213,28 +201,35 @@ const WorkspacePermissionSettingInput = ({ interface WorkspacePermissionSettingPanelProps { errors?: string[]; - value?: Array>; + firstUserDeletable?: boolean; + permissionSettings: Array>; onChange?: (value: Array>) => void; - firstRowDeletable?: boolean; } -export const WorkspacePermissionSettingPanel = ({ +interface UserOrGroupSectionProps extends WorkspacePermissionSettingPanelProps { + title: string; + type: WorkspacePermissionItemType; +} + +const UserOrGroupSection = ({ + type, + title, errors, - value, onChange, - firstRowDeletable, -}: WorkspacePermissionSettingPanelProps) => { + permissionSettings, + firstUserDeletable, +}: UserOrGroupSectionProps) => { const transformedValue = useMemo(() => { - if (!value) { + if (!permissionSettings) { return []; } const result: Array> = []; /** - * One workspace permission setting may includes multi setting options, + * 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 < value.length; i++) { - const valueItem = value[i]; + 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 || @@ -261,11 +256,15 @@ export const WorkspacePermissionSettingPanel = ({ } } return result; - }, [value]); + }, [permissionSettings]); + // default permission mode is read const handleAddNewOne = useCallback(() => { - onChange?.([...(transformedValue ?? []), {}]); - }, [onChange, transformedValue]); + onChange?.([ + ...(transformedValue ?? []), + { type, modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Read] }, + ]); + }, [onChange, type, transformedValue]); const handleDelete = useCallback( (index: number) => { @@ -287,17 +286,6 @@ export const WorkspacePermissionSettingPanel = ({ [onChange, transformedValue] ); - const handleTypeChange = useCallback( - (type, index) => { - onChange?.( - (transformedValue ?? []).map((item, itemIndex) => - index === itemIndex ? { ...item, type } : item - ) - ); - }, - [onChange, transformedValue] - ); - const handleGroupOrUserIdChange = useCallback< WorkspacePermissionSettingInputProps['onGroupOrUserIdChange'] >( @@ -313,26 +301,91 @@ export const WorkspacePermissionSettingPanel = ({ [onChange, transformedValue] ); + // assume that group items are always deletable return ( - Users, User Groups & Groups}> +
+ + {title} + + {transformedValue?.map((item, index) => ( ))} - - Add new + + {i18n.translate('workspace.form.permissionSettingPanel.addNew', { + defaultMessage: 'Add New', + })} - +
+ ); +}; + +export const WorkspacePermissionSettingPanel = ({ + errors, + onChange, + permissionSettings, + firstUserDeletable, +}: 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]); + + return ( +
+ + + +
); }; diff --git a/src/plugins/workspace/public/components/workspace_creator_app.tsx b/src/plugins/workspace/public/components/workspace_creator_app.tsx index a292d21b18da..b74359929352 100644 --- a/src/plugins/workspace/public/components/workspace_creator_app.tsx +++ b/src/plugins/workspace/public/components/workspace_creator_app.tsx @@ -21,7 +21,7 @@ export const WorkspaceCreatorApp = () => { chrome?.setBreadcrumbs([ { text: i18n.translate('workspace.workspaceCreateTitle', { - defaultMessage: 'Workspace Create', + defaultMessage: 'Create workspace', }), }, ]); diff --git a/src/plugins/workspace/public/components/workspace_list_app.tsx b/src/plugins/workspace/public/components/workspace_list_app.tsx index ec2fb6875bba..a9da8dc49fae 100644 --- a/src/plugins/workspace/public/components/workspace_list_app.tsx +++ b/src/plugins/workspace/public/components/workspace_list_app.tsx @@ -20,8 +20,8 @@ export const WorkspaceListApp = () => { useEffect(() => { chrome?.setBreadcrumbs([ { - text: i18n.translate('workspace.workspaceCreateTitle', { - defaultMessage: 'Workspace Create', + text: i18n.translate('workspace.workspaceListTitle', { + defaultMessage: 'Workspace List', }), }, ]); 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 4105c0bc26d2..6a8d85d450dd 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -11,6 +11,7 @@ import { EuiPageContent, EuiButton, EuiPanel, + EuiSpacer, } from '@elastic/eui'; import { useObservable } from 'react-use'; import { i18n } from '@osd/i18n'; @@ -156,10 +157,10 @@ export const WorkspaceUpdater = () => { return ( - + { ] } /> + // list core.application.register({ id: WORKSPACE_LIST_APP_ID, - title: i18n.translate('workspace.settings.workspaceList', { - defaultMessage: 'See More', - }), - euiIconType: 'folderClosed', - category: WORKSPACE_NAV_CATEGORY, + title: '', navLinkStatus: AppNavLinkStatus.hidden, async mount(params: AppMountParameters) { const { renderListApp } = await import('./application');