From eabfebf0f777a561f21e26006c9beb151cefe6ff Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 10 Nov 2025 15:28:08 +0800 Subject: [PATCH 1/6] Refactor/use text confirm modal for delete buckets (#40291) * Refactor DeleteBucketModal to use TextConfirmModal * Refactor DeleteAnalyticsBucket to use TextConfirmModal * Update TextConfirmModal to disable button until text match * Refactor DeleteVectorBucketModal to use TextConfirmModal * Remove test case - no longer valid --- .../SimpleConfigurationDetails.tsx | 2 + .../DeleteAnalyticsBucketModal.tsx | 121 +++------------- .../interfaces/Storage/DeleteBucketModal.tsx | 125 ++++------------ .../VectorBuckets/DeleteVectorBucketModal.tsx | 133 ++++-------------- .../__tests__/DeleteBucketModal.test.tsx | 19 --- .../src/Dialogs/TextConfirmModal.tsx | 6 +- 6 files changed, 78 insertions(+), 328 deletions(-) diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx index 24b069cb8a1eb..4235fbaa115e5 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx @@ -23,6 +23,8 @@ export const SimpleConfigurationDetails = ({ bucketName }: { bucketName?: string const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucketName }) const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) + if (!wrapperInstance) return null + return ( diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/DeleteAnalyticsBucketModal.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/DeleteAnalyticsBucketModal.tsx index 0568c24ceb0e2..dc91ae53721f1 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/DeleteAnalyticsBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/DeleteAnalyticsBucketModal.tsx @@ -1,27 +1,9 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' -import z from 'zod' import { useParams } from 'common' import { useAnalyticsBucketDeleteMutation } from 'data/storage/analytics-bucket-delete-mutation' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogSection, - DialogSectionSeparator, - DialogTitle, - Form_Shadcn_, - FormControl_Shadcn_, - FormField_Shadcn_, - Input_Shadcn_, -} from 'ui' -import { Admonition } from 'ui-patterns' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' import { useAnalyticsBucketAssociatedEntities, useAnalyticsBucketDeleteCleanUp, @@ -34,10 +16,6 @@ export interface DeleteAnalyticsBucketModalProps { onSuccess?: () => void } -const formId = `delete-analytics-bucket-form` - -// [Joshen] Can refactor to use TextConfirmModal - export const DeleteAnalyticsBucketModal = ({ visible, bucketId, @@ -47,16 +25,6 @@ export const DeleteAnalyticsBucketModal = ({ const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() - const schema = z.object({ - confirm: z.literal(bucketId, { - errorMap: () => ({ message: `Please enter "${bucketId}" to confirm` }), - }), - }) - - const form = useForm>({ - resolver: zodResolver(schema), - }) - const { icebergWrapper, icebergWrapperMeta, s3AccessKey, publication } = useAnalyticsBucketAssociatedEntities({ projectRef, bucketId: bucketId }) @@ -83,7 +51,7 @@ export const DeleteAnalyticsBucketModal = ({ }, }) - const onSubmit: SubmitHandler> = async () => { + const onConfirmDelete = async () => { if (!projectRef) return console.error('Project ref is required') if (!bucketId) return console.error('No bucket is selected') deleteAnalyticsBucket({ projectRef, id: bucketId }) @@ -92,73 +60,26 @@ export const DeleteAnalyticsBucketModal = ({ const isDeleting = isDeletingAnalyticsBucket || isCleaningUpAnalyticsBucket return ( - { - if (!open) onClose() + - - - Confirm deletion of {bucketId} - - - - - - - -

- Your bucket {bucketId} and all its - contents will be permanently deleted. -

-
- - - -
- ( - - Type {bucketId} to - confirm. - - } - > - - - - - )} - /> - -
-
- - - - -
-
+

+ Your bucket {bucketId} and all of its + contents will be permanently deleted. +

