From 65878cc05d2cb287cabef37650cc19cf217a3dce Mon Sep 17 00:00:00 2001 From: Jason Gill <103820+gilluminate@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:26:10 -0600 Subject: [PATCH 1/3] ENG-3180: migrate messaging template forms to antd Form Replace Formik + Chakra-backed inputs (CustomTextInput, CustomTextArea, CustomSwitch) with antd Form + Form.Item + native antd inputs across the messaging-templates feature. Submit button derives disabled state from form.isFieldsTouched() + form.getFieldsError() inside a shouldUpdate render-prop, with no extra useState/useEffect. The form re-mounts via a content-hashed key prop so antd's "touched" state resets after a successful save without a setFieldsValue dance. AddMessagingTemplateModal switches to the standard modal body pattern (footer={null} with buttons inside the body) and Typography.Paragraph, and the template-type-selector test id moves directly onto the Select. Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/cypress/e2e/messaging.cy.ts | 6 +- .../AddMessagingTemplateModal.tsx | 73 +++----- .../EmailTemplatesForm.tsx | 112 ++++++------ .../PropertySpecificMessagingTemplateForm.tsx | 172 +++++++++--------- .../pages/notifications/templates/[id].tsx | 4 +- .../notifications/templates/add-template.tsx | 4 +- 6 files changed, 184 insertions(+), 187 deletions(-) diff --git a/clients/admin-ui/cypress/e2e/messaging.cy.ts b/clients/admin-ui/cypress/e2e/messaging.cy.ts index f04465d093c..30edd16a603 100644 --- a/clients/admin-ui/cypress/e2e/messaging.cy.ts +++ b/clients/admin-ui/cypress/e2e/messaging.cy.ts @@ -87,9 +87,9 @@ describe("Messaging", () => { cy.getByTestId("add-message-btn").click(); - cy.getByTestId("template-type-selector") - .find(".ant-select") - .antSelect("Access request completed"); + cy.getByTestId("template-type-selector").antSelect( + "Access request completed", + ); cy.getByTestId("confirm-btn").click(); diff --git a/clients/admin-ui/src/features/messaging-templates/AddMessagingTemplateModal.tsx b/clients/admin-ui/src/features/messaging-templates/AddMessagingTemplateModal.tsx index cf84802f24c..fe955e2135c 100644 --- a/clients/admin-ui/src/features/messaging-templates/AddMessagingTemplateModal.tsx +++ b/clients/admin-ui/src/features/messaging-templates/AddMessagingTemplateModal.tsx @@ -1,11 +1,4 @@ -import { - Button, - ChakraBox as Box, - ChakraText as Text, - Flex, - Modal, - Select, -} from "fidesui"; +import { Button, Flex, Modal, Select, Typography } from "fidesui"; import { useState } from "react"; import { MODAL_SIZE } from "~/features/common/modals/modal-sizes"; @@ -45,47 +38,35 @@ const AddMessagingTemplateModal = ({ width={MODAL_SIZE.md} data-testid="add-messaging-template-modal" title="Select message template" - footer={ - - - - - } + footer={null} > - + Add a new email message by selecting a template below and clicking accept. - - - Choose template: - - - - - options={options} - onChange={(value) => { - setSelectedTemplateType(value); - }} - className="w-full" - aria-label="Select a template" - /> - + + + data-testid="template-type-selector" + options={options} + onChange={(value) => { + setSelectedTemplateType(value); + }} + className="w-full" + placeholder="Choose template" + aria-label="Select a template" + /> + + + + ); }; diff --git a/clients/admin-ui/src/features/messaging-templates/EmailTemplatesForm.tsx b/clients/admin-ui/src/features/messaging-templates/EmailTemplatesForm.tsx index 95d2e420494..e40d9155c44 100644 --- a/clients/admin-ui/src/features/messaging-templates/EmailTemplatesForm.tsx +++ b/clients/admin-ui/src/features/messaging-templates/EmailTemplatesForm.tsx @@ -1,15 +1,7 @@ import { SerializedError } from "@reduxjs/toolkit"; import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; -import { - Button, - ChakraBox as Box, - ChakraFlex as Flex, - useMessage, -} from "fidesui"; -import { Form, Formik, FormikHelpers } from "formik"; +import { Button, Card, Flex, Form, Input, useMessage } from "fidesui"; -import FormSection from "~/features/common/form/FormSection"; -import { CustomTextArea, CustomTextInput } from "~/features/common/form/inputs"; import { getErrorMessage, isErrorResult } from "~/features/common/helpers"; import { @@ -36,11 +28,17 @@ const EmailTemplatesForm = ({ emailTemplates }: EmailTemplatesFormProps) => { const [updateMessagingTemplates, { isLoading }] = useUpdateMessagingTemplatesMutation(); const message = useMessage(); + const [form] = Form.useForm(); + + const initialValues = emailTemplates.reduce( + (acc, template) => ({ + ...acc, + [template.type]: { label: template.label, content: template.content }, + }), + {} as EmailTemplatesFormValues, + ); - const handleSubmit = async ( - values: EmailTemplatesFormValues, - formikHelpers: FormikHelpers, - ) => { + const handleSubmit = async (values: EmailTemplatesFormValues) => { const handleResult = ( result: | { data: object } @@ -54,7 +52,9 @@ const EmailTemplatesForm = ({ emailTemplates }: EmailTemplatesFormProps) => { message.error(errorMsg); } else { message.success("Email templates saved."); - formikHelpers.resetForm({ values }); + // Re-baseline the form so Save becomes disabled again until the next edit. + form.resetFields(); + form.setFieldsValue(values); } }; @@ -70,52 +70,54 @@ const EmailTemplatesForm = ({ emailTemplates }: EmailTemplatesFormProps) => { handleResult(result); }; - const initialValues = emailTemplates.reduce( - (acc, template) => ({ - ...acc, - [template.type]: { label: template.label, content: template.content }, - }), - {} as EmailTemplatesFormValues, - ); - return ( - - {() => ( -
- {Object.entries(initialValues).map(([key, value]) => ( - - - - - - - ))} - - - -
- )} -
+ )} + + + ); }; diff --git a/clients/admin-ui/src/features/messaging-templates/PropertySpecificMessagingTemplateForm.tsx b/clients/admin-ui/src/features/messaging-templates/PropertySpecificMessagingTemplateForm.tsx index e9245e605e4..925ceafecd4 100644 --- a/clients/admin-ui/src/features/messaging-templates/PropertySpecificMessagingTemplateForm.tsx +++ b/clients/admin-ui/src/features/messaging-templates/PropertySpecificMessagingTemplateForm.tsx @@ -1,15 +1,8 @@ import { NOTIFICATIONS_TEMPLATES_ROUTE } from "common/nav/routes"; -import { Button, ChakraBox as Box, ChakraFlex as Flex } from "fidesui"; -import { Form, Formik, useFormikContext } from "formik"; +import { Button, Card, Flex, Form, Input, Switch } from "fidesui"; import { useRouter } from "next/router"; import { useAppSelector } from "~/app/hooks"; -import FormSection from "~/features/common/form/FormSection"; -import { - CustomSwitch, - CustomTextArea, - CustomTextInput, -} from "~/features/common/form/inputs"; import ScrollableList from "~/features/common/ScrollableList"; import { CustomizableMessagingTemplatesEnum } from "~/features/messaging-templates/CustomizableMessagingTemplatesEnum"; import CustomizableMessagingTemplatesLabelEnum from "~/features/messaging-templates/CustomizableMessagingTemplatesLabelEnum"; @@ -26,6 +19,7 @@ interface Props { template: MessagingTemplateResponse; handleSubmit: (values: FormValues) => Promise; handleDelete?: () => void; + isSaving?: boolean; } export interface FormValues { @@ -39,25 +33,30 @@ export interface FormValues { properties?: MinimalProperty[]; } -const PropertiesList = () => { +interface PropertiesListProps { + value?: MinimalProperty[]; + onChange?: (value: MinimalProperty[]) => void; +} + +const PropertiesList = ({ value, onChange }: PropertiesListProps) => { const propertyPage = useAppSelector(selectPropertyPage); const propertyPageSize = useAppSelector(selectPropertyPageSize); useGetAllPropertiesQuery({ page: propertyPage, size: propertyPageSize }); const allProperties = useAppSelector(selectAllProperties); - const { values, setFieldValue } = useFormikContext(); return ( ({ - id: property.id, - name: property.name, - }))} - values={values.properties ?? []} - setValues={(newValues) => setFieldValue("properties", newValues)} + allItems={allProperties.reduce((acc, property) => { + if (property.id) { + acc.push({ id: property.id, name: property.name }); + } + return acc; + }, [])} + values={value ?? []} + setValues={(newValues) => onChange?.(newValues)} draggable maxHeight={100} baseTestId="property" @@ -69,14 +68,16 @@ const PropertySpecificMessagingTemplateForm = ({ template, handleSubmit, handleDelete, + isSaving, }: Props) => { const router = useRouter(); + const [form] = Form.useForm(); const handleCancel = () => { router.push(NOTIFICATIONS_TEMPLATES_ROUTE); }; - const initialValues: MessagingTemplateResponse = { + const initialValues: FormValues = { type: template.type, content: template.content, properties: template.properties || [], @@ -84,80 +85,89 @@ const PropertySpecificMessagingTemplateForm = ({ id: template.id || "", }; + // Re-mount the form whenever the upstream template data meaningfully changes + // (e.g. after a successful save returning fresh data). This resets antd's + // "touched" state without needing a useEffect + setFieldsValue dance. + const formKey = `${template.id || template.type}-${template.is_enabled}-${template.content.subject}-${template.content.body}`; + return ( - - {({ dirty, isValid, isSubmitting }) => ( -
+ - - - - - - - - - - - - {initialValues.id && handleDelete && ( - - )} - - + + + + + + + + + + + + + + {initialValues.id && handleDelete && ( + + )} + + + + {() => ( - - -
- )} -
+ )} + + + + ); }; diff --git a/clients/admin-ui/src/pages/notifications/templates/[id].tsx b/clients/admin-ui/src/pages/notifications/templates/[id].tsx index 8c2f9bb4e6f..19aee7b46ba 100644 --- a/clients/admin-ui/src/pages/notifications/templates/[id].tsx +++ b/clients/admin-ui/src/pages/notifications/templates/[id].tsx @@ -37,7 +37,8 @@ const EditNotificationTemplatePage: NextPage = () => { error, } = useGetMessagingTemplateByIdQuery(templateId as string); - const [putMessagingTemplate] = usePutMessagingTemplateByIdMutation(); + const [putMessagingTemplate, { isLoading: isSaving }] = + usePutMessagingTemplateByIdMutation(); const [deleteMessagingTemplate] = useDeleteMessagingTemplateByIdMutation(); const handleSubmit = async (values: FormValues) => { @@ -128,6 +129,7 @@ const EditNotificationTemplatePage: NextPage = () => { template={messagingTemplate} handleSubmit={handleSubmit} handleDelete={onDeleteOpen} + isSaving={isSaving} /> { const message = useMessage(); const router = useRouter(); const { templateType } = router.query; - const [createMessagingTemplate] = useCreateMessagingTemplateByTypeMutation(); + const [createMessagingTemplate, { isLoading: isSaving }] = + useCreateMessagingTemplateByTypeMutation(); const { data: messagingTemplate, isLoading } = useGetMessagingTemplateDefaultQuery(templateType as string); @@ -75,6 +76,7 @@ const AddNotificationTemplatePage: NextPage = () => { From e0698c22c8aa26aa0028bfda358ba6e98d753930 Mon Sep 17 00:00:00 2001 From: Jason Gill <103820+gilluminate@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:26:46 -0600 Subject: [PATCH 2/3] add changelog for PR #7939 --- changelog/7939-messaging-templates-antd-form.yaml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog/7939-messaging-templates-antd-form.yaml diff --git a/changelog/7939-messaging-templates-antd-form.yaml b/changelog/7939-messaging-templates-antd-form.yaml new file mode 100644 index 00000000000..5c981ae3eaf --- /dev/null +++ b/changelog/7939-messaging-templates-antd-form.yaml @@ -0,0 +1,4 @@ +type: Changed +description: Migrated messaging template forms to Ant Design Form +pr: 7939 +labels: [] From ec87883316052f7a0786cbbf3120e0d0a76fdab1 Mon Sep 17 00:00:00 2001 From: Jason Gill <103820+gilluminate@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:54:50 -0600 Subject: [PATCH 3/3] Address PR review feedback on messaging template forms - Swap Cancel Button for NextLink wrapper for proper link semantics - Scope formKey to template.id || template.type so background refetches don't discard in-progress edits - Reset touched state via onFinish wrapper on successful save; handleSubmit now returns Promise so the form can tell success from failure - Expand inline comment explaining why EmailTemplatesForm needs both resetFields() and setFieldsValue() after a successful save --- .../EmailTemplatesForm.tsx | 5 ++- .../PropertySpecificMessagingTemplateForm.tsx | 36 +++++++++++-------- .../pages/notifications/templates/[id].tsx | 5 +-- .../notifications/templates/add-template.tsx | 5 +-- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/clients/admin-ui/src/features/messaging-templates/EmailTemplatesForm.tsx b/clients/admin-ui/src/features/messaging-templates/EmailTemplatesForm.tsx index e40d9155c44..f6741698f72 100644 --- a/clients/admin-ui/src/features/messaging-templates/EmailTemplatesForm.tsx +++ b/clients/admin-ui/src/features/messaging-templates/EmailTemplatesForm.tsx @@ -52,7 +52,10 @@ const EmailTemplatesForm = ({ emailTemplates }: EmailTemplatesFormProps) => { message.error(errorMsg); } else { message.success("Email templates saved."); - // Re-baseline the form so Save becomes disabled again until the next edit. + // Re-baseline the form: resetFields() clears antd's touched flags (but + // reverts to the original initialValues), then setFieldsValue re-applies + // the just-saved data on top. Together they make the saved values the + // new baseline so Save becomes disabled until the next edit. form.resetFields(); form.setFieldsValue(values); } diff --git a/clients/admin-ui/src/features/messaging-templates/PropertySpecificMessagingTemplateForm.tsx b/clients/admin-ui/src/features/messaging-templates/PropertySpecificMessagingTemplateForm.tsx index 925ceafecd4..92d3b256790 100644 --- a/clients/admin-ui/src/features/messaging-templates/PropertySpecificMessagingTemplateForm.tsx +++ b/clients/admin-ui/src/features/messaging-templates/PropertySpecificMessagingTemplateForm.tsx @@ -1,6 +1,6 @@ import { NOTIFICATIONS_TEMPLATES_ROUTE } from "common/nav/routes"; import { Button, Card, Flex, Form, Input, Switch } from "fidesui"; -import { useRouter } from "next/router"; +import NextLink from "next/link"; import { useAppSelector } from "~/app/hooks"; import ScrollableList from "~/features/common/ScrollableList"; @@ -17,7 +17,7 @@ import { MinimalProperty } from "~/types/api"; interface Props { template: MessagingTemplateResponse; - handleSubmit: (values: FormValues) => Promise; + handleSubmit: (values: FormValues) => Promise; handleDelete?: () => void; isSaving?: boolean; } @@ -70,13 +70,8 @@ const PropertySpecificMessagingTemplateForm = ({ handleDelete, isSaving, }: Props) => { - const router = useRouter(); const [form] = Form.useForm(); - const handleCancel = () => { - router.push(NOTIFICATIONS_TEMPLATES_ROUTE); - }; - const initialValues: FormValues = { type: template.type, content: template.content, @@ -85,10 +80,21 @@ const PropertySpecificMessagingTemplateForm = ({ id: template.id || "", }; - // Re-mount the form whenever the upstream template data meaningfully changes - // (e.g. after a successful save returning fresh data). This resets antd's - // "touched" state without needing a useEffect + setFieldsValue dance. - const formKey = `${template.id || template.type}-${template.is_enabled}-${template.content.subject}-${template.content.body}`; + // Re-mount the form only when the underlying template identity changes, not + // on every content update. Scoping the key this narrowly avoids discarding + // in-progress edits if a background refetch returns data that differs from + // what the user has typed. + const formKey = template.id || template.type; + + const onFinish = async (values: FormValues) => { + const succeeded = await handleSubmit(values); + if (succeeded) { + // Re-baseline after a successful save so `isFieldsTouched()` flips back + // to false and Save becomes disabled again until the next edit. + form.resetFields(); + form.setFieldsValue(values); + } + }; return (
)} - + + + {() => (