diff --git a/.changeset/thin-birds-press.md b/.changeset/thin-birds-press.md new file mode 100644 index 00000000..8fc2087f --- /dev/null +++ b/.changeset/thin-birds-press.md @@ -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`. diff --git a/src/components/OpenTrasition.tsx b/src/components/OpenTrasition.tsx index e7e8e012..59806d9c 100644 --- a/src/components/OpenTrasition.tsx +++ b/src/components/OpenTrasition.tsx @@ -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, diff --git a/src/components/forms/Form/ComplexForm.stories.tsx b/src/components/forms/Form/ComplexForm.stories.tsx index 4a2854cd..4c15e32f 100644 --- a/src/components/forms/Form/ComplexForm.stories.tsx +++ b/src/components/forms/Form/ComplexForm.stories.tsx @@ -32,6 +32,44 @@ export default { parameters: { controls: { exclude: baseProps } }, }; +const UnknownSubmitErrorTemplate: StoryFn = (args) => { + const [form] = Form.useForm(); + + return ( +
{ + console.log('onSubmit:', v); + + throw new Error('Unknown error'); + }} + onSubmitFailed={(e) => { + console.log('onSubmitFailed', e); + }} + onValuesChange={(v) => { + console.log('onChange', v); + }} + > + ({ + async validator() { + await timeout(1000); + }, + }), + ]} + > + + + Submit + + + ); +}; + const CustomSubmitErrorTemplate: StoryFn = (args) => { const [form] = Form.useForm(); @@ -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(); + }); +}; diff --git a/src/components/forms/Form/Form.tsx b/src/components/forms/Form/Form.tsx index 96d299e5..bd8ed11b 100644 --- a/src/components/forms/Form/Form.tsx +++ b/src/components/forms/Form/Form.tsx @@ -67,11 +67,11 @@ export interface CubeFormProps name?: string; /** Default field values */ defaultValues?: Partial; - /** Trigger when any value of Field changed */ + /** Trigger when any value of the Field is changed */ onValuesChange?: CubeFormInstance['onValuesChange']; - /** Trigger when form submit and success */ + /** Trigger on form submit and success */ onSubmit?: CubeFormInstance['onSubmit']; - /** Trigger when form submit and failed */ + /** Trigger on form submit and failed */ onSubmitFailed?: (any?) => void | Promise; /** Set form instance created by useForm */ form?: CubeFormInstance>; @@ -137,20 +137,28 @@ function Form( 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); } diff --git a/src/components/forms/Form/SubmitError.tsx b/src/components/forms/Form/SubmitError.tsx index aec82dde..31a1ead8 100644 --- a/src/components/forms/Form/SubmitError.tsx +++ b/src/components/forms/Form/SubmitError.tsx @@ -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 ( - {submitError} + {submitError as ReactNode} ); -} +}; diff --git a/src/components/forms/Form/submit-error.test.tsx b/src/components/forms/Form/submit-error.test.tsx new file mode 100644 index 00000000..9711f105 --- /dev/null +++ b/src/components/forms/Form/submit-error.test.tsx @@ -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('', () => { + 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( + <> + + + + + Submit + + + , + { 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( + <> + + + + + Submit + + + , + { 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(); + }); + }); +}); diff --git a/src/components/forms/Form/submit.test.tsx b/src/components/forms/Form/submit.test.tsx new file mode 100644 index 00000000..3c081d02 --- /dev/null +++ b/src/components/forms/Form/submit.test.tsx @@ -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('
', () => { + 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( + <> + + + + + Submit + + + , + { 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( + <> + + + + + Submit + + + , + { 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(); + }); + }); +}); diff --git a/src/components/forms/Form/useForm.tsx b/src/components/forms/Form/useForm.tsx index a5243b63..be42b021 100644 --- a/src/components/forms/Form/useForm.tsx +++ b/src/components/forms/Form/useForm.tsx @@ -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 = () => {}; public onSubmit: (data: T) => void | Promise = () => {}; @@ -310,6 +310,8 @@ export class CubeFormInstance< } setSubmitting(isSubmitting: boolean) { + if (this.isSubmitting === isSubmitting) return; + this.isSubmitting = isSubmitting; this.forceReRender(); } diff --git a/src/components/overlays/Modal/OpenTransition.tsx b/src/components/overlays/Modal/OpenTransition.tsx index 0937c142..3f1104c7 100644 --- a/src/components/overlays/Modal/OpenTransition.tsx +++ b/src/components/overlays/Modal/OpenTransition.tsx @@ -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, diff --git a/src/test/render.tsx b/src/test/render.tsx index f7efcba9..be5095f2 100644 --- a/src/test/render.tsx +++ b/src/test/render.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, RenderOptions } from '@testing-library/react'; +import { Form, CubeFormInstance, CubeFormProps } from '../components/forms'; import { Root } from '../components/Root'; export function renderWithRoot( @@ -10,4 +11,36 @@ export function renderWithRoot( return render(ui, { ...options, wrapper: Root }); } +export function renderWithForm( + ui: React.ReactElement, + options?: Omit & { + formProps?: Partial>; + }, +) { + const { formProps, ...testingLibraryOptions } = options ?? {}; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let formInstance: CubeFormInstance; + + return { + ...render(ui, { + ...testingLibraryOptions, + wrapper: function Wrapper({ children }) { + const [form] = Form.useForm(); + formInstance = form; + + return ( + + + {children} + + + ); + }, + }), + // @ts-expect-error TS2454 + formInstance, + } as const; +} + export * from '@testing-library/react';