+ ) } diff --git a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx index c75c54bc2348c..206489e89197d 100644 --- a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx @@ -1,58 +1,27 @@ -import { zodResolver } from '@hookform/resolvers/zod' import { get as _get, find } from 'lodash' import { useRouter } from 'next/router' -import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' -import z from 'zod' import { useParams } from 'common' import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' import { useDatabasePolicyDeleteMutation } from 'data/database-policies/database-policy-delete-mutation' -import { AnalyticsBucket } from 'data/storage/analytics-buckets-query' import { useBucketDeleteMutation } from 'data/storage/bucket-delete-mutation' import { Bucket, useBucketsQuery } from 'data/storage/buckets-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogSection, - DialogSectionSeparator, - DialogTitle, - Form_Shadcn_, - FormControl_Shadcn_, - FormField_Shadcn_, - Input_Shadcn_, -} from 'ui' -import { Admonition } from 'ui-patterns' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { TextConfirmModal } from 'ui-patterns/Dialogs/TextConfirmModal' import { formatPoliciesForStorage } from './Storage.utils' export interface DeleteBucketModalProps { visible: boolean - bucket: Bucket | AnalyticsBucket + bucket: Bucket onClose: () => void } -const formId = `delete-storage-bucket-form` - export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModalProps) => { const router = useRouter() const { ref: projectRef, bucketId } = useParams() const { data: project } = useSelectedProjectQuery() - const schema = z.object({ - confirm: z.literal(bucket.id, { - errorMap: () => ({ message: `Please enter "${bucket.id}" to confirm` }), - }), - }) - - const form = useForm>({ - resolver: zodResolver(schema), - }) - const { data } = useBucketsQuery({ projectRef }) const buckets = data ?? [] @@ -62,7 +31,8 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa schema: 'storage', }) - const { mutateAsync: deletePolicy } = useDatabasePolicyDeleteMutation() + const { mutateAsync: deletePolicy, isLoading: isDeletingPolicies } = + useDatabasePolicyDeleteMutation() const { mutate: deleteBucket, isLoading: isDeletingBucket } = useBucketDeleteMutation({ onSuccess: async () => { @@ -102,80 +72,33 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa }, }) - const onSubmit: SubmitHandler> = async () => { + const onConfirmDelete = async () => { if (!projectRef) return console.error('Project ref is required') if (!bucket) return console.error('No bucket is selected') deleteBucket({ projectRef, id: bucket.id }) } return ( - { - if (!open) onClose() + - - - Confirm deletion of {bucket.id} - - - - - - - -

- Your bucket {bucket.id} and all its - contents will be permanently deleted. -

-
- - - -
- ( - - Type {bucket.id} to - confirm. - - } - > - - - - - )} - /> - -
-
- - - - -
-
+

+ Your bucket {bucket.id} and all of its + contents will be permanently deleted. +

+ ) } diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx index 9d66974412c0d..40db9b068ea6a 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx @@ -1,28 +1,10 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' -import z from 'zod' import { useParams } from 'common' import { useVectorBucketDeleteMutation } from 'data/storage/vector-bucket-delete-mutation' import { deleteVectorBucketIndex } from 'data/storage/vector-bucket-index-delete-mutation' import { useVectorBucketsIndexesQuery } from 'data/storage/vector-buckets-indexes-query' -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogSection, - DialogSectionSeparator, - DialogTitle, - Form_Shadcn_, - FormControl_Shadcn_, - FormField_Shadcn_, - Input_Shadcn_, -} from 'ui' -import { Admonition } from 'ui-patterns/admonition' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' export interface DeleteVectorBucketModalProps { visible: boolean @@ -31,10 +13,6 @@ export interface DeleteVectorBucketModalProps { onSuccess: () => void } -const formId = `delete-storage-vector-bucket-form` - -// [Joshen] Can refactor to use TextConfirmModal - export const DeleteVectorBucketModal = ({ visible, bucketName, @@ -43,29 +21,20 @@ export const DeleteVectorBucketModal = ({ }: DeleteVectorBucketModalProps) => { const { ref: projectRef } = useParams() - const schema = z.object({ - confirm: z.literal(bucketName, { - errorMap: () => ({ message: `Please enter "${bucketName}" to confirm` }), - }), - }) - - const form = useForm>({ - resolver: zodResolver(schema), - }) - - const { mutate: deleteBucket, isLoading } = useVectorBucketDeleteMutation({ + const { mutate: deleteBucket, isLoading: isDeletingBucket } = useVectorBucketDeleteMutation({ onSuccess: async () => { toast.success(`Bucket "${bucketName}" deleted successfully`) onSuccess() }, }) - const { data: { indexes = [] } = {} } = useVectorBucketsIndexesQuery({ - projectRef, - vectorBucketName: bucketName, - }) + const { data: { indexes = [] } = {}, isLoading: isDeletingIndexes } = + useVectorBucketsIndexesQuery({ + projectRef, + vectorBucketName: bucketName, + }) - const onSubmit: SubmitHandler> = async () => { + const onConfirmDelete = async () => { if (!projectRef) return console.error('Project ref is required') if (!bucketName) return console.error('No bucket is selected') @@ -79,7 +48,6 @@ export const DeleteVectorBucketModal = ({ }) ) await Promise.all(promises) - deleteBucket({ projectRef, bucketName }) } catch (error) { toast.error( @@ -89,73 +57,26 @@ export const DeleteVectorBucketModal = ({ } return ( - { - if (!open) onCancel() + - - - Confirm deletion of {bucketName} - - - - - - - -

- Your bucket {bucketName} and all its - contents will be permanently deleted. -

-
- - - -
- ( - - Type {bucketName} to - confirm. - - } - > - - - - - )} - /> - -
-
- - - - -
-
+

