Skip to content

fix: transition import & SubmitError handling #230

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Oct 26, 2022
6 changes: 6 additions & 0 deletions .changeset/thin-birds-press.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cube-dev/ui-kit": patch
---

The Form component set `submitError` no more on failed validation.
`SubmitError` component now handles non-valid errors as `Internal error`.
2 changes: 1 addition & 1 deletion src/components/OpenTrasition.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Children, cloneElement } from 'react';
import Transition from 'react-transition-group/Transition';
import { Transition } from 'react-transition-group';

const OPEN_STATES = {
entering: false,
Expand Down
57 changes: 57 additions & 0 deletions src/components/forms/Form/ComplexForm.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,44 @@ export default {
parameters: { controls: { exclude: baseProps } },
};

const UnknownSubmitErrorTemplate: StoryFn<typeof Form> = (args) => {
const [form] = Form.useForm();

return (
<Form
form={form}
{...args}
onSubmit={(v) => {
console.log('onSubmit:', v);

throw new Error('Unknown error');
}}
onSubmitFailed={(e) => {
console.log('onSubmitFailed', e);
}}
onValuesChange={(v) => {
console.log('onChange', v);
}}
>
<Field
name="text"
label="Text input"
rules={[
() => ({
async validator() {
await timeout(1000);
},
}),
]}
>
<TextInput />
</Field>
<Submit>Submit</Submit>
<SubmitError />
</Form>
);
};

const CustomSubmitErrorTemplate: StoryFn<typeof Form> = (args) => {
const [form] = Form.useForm();

Expand Down Expand Up @@ -344,3 +382,22 @@ ErrorMessage.play = CustomErrorMessage.play = async ({ canvasElement }) => {

await waitFor(() => expect(canvas.getByRole('alert')).toBeInTheDocument());
};

export const UnknownErrorMessage = UnknownSubmitErrorTemplate.bind({});

UnknownErrorMessage.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.getByRole('button');

await userEvent.click(button);

await waitFor(async () => {
const alertElement = await canvas.getByText('Internal error');

await expect(alertElement).toBeInTheDocument();

await userEvent.click(button);

expect(alertElement).not.toBeInTheDocument();
});
};
22 changes: 15 additions & 7 deletions src/components/forms/Form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ export interface CubeFormProps<T extends FieldTypes = FieldTypes>
name?: string;
/** Default field values */
defaultValues?: Partial<T>;
/** Trigger when any value of Field changed */
/** Trigger when any value of the Field is changed */
onValuesChange?: CubeFormInstance<T>['onValuesChange'];
/** Trigger when form submit and success */
/** Trigger on form submit and success */
onSubmit?: CubeFormInstance<T>['onSubmit'];
/** Trigger when form submit and failed */
/** Trigger on form submit and failed */
onSubmitFailed?: (any?) => void | Promise<any>;
/** Set form instance created by useForm */
form?: CubeFormInstance<T, CubeFormData<T>>;
Expand Down Expand Up @@ -137,20 +137,28 @@ function Form<T extends FieldTypes>(
form.setSubmitting(true);

try {
await form.validateFields();
try {
await form.validateFields();
} catch (e) {
form?.setSubmitting(false);

return;
}

await timeout();
await onSubmit?.(form.getFormData());
} catch (e) {
await timeout();

if (e instanceof Error) {
throw e;
}
// errors are shown
setSubmitError(e as ReactNode);
form.submitError = e as ReactNode;
// transfer errors to the callback
onSubmitFailed?.(e);

if (e instanceof Error) {
throw e;
}
} finally {
form?.setSubmitting(false);
}
Expand Down
21 changes: 14 additions & 7 deletions src/components/forms/Form/SubmitError.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,33 @@
import { ReactNode, useContext } from 'react';
import { ReactNode, useContext, isValidElement } from 'react';

import { Alert, CubeAlertProps } from '../../content/Alert';

import { FormContext } from './Form';

type SubmitErrorProps = {
submitError?: ReactNode;
type SubmitErrorContextProps = {
submitError?: unknown;
};

/**
* An alert that shows a form error message received from the onSubmit callback.
*/
export function SubmitError(props: CubeAlertProps) {
const { submitError } = useContext(FormContext) as SubmitErrorProps;
export const SubmitError = function SubmitError(props: CubeAlertProps) {
let { submitError } = useContext(FormContext) as SubmitErrorContextProps;

if (!submitError) {
return null;
}

if (
!isValidElement(submitError as ReactNode) &&
typeof submitError !== 'string'
) {
submitError = 'Internal error';
}

return (
<Alert theme="danger" {...props}>
{submitError}
{submitError as ReactNode}
</Alert>
);
}
};
86 changes: 86 additions & 0 deletions src/components/forms/Form/submit-error.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import userEvents from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';

import { renderWithForm } from '../../../test';
import { Submit } from '../../actions';
import { TextInput } from '../TextInput/TextInput';

import { Form } from '.';

describe('<SubmitError />', () => {
it('should display a submit error if onSubmit callback is failed', async () => {
const onSubmit = jest.fn(() => Promise.reject('Custom Error'));
const onSubmitFailed = jest.fn(() => {});

const { getByRole, getByText } = renderWithForm(
<>
<Form.Item name="test" label="Test">
<TextInput />
</Form.Item>

<Submit>Submit</Submit>

<Form.SubmitError />
</>,
{ formProps: { onSubmit, onSubmitFailed } },
);

const submit = getByRole('button');
const input = getByRole('textbox');

await userEvents.type(input, 'test');
await userEvents.click(submit);

await waitFor(() => {
// onSubmitFailed callback should only be called if onSubmit callback is called and failed
expect(onSubmitFailed).toBeCalledTimes(1);
});

await waitFor(() => {
expect(onSubmit).toBeCalledTimes(1);
});

await waitFor(() => {
expect(getByText('Custom Error')).toBeInTheDocument();
});
});

it('should display an error placeholder if error is not handled properly', async () => {
const onSubmit = jest.fn(() => {
return Promise.reject([]); // non-valid error
});
const onSubmitFailed = jest.fn(() => {});

const { getByRole, getByText } = renderWithForm(
<>
<Form.Item name="test" label="Test">
<TextInput />
</Form.Item>

<Submit>Submit</Submit>

<Form.SubmitError />
</>,
{ formProps: { onSubmit, onSubmitFailed } },
);

const submit = getByRole('button');
const input = getByRole('textbox');

await userEvents.type(input, 'test');
await userEvents.click(submit);

await waitFor(() => {
// onSubmitFailed callback should only be called if onSubmit callback is called and failed
expect(onSubmitFailed).toBeCalledTimes(1);
});

await waitFor(() => {
expect(onSubmit).toBeCalledTimes(1);
});

await waitFor(() => {
expect(getByText('Internal error')).toBeInTheDocument();
});
});
});
88 changes: 88 additions & 0 deletions src/components/forms/Form/submit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import userEvents from '@testing-library/user-event';
import { act, waitFor } from '@testing-library/react';

import { renderWithForm } from '../../../test';
import { Submit } from '../../actions';
import { TextInput } from '../TextInput/TextInput';

import { Form } from '.';

describe('<Form />', () => {
it('should not be displayed if validation is failed on submit', async () => {
const onSubmit = jest.fn(() => Promise.reject('Custom Error'));
const onSubmitFailed = jest.fn(() => {});

const { getByRole, formInstance } = renderWithForm(
<>
<Form.Item
name="test"
label="Test"
rules={[
{
validator(rule, value) {
return Promise.reject('invalid');
},
},
]}
>
<TextInput />
</Form.Item>

<Submit>Submit</Submit>

<Form.SubmitError />
</>,
{ formProps: { onSubmit, onSubmitFailed } },
);

const submit = getByRole('button');
const input = getByRole('textbox');

await userEvents.type(input, 'test');
await userEvents.click(submit);

await waitFor(() => {
// onSubmitFailed callback should only be called if onSubmit callback is called and failed
expect(onSubmitFailed).not.toBeCalled();
});

await waitFor(() => {
expect(formInstance.submitError).toBeNull();
});
});

it('should throw uncaught rejection if error is not handled', async () => {
const onSubmit = jest.fn(() => {
throw new Error('Custom Error');
});
const onSubmitFailed = jest.fn(() => {});

const { getByRole, getByText, formInstance } = renderWithForm(
<>
<Form.Item name="test" label="Test">
<TextInput />
</Form.Item>

<Submit>Submit</Submit>

<Form.SubmitError />
</>,
{ formProps: { onSubmit, onSubmitFailed } },
);

const input = getByRole('textbox');

await userEvents.type(input, 'test');

await act(async () => {
await expect(formInstance.submit()).rejects.toThrow('Custom Error');
});

await expect(onSubmitFailed).toBeCalledTimes(1);
await expect(onSubmit).toBeCalledTimes(1);

await waitFor(() => {
expect(getByText('Internal error')).toBeInTheDocument();
});
});
});
4 changes: 3 additions & 1 deletion src/components/forms/Form/useForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class CubeFormInstance<
private fields: TFormData = {} as TFormData;
public ref = {};
public isSubmitting = false;
public submitError: ReactNode = null;
public submitError: unknown = null;

public onValuesChange: (data: T) => void | Promise<void> = () => {};
public onSubmit: (data: T) => void | Promise<void> = () => {};
Expand Down Expand Up @@ -310,6 +310,8 @@ export class CubeFormInstance<
}

setSubmitting(isSubmitting: boolean) {
if (this.isSubmitting === isSubmitting) return;

this.isSubmitting = isSubmitting;
this.forceReRender();
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/overlays/Modal/OpenTransition.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Children, cloneElement } from 'react';
import Transition from 'react-transition-group/Transition';
import { Transition } from 'react-transition-group';

const OPEN_STATES = {
entering: false,
Expand Down
Loading