From 6838868fdcdf9336cb441774dcbb2761cc71a597 Mon Sep 17 00:00:00 2001 From: mirfalltnixein <22786734+mirfalltnixein@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:04:24 +0100 Subject: [PATCH 1/3] Prevent dismissing unsaved form modals --- .../src/components/Form/FormModal.test.tsx | 70 +++++++++++++++++++ frontend/src/components/Form/FormModal.tsx | 38 ++++++++++ frontend/src/components/Form/index.ts | 1 + frontend/src/locale/src/en.json | 3 + frontend/src/locale/src/et.json | 3 + frontend/src/modals/AccessListModal.tsx | 17 +++-- frontend/src/modals/ChangePasswordModal.tsx | 17 +++-- .../src/modals/CustomCertificateModal.tsx | 17 +++-- frontend/src/modals/DNSCertificateModal.tsx | 17 +++-- frontend/src/modals/DeadHostModal.tsx | 17 +++-- frontend/src/modals/HTTPCertificateModal.tsx | 17 +++-- frontend/src/modals/PermissionsModal.tsx | 17 +++-- frontend/src/modals/ProxyHostModal.tsx | 17 +++-- frontend/src/modals/RedirectionHostModal.tsx | 17 +++-- frontend/src/modals/SetPasswordModal.tsx | 17 +++-- frontend/src/modals/StreamModal.tsx | 17 +++-- frontend/src/modals/UserModal.tsx | 17 +++-- 17 files changed, 235 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/Form/FormModal.test.tsx create mode 100644 frontend/src/components/Form/FormModal.tsx diff --git a/frontend/src/components/Form/FormModal.test.tsx b/frontend/src/components/Form/FormModal.test.tsx new file mode 100644 index 000000000..7ed649dc4 --- /dev/null +++ b/frontend/src/components/Form/FormModal.test.tsx @@ -0,0 +1,70 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { Field, Form } from "formik"; +import Modal from "react-bootstrap/Modal"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FormModal } from "./FormModal"; + +const renderModal = (onHide = vi.fn()) => { + render( + + {({ onHide: hide }) => ( +
+ + + + + + )} +
, + ); + return onHide; +}; + +describe("FormModal", () => { + beforeEach(() => { + vi.stubGlobal("confirm", vi.fn()); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("dismisses a pristine form without confirmation", () => { + const onHide = renderModal(); + const confirm = vi.spyOn(window, "confirm"); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(confirm).not.toHaveBeenCalled(); + expect(onHide).toHaveBeenCalledOnce(); + }); + + it("keeps a dirty form open when dismissal is cancelled", () => { + const onHide = renderModal(); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } }); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + fireEvent.click(document.querySelector(".modal") as Element); + fireEvent.keyDown(document, { key: "Escape", keyCode: 27 }); + + expect(confirm).toHaveBeenCalledTimes(4); + expect(onHide).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Name")).toHaveValue("example.com"); + }); + + it("dismisses a dirty form after confirmation", () => { + const onHide = renderModal(); + vi.spyOn(window, "confirm").mockReturnValue(true); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } }); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onHide).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/components/Form/FormModal.tsx b/frontend/src/components/Form/FormModal.tsx new file mode 100644 index 000000000..8c305ac4d --- /dev/null +++ b/frontend/src/components/Form/FormModal.tsx @@ -0,0 +1,38 @@ +import { Formik, type FormikConfig, type FormikProps, type FormikValues } from "formik"; +import type { ReactNode } from "react"; +import Modal from "react-bootstrap/Modal"; +import { intl } from "src/locale"; + +interface FormModalProps extends Omit, "children"> { + show: boolean; + onHide: () => void; + children: (props: FormikProps & { onHide: () => void }) => ReactNode; +} + +const FormModal = ({ show, onHide, children, ...formikProps }: FormModalProps) => ( + + {(formikProps) => { + const handleHide = () => { + if ( + !formikProps.dirty || + window.confirm( + intl.formatMessage({ + id: "form.unsaved-changes", + defaultMessage: "You have unsaved changes. Are you sure you want to discard them?", + }), + ) + ) { + onHide(); + } + }; + + return ( + + {children({ ...formikProps, onHide: handleHide })} + + ); + }} + +); + +export { FormModal }; diff --git a/frontend/src/components/Form/index.ts b/frontend/src/components/Form/index.ts index f218b2e33..8530d6f6d 100644 --- a/frontend/src/components/Form/index.ts +++ b/frontend/src/components/Form/index.ts @@ -3,6 +3,7 @@ export * from "./AccessField"; export * from "./BasicAuthFields"; export * from "./DNSProviderFields"; export * from "./DomainNamesField"; +export * from "./FormModal"; export * from "./LocationsFields"; export * from "./NginxConfigField"; export * from "./SSLCertificateField"; diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 11d09ea71..14c1d5b44 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -437,6 +437,9 @@ "footer.github-fork": { "defaultMessage": "Fork me on Github" }, + "form.unsaved-changes": { + "defaultMessage": "You have unsaved changes. Are you sure you want to discard them?" + }, "host.flags.block-exploits": { "defaultMessage": "Block Common Exploits" }, diff --git a/frontend/src/locale/src/et.json b/frontend/src/locale/src/et.json index 316ede752..2301c5a46 100644 --- a/frontend/src/locale/src/et.json +++ b/frontend/src/locale/src/et.json @@ -437,6 +437,9 @@ "footer.github-fork": { "defaultMessage": "Tee GitHubis fork" }, + "form.unsaved-changes": { + "defaultMessage": "Sul on salvestamata muudatusi. Kas oled kindel, et soovid need tühistada?" + }, "host.flags.block-exploits": { "defaultMessage": "Blokeeri levinud ründed" }, diff --git a/frontend/src/modals/AccessListModal.tsx b/frontend/src/modals/AccessListModal.tsx index 79537f5cd..559740d3c 100644 --- a/frontend/src/modals/AccessListModal.tsx +++ b/frontend/src/modals/AccessListModal.tsx @@ -1,6 +1,7 @@ import cn from "classnames"; import EasyModal, { type InnerModalProps } from "ez-modal-react"; -import { Field, Form, Formik } from "formik"; +import { Field, Form } from "formik"; +import { FormModal } from "src/components/Form"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; @@ -86,7 +87,7 @@ const AccessListModal = EasyModal.create(({ id, visible, remove }: Props) => { const toggleEnabled = cn(toggleClasses, "bg-cyan"); return ( - + <> {!isLoading && error && ( {error?.message || "Unknown error"} @@ -94,7 +95,9 @@ const AccessListModal = EasyModal.create(({ id, visible, remove }: Props) => { )} {isLoading && } {!isLoading && data && ( - { } onSubmit={onSubmit} > - {({ setFieldValue }: any) => ( + {({ onHide, setFieldValue }: any) => (
@@ -267,7 +270,7 @@ const AccessListModal = EasyModal.create(({ id, visible, remove }: Props) => { -
@@ -206,8 +209,8 @@ const HTTPCertificateModal = EasyModal.create(({ visible, remove }: InnerModalPr )} - - + + ); }); diff --git a/frontend/src/modals/PermissionsModal.tsx b/frontend/src/modals/PermissionsModal.tsx index d363de9ed..3c808abf0 100644 --- a/frontend/src/modals/PermissionsModal.tsx +++ b/frontend/src/modals/PermissionsModal.tsx @@ -1,7 +1,8 @@ import { useQueryClient } from "@tanstack/react-query"; import cn from "classnames"; import EasyModal, { type InnerModalProps } from "ez-modal-react"; -import { Field, Form, Formik } from "formik"; +import { Field, Form } from "formik"; +import { FormModal } from "src/components/Form"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; @@ -133,7 +134,7 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => { const isAdmin = data?.roles.indexOf("admin") !== -1; return ( - + <> {!isLoading && error && ( {error?.message || "Unknown error"} @@ -141,7 +142,9 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => { )} {isLoading && } {!isLoading && data && ( - { } onSubmit={onSubmit} > - {() => ( + {({ onHide }) => (
@@ -263,7 +266,7 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => { )} -
- @@ -368,9 +371,9 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { )} -
+ )} -
+ ); }); diff --git a/frontend/src/modals/RedirectionHostModal.tsx b/frontend/src/modals/RedirectionHostModal.tsx index 9a0d95ce8..a621cf1f7 100644 --- a/frontend/src/modals/RedirectionHostModal.tsx +++ b/frontend/src/modals/RedirectionHostModal.tsx @@ -1,7 +1,8 @@ import { IconSettings } from "@tabler/icons-react"; import cn from "classnames"; import EasyModal, { type InnerModalProps } from "ez-modal-react"; -import { Field, Form, Formik } from "formik"; +import { Field, Form } from "formik"; +import { FormModal } from "src/components/Form"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; @@ -55,7 +56,7 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = }; return ( - + <> {!isLoading && error && ( {error?.message || "Unknown error"} @@ -63,7 +64,9 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = )} {isLoading && } {!isLoading && data && ( - - {() => ( + {({ onHide }) => (
@@ -326,7 +329,7 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = - - - - - )} - - + + {({ onHide }) => ( +
+ + + + + + + setError(null)} dismissible> + {error} + +
+ + {({ field, form }: any) => ( +
+ + + {form.errors.name ? ( +
+ {form.errors.current && form.touched.current + ? form.errors.current + : null} +
+ ) : null} +
+ )} +
+
+
+ + {({ field, form }: any) => ( +
+ + + {form.errors.new ? ( +
+ {form.errors.new && form.touched.new ? form.errors.new : null} +
+ ) : null} +
+ )} +
+
+
+ + {({ field, form }: any) => ( +
+ + {form.errors.confirm ? ( +
+ {form.errors.confirm && form.touched.confirm + ? form.errors.confirm + : null} +
+ ) : null} + +
+ )} +
+
+
+ + + + +
+ )} +
); }); diff --git a/frontend/src/modals/CustomCertificateModal.tsx b/frontend/src/modals/CustomCertificateModal.tsx index b77a56d4b..c373a8d36 100644 --- a/frontend/src/modals/CustomCertificateModal.tsx +++ b/frontend/src/modals/CustomCertificateModal.tsx @@ -2,12 +2,12 @@ import { IconAlertTriangle } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import EasyModal, { type InnerModalProps } from "ez-modal-react"; import { Field, Form } from "formik"; -import { FormModal } from "src/components/Form"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; import { type Certificate, createCertificate, uploadCertificate, validateCertificate } from "src/api/backend"; import { Button } from "src/components"; +import { FormModal } from "src/components/Form"; import { T } from "src/locale"; import { validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; @@ -58,177 +58,176 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal }; return ( - <> - - {({ onHide }) => ( -
- - - - - - - setErrorMsg(null)} dismissible> - {errorMsg} - -
-
-

- - -

- - {({ field, form }: any) => ( -
- - - {form.errors.niceName ? ( -
- {form.errors.niceName && form.touched.niceName - ? form.errors.niceName - : null} -
- ) : null} -
- )} -
- - {({ field, form }: any) => ( -
- - { - form.setFieldValue( - field.name, - event.currentTarget.files?.length - ? event.currentTarget.files[0] - : null, - ); - }} - /> - {form.errors.certificateKey ? ( -
- {form.errors.certificateKey && form.touched.certificateKey - ? form.errors.certificateKey - : null} -
- ) : null} -
- )} -
- - {({ field, form }: any) => ( -
- - { - form.setFieldValue( - field.name, - event.currentTarget.files?.length - ? event.currentTarget.files[0] - : null, - ); - }} - /> - {form.errors.certificate ? ( -
- {form.errors.certificate && form.touched.certificate - ? form.errors.certificate - : null} -
- ) : null} -
- )} -
- - {({ field, form }: any) => ( -
- - { - form.setFieldValue( - field.name, - event.currentTarget.files?.length - ? event.currentTarget.files[0] - : null, - ); - }} - /> - {form.errors.intermediateCertificate ? ( -
- {form.errors.intermediateCertificate && - form.touched.intermediateCertificate - ? form.errors.intermediateCertificate - : null} -
- ) : null} -
- )} -
-
+ + {({ onHide }) => ( + + + + + + + + setErrorMsg(null)} dismissible> + {errorMsg} + +
+
+

+ + +

+ + {({ field, form }: any) => ( +
+ + + {form.errors.niceName ? ( +
+ {form.errors.niceName && form.touched.niceName + ? form.errors.niceName + : null} +
+ ) : null} +
+ )} +
+ + {({ field, form }: any) => ( +
+ + { + form.setFieldValue( + field.name, + event.currentTarget.files?.length + ? event.currentTarget.files[0] + : null, + ); + }} + /> + {form.errors.certificateKey ? ( +
+ {form.errors.certificateKey && form.touched.certificateKey + ? form.errors.certificateKey + : null} +
+ ) : null} +
+ )} +
+ + {({ field, form }: any) => ( +
+ + { + form.setFieldValue( + field.name, + event.currentTarget.files?.length + ? event.currentTarget.files[0] + : null, + ); + }} + /> + {form.errors.certificate ? ( +
+ {form.errors.certificate && form.touched.certificate + ? form.errors.certificate + : null} +
+ ) : null} +
+ )} +
+ + {({ field, form }: any) => ( +
+ + { + form.setFieldValue( + field.name, + event.currentTarget.files?.length + ? event.currentTarget.files[0] + : null, + ); + }} + /> + {form.errors.intermediateCertificate ? ( +
+ {form.errors.intermediateCertificate && + form.touched.intermediateCertificate + ? form.errors.intermediateCertificate + : null} +
+ ) : null} +
+ )} +
- - - - - - - )} - - +
+
+ + + + + + )} +
); }); diff --git a/frontend/src/modals/DNSCertificateModal.tsx b/frontend/src/modals/DNSCertificateModal.tsx index 134aef62c..be4cf3c22 100644 --- a/frontend/src/modals/DNSCertificateModal.tsx +++ b/frontend/src/modals/DNSCertificateModal.tsx @@ -1,12 +1,12 @@ import { useQueryClient } from "@tanstack/react-query"; import EasyModal, { type InnerModalProps } from "ez-modal-react"; import { Field, Form } from "formik"; -import { FormModal } from "src/components/Form"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; import { createCertificate } from "src/api/backend"; import { Button, DNSProviderFields, DomainNamesField } from "src/components"; +import { FormModal } from "src/components/Form"; import { T } from "src/locale"; import { showObjectSuccess } from "src/notifications"; @@ -37,79 +37,78 @@ const DNSCertificateModal = EasyModal.create(({ visible, remove }: InnerModalPro }; return ( - <> - - {({ onHide }) => ( -
- - - - - - - setErrorMsg(null)} dismissible> - {errorMsg} - -
-
- - - {({ field }: any) => ( -
- - - - - -
- )} -
- -
+ + {({ onHide }) => ( + + + + + + + + setErrorMsg(null)} dismissible> + {errorMsg} + +
+
+ + + {({ field }: any) => ( +
+ + + + + +
+ )} +
+
- - - - - - - )} - - +
+
+ + + + + + )} +
); }); diff --git a/frontend/src/modals/DeadHostModal.tsx b/frontend/src/modals/DeadHostModal.tsx index f0c0d96c0..8205936a5 100644 --- a/frontend/src/modals/DeadHostModal.tsx +++ b/frontend/src/modals/DeadHostModal.tsx @@ -1,7 +1,6 @@ import { IconSettings } from "@tabler/icons-react"; import EasyModal, { type InnerModalProps } from "ez-modal-react"; import { Form } from "formik"; -import { FormModal } from "src/components/Form"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; @@ -13,6 +12,7 @@ import { SSLCertificateField, SSLOptionsFields, } from "src/components"; +import { FormModal } from "src/components/Form"; import { useDeadHost, useSetDeadHost } from "src/hooks"; import { T } from "src/locale"; import { showObjectSuccess } from "src/notifications"; @@ -55,16 +55,21 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => { return ( <> - {!isLoading && error && ( - - {error?.message || "Unknown error"} - + {(isLoading || (error && !data)) && ( + + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} + {isLoading && } + )} - {isLoading && } {!isLoading && data && ( - - {({ onHide }) => ( -
- - - - - - - setErrorMsg(null)} dismissible> - {errorMsg} - -
-
-

- - -

- { - setDomains(doms); - setTestResults(null); - }} - /> - - {({ field }: any) => ( -
- - - - - -
- )} -
+ + {({ onHide }) => ( + + + + + + + + setErrorMsg(null)} dismissible> + {errorMsg} + +
+
+

+ + +

+ { + setDomains(doms); + setTestResults(null); + }} + /> + + {({ field }: any) => ( +
+ + + + + +
+ )} +
+
+ {testResults ? ( +
+
+ +
+ {parseTestResults()}
- {testResults ? ( -
-
- -
- {parseTestResults()} -
- ) : null} -
-
- -
+ + + +
+ -
- - -
- - - )} - - + +
+
+ + )} + ); }); diff --git a/frontend/src/modals/PermissionsModal.tsx b/frontend/src/modals/PermissionsModal.tsx index 3c808abf0..4f6bc0fab 100644 --- a/frontend/src/modals/PermissionsModal.tsx +++ b/frontend/src/modals/PermissionsModal.tsx @@ -2,12 +2,12 @@ import { useQueryClient } from "@tanstack/react-query"; import cn from "classnames"; import EasyModal, { type InnerModalProps } from "ez-modal-react"; import { Field, Form } from "formik"; -import { FormModal } from "src/components/Form"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; import { setPermissions } from "src/api/backend"; import { Button, Loading } from "src/components"; +import { FormModal } from "src/components/Form"; import { useUser } from "src/hooks"; import { T } from "src/locale"; import styles from "./PermissionsModal.module.css"; @@ -135,16 +135,21 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => { return ( <> - {!isLoading && error && ( - - {error?.message || "Unknown error"} - + {(isLoading || (error && !data)) && ( + + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} + {isLoading && } + )} - {isLoading && } {!isLoading && data && ( { return ( <> - {!isLoading && (error || userError) && ( - - {error?.message || userError?.message || "Unknown error"} - + {(isLoading || userIsLoading || ((error || userError) && (!data || !currentUser))) && ( + + {!isLoading && (error || userError) && ( + + {error?.message || userError?.message || "Unknown error"} + + )} + {(isLoading || userIsLoading) && } + )} - {isLoading || (userIsLoading && )} {!isLoading && !userIsLoading && data && currentUser && ( - {!isLoading && error && ( - - {error?.message || "Unknown error"} - + {(isLoading || (error && !data)) && ( + + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} + {isLoading && } + )} - {isLoading && } {!isLoading && data && ( { }; return ( - <> - - {({ onHide }) => ( -
- - - - - - - setError(null)} dismissible> - {error} - -
- - {({ field, form }: any) => ( - <> -

- - { - e.preventDefault(); - form.setFieldValue( - field.name, - generate({ - length: 12, - numbers: true, - }), - ); - setShowPassword(true); - }} - > - - {" "} - —{" "} - { - e.preventDefault(); - setShowPassword(!showPassword); - }} - > - - - -

-
- - - {form.errors.new ? ( -
- {form.errors.new && form.touched.new ? form.errors.new : null} -
- ) : null} -
- - )} -
-
-
- - - - -
- )} -
- + + {({ onHide }) => ( +
+ + + + + + + setError(null)} dismissible> + {error} + +
+ + {({ field, form }: any) => ( + <> +

+ + { + e.preventDefault(); + form.setFieldValue( + field.name, + generate({ + length: 12, + numbers: true, + }), + ); + setShowPassword(true); + }} + > + + {" "} + —{" "} + { + e.preventDefault(); + setShowPassword(!showPassword); + }} + > + + + +

+
+ + + {form.errors.new ? ( +
+ {form.errors.new && form.touched.new ? form.errors.new : null} +
+ ) : null} +
+ + )} +
+
+
+ + + + +
+ )} +
); }); diff --git a/frontend/src/modals/StreamModal.tsx b/frontend/src/modals/StreamModal.tsx index b6f35622f..417ae3103 100644 --- a/frontend/src/modals/StreamModal.tsx +++ b/frontend/src/modals/StreamModal.tsx @@ -1,10 +1,10 @@ import EasyModal, { type InnerModalProps } from "ez-modal-react"; import { Field, Form } from "formik"; -import { FormModal } from "src/components/Form"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; import { Button, Loading, SSLCertificateField, SSLOptionsFields } from "src/components"; +import { FormModal } from "src/components/Form"; import { useSetStream, useStream } from "src/hooks"; import { intl, T } from "src/locale"; import { validateNumber, validateString } from "src/modules/Validations"; @@ -48,16 +48,21 @@ const StreamModal = EasyModal.create(({ id, visible, remove }: Props) => { return ( <> - {!isLoading && error && ( - - {error?.message || "Unknown error"} - + {(isLoading || (error && !data)) && ( + + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} + {isLoading && } + )} - {isLoading && } {!isLoading && data && ( { const [setupData, setSetupData] = useState<{ secret: string; otpauthUrl: string } | null>(null); const [backupCodes, setBackupCodes] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); + const [formDirty, setFormDirty] = useState(false); + + const handleHide = () => { + if (!isSubmitting && confirmDiscardChanges(formDirty)) { + remove(); + } + }; const loadStatus = useCallback(async () => { try { @@ -172,8 +180,9 @@ const TwoFactorModal = EasyModal.create(({ id, visible, remove }: Props) => { /> - {() => ( + {({ dirty }) => (
+ {({ field, form }: any) => (