diff --git a/.gitignore b/.gitignore index 46228988..22359798 100644 --- a/.gitignore +++ b/.gitignore @@ -132,5 +132,8 @@ dist # Storybook storybook-static -#VScode -.vscode \ No newline at end of file +# VScode +.vscode + +# Local Scripts +local/ \ No newline at end of file diff --git a/package.json b/package.json index a26e56ff..6b06abfe 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "zod": "^3.23.6" }, "dependencies": { - "@douglasneuroinformatics/libjs": "^2.7.0", + "@douglasneuroinformatics/libjs": "^2.8.0", "@douglasneuroinformatics/libui-form-types": "^0.11.0", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0a6b3bb..b4fa653a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,8 +8,8 @@ importers: .: dependencies: '@douglasneuroinformatics/libjs': - specifier: ^2.7.0 - version: 2.7.0(neverthrow@8.2.0)(zod@3.24.2) + specifier: ^2.8.0 + version: 2.8.0(neverthrow@8.2.0)(zod@3.24.2) '@douglasneuroinformatics/libui-form-types': specifier: ^0.11.0 version: 0.11.0 @@ -467,9 +467,9 @@ packages: typescript: optional: true - '@douglasneuroinformatics/libjs@2.7.0': + '@douglasneuroinformatics/libjs@2.8.0': resolution: - { integrity: sha512-mx6jhGPbPC5SK6Xv9+N2y5uNtBkSwsJuRudUfDj5C8UB3sigPUsVeWPRhkkVZCHNUXIWNvXEd4i+NW7lY4FLbQ== } + { integrity: sha512-2hNFONcYsfQ5wSsTn3NjXSrqEpwdmQ0GmTajaUrX1DOQpRKSt5MmlxhTzLon51S86eR4rbSUkBEsD0AUonFGiw== } peerDependencies: neverthrow: ^8.2.0 zod: ^3.22.6 @@ -5452,6 +5452,11 @@ packages: engines: { node: '>=10' } hasBin: true + serialize-error@12.0.0: + resolution: + { integrity: sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw== } + engines: { node: '>=18' } + set-function-length@1.2.2: resolution: { integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== } @@ -6582,11 +6587,12 @@ snapshots: - svelte - ts-node - '@douglasneuroinformatics/libjs@2.7.0(neverthrow@8.2.0)(zod@3.24.2)': + '@douglasneuroinformatics/libjs@2.8.0(neverthrow@8.2.0)(zod@3.24.2)': dependencies: clean-stack: 5.2.0 extract-stack: 3.0.0 neverthrow: 8.2.0 + serialize-error: 12.0.0 stringify-object: 5.0.0 type-fest: 4.39.0 zod: 3.24.2 @@ -10814,6 +10820,10 @@ snapshots: semver@7.7.1: {} + serialize-error@12.0.0: + dependencies: + type-fest: 4.39.0 + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 6c80e862..86a7d0c5 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -14,7 +14,7 @@ export const BUTTON_ICON_SIZE = { }; export const buttonVariants = cva( - 'flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + 'flex items-center justify-center whitespace-nowrap cursor-pointer rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', { defaultVariants: { size: 'md', diff --git a/src/components/Dialog/DialogContent.tsx b/src/components/Dialog/DialogContent.tsx index f94b7278..94f4cc22 100644 --- a/src/components/Dialog/DialogContent.tsx +++ b/src/components/Dialog/DialogContent.tsx @@ -16,7 +16,7 @@ export const DialogContent = forwardRef< { + it('should render', () => { + const error = new Error('Something went wrong'); + render(); + expect(screen.getByTestId(TEST_ID)).toBeInTheDocument(); + }); +}); diff --git a/src/components/ErrorFallback/ErrorFallback.tsx b/src/components/ErrorFallback/ErrorFallback.tsx index 6f64811a..5608e947 100644 --- a/src/components/ErrorFallback/ErrorFallback.tsx +++ b/src/components/ErrorFallback/ErrorFallback.tsx @@ -12,10 +12,13 @@ export const ErrorFallback = ({ error }: ErrorFallbackProps) => { }, [error]); return ( -
-

Unexpected Error

+
+

Unexpected Error

Something Went Wrong

-

+

We apologize for the inconvenience. Please contact us for further assistance.

