Files
supabase/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx
Joshen Lim 7f8ae81d64 Clean up table editor header (#45452)
## Context

Resolves FE-3126

Just cleaning up the table editor header with a bit of refactors
(pre-req to investigating collapsing filter bar and table editor header
actions into a single row)

## Non-visual changes involved
- Break down components within `GridHeaderActions` into smaller ones
  - `IndexAdvisorPopover`
  - `SecurityDefinerViewPopover`
  - `RealtimeToggle`
- Deprecate use of `useUrlState` in `GridHeaderActions` to use
`useQueryState` instead
- Improve types for `TwoOptionToggle`

## Visual changes involved
- Collapse realtime button toggle into a button icon, with no text (just
tooltip)
- Adjust layout of buttons a little

### Before
<img width="796" height="118" alt="image"
src="https://github.com/user-attachments/assets/436bca94-4d91-471a-a184-487c6f78dc04"
/>

### After
<img width="731" height="132" alt="image"
src="https://github.com/user-attachments/assets/5fd30982-a1fc-4f92-a590-146d1e69d52a"
/>


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

* **New Features**
  * Index Advisor popover with recommendations.
  * Realtime toggle to manage realtime table publication.
  * Security Definer view popover with optional autofix.
  * Insert menu for adding rows/columns and CSV import.

* **Bug Fixes**
  * Adjusted filter bar input sizing for improved readability.

* **Refactor**
* Header layout updated and insert/import actions moved into dedicated
components.

* **Tests**
  * Updated end-to-end selectors for the Insert row menu item.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-01 18:45:21 +08:00

373 lines
13 KiB
TypeScript
Raw Permalink 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_Shadcn_,
Label_Shadcn_,
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>()
// [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_Shadcn_ 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_Shadcn_>Body</Label_Shadcn_>
<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>
)
}