Files
supabase/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx
T
Gildas Garcia 86a3f8b03d chore: upgrade to react-19 (#45886)
- Most changes are related to either types or `useRef` usages (it now
requires an initial value).
- also updated `vaul` to its latest version and haven't noticed any
change ([design-system
demo](https://design-system-git-react-19-supabase.vercel.app/design-system/docs/components/drawer))

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Upgraded workspace to React 19.

* **Bug Fixes**
* Improved null-safety and ref handling across editors, UI components,
shortcuts, and markdown/image rendering to reduce runtime errors.
* Safer event/timeout/interval cleanup and more robust command/context
handling.

* **Chores**
  * Bumped vaul dependency versions.

* **Documentation**
* Type and TypeScript accuracy improvements for clearer developer
feedback.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45886)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 16:04:41 +02:00

373 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import type { editor } from 'monaco-editor'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import ReactMarkdown from 'react-markdown'
import { toast } from 'sonner'
import {
Button,
CardContent,
CardFooter,
Form,
FormControl,
FormField,
Input,
Label,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { SpamValidation } from './SpamValidation'
import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
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'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import type { FormSchema } from '@/types'
interface TemplateEditorProps {
template: FormSchema
}
export const TemplateEditor = ({ template }: TemplateEditorProps) => {
const { ref: projectRef } = useParams()
const { can: canUpdateConfig } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'custom_config_gotrue'
)
const editorRef = useRef<editor.IStandaloneCodeEditor>(null)
// [Joshen] Error state is handled in the parent
const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef })
const { mutate: validateSpam } = useValidateSpamMutation()
const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation({
onError: (error) => {
setIsSavingTemplate(false)
toast.error(`Failed to update email templates: ${error.message}`)
},
})
const { id, properties } = template
const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as keyof typeof authConfig
const messageProperty = properties[messageSlug]
const builtInSMTP =
isSuccess &&
authConfig &&
(!authConfig.SMTP_HOST || !authConfig.SMTP_USER || !authConfig.SMTP_PASS)
const [validationResult, setValidationResult] = useState<ValidateSpamResponse>()
const [bodyValue, setBodyValue] = useState((authConfig && authConfig[messageSlug]) ?? '')
const [, 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 INITIAL_VALUES = useMemo(() => {
const result: { [x: string]: string } = {}
Object.keys(properties).forEach((key) => {
result[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string
})
return result
}, [authConfig, properties])
const form = useForm({
defaultValues: INITIAL_VALUES,
resolver: zodResolver(template.validationSchema),
})
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
// its state is kept separately from the form state, hence why we manually inject it here
delete payload[messageSlug]
if (messageProperty) payload[messageSlug] = bodyValue
const [subjectKey] = Object.keys(properties)
validateSpam(
{
projectRef,
template: {
subject: payload[subjectKey],
content: payload[messageSlug],
},
},
{
onSuccess: (res) => {
setValidationResult(res)
const spamRules = (res?.rules ?? []).filter((rule) => rule.score > 0)
const preventSaveFromSpamCheck = builtInSMTP && spamRules.length > 0
if (preventSaveFromSpamCheck) {
setIsSavingTemplate(false)
toast.error(
'Please rectify all spam warnings before saving while using the built-in email service'
)
} else {
updateAuthConfig(
{ projectRef: projectRef, config: payload },
{
onSuccess: () => {
setIsSavingTemplate(false)
setHasUnsavedChanges(false) // Reset the unsaved changes state
toast.success('Successfully updated email template')
},
}
)
}
},
onError: () => setIsSavingTemplate(false),
}
)
}
// Single useMemo hook to parse and prepare message variables
const messageVariables = useMemo(() => {
if (!messageProperty?.description) return []
// Parse bullet point format: - `{{ .Variable }}` : Description
const lines = messageProperty.description.split('\n')
const variables: { variable: string; description: string }[] = []
for (const line of lines) {
// Match lines that start with a bullet point followed by a variable in the format {{ .Variable }}
// Handle variations in formatting (with or without backticks, different spacing)
const match = line.match(/-\s*`?({{\s*\.\w+\s*}})`?\s*(?::|-)?\s*(.+)/)
if (match && match[1] && match[2]) {
variables.push({
variable: match[1].replace(/`/g, '').trim(),
description: match[2].trim(),
})
}
}
return variables
}, [messageProperty?.description])
// Check if form values have changed
const formValues = form.watch()
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) => {
if (!editorRef.current) return
const editor = editorRef.current
const selection = editor.getSelection()
if (selection) {
const range = {
startLineNumber: selection.startLineNumber,
startColumn: selection.startColumn,
endLineNumber: selection.endLineNumber,
endColumn: selection.endColumn,
}
editor.executeEdits('insert-variable', [
{
range,
text,
forceMoveMarkers: true,
},
])
// Focus the editor after insertion
editor.focus()
}
}
// 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(() => {
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent>
{Object.keys(properties).map((x: string) => {
const property = properties[x]
if (property.type === 'string' && x !== messageSlug) {
return (
<FormField
key={x}
control={form.control}
name={x}
render={({ field }) => (
<FormItemLayout
className="gap-y-3"
layout="vertical"
label={property.title}
description={
property.description ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{property.description}
</ReactMarkdown>
) : null
}
labelOptional={
property.descriptionOptional ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{property.descriptionOptional}
</ReactMarkdown>
) : null
}
>
<FormControl>
<Input id={x} {...field} disabled={!canUpdateConfig} />
</FormControl>
</FormItemLayout>
)}
/>
)
}
return null
})}
</CardContent>
{messageProperty && (
<>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<Label>Body</Label>
<TwoOptionToggle
width={60}
options={['preview', 'source']}
activeOption={activeView}
onClickOption={(option) => setActiveView(option as 'source' | 'preview')}
borderOverride="border-muted"
/>
</div>
{activeView === 'source' ? (
<>
<div className="overflow-hidden rounded-md border dark:border-control overflow-hidden [&_.monaco-editor]:outline-0 [&_.monaco-editor-background]:bg-surface-200/30! [&_.monaco-editor_.margin]:bg-surface-200/30! dark:[&_.monaco-editor-background]:bg-surface-300! dark:[&_.monaco-editor_.margin]:bg-surface-300!">
<CodeEditor
id="code-id"
language="html"
isReadOnly={!canUpdateConfig}
className="mb-0! relative h-96 outline-hidden outline-offset-0 outline-width-0 outline-0"
onInputChange={(e: string | undefined) => {
setBodyValue(e ?? '')
if (bodyValue !== e) setHasUnsavedChanges(true)
}}
options={{ wordWrap: 'on', contextmenu: false, padding: { top: 16 } }}
value={bodyValue}
editorRef={editorRef}
/>
</div>
{messageVariables.length > 0 && (
<div className="flex flex-wrap gap-1">
{messageVariables.map(({ variable, description }) => (
<Tooltip key={variable}>
<TooltipTrigger asChild>
<Button
type="outline"
size="tiny"
className="rounded-full"
onClick={() => insertTextAtCursor(variable)}
>
{variable}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{description || 'Variable description not available'}</p>
</TooltipContent>
</Tooltip>
))}
</div>
)}
</>
) : (
<>
<iframe
className="mb-0! mt-0 overflow-hidden h-96 w-full rounded-md border bg-white"
title={id}
srcDoc={bodyValue}
sandbox="allow-scripts allow-forms"
/>
<Admonition
type="default"
title="Email rendering may differ"
description="The preview shown here may differ slightly from how your email appears in the recipient’s email client."
/>
</>
)}
</CardContent>
<SpamValidation spamRules={spamRules} />
<CardFooter className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button
type="default"
onClick={() => {
form.reset(INITIAL_VALUES)
setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
setHasUnsavedChanges(false)
}}
>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={!canUpdateConfig || isSavingTemplate || !hasChanges}
loading={isSavingTemplate}
>
Save changes
</Button>
</CardFooter>
</>
)}
</form>
<PreventNavigationOnUnsavedChanges hasChanges={hasChanges} />
</Form>
)
}