refactor: configure service form (#11734)

* refactor: configure service form

* improve unit test

* address comments
This commit is contained in:
Sachin Chaurasiya 2023-05-25 13:33:29 +05:30 committed by GitHub
parent ef2869cd9a
commit c68d85ebd5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 132 additions and 232 deletions

View File

@ -17,6 +17,7 @@ import {
CUSTOM_PROPERTY_INVALID_NAMES, CUSTOM_PROPERTY_INVALID_NAMES,
CUSTOM_PROPERTY_NAME_VALIDATION_ERROR, CUSTOM_PROPERTY_NAME_VALIDATION_ERROR,
DELETE_TERM, DELETE_TERM,
NAME_VALIDATION_ERROR,
SEARCH_INDEX, SEARCH_INDEX,
} from '../constants/constants'; } from '../constants/constants';
@ -213,7 +214,20 @@ export const testServiceCreationAndIngestion = ({
cy.get('[data-testid="next-button"]').should('exist').click(); cy.get('[data-testid="next-button"]').should('exist').click();
// Enter service name in step 2 // Enter service name in step 2
cy.get('[data-testid="service-name"]').should('exist').type(serviceName);
// validation should work
cy.get('[data-testid="next-button"]').should('exist').click();
cy.get('#name_help').should('be.visible').contains('name is required');
// invalid name validation should work
cy.get('[data-testid="service-name"]').should('exist').type('!@#$%^&*()');
cy.get('#name_help').should('be.visible').contains(NAME_VALIDATION_ERROR);
cy.get('[data-testid="service-name"]')
.should('exist')
.clear()
.type(serviceName);
interceptURL('GET', '/api/v1/services/ingestionPipelines/ip', 'ipApi'); interceptURL('GET', '/api/v1/services/ingestionPipelines/ip', 'ipApi');
interceptURL( interceptURL(
'GET', 'GET',

View File

@ -28,16 +28,11 @@ import { useHistory } from 'react-router-dom';
import { showErrorToast } from 'utils/ToastUtils'; import { showErrorToast } from 'utils/ToastUtils';
import { getServiceDetailsPath } from '../../constants/constants'; import { getServiceDetailsPath } from '../../constants/constants';
import { GlobalSettingsMenuCategory } from '../../constants/GlobalSettings.constants'; import { GlobalSettingsMenuCategory } from '../../constants/GlobalSettings.constants';
import { delimiterRegex, nameWithSpace } from '../../constants/regex.constants';
import { FormSubmitType } from '../../enums/form.enum'; import { FormSubmitType } from '../../enums/form.enum';
import { ServiceCategory } from '../../enums/service.enum'; import { ServiceCategory } from '../../enums/service.enum';
import { PipelineType } from '../../generated/entity/services/ingestionPipelines/ingestionPipeline'; import { PipelineType } from '../../generated/entity/services/ingestionPipelines/ingestionPipeline';
import { ConfigData } from '../../interface/service.interface'; import { ConfigData } from '../../interface/service.interface';
import { import { getCurrentUserId, getServiceLogo } from '../../utils/CommonUtils';
getCurrentUserId,
getServiceLogo,
isUrlFriendlyName,
} from '../../utils/CommonUtils';
import { getAddServicePath, getSettingPath } from '../../utils/RouterUtils'; import { getAddServicePath, getSettingPath } from '../../utils/RouterUtils';
import { import {
getServiceCreatedLabel, getServiceCreatedLabel,
@ -50,7 +45,7 @@ import SuccessScreen from '../common/success-screen/SuccessScreen';
import TitleBreadcrumb from '../common/title-breadcrumb/title-breadcrumb.component'; import TitleBreadcrumb from '../common/title-breadcrumb/title-breadcrumb.component';
import IngestionStepper from '../IngestionStepper/IngestionStepper.component'; import IngestionStepper from '../IngestionStepper/IngestionStepper.component';
import ConnectionConfigForm from '../ServiceConfig/ConnectionConfigForm'; import ConnectionConfigForm from '../ServiceConfig/ConnectionConfigForm';
import { AddServiceProps } from './AddService.interface'; import { AddServiceProps, ServiceConfig } from './AddService.interface';
import ConfigureService from './Steps/ConfigureService'; import ConfigureService from './Steps/ConfigureService';
import SelectServiceType from './Steps/SelectServiceType'; import SelectServiceType from './Steps/SelectServiceType';
@ -78,16 +73,21 @@ const AddService = ({
const [activeServiceStep, setActiveServiceStep] = useState(1); const [activeServiceStep, setActiveServiceStep] = useState(1);
const [activeIngestionStep, setActiveIngestionStep] = useState(1); const [activeIngestionStep, setActiveIngestionStep] = useState(1);
const [selectServiceType, setSelectServiceType] = useState(''); const [selectServiceType, setSelectServiceType] = useState('');
const [serviceName, setServiceName] = useState(''); const [serviceConfig, setServiceConfig] = useState<ServiceConfig>({
const [description, setDescription] = useState(''); serviceName: '',
description: '',
});
const [saveServiceState, setSaveServiceState] = const [saveServiceState, setSaveServiceState] =
useState<LoadingState>('initial'); useState<LoadingState>('initial');
const [activeField, setActiveField] = useState<string>(''); const [activeField, setActiveField] = useState<string>('');
const handleServiceTypeClick = (type: string) => { const handleServiceTypeClick = (type: string) => {
setShowErrorMessage({ ...showErrorMessage, serviceType: false }); setShowErrorMessage({ ...showErrorMessage, serviceType: false });
setServiceName(''); setServiceConfig({
setDescription(''); serviceName: '',
description: '',
});
setSelectServiceType(type); setSelectServiceType(type);
}; };
@ -117,47 +117,18 @@ const AddService = ({
// Configure service name // Configure service name
const handleConfigureServiceBackClick = () => setActiveServiceStep(1); const handleConfigureServiceBackClick = () => setActiveServiceStep(1);
const handleConfigureServiceNextClick = (descriptionValue: string) => { const handleConfigureServiceNextClick = (value: ServiceConfig) => {
setDescription(descriptionValue.trim()); setServiceConfig(value);
if (!serviceName.trim()) {
setShowErrorMessage({ ...showErrorMessage, name: true, isError: true });
} else if (nameWithSpace.test(serviceName)) {
setShowErrorMessage({
...showErrorMessage,
nameWithSpace: true,
isError: true,
});
} else if (delimiterRegex.test(serviceName)) {
setShowErrorMessage({
...showErrorMessage,
delimit: true,
isError: true,
});
} else if (!isUrlFriendlyName(serviceName.trim())) {
setShowErrorMessage({
...showErrorMessage,
specialChar: true,
isError: true,
});
} else if (serviceName.length < 1 || serviceName.length > 128) {
setShowErrorMessage({
...showErrorMessage,
nameLength: true,
isError: true,
});
} else if (!showErrorMessage.isError) {
setActiveServiceStep(3); setActiveServiceStep(3);
}
}; };
// Service connection // Service connection
const handleConnectionDetailsBackClick = () => setActiveServiceStep(2); const handleConnectionDetailsBackClick = () => setActiveServiceStep(2);
const handleConfigUpdate = async (newConfigData: ConfigData) => { const handleConfigUpdate = async (newConfigData: ConfigData) => {
const data = { const data = {
name: serviceName, name: serviceConfig.serviceName,
serviceType: selectServiceType, serviceType: selectServiceType,
description: description, description: serviceConfig.description,
owner: { owner: {
id: getCurrentUserId(), id: getCurrentUserId(),
type: 'user', type: 'user',
@ -183,7 +154,7 @@ const AddService = ({
showErrorToast( showErrorToast(
t('server.entity-already-exist', { t('server.entity-already-exist', {
entity: t('label.service'), entity: t('label.service'),
name: serviceName, name: serviceConfig.serviceName,
}) })
); );
@ -203,24 +174,6 @@ const AddService = ({
} }
}; };
// Service name validation
const handleServiceNameValidation = (
event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const value = event.target.value;
setServiceName(value);
if (value) {
setShowErrorMessage({
...showErrorMessage,
name: false,
delimit: false,
specialChar: false,
nameLength: false,
isError: false,
});
}
};
// Service focused field // Service focused field
const handleFieldFocus = (fieldName: string) => { const handleFieldFocus = (fieldName: string) => {
if (isEmpty(fieldName)) { if (isEmpty(fieldName)) {
@ -267,18 +220,7 @@ const AddService = ({
{activeServiceStep === 2 && ( {activeServiceStep === 2 && (
<ConfigureService <ConfigureService
description={description} serviceName={serviceConfig.serviceName}
handleValidation={handleServiceNameValidation}
serviceName={serviceName}
showError={{
name: showErrorMessage.name,
duplicateName: showErrorMessage.duplicateName,
nameWithSpace: showErrorMessage.nameWithSpace,
delimit: showErrorMessage.delimit,
specialChar: showErrorMessage.specialChar,
nameLength: showErrorMessage.nameLength,
allowChar: showErrorMessage.allowChar,
}}
onBack={handleConfigureServiceBackClick} onBack={handleConfigureServiceBackClick}
onNext={handleConfigureServiceNextClick} onNext={handleConfigureServiceNextClick}
/> />
@ -303,7 +245,7 @@ const AddService = ({
showIngestionButton showIngestionButton
handleIngestionClick={() => handleAddIngestion(true)} handleIngestionClick={() => handleAddIngestion(true)}
handleViewServiceClick={handleViewServiceClick} handleViewServiceClick={handleViewServiceClick}
name={serviceName} name={serviceConfig.serviceName}
state={FormSubmitType.ADD} state={FormSubmitType.ADD}
suffix={getServiceCreatedLabel(serviceCategory)} suffix={getServiceCreatedLabel(serviceCategory)}
/> />

View File

@ -31,3 +31,8 @@ export interface AddServiceProps {
slashedBreadcrumb: TitleBreadcrumbProps['titleLinks']; slashedBreadcrumb: TitleBreadcrumbProps['titleLinks'];
onIngestionDeploy?: () => Promise<void>; onIngestionDeploy?: () => Promise<void>;
} }
export interface ServiceConfig {
serviceName: string;
description: string;
}

View File

@ -11,83 +11,63 @@
* limitations under the License. * limitations under the License.
*/ */
import { import { act, render, screen } from '@testing-library/react';
findByTestId, import userEvent from '@testing-library/user-event';
findByText, import React from 'react';
fireEvent,
render,
} from '@testing-library/react';
import React, { forwardRef } from 'react';
import ConfigureService from './ConfigureService'; import ConfigureService from './ConfigureService';
import { ConfigureServiceProps } from './Steps.interface'; import { ConfigureServiceProps } from './Steps.interface';
const mockConfigureServiceProps: ConfigureServiceProps = { const mockConfigureServiceProps: ConfigureServiceProps = {
serviceName: 'testService', serviceName: 'testService',
description: '',
showError: {
name: false,
duplicateName: false,
nameWithSpace: false,
delimit: false,
specialChar: false,
nameLength: false,
allowChar: false,
},
handleValidation: jest.fn(),
onBack: jest.fn(), onBack: jest.fn(),
onNext: jest.fn(), onNext: jest.fn(),
}; };
jest.mock('../../common/rich-text-editor/RichTextEditor', () => {
return forwardRef(
jest.fn().mockImplementation(({ initialValue }) => {
return (
<div
ref={(input) => {
return {
getEditorContent: input,
};
}}>
{initialValue}RichTextEditor.component
</div>
);
})
);
});
describe('Test ConfigureService component', () => { describe('Test ConfigureService component', () => {
it('ConfigureService component should render', async () => { it('ConfigureService component should render', async () => {
const { container } = render( render(<ConfigureService {...mockConfigureServiceProps} />);
<ConfigureService {...mockConfigureServiceProps} />
);
const configureServiceContainer = await findByTestId( const configureServiceContainer = screen.getByTestId(
container,
'configure-service-container' 'configure-service-container'
); );
const serviceName = await findByTestId(container, 'service-name'); const serviceName = screen.getByTestId('service-name');
const backButton = await findByTestId(container, 'back-button'); const backButton = screen.getByTestId('back-button');
const nextButton = await findByTestId(container, 'next-button'); const nextButton = screen.getByTestId('next-button');
const richTextEditor = await findByText( const richTextEditor = screen.getByTestId('editor');
container,
'RichTextEditor.component'
);
fireEvent.change(serviceName, {
target: {
value: 'newName',
},
});
fireEvent.click(backButton);
fireEvent.click(nextButton);
expect(configureServiceContainer).toBeInTheDocument(); expect(configureServiceContainer).toBeInTheDocument();
expect(richTextEditor).toBeInTheDocument(); expect(richTextEditor).toBeInTheDocument();
expect(serviceName).toBeInTheDocument(); expect(serviceName).toBeInTheDocument();
expect(backButton).toBeInTheDocument(); expect(backButton).toBeInTheDocument();
expect(nextButton).toBeInTheDocument(); expect(nextButton).toBeInTheDocument();
expect(mockConfigureServiceProps.handleValidation).toHaveBeenCalled(); });
it('Back button should work', () => {
render(<ConfigureService {...mockConfigureServiceProps} />);
const backButton = screen.getByTestId('back-button');
userEvent.click(backButton);
expect(mockConfigureServiceProps.onBack).toHaveBeenCalled(); expect(mockConfigureServiceProps.onBack).toHaveBeenCalled();
});
it('Next button should work', async () => {
render(<ConfigureService {...mockConfigureServiceProps} />);
const serviceName = screen.getByTestId('service-name');
const nextButton = screen.getByTestId('next-button');
userEvent.type(serviceName, 'newName');
await act(async () => {
userEvent.click(nextButton);
});
expect(serviceName).toHaveValue('newName');
expect(mockConfigureServiceProps.onNext).toHaveBeenCalled(); expect(mockConfigureServiceProps.onNext).toHaveBeenCalled();
expect(mockConfigureServiceProps.onNext).toHaveBeenCalledWith({
description: '',
serviceName: 'newName',
});
}); });
}); });

View File

@ -11,96 +11,79 @@
* limitations under the License. * limitations under the License.
*/ */
import { Button } from 'antd'; import { Button, Form, FormProps, Space } from 'antd';
import { ENTITY_NAME_REGEX } from 'constants/regex.constants';
import { t } from 'i18next'; import { t } from 'i18next';
import React, { useRef } from 'react'; import React from 'react';
import { errorMsg, requiredField } from '../../../utils/CommonUtils'; import { FieldProp, FieldTypes, generateFormFields } from 'utils/formUtils';
import RichTextEditor from '../../common/rich-text-editor/RichTextEditor';
import { EditorContentRef } from '../../common/rich-text-editor/RichTextEditor.interface';
import { Field } from '../../Field/Field';
import { ConfigureServiceProps } from './Steps.interface'; import { ConfigureServiceProps } from './Steps.interface';
const ConfigureService = ({ const ConfigureService = ({
serviceName, serviceName,
description,
showError,
handleValidation,
onBack, onBack,
onNext, onNext,
}: ConfigureServiceProps) => { }: ConfigureServiceProps) => {
const markdownRef = useRef<EditorContentRef>(); const [form] = Form.useForm();
const validationErrorMsg = (): string => { const formFields: FieldProp[] = [
if (showError.name) { {
return t('message.field-text-is-required', { name: 'name',
fieldText: t('label.service-name'), id: 'root/name',
}); required: true,
} label: t('label.service-name'),
if (showError.duplicateName) { type: FieldTypes.TEXT,
return t('message.entity-already-exists', { rules: [
entity: t('label.service-name'), {
}); pattern: ENTITY_NAME_REGEX,
} message: t('message.entity-name-validation'),
if (showError.delimit) { },
return t('message.service-with-delimiters-not-allowed'); ],
} props: {
if (showError.nameWithSpace) { 'data-testid': 'service-name',
return t('message.service-with-space-not-allowed'); },
} placeholder: t('label.service-name'),
if (showError.nameLength) { formItemProps: {
return t('message.service-name-length'); initialValue: serviceName,
} },
if (showError.specialChar) { },
return t('message.special-character-not-allowed'); {
} name: 'description',
required: false,
label: t('label.description'),
id: 'root/description',
type: FieldTypes.DESCRIPTION,
props: {
'data-testid': 'description',
initialValue: '',
},
formItemProps: {
trigger: 'onTextChange',
valuePropName: 'initialValue',
},
},
];
return ''; const handleSubmit: FormProps['onFinish'] = (data) => {
onNext({ serviceName: data.name, description: data.description ?? '' });
}; };
return ( return (
<div data-testid="configure-service-container"> <Form
<Field> data-testid="configure-service-container"
<label className="tw-block tw-form-label" htmlFor="serviceName"> form={form}
{requiredField(`${t('label.service-name')}:`)} layout="vertical"
</label> onFinish={handleSubmit}>
{generateFormFields(formFields)}
<input <Space className="w-full justify-end">
className="tw-form-inputs tw-form-inputs-padding" <Button data-testid="back-button" type="link" onClick={onBack}>
data-testid="service-name"
id="serviceName"
name="serviceName"
placeholder={t('label.service-name')}
type="text"
value={serviceName}
onChange={handleValidation}
/>
{errorMsg(validationErrorMsg())}
</Field>
<Field>
<label className="tw-block tw-form-label" htmlFor="description">
{`${t('label.description')}:`}
</label>
<RichTextEditor initialValue={description} ref={markdownRef} />
</Field>
<Field className="d-flex tw-justify-end tw-mt-10">
<Button
className="m-r-xs"
data-testid="back-button"
type="link"
onClick={onBack}>
{t('label.back')} {t('label.back')}
</Button> </Button>
<Button <Button data-testid="next-button" htmlType="submit" type="primary">
className="font-medium p-x-md p-y-xxs h-auto rounded-6"
data-testid="next-button"
type="primary"
onClick={() => onNext(markdownRef.current?.getEditorContent() || '')}>
{t('label.next')} {t('label.next')}
</Button> </Button>
</Field> </Space>
</div> </Form>
); );
}; };

View File

@ -13,6 +13,7 @@
import { DynamicFormFieldType } from 'Models'; import { DynamicFormFieldType } from 'Models';
import { ServiceCategory } from '../../../enums/service.enum'; import { ServiceCategory } from '../../../enums/service.enum';
import { ServiceConfig } from '../AddService.interface';
export type SelectServiceTypeProps = { export type SelectServiceTypeProps = {
showError: boolean; showError: boolean;
@ -26,21 +27,8 @@ export type SelectServiceTypeProps = {
export type ConfigureServiceProps = { export type ConfigureServiceProps = {
serviceName: string; serviceName: string;
description: string;
showError: {
name: boolean;
duplicateName: boolean;
nameWithSpace: boolean;
delimit: boolean;
specialChar: boolean;
nameLength: boolean;
allowChar: boolean;
};
handleValidation: (
event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => void;
onBack: () => void; onBack: () => void;
onNext: (description: string) => void; onNext: (data: ServiceConfig) => void;
}; };
export type ConnectionDetailsProps = { export type ConnectionDetailsProps = {

View File

@ -307,14 +307,6 @@ export const STEPS_FOR_ADD_SERVICE: Array<StepperStepType> = [
export const SERVICE_DEFAULT_ERROR_MAP = { export const SERVICE_DEFAULT_ERROR_MAP = {
serviceType: false, serviceType: false,
name: false,
duplicateName: false,
nameWithSpace: false,
delimit: false,
specialChar: false,
nameLength: false,
allowChar: false,
isError: false,
}; };
// 2 minutes // 2 minutes
export const FETCHING_EXPIRY_TIME = 2 * 60 * 1000; export const FETCHING_EXPIRY_TIME = 2 * 60 * 1000;

View File

@ -137,10 +137,6 @@
@apply tw-font-normal tw-px-2; @apply tw-font-normal tw-px-2;
} }
.tw-form-label {
@apply tw-block tw-text-body tw-font-normal tw-text-grey-body tw-mb-2;
}
.activeCategory .label-category { .activeCategory .label-category {
@apply tw-text-primary; @apply tw-text-primary;
} }