diff --git a/changelogs/fragments/7656.yml b/changelogs/fragments/7656.yml new file mode 100644 index 000000000000..0447cdf6e893 --- /dev/null +++ b/changelogs/fragments/7656.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace]Add name and description characters limitation ([#7656](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7656)) \ No newline at end of file diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index d07c4437bfc0..5eae709784f3 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -185,3 +185,6 @@ export const WORKSPACE_USE_CASES = Object.freeze({ export const MAX_WORKSPACE_PICKER_NUM = 3; export const RECENT_WORKSPACES_KEY = 'recentWorkspaces'; export const CURRENT_USER_PLACEHOLDER = '%me%'; + +export const MAX_WORKSPACE_NAME_LENGTH = 40; +export const MAX_WORKSPACE_DESCRIPTION_LENGTH = 200; diff --git a/src/plugins/workspace/public/components/workspace_form/fields/workspace_description_field.test.tsx b/src/plugins/workspace/public/components/workspace_form/fields/workspace_description_field.test.tsx new file mode 100644 index 000000000000..e8795bef7a76 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/fields/workspace_description_field.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { MAX_WORKSPACE_DESCRIPTION_LENGTH } from '../../../../common/constants'; +import { WorkspaceDescriptionField } from './workspace_description_field'; + +describe('', () => { + it('should call onChange when the new value', () => { + const onChangeMock = jest.fn(); + const value = 'test'; + + render(); + + const textarea = screen.getByPlaceholderText('Describe the workspace'); + fireEvent.change(textarea, { target: { value: 'new value' } }); + + expect(onChangeMock).toHaveBeenCalledWith('new value'); + + fireEvent.change(textarea, { + target: { value: 'a'.repeat(MAX_WORKSPACE_DESCRIPTION_LENGTH + 1) }, + }); + + expect(onChangeMock).toHaveBeenCalledWith('a'.repeat(MAX_WORKSPACE_DESCRIPTION_LENGTH + 1)); + }); + + it('should render the correct number of characters left when value larger than MAX_WORKSPACE_DESCRIPTION_LENGTH', () => { + render( + + ); + + const helpText = screen.getByText(new RegExp(`-1.+characters left\.`)); + expect(helpText).toBeInTheDocument(); + }); + + it('should render the correct number of characters left when value is empty', () => { + render(); + + const helpText = screen.getByText( + new RegExp(`${MAX_WORKSPACE_DESCRIPTION_LENGTH}.+characters left\.`) + ); + expect(helpText).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/fields/workspace_description_field.tsx b/src/plugins/workspace/public/components/workspace_form/fields/workspace_description_field.tsx new file mode 100644 index 000000000000..599445cd25d2 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/fields/workspace_description_field.tsx @@ -0,0 +1,66 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiCompressedFormRow, EuiCompressedTextArea, EuiTextColor } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React, { useCallback } from 'react'; + +import { MAX_WORKSPACE_DESCRIPTION_LENGTH } from '../../../../common/constants'; + +export interface WorkspaceDescriptionFieldProps { + value?: string; + onChange: (newValue: string) => void; + error?: string; + readOnly?: boolean; +} + +export const WorkspaceDescriptionField = ({ + value, + error, + readOnly, + onChange, +}: WorkspaceDescriptionFieldProps) => { + const handleChange = useCallback( + (e) => { + onChange(e.currentTarget.value); + }, + [onChange] + ); + const leftCharacters = MAX_WORKSPACE_DESCRIPTION_LENGTH - (value?.length ?? 0); + const charactersOverflow = leftCharacters < 0; + + return ( + + Description - optional + + } + isInvalid={!!error || charactersOverflow} + error={error} + helpText={ + + {i18n.translate('workspace.form.description.charactersLeft', { + defaultMessage: '{leftCharacters} characters left.', + values: { + leftCharacters, + }, + })} + + } + > + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.test.tsx b/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.test.tsx new file mode 100644 index 000000000000..50779124f3f8 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { MAX_WORKSPACE_NAME_LENGTH } from '../../../../common/constants'; +import { WorkspaceNameField } from './workspace_name_field'; + +describe('', () => { + it('should call onChange when the new value', () => { + const onChangeMock = jest.fn(); + const value = 'test'; + + render(); + + const input = screen.getByPlaceholderText('Enter a name'); + fireEvent.change(input, { target: { value: 'new value' } }); + + expect(onChangeMock).toHaveBeenCalledWith('new value'); + + fireEvent.change(input, { target: { value: 'a'.repeat(MAX_WORKSPACE_NAME_LENGTH + 1) } }); + + expect(onChangeMock).toHaveBeenCalledWith('a'.repeat(MAX_WORKSPACE_NAME_LENGTH + 1)); + }); + + it('should render the correct number of characters left when value greater than MAX_WORKSPACE_NAME_LENGTH', () => { + render( + + ); + + const helpText = screen.getByText(new RegExp(`-1.+characters left\.`)); + expect(helpText).toBeInTheDocument(); + }); + + it('should render the correct number of characters left when value is empty', () => { + render(); + + const helpText = screen.getByText( + new RegExp(`${MAX_WORKSPACE_NAME_LENGTH}.+characters left\.`) + ); + expect(helpText).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.tsx b/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.tsx new file mode 100644 index 000000000000..e8ddf6b4e3ea --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.tsx @@ -0,0 +1,70 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiCompressedFieldText, EuiCompressedFormRow, EuiTextColor } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React, { useCallback } from 'react'; + +import { MAX_WORKSPACE_NAME_LENGTH } from '../../../../common/constants'; + +export interface WorkspaceNameFieldProps { + value?: string; + onChange: (newValue: string) => void; + error?: string; + readOnly?: boolean; +} + +export const WorkspaceNameField = ({ + value, + error, + readOnly, + onChange, +}: WorkspaceNameFieldProps) => { + const handleChange = useCallback( + (e) => { + onChange(e.currentTarget.value); + }, + [onChange] + ); + const leftCharacters = MAX_WORKSPACE_NAME_LENGTH - (value?.length ?? 0); + const charactersOverflow = leftCharacters < 0; + + return ( + + + {i18n.translate('workspace.form.name.charactersLeft', { + defaultMessage: '{leftCharacters} characters left.', + values: { + leftCharacters, + }, + })} + +
+ {i18n.translate('workspace.form.workspaceDetails.name.helpText', { + defaultMessage: + 'Use a unique name for the workspace. Valid characters are a-z, A-Z, 0-9, (), [], _ (underscore), - (hyphen) and (space).', + })} + + } + isInvalid={!!error || charactersOverflow} + error={error} + > + +
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts index e0b01454fa0f..8b03d246d568 100644 --- a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts @@ -4,12 +4,7 @@ */ import { useCallback, useState, FormEventHandler, useRef, useMemo } from 'react'; -import { - htmlIdGenerator, - EuiFieldTextProps, - EuiTextAreaProps, - EuiColorPickerProps, -} from '@elastic/eui'; +import { htmlIdGenerator, EuiColorPickerProps } from '@elastic/eui'; import { useApplications } from '../../hooks'; import { @@ -38,7 +33,7 @@ export const useWorkspaceForm = ({ permissionEnabled, }: WorkspaceFormProps) => { const applications = useApplications(application); - const [name, setName] = useState(defaultValues?.name); + const [name, setName] = useState(defaultValues?.name ?? ''); const [description, setDescription] = useState(defaultValues?.description); const [color, setColor] = useState(defaultValues?.color); const defaultValuesRef = useRef(defaultValues); @@ -133,14 +128,6 @@ export const useWorkspaceForm = ({ [onSubmit, permissionEnabled] ); - const handleNameInputChange = useCallback['onChange']>((e) => { - setName(e.target.value); - }, []); - - const handleDescriptionChange = useCallback['onChange']>((e) => { - setDescription(e.target.value); - }, []); - const handleColorChange = useCallback['onChange']>((text) => { setColor(text); }, []); @@ -152,12 +139,12 @@ export const useWorkspaceForm = ({ applications, numberOfErrors, numberOfChanges, + setName, + setDescription, handleFormSubmit, handleColorChange, handleUseCaseChange, - handleNameInputChange, setPermissionSettings, setSelectedDataSources, - handleDescriptionChange, }; }; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_create_action_panel.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_create_action_panel.test.tsx new file mode 100644 index 000000000000..6f1dbc58bf9e --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_create_action_panel.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { applicationServiceMock } from '../../../../../core/public/mocks'; +import { + MAX_WORKSPACE_DESCRIPTION_LENGTH, + MAX_WORKSPACE_NAME_LENGTH, +} from '../../../common/constants'; +import { WorkspaceCreateActionPanel } from './workspace_create_action_panel'; + +const mockApplication = applicationServiceMock.createStartContract(); + +describe('WorkspaceCreateActionPanel', () => { + const formId = 'workspaceForm'; + const formData = { + name: 'Test Workspace', + description: 'This is a test workspace', + }; + + it('should disable the "Create Workspace" button when name exceeds the maximum length', () => { + const longName = 'a'.repeat(MAX_WORKSPACE_NAME_LENGTH + 1); + render( + + ); + const createButton = screen.getByText('Create workspace'); + expect(createButton.closest('button')).toBeDisabled(); + }); + + it('should disable the "Create Workspace" button when description exceeds the maximum length', () => { + const longDescription = 'a'.repeat(MAX_WORKSPACE_DESCRIPTION_LENGTH + 1); + render( + + ); + const createButton = screen.getByText('Create workspace'); + expect(createButton.closest('button')).toBeDisabled(); + }); + + it('should enable the "Create Workspace" button when name and description are within the maximum length', () => { + render( + + ); + const createButton = screen.getByText('Create workspace'); + expect(createButton.closest('button')).not.toBeDisabled(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_create_action_panel.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_create_action_panel.tsx index a7824ffac428..0b914c0a7658 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_create_action_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_create_action_panel.tsx @@ -6,21 +6,31 @@ import { EuiSmallButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React, { useState, useCallback } from 'react'; -import { ApplicationStart } from 'opensearch-dashboards/public'; +import type { ApplicationStart } from 'opensearch-dashboards/public'; +import type { WorkspaceFormData } from './types'; import { WorkspaceCancelModal } from './workspace_cancel_modal'; +import { + MAX_WORKSPACE_DESCRIPTION_LENGTH, + MAX_WORKSPACE_NAME_LENGTH, +} from '../../../common/constants'; interface WorkspaceCreateActionPanelProps { formId: string; + formData: Partial>; application: ApplicationStart; } export const WorkspaceCreateActionPanel = ({ formId, + formData, application, }: WorkspaceCreateActionPanelProps) => { const [isCancelModalVisible, setIsCancelModalVisible] = useState(false); const closeCancelModal = useCallback(() => setIsCancelModalVisible(false), []); const showCancelModal = useCallback(() => setIsCancelModalVisible(true), []); + const createButtonDisabled = + (formData.name?.length ?? 0) > MAX_WORKSPACE_NAME_LENGTH || + (formData.description?.length ?? 0) > MAX_WORKSPACE_DESCRIPTION_LENGTH; return ( <> @@ -41,6 +51,7 @@ export const WorkspaceCreateActionPanel = ({ type="submit" form={formId} data-test-subj="workspaceForm-bottomBar-createButton" + disabled={createButtonDisabled} > {i18n.translate('workspace.form.bottomBar.createWorkspace', { defaultMessage: 'Create workspace', diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx index b4b42743b410..271f90ca69fa 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx @@ -57,13 +57,13 @@ export const WorkspaceDetailForm = (props: WorkspaceDetailedFormProps) => { formErrors, numberOfErrors, numberOfChanges, + setName, + setDescription, handleFormSubmit, handleColorChange, handleUseCaseChange, setPermissionSettings, - handleNameInputChange, setSelectedDataSources, - handleDescriptionChange, } = useWorkspaceForm(props); const isDashboardAdmin = application?.capabilities?.dashboards?.isDashboardAdmin ?? false; @@ -101,8 +101,8 @@ export const WorkspaceDetailForm = (props: WorkspaceDetailedFormProps) => { description={formData.description} color={formData.color} readOnly={!!defaultValues?.reserved} - handleNameInputChange={handleNameInputChange} - handleDescriptionChange={handleDescriptionChange} + onNameChange={setName} + onDescriptionChange={setDescription} handleColorChange={handleColorChange} /> diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_enter_details_panel.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_enter_details_panel.tsx index 88915665c8cb..fc4a669426b5 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_enter_details_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_enter_details_panel.tsx @@ -15,6 +15,8 @@ import { i18n } from '@osd/i18n'; import React from 'react'; import { EuiColorPickerOutput } from '@elastic/eui/src/components/color_picker/color_picker'; import { WorkspaceFormErrors } from './types'; +import { WorkspaceNameField } from './fields/workspace_name_field'; +import { WorkspaceDescriptionField } from './fields/workspace_description_field'; export interface EnterDetailsPanelProps { formErrors: WorkspaceFormErrors; @@ -22,8 +24,8 @@ export interface EnterDetailsPanelProps { description?: string; color?: string; readOnly: boolean; - handleNameInputChange: React.ChangeEventHandler; - handleDescriptionChange: React.ChangeEventHandler; + onNameChange: (newValue: string) => void; + onDescriptionChange: (newValue: string) => void; handleColorChange: (text: string, output: EuiColorPickerOutput) => void; } @@ -33,69 +35,35 @@ export const EnterDetailsPanel = ({ description, color, readOnly, - handleNameInputChange, - handleDescriptionChange, + onNameChange, + onDescriptionChange, handleColorChange, }: EnterDetailsPanelProps) => { return ( <> - - - - - Description - optional - - } - > - <> - - {i18n.translate('workspace.form.workspaceDetails.description.introduction', { - defaultMessage: - 'Help others understand the purpose of this workspace by providing an overview of the workspace you’re creating.', - })} - - - - + /> +
- {i18n.translate('workspace.form.workspaceDetails.color.helpText', { - defaultMessage: 'Accent color for your workspace', + {i18n.translate('workspace.form.workspaceDetails.color.description', { + defaultMessage: 'Select a background color for the icon representing this workspace.', })} diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx index 70d2a34d7585..e8cfd534d922 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -39,13 +39,13 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { formErrors, numberOfErrors, numberOfChanges, + setName, + setDescription, handleFormSubmit, handleColorChange, handleUseCaseChange, - handleNameInputChange, setPermissionSettings, setSelectedDataSources, - handleDescriptionChange, } = useWorkspaceForm(props); const disabledUserOrGroupInputIdsRef = useRef( @@ -73,9 +73,9 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { description={formData.description} color={formData.color} readOnly={!!defaultValues?.reserved} - handleNameInputChange={handleNameInputChange} - handleDescriptionChange={handleDescriptionChange} handleColorChange={handleColorChange} + onNameChange={setName} + onDescriptionChange={setDescription} /> @@ -128,7 +128,7 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { )} {operationType === WorkspaceOperationType.Create && ( - + )} {operationType === WorkspaceOperationType.Update && (