+ Your bucket {bucketName} and all of its + contents will be permanently deleted. +

+ ) } diff --git a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx index a119c6a7558be..aa407b64a6330 100644 --- a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx +++ b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx @@ -119,23 +119,4 @@ describe(`DeleteBucketModal`, () => { await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) expect(routerMock.asPath).toStrictEqual(`/project/default/storage/files`) }) - - it(`prevents submission when the input doesn't match the bucket name`, async () => { - const onClose = vi.fn() - render() - - const openButton = screen.getByRole(`button`, { name: `Open` }) - await userEvent.click(openButton) - await screen.findByRole(`dialog`) - - const input = screen.getByLabelText(/Type/) - await userEvent.type(input, `invalid`) - - const confirmButton = screen.getByRole(`button`, { name: `Delete bucket` }) - fireEvent.click(confirmButton) - - await waitFor(() => { - expect(screen.getByText(/Please enter/)).toBeInTheDocument() - }) - }) }) diff --git a/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx b/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx index 9c81a4c4d617a..60cc6f2e74daf 100644 --- a/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx +++ b/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx @@ -51,7 +51,7 @@ export interface TextConfirmModalProps { errorMessage?: string } -const TextConfirmModal = forwardRef< +export const TextConfirmModal = forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & TextConfirmModalProps >( @@ -99,6 +99,8 @@ const TextConfirmModal = forwardRef< }, }) + const isFormValid = form.formState.isValid + // 2. Define a submit handler. function onSubmit(values: z.infer) { // Do something with the form values. @@ -197,7 +199,7 @@ const TextConfirmModal = forwardRef< } htmlType="submit" loading={loading} - disabled={loading} + disabled={!isFormValid || loading} className="truncate" > {confirmLabel} From e900020014ca819cd56c96e051eeff7203612731 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 10 Nov 2025 19:52:07 +1100 Subject: [PATCH 2/6] feat(studio): email template configuration (#40069) * basics * code editor * template editor * validator cleanup * validator fixes * style tweaks * validator * style improvements * copy * related setting * clearer ternary * handle path in related setting * copywriting fixes * smtp copy cleanup * fix type * remove related setting work * copy nit * backwards compatability * fix type error * copy cleanup * revert horizontal subject field * remove extraneous hover * remove excess padding * fix whitespace * document whitespace issue * simplify spam check * fix saved value issue * fix spacing * Add console logs * Clean console logs * Small improvements + clean ups --------- Co-authored-by: Joshen Lim --- .../content/docs/components/switch.mdx | 22 +- .../Auth/EmailTemplates/EmailTemplates.tsx | 22 +- .../Auth/EmailTemplates/SpamValidation.tsx | 98 +++---- .../Auth/EmailTemplates/TemplateEditor.tsx | 240 +++++++++--------- .../interfaces/Auth/SmtpForm/SmtpForm.tsx | 94 +++---- ...sourceExhaustionWarningBanner.constants.ts | 2 +- .../[ref]/auth/templates/[templateId].tsx | 156 +++++++++++- 7 files changed, 384 insertions(+), 250 deletions(-) diff --git a/apps/design-system/content/docs/components/switch.mdx b/apps/design-system/content/docs/components/switch.mdx index 3dcf332821f99..6e7ec2d1f7153 100644 --- a/apps/design-system/content/docs/components/switch.mdx +++ b/apps/design-system/content/docs/components/switch.mdx @@ -1,6 +1,6 @@ --- title: Switch -description: A control that allows the user to toggle between checked and not checked. +description: A control for toggling between unchecked and checked. component: true links: doc: https://www.radix-ui.com/docs/primitives/components/switch @@ -60,6 +60,26 @@ import { Switch } from '@/components/ui/switch' ``` +### Relative positioning + +When using Switch in custom flex layouts (especially with height constraints like `h-full`), you may need to add `relative` positioning to the parent container to ensure proper rendering of focus rings, form validation messages, and overflow. + +```tsx +
+ ( + + + + )} + /> +
+``` + +You don’t need to add `relative` manually when using FormItemLayout as it provides the necessary positioning context automatically. + ## Examples ### Form diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx index 26ddf458cbb89..24fde2379c740 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx @@ -120,14 +120,14 @@ export const EmailTemplates = () => { )} {isSuccess && ( -
+ <> {builtInSMTP ? ( -
+
) : null} {isSecurityNotificationsEnabled ? ( -
+
Authentication @@ -149,11 +149,8 @@ export const EmailTemplates = () => { )}
-
- +
+
@@ -191,7 +188,7 @@ export const EmailTemplates = () => { )} -
+
{ href={`/project/${projectRef}/auth/templates/${templateSlug}`} className="py-6 pr-6" > - +
@@ -271,7 +265,7 @@ export const EmailTemplates = () => { )} -
+ )} ) diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx index 89d69e61ed8f1..7853be1b2d7e0 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx @@ -1,66 +1,66 @@ -import { Check, MailWarning } from 'lucide-react' +import { AnimatePresence, motion } from 'framer-motion' import { Markdown } from 'components/interfaces/Markdown' import { ValidateSpamResponse } from 'data/auth/validate-spam-mutation' -import { Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' +import { CardContent, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' +import { Admonition } from 'ui-patterns' interface SpamValidationProps { - validationResult?: ValidateSpamResponse + spamRules?: ValidateSpamResponse['rules'] } // [Joshen] According to API, we label as a spam risk as long as there are spam // rules identified with scores above 0. Scores are irrelevant in our context and // are hence not visualized in the UI -export const SpamValidation = ({ validationResult }: SpamValidationProps) => { - const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score >= 0) - const hasSpamWarning = spamRules.length > 0 +export const SpamValidation = ({ spamRules = [] }: SpamValidationProps) => { + const rules = spamRules.filter((rule) => rule.score >= 0) return ( -
-
- {hasSpamWarning ? ( - - ) : ( - - )} -
-
-
- {hasSpamWarning - ? 'Email has a high probability of being marked as spam - review issues below to improve deliverability.' - : 'Email content is unlikely to be marked as spam'} -
- {hasSpamWarning && ( - <> -
- - - - Warning - Description - - - - {spamRules.map((rule) => ( - - - {rule.name} - - {rule.desc} + + {rules.length > 0 && ( + + + + +
+
+
+ + + Warning + Description - ))} - -
+ + + {rules.map((rule) => ( + + {rule.name} + {rule.desc} + + ))} + + +
+
- - - - )} -
-
+ + + )} + ) } diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx index 47334435239c0..045bb4f370448 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Code, Monitor } from 'lucide-react' import { editor } from 'monaco-editor' import { useEffect, useMemo, useRef, useState } from 'react' import { useForm } from 'react-hook-form' @@ -8,6 +7,7 @@ import { toast } from 'sonner' import { useParams } from 'common' import CodeEditor from 'components/ui/CodeEditor/CodeEditor' +import TwoOptionToggle from 'components/ui/TwoOptionToggle' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' import { useValidateSpamMutation, ValidateSpamResponse } from 'data/auth/validate-spam-mutation' @@ -22,10 +22,6 @@ import { FormField_Shadcn_, Input_Shadcn_, Label_Shadcn_, - Tabs_Shadcn_, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, Tooltip, TooltipContent, TooltipTrigger, @@ -44,16 +40,12 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { PermissionAction.UPDATE, 'custom_config_gotrue' ) - - // Add a ref to the code editor const editorRef = useRef() // [Joshen] Error state is handled in the parent const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef }) - const { mutate: validateSpam } = useValidateSpamMutation({ - onSuccess: (res) => setValidationResult(res), - }) + const { mutate: validateSpam } = useValidateSpamMutation() const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation({ onError: (error) => { @@ -75,11 +67,10 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { const [bodyValue, setBodyValue] = useState((authConfig && authConfig[messageSlug]) ?? '') const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) const [isSavingTemplate, setIsSavingTemplate] = useState(false) + const [activeView, setActiveView] = useState<'source' | 'preview'>('source') const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score > 0) - const preventSaveFromSpamCheck = builtInSMTP && spamRules.length > 0 - // Create form values const INITIAL_VALUES = useMemo(() => { const result: { [x: string]: string } = {} Object.keys(properties).forEach((key) => { @@ -88,27 +79,13 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { return result }, [authConfig, properties]) - // Setup React Hook Form - const form = useForm({ - defaultValues: INITIAL_VALUES, - }) - - // Update form values when authConfig changes - useEffect(() => { - if (authConfig) { - const values: { [key: string]: string } = {} - Object.keys(properties).forEach((key) => { - values[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string - }) - form.reset(values) - setBodyValue((authConfig && authConfig[messageSlug]) ?? '') - } - }, [authConfig, properties, messageSlug, form]) + const form = useForm({ defaultValues: INITIAL_VALUES }) const onSubmit = (values: any) => { if (!projectRef) return console.error('Project ref is required') setIsSavingTemplate(true) + const payload = { ...values } // Because the template content uses the code editor which is not a form component @@ -128,6 +105,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { }, { onSuccess: (res) => { + setValidationResult(res) const spamRules = (res?.rules ?? []).filter((rule) => rule.score > 0) const preventSaveFromSpamCheck = builtInSMTP && spamRules.length > 0 @@ -142,8 +120,8 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { { onSuccess: () => { setIsSavingTemplate(false) - toast.success('Successfully updated settings') setHasUnsavedChanges(false) // Reset the unsaved changes state + toast.success('Successfully updated email template') }, } ) @@ -154,36 +132,6 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { ) } - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (hasUnsavedChanges) { - e.preventDefault() - e.returnValue = '' // deprecated, but older browsers still require this - } - } - - window.addEventListener('beforeunload', handleBeforeUnload) - - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload) - } - }, [hasUnsavedChanges]) - - useEffect(() => { - if (projectRef && id && !!authConfig) { - const [subjectKey] = Object.keys(properties) - - validateSpam({ - projectRef, - template: { - subject: authConfig[subjectKey as keyof typeof authConfig] as string, - content: authConfig[messageSlug], - }, - }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]) - // Single useMemo hook to parse and prepare message variables const messageVariables = useMemo(() => { if (!messageProperty?.description) return [] @@ -209,8 +157,10 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { // Check if form values have changed const formValues = form.watch() - const hasFormChanges = JSON.stringify(formValues) !== JSON.stringify(INITIAL_VALUES) - const hasChanges = hasFormChanges || ((authConfig && authConfig[messageSlug]) ?? '') !== bodyValue + const baselineValues = INITIAL_VALUES + const baselineBodyValue = (authConfig && authConfig[messageSlug]) ?? '' + const hasFormChanges = JSON.stringify(formValues) !== JSON.stringify(baselineValues) + const hasChanges = hasFormChanges || baselineBodyValue !== bodyValue // Function to insert text at cursor position const insertTextAtCursor = (text: string) => { @@ -240,6 +190,52 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { } } + // Update form values when authConfig changes + useEffect(() => { + if (authConfig) { + const values: { [key: string]: string } = {} + Object.keys(properties).forEach((key) => { + values[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string + }) + form.reset(values) + setBodyValue((authConfig && authConfig[messageSlug]) ?? '') + } + }, [authConfig, properties, messageSlug, form]) + + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasUnsavedChanges) { + e.preventDefault() + e.returnValue = '' // deprecated, but older browsers still require this + } + } + + window.addEventListener('beforeunload', handleBeforeUnload) + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + } + }, [hasUnsavedChanges]) + + useEffect(() => { + if (projectRef && id && !!authConfig) { + const [subjectKey] = Object.keys(properties) + + validateSpam({ + projectRef, + template: { + subject: authConfig[subjectKey as keyof typeof authConfig] as string, + content: authConfig[messageSlug], + }, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]) + + useEffect(() => { + if (!hasChanges) setValidationResult(undefined) + }, [hasChanges]) + return (
@@ -254,6 +250,8 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { name={x} render={({ field }) => ( { {messageProperty && ( <> - - Message body - - - - - Source - - - - Preview - - - - { - setBodyValue(e ?? '') - if (bodyValue !== e) setHasUnsavedChanges(true) - }} - options={{ wordWrap: 'on', contextmenu: false, padding: { top: 16 } }} - value={bodyValue} - editorRef={editorRef} - /> + +
+ Body + setActiveView(option)} + borderOverride="border-muted" + /> +
+ {activeView === 'source' ? ( + <> +
+ { + setBodyValue(e ?? '') + if (bodyValue !== e) setHasUnsavedChanges(true) + }} + options={{ wordWrap: 'on', contextmenu: false, padding: { top: 16 } }} + value={bodyValue} + editorRef={editorRef} + /> +
{messageVariables.length > 0 && ( -
-
- {messageVariables.map(({ variable, description }) => ( - - - - - -

{description || 'Variable description not available'}

-
-
- ))} -
+
+ {messageVariables.map(({ variable, description }) => ( + + + + + +

{description || 'Variable description not available'}

+
+
+ ))}
)} - - + + ) : ( + <>