diff --git a/src/components/Form/ErrorMessage.tsx b/src/components/Form/ErrorMessage.tsx index 8d2a09cd..30de0914 100644 --- a/src/components/Form/ErrorMessage.tsx +++ b/src/components/Form/ErrorMessage.tsx @@ -2,13 +2,15 @@ import * as React from 'react'; import { CircleAlertIcon } from 'lucide-react'; -export const ErrorMessage: React.FC<{ error?: null | string[] }> = ({ error }) => { +import { cn } from '@/utils'; + +export const ErrorMessage: React.FC<{ className?: string; error?: null | string[] }> = ({ className, error }) => { return error ? (
{error.map((message) => ( -
+
- {message} + {message}
)) ?? null}
diff --git a/src/components/Form/Form.stories.tsx b/src/components/Form/Form.stories.tsx index 1a0b6272..ec7cdb5e 100644 --- a/src/components/Form/Form.stories.tsx +++ b/src/components/Form/Form.stories.tsx @@ -530,3 +530,27 @@ export const WithSuspend: StoryObj, { dela }) } }; + +export const WithError: StoryObj = { + args: { + content: { + name: { + kind: 'string', + label: 'Name', + variant: 'input' + } + }, + beforeSubmit: (data) => { + if (data.name === 'Winston') { + return { success: true }; + } + return { success: false, errorMessage: "Name must be 'Winston'" }; + }, + onSubmit: () => { + alert('Success!'); + }, + validationSchema: $SimpleExampleFormData.extend({ + name: z.string().min(3) + }) + } +}; diff --git a/src/components/Form/Form.test.tsx b/src/components/Form/Form.test.tsx index dc3239b0..fb1c93f6 100644 --- a/src/components/Form/Form.test.tsx +++ b/src/components/Form/Form.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; import { z } from 'zod'; import { Form } from './Form'; @@ -114,4 +115,68 @@ describe('Form', () => { expect(onSubmit.mock.lastCall?.[0].b).toBeUndefined(); }); }); + + describe('custom beforeSubmit error', () => { + let beforeSubmit: Mock; + + beforeEach(() => { + beforeSubmit = vi.fn(); + render( +
+ ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render', () => { + expect(screen.getByTestId(testid)).toBeInTheDocument(); + }); + + it('should not allow submitting the form with a zod error', async () => { + fireEvent.submit(screen.getByTestId(testid)); + await waitFor(() => + expect(screen.getAllByTestId('error-message-text').map((e) => e.innerHTML)).toMatchObject([ + 'Please enter a number' + ]) + ); + expect(beforeSubmit).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('should not allow submitting the form with the beforeSubmit error', async () => { + beforeSubmit.mockResolvedValueOnce({ errorMessage: 'Invalid!', success: false }); + const field: HTMLInputElement = screen.getByLabelText('Value'); + await userEvent.type(field, '-1'); + fireEvent.submit(screen.getByTestId(testid)); + await waitFor(() => + expect(screen.getAllByTestId('error-message-text').map((e) => e.innerHTML)).toMatchObject(['Invalid!']) + ); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('should allow submitting the form if beforeSubmit returns true', async () => { + beforeSubmit.mockResolvedValueOnce({ success: true }); + const field: HTMLInputElement = screen.getByLabelText('Value'); + await userEvent.type(field, '-1'); + fireEvent.submit(screen.getByTestId(testid)); + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + }); + }); }); diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx index b014df68..cf42703b 100644 --- a/src/components/Form/Form.tsx +++ b/src/components/Form/Form.tsx @@ -29,8 +29,13 @@ type FormProps, TData extends z.TypeOf) => Promisable<{ errorMessage: string; success: false } | { success: true }>; className?: string; content: FormContent; + customStyles?: { + resetBtn?: string; + submitBtn?: string; + }; fieldsFooter?: React.ReactNode; id?: string; initialValues?: PartialNullableFormDataType>; @@ -47,8 +52,10 @@ type FormProps, TData extends z.TypeOf, TData extends z.TypeOf = z.TypeOf>({ additionalButtons, + beforeSubmit, className, content, + customStyles, fieldsFooter, id, initialValues, @@ -73,6 +80,7 @@ const Form = , TData extends z.TypeOf) => { const fieldErrors: FormErrors = {}; + const rootErrors: string[] = []; for (const issue of error.issues) { if (issue.path.length > 0) { const current = get(fieldErrors, issue.path) as string[] | undefined; @@ -82,10 +90,11 @@ const Form = , TData extends z.TypeOf [...prevErrors, issue.message]); + rootErrors.push(issue.message); } } setErrors(fieldErrors); + setRootErrors(rootErrors); if (onError) { onError(error); } @@ -107,6 +116,15 @@ const Form = , TData extends z.TypeOf, TData extends z.TypeOf )} {fieldGroup.description && ( -

{fieldGroup.description}

+

{fieldGroup.description}

)}
, TData extends z.TypeOf )} + {Boolean(rootErrors.length) && } {fieldsFooter}
{additionalButtons?.left} {/** Note - aria-label is used for testing in downstream packages */}