Skip to content
This repository was archived by the owner on Jul 9, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions Composer/packages/client/__tests__/hooks/useForm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { hooks } from '@bfc/test-utils';

import { useForm, FieldConfig } from '../../src/hooks/useForm';

interface TestFormData {
requiredField: string;
customValidationField: string;
asyncValidationField: string;
}

afterEach(hooks.cleanup);

describe('useForm', () => {
const custValidate = jest.fn();
const asyncValidate = jest.fn();

const fields: FieldConfig<TestFormData> = {
requiredField: {
defaultValue: 'foo',
required: true,
},
customValidationField: {
defaultValue: 'bar',
validate: custValidate,
},
asyncValidationField: {
defaultValue: 'baz',
validate: asyncValidate,
},
};

describe('formData', () => {
it('applies default values', () => {
const { result } = hooks.renderHook(() => useForm(fields));

expect(result.current.formData).toEqual({
requiredField: 'foo',
customValidationField: 'bar',
asyncValidationField: 'baz',
});
});

it('can update single fields', async () => {
const { result, waitForNextUpdate } = hooks.renderHook(() => useForm(fields));

await hooks.act(async () => {
result.current.updateField('requiredField', 'new value');
await waitForNextUpdate();
});

expect(result.current.formData.requiredField).toEqual('new value');
});

it('can update the whole object', async () => {
const { result, waitForNextUpdate } = hooks.renderHook(() => useForm(fields));

await hooks.act(async () => {
result.current.updateForm({
requiredField: 'new',
customValidationField: 'form',
asyncValidationField: 'data',
});
await waitForNextUpdate();
});

expect(result.current.formData).toEqual({
requiredField: 'new',
customValidationField: 'form',
asyncValidationField: 'data',
});
});
});

describe('formErrors', () => {
it('can validate when mounting', async () => {
custValidate.mockReturnValue('custom');
asyncValidate.mockResolvedValue('async');
const { result, waitForNextUpdate } = hooks.renderHook(() => useForm(fields, { validateOnMount: true }));
await waitForNextUpdate();

expect(result.current.formErrors).toMatchObject({
customValidationField: 'custom',
asyncValidationField: 'async',
});
});

it('validates required fields', async () => {
const { result, waitForNextUpdate } = hooks.renderHook(() => useForm(fields));

await hooks.act(async () => {
result.current.updateField('requiredField', '');
await waitForNextUpdate();
});

expect(result.current.formErrors.requiredField).toEqual('requiredField is required');
});

it('validates using a custom validator', async () => {
custValidate.mockReturnValue('my custom validation');
const { result, waitForNextUpdate } = hooks.renderHook(() => useForm(fields));

await hooks.act(async () => {
result.current.updateField('customValidationField', 'foo');
await waitForNextUpdate();
});

expect(result.current.formErrors.customValidationField).toEqual('my custom validation');
});

it('validates using an asyn validator', async () => {
asyncValidate.mockResolvedValue('my async validation');
const { result, waitForNextUpdate } = hooks.renderHook(() => useForm(fields));

await hooks.act(async () => {
result.current.updateField('asyncValidationField', 'foo');
await waitForNextUpdate();
});

expect(result.current.formErrors.asyncValidationField).toEqual('my async validation');
});
});

describe('hasErrors', () => {
it('returns true when there are errors', async () => {
const { result, waitForNextUpdate } = hooks.renderHook(() => useForm(fields));

expect(result.current.hasErrors).toBe(false);

await hooks.act(async () => {
result.current.updateField('requiredField', '');
await waitForNextUpdate();
});

expect(result.current.hasErrors).toBe(true);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { DialogFooter } from 'office-ui-fabric-react/lib/Dialog';
import formatMessage from 'format-message';
import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button';
import { Stack, StackItem } from 'office-ui-fabric-react/lib/Stack';
import React, { useState, Fragment, useEffect } from 'react';
import React, { Fragment, useEffect, useCallback } from 'react';
import { TextField } from 'office-ui-fabric-react/lib/TextField';
import { RouteComponentProps } from '@reach/router';
import querystring from 'query-string';
Expand All @@ -19,35 +19,30 @@ import { DialogWrapper } from '../../DialogWrapper';
import { DialogTypes } from '../../DialogWrapper/styles';
import { LocationSelectContent } from '../LocationBrowser/LocationSelectContent';
import { StorageFolder } from '../../../store/types';
import { FieldConfig, useForm } from '../../../hooks';

import { name, description, halfstack, stackinput } from './styles';
const MAXTRYTIMES = 10000;

interface FormData {
interface DefineConversationFormData {
name: string;
description: string;
schemaUrl: string;
}

interface FormDataError {
name?: string;
}

interface DefineConversationProps
extends RouteComponentProps<{
templateId: string;
location: string;
}> {
onSubmit: (formData: FormData) => void;
onSubmit: (formData: DefineConversationFormData) => void;
onDismiss: () => void;
onCurrentPathUpdate: (newPath?: string, storageId?: string) => void;
onGetErrorMessage?: (text: string) => void;
saveTemplateId?: (templateId: string) => void;
focusedStorageFolder: StorageFolder;
}

const initialFormDataError: FormDataError = {};

const DefineConversation: React.FC<DefineConversationProps> = (props) => {
const { onSubmit, onDismiss, onCurrentPathUpdate, saveTemplateId, templateId, focusedStorageFolder } = props;
const files = get(focusedStorageFolder, 'children', []);
Expand All @@ -68,54 +63,40 @@ const DefineConversation: React.FC<DefineConversationProps> = (props) => {
return defaultName;
};

const initalFormData: FormData = { name: '', description: '', schemaUrl: '' };
const [formData, setFormData] = useState(initalFormData);
const [formDataErrors, setFormDataErrors] = useState(initialFormDataError);
const [disable, setDisable] = useState(false);

const updateForm = (field) => (e, newValue) => {
setFormData({
...formData,
[field]: newValue,
});
};

const nameRegex = /^[a-zA-Z0-9-_.]+$/;
const validateForm = (data: FormData) => {
const errors: FormDataError = {};
const { name } = data;
if (!name || !nameRegex.test(name)) {
errors.name = formatMessage(
'Spaces and special characters are not allowed. Use letters, numbers, -, or _., numbers, -, and _'
);
}
const newBotPath =
focusedStorageFolder && Object.keys(focusedStorageFolder as Record<string, any>).length
? Path.join(focusedStorageFolder.parent, focusedStorageFolder.name, name)
: '';
if (
name &&
files &&
files.find((bot) => {
return bot.path.toLowerCase() === newBotPath.toLowerCase();
})
) {
errors.name = formatMessage('Duplication of names');
}
return errors;
const formConfig: FieldConfig<DefineConversationFormData> = {
name: {
required: true,
validate: (value) => {
const nameRegex = /^[a-zA-Z0-9-_.]+$/;
if (!value || !nameRegex.test(value)) {
return formatMessage(
'Spaces and special characters are not allowed. Use letters, numbers, -, or _., numbers, -, and _'
);
}

const newBotPath =
focusedStorageFolder && Object.keys(focusedStorageFolder as Record<string, any>).length
? Path.join(focusedStorageFolder.parent, focusedStorageFolder.name, value)
: '';
if (
name &&
files &&
files.find((bot) => {
return bot.path.toLowerCase() === newBotPath.toLowerCase();
})
) {
return formatMessage('Duplication of names');
}
},
},
description: {
required: false,
},
schemaUrl: {
required: false,
},
};

useEffect(() => {
if (formData.name) {
const errors = validateForm(formData);
if (Object.keys(errors).length || !focusedStorageFolder.writable) {
setDisable(true);
} else {
setDisable(false);
}
setFormDataErrors(errors);
}
}, [focusedStorageFolder, formData.name]);
const { formData, formErrors, hasErrors, updateField, updateForm } = useForm(formConfig);

useEffect(() => {
if (saveTemplateId && templateId) {
Expand All @@ -124,8 +105,8 @@ const DefineConversation: React.FC<DefineConversationProps> = (props) => {
});

useEffect(() => {
const formData: FormData = { name: getDefaultName(), description: '', schemaUrl: '' };
setFormData(formData);
const formData: DefineConversationFormData = { name: getDefaultName(), description: '', schemaUrl: '' };
updateForm(formData);
if (props.location && props.location.search) {
const updatedFormData = {
...formData,
Expand All @@ -146,22 +127,24 @@ const DefineConversation: React.FC<DefineConversationProps> = (props) => {
} else {
updatedFormData.name = getDefaultName();
}
setFormData(updatedFormData);
updateForm(updatedFormData);
}
}, [templateId]);

const handleSubmit = (e) => {
e.preventDefault();
const errors = validateForm(formData);
if (Object.keys(errors).length) {
setFormDataErrors(errors);
return;
}
const handleSubmit = useCallback(
(e) => {
e.preventDefault();
if (hasErrors) {
return;
}

onSubmit({
...formData,
});
},
[hasErrors, formData]
);

onSubmit({
...formData,
});
};
return (
<Fragment>
<DialogWrapper
Expand All @@ -178,11 +161,11 @@ const DefineConversation: React.FC<DefineConversationProps> = (props) => {
autoFocus
required
data-testid="NewDialogName"
errorMessage={formDataErrors.name}
errorMessage={formErrors.name}
label={formatMessage('Name')}
styles={name}
value={formData.name}
onChange={updateForm('name')}
onChange={(_e, val) => updateField('name', val)}
/>
</StackItem>
<StackItem grow={0} styles={halfstack}>
Expand All @@ -192,7 +175,7 @@ const DefineConversation: React.FC<DefineConversationProps> = (props) => {
resizable={false}
styles={description}
value={formData.description}
onChange={updateForm('description')}
onChange={(_e, val) => updateField('description', val)}
/>
</StackItem>
</Stack>
Expand All @@ -206,7 +189,7 @@ const DefineConversation: React.FC<DefineConversationProps> = (props) => {
<DefaultButton text={formatMessage('Cancel')} onClick={onDismiss} />
<PrimaryButton
data-testid="SubmitNewBotBtn"
disabled={disable}
disabled={hasErrors}
text={formatMessage('Next')}
onClick={handleSubmit}
/>
Expand Down
4 changes: 4 additions & 0 deletions Composer/packages/client/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

export * from './useForm';
Loading