import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ScheduleDialog } from '../schedule-dialog'
import { TemplatePicker, EmailTemplate } from '../template-picker'
import {
MergeFieldsMenu,
MergeFieldPreview,
replaceMergeFields,
} from '../merge-fields'
import { PreviewDialog } from '../preview-dialog'
// ---------------------------------------------------------------------------
// Shared mocks
// ---------------------------------------------------------------------------
// Render Radix portals inline so dialog content is queryable
jest.mock('@radix-ui/react-dialog', () => {
const actual = jest.requireActual('@radix-ui/react-dialog')
return {
...actual,
Portal: ({ children }: { children: React.ReactNode }) => <>{children}>,
}
})
// Render Radix dropdown portal inline
jest.mock('@radix-ui/react-dropdown-menu', () => {
const actual = jest.requireActual('@radix-ui/react-dropdown-menu')
return {
...actual,
Portal: ({ children }: { children: React.ReactNode }) => <>{children}>,
}
})
// Render Radix select portal inline
jest.mock('@radix-ui/react-select', () => {
const actual = jest.requireActual('@radix-ui/react-select')
return {
...actual,
Portal: ({ children }: { children: React.ReactNode }) => <>{children}>,
}
})
// Render Radix popover portal inline
jest.mock('@radix-ui/react-popover', () => {
const actual = jest.requireActual('@radix-ui/react-popover')
return {
...actual,
Portal: ({ children }: { children: React.ReactNode }) => <>{children}>,
}
})
// Mock useToast so we can assert toast calls without real toast state
const mockToast = jest.fn()
jest.mock('../../toast', () => ({
useToast: () => ({ toast: mockToast }),
}))
// ---------------------------------------------------------------------------
// ScheduleDialog
// ---------------------------------------------------------------------------
describe('ScheduleDialog', () => {
const defaultProps = {
open: true,
onOpenChange: jest.fn(),
onSchedule: jest.fn().mockResolvedValue(undefined),
summaryItems: [
{ label: 'Subject', value: 'Q1 Update' },
{ label: 'Recipients', value: '150 contacts' },
],
}
beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers()
jest.setSystemTime(new Date('2026-01-01T08:00:00.000Z'))
})
afterEach(() => {
jest.runOnlyPendingTimers()
jest.useRealTimers()
})
it('renders the default title', () => {
render()
expect(screen.getByRole('heading', { name: 'Schedule Send' })).toBeInTheDocument()
})
it('renders the default description', () => {
render()
expect(screen.getByText('Choose when to send this email')).toBeInTheDocument()
})
it('renders a custom title and description', () => {
render(
)
expect(screen.getByRole('heading', { name: 'Schedule Campaign' })).toBeInTheDocument()
expect(screen.getByText('Pick a delivery time')).toBeInTheDocument()
})
it('renders summary item labels and values', () => {
render()
expect(screen.getByText('Subject:')).toBeInTheDocument()
expect(screen.getByText('Q1 Update')).toBeInTheDocument()
expect(screen.getByText('Recipients:')).toBeInTheDocument()
expect(screen.getByText('150 contacts')).toBeInTheDocument()
})
it('renders all four quick schedule option buttons', () => {
render()
expect(screen.getByRole('button', { name: 'Tomorrow 9 AM' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Tomorrow 2 PM' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Monday 9 AM' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
})
it('renders Date and Time labels', () => {
render()
expect(screen.getByText('Date')).toBeInTheDocument()
expect(screen.getByText('Time')).toBeInTheDocument()
})
it('renders Cancel and Schedule Send buttons', () => {
render()
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /schedule send/i })).toBeInTheDocument()
})
it('calls onOpenChange(false) when Cancel is clicked', () => {
render()
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(defaultProps.onOpenChange).toHaveBeenCalledWith(false)
})
it('Schedule Send button is enabled when default date is in the future', () => {
// System time = 2026-01-01T08:00:00Z. Default date = addDays(now, 1) at 09:00 — valid.
render()
expect(screen.getByRole('button', { name: /schedule send/i })).not.toBeDisabled()
})
it('calls onSchedule with an ISO date string when Schedule Send is clicked', async () => {
render()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /schedule send/i }))
})
await waitFor(() => {
expect(defaultProps.onSchedule).toHaveBeenCalledTimes(1)
})
const calledWith: string = defaultProps.onSchedule.mock.calls[0][0]
expect(typeof calledWith).toBe('string')
expect(isNaN(Date.parse(calledWith))).toBe(false)
})
it('calls onSaveDraft before onSchedule when hasUnsavedChanges is true', async () => {
const onSaveDraft = jest.fn().mockResolvedValue(undefined)
const onSchedule = jest.fn().mockResolvedValue(undefined)
render(
)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /schedule send/i }))
})
await waitFor(() => {
expect(onSaveDraft).toHaveBeenCalled()
expect(onSchedule).toHaveBeenCalled()
})
expect(onSaveDraft.mock.invocationCallOrder[0]).toBeLessThan(
onSchedule.mock.invocationCallOrder[0]
)
})
it('does not call onSaveDraft when hasUnsavedChanges is false', async () => {
const onSaveDraft = jest.fn().mockResolvedValue(undefined)
const onSchedule = jest.fn().mockResolvedValue(undefined)
render(
)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /schedule send/i }))
})
await waitFor(() => expect(onSchedule).toHaveBeenCalled())
expect(onSaveDraft).not.toHaveBeenCalled()
})
it('calls onPrepareRecipients before onSchedule when provided', async () => {
const onPrepareRecipients = jest.fn().mockResolvedValue(undefined)
const onSchedule = jest.fn().mockResolvedValue(undefined)
render(
)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /schedule send/i }))
})
await waitFor(() => {
expect(onPrepareRecipients).toHaveBeenCalled()
expect(onSchedule).toHaveBeenCalled()
})
expect(onPrepareRecipients.mock.invocationCallOrder[0]).toBeLessThan(
onSchedule.mock.invocationCallOrder[0]
)
})
it('shows a success toast and closes dialog after successful scheduling', async () => {
const onOpenChange = jest.fn()
render()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /schedule send/i }))
})
await waitFor(() => {
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({ description: expect.stringContaining('Scheduled for') })
)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})
it('shows a destructive toast when onSchedule throws', async () => {
const onSchedule = jest.fn().mockRejectedValue(new Error('Network error'))
render()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /schedule send/i }))
})
await waitFor(() => {
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({
description: 'Network error',
variant: 'destructive',
})
)
})
})
it('shows scheduled preview after clicking a quick option', async () => {
render()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
})
expect(screen.getByText(/Scheduled for:/)).toBeInTheDocument()
})
it('renders custom scheduledInfoMessage in the preview box', async () => {
render(
)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
})
expect(screen.getByText('Remember to review before send.')).toBeInTheDocument()
})
it('does not render when open is false', () => {
render()
expect(screen.queryByRole('heading', { name: 'Schedule Send' })).not.toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// TemplatePicker
// ---------------------------------------------------------------------------
const SAMPLE_TEMPLATES: EmailTemplate[] = [
{
id: '1',
name: 'Welcome Email',
description: 'Onboarding welcome message',
subject: 'Welcome to our platform!',
blocks: null,
category: 'onboarding',
isDefault: true,
useCount: 10,
},
{
id: '2',
name: 'Follow Up',
description: 'Post-demo follow up',
subject: 'Great talking with you',
blocks: null,
category: 'sales',
isDefault: false,
useCount: 5,
},
{
id: '3',
name: 'Newsletter',
description: null,
subject: 'Monthly updates',
blocks: null,
category: 'marketing',
isDefault: false,
useCount: 0,
},
]
describe('TemplatePicker', () => {
const defaultProps = {
open: true,
onOpenChange: jest.fn(),
onSelectTemplate: jest.fn(),
onLoadTemplates: jest.fn().mockResolvedValue(SAMPLE_TEMPLATES),
}
beforeEach(() => {
jest.clearAllMocks()
defaultProps.onLoadTemplates.mockResolvedValue(SAMPLE_TEMPLATES)
})
it('shows a loading state while templates are being fetched', async () => {
let resolve!: (v: EmailTemplate[]) => void
const onLoadTemplates = jest.fn(
() => new Promise((res) => { resolve = res })
)
render()
expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument()
await act(async () => { resolve([]) })
})
it('renders all templates after loading', async () => {
render()
await waitFor(() => {
expect(screen.getByText('Welcome Email')).toBeInTheDocument()
expect(screen.getByText('Follow Up')).toBeInTheDocument()
expect(screen.getByText('Newsletter')).toBeInTheDocument()
})
})
it('renders template descriptions when present', async () => {
render()
await waitFor(() => {
expect(screen.getByText('Onboarding welcome message')).toBeInTheDocument()
expect(screen.getByText('Post-demo follow up')).toBeInTheDocument()
})
})
it('renders template subjects', async () => {
render()
await waitFor(() => {
expect(screen.getByText(/Welcome to our platform!/)).toBeInTheDocument()
})
})
it('renders a "Default" badge for default templates', async () => {
render()
await waitFor(() => {
expect(screen.getByText('Default')).toBeInTheDocument()
})
})
it('shows available template count', async () => {
render()
await waitFor(() => {
expect(screen.getByText('3 templates available')).toBeInTheDocument()
})
})
it('renders the search input', async () => {
render()
await waitFor(() => {
expect(screen.getByPlaceholderText('Search templates...')).toBeInTheDocument()
})
})
it('filters templates by name when searching', async () => {
render()
await waitFor(() => screen.getByPlaceholderText('Search templates...'))
fireEvent.change(screen.getByPlaceholderText('Search templates...'), {
target: { value: 'follow' },
})
expect(screen.getByText('Follow Up')).toBeInTheDocument()
expect(screen.queryByText('Welcome Email')).not.toBeInTheDocument()
expect(screen.queryByText('Newsletter')).not.toBeInTheDocument()
})
it('filters templates by subject when searching', async () => {
render()
await waitFor(() => screen.getByPlaceholderText('Search templates...'))
fireEvent.change(screen.getByPlaceholderText('Search templates...'), {
target: { value: 'monthly' },
})
expect(screen.getByText('Newsletter')).toBeInTheDocument()
expect(screen.queryByText('Welcome Email')).not.toBeInTheDocument()
})
it('filters templates by description when searching', async () => {
render()
await waitFor(() => screen.getByPlaceholderText('Search templates...'))
fireEvent.change(screen.getByPlaceholderText('Search templates...'), {
target: { value: 'onboarding' },
})
expect(screen.getByText('Welcome Email')).toBeInTheDocument()
expect(screen.queryByText('Follow Up')).not.toBeInTheDocument()
})
it('shows "No templates match your search" when no results', async () => {
render()
await waitFor(() => screen.getByPlaceholderText('Search templates...'))
fireEvent.change(screen.getByPlaceholderText('Search templates...'), {
target: { value: 'xyzzy-no-match' },
})
expect(screen.getByText('No templates match your search')).toBeInTheDocument()
})
it('updates count as search filters results', async () => {
render()
await waitFor(() => screen.getByPlaceholderText('Search templates...'))
fireEvent.change(screen.getByPlaceholderText('Search templates...'), {
target: { value: 'follow' },
})
expect(screen.getByText('1 template available')).toBeInTheDocument()
})
it('calls onSelectTemplate with the clicked template and closes the dialog', async () => {
const onSelectTemplate = jest.fn()
const onOpenChange = jest.fn()
render(
)
await waitFor(() => screen.getByText('Follow Up'))
fireEvent.click(screen.getByRole('option', { name: /select follow up template/i }))
expect(onSelectTemplate).toHaveBeenCalledTimes(1)
expect(onSelectTemplate).toHaveBeenCalledWith(
expect.objectContaining({ id: '2', name: 'Follow Up' })
)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('filters by categoryFilter prop', async () => {
render()
await waitFor(() => {
expect(screen.getByText('Follow Up')).toBeInTheDocument()
expect(screen.queryByText('Welcome Email')).not.toBeInTheDocument()
expect(screen.queryByText('Newsletter')).not.toBeInTheDocument()
})
})
it('shows error state when onLoadTemplates rejects', async () => {
const onLoadTemplates = jest.fn().mockRejectedValue(new Error('API down'))
render()
await waitFor(() => {
expect(screen.getByText('Failed to load templates')).toBeInTheDocument()
})
})
it('shows Retry button on error and retries on click', async () => {
const onLoadTemplates = jest.fn()
.mockRejectedValueOnce(new Error('API down'))
.mockResolvedValueOnce(SAMPLE_TEMPLATES)
render()
await waitFor(() => screen.getByText('Retry'))
fireEvent.click(screen.getByText('Retry'))
await waitFor(() => {
expect(screen.getByText('Welcome Email')).toBeInTheDocument()
})
})
it('shows empty state when no templates are returned', async () => {
const onLoadTemplates = jest.fn().mockResolvedValue([])
render()
await waitFor(() => {
expect(screen.getByText('No templates available')).toBeInTheDocument()
})
})
it('shows "Create Default Templates" button in empty state when onSeedTemplates is provided', async () => {
const onLoadTemplates = jest.fn().mockResolvedValue([])
const onSeedTemplates = jest.fn().mockResolvedValue(undefined)
render(
)
await waitFor(() => {
expect(screen.getByText('Create Default Templates')).toBeInTheDocument()
})
})
it('normalizes templates using subjectTemplate when subject is empty', async () => {
const rawTemplates = [
{
id: '99',
name: 'Alt Template',
description: null,
subject: '',
blocks: null,
category: null,
isDefault: false,
useCount: 0,
subjectTemplate: 'Fallback Subject',
},
]
const onLoadTemplates = jest.fn().mockResolvedValue(rawTemplates)
render()
await waitFor(() => {
expect(screen.getByText(/Fallback Subject/)).toBeInTheDocument()
})
})
it('calls onOpenChange(false) when Cancel is clicked', async () => {
const onOpenChange = jest.fn()
render()
await waitFor(() => screen.getByText('Cancel'))
fireEvent.click(screen.getByText('Cancel'))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('does not render content when open is false', () => {
render()
expect(screen.queryByText('Choose a Template')).not.toBeInTheDocument()
})
it('resets search when dialog is reopened', async () => {
const { rerender } = render()
await waitFor(() => screen.getByPlaceholderText('Search templates...'))
fireEvent.change(screen.getByPlaceholderText('Search templates...'), {
target: { value: 'follow' },
})
expect(screen.queryByText('Welcome Email')).not.toBeInTheDocument()
rerender()
rerender()
await waitFor(() => {
expect(screen.getByText('Welcome Email')).toBeInTheDocument()
})
})
})
// ---------------------------------------------------------------------------
// MergeFieldsMenu
// ---------------------------------------------------------------------------
describe('MergeFieldsMenu', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('renders the "Insert Merge Field" trigger button by default', () => {
render()
expect(screen.getByRole('button', { name: /insert merge field/i })).toBeInTheDocument()
})
it('renders a compact trigger button when variant is "compact"', () => {
render()
expect(screen.getByText('Merge')).toBeInTheDocument()
})
it('opens the dropdown menu when trigger is clicked', async () => {
const user = userEvent.setup()
render()
await user.click(screen.getByRole('button', { name: /insert merge field/i }))
await waitFor(() => {
expect(screen.getByText('Recipient')).toBeInTheDocument()
})
})
it('renders all default category labels after opening', async () => {
const user = userEvent.setup()
render()
await user.click(screen.getByRole('button', { name: /insert merge field/i }))
await waitFor(() => {
expect(screen.getByText('Recipient')).toBeInTheDocument()
// 'Organization' appears as both a category label and a field label —
// assert at least one instance is present
expect(screen.getAllByText('Organization').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('Sender')).toBeInTheDocument()
expect(screen.getByText('Date')).toBeInTheDocument()
})
})
it('renders field labels from default fields after opening', async () => {
const user = userEvent.setup()
render()
await user.click(screen.getByRole('button', { name: /insert merge field/i }))
await waitFor(() => {
expect(screen.getByText('First Name')).toBeInTheDocument()
expect(screen.getByText('Last Name')).toBeInTheDocument()
})
})
it('renders example values for each field', async () => {
const user = userEvent.setup()
render()
await user.click(screen.getByRole('button', { name: /insert merge field/i }))
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument()
expect(screen.getByText('Acme Corp')).toBeInTheDocument()
})
})
it('calls onInsert with the field key when a merge field item is clicked', async () => {
const user = userEvent.setup()
const onInsert = jest.fn()
render()
await user.click(screen.getByRole('button', { name: /insert merge field/i }))
await waitFor(() => screen.getByText('First Name'))
await user.click(screen.getByText('First Name'))
expect(onInsert).toHaveBeenCalledTimes(1)
expect(onInsert).toHaveBeenCalledWith('{{recipient.firstName}}')
})
it('calls onInsert with sender.name key when "Your Company" is clicked', async () => {
const user = userEvent.setup()
const onInsert = jest.fn()
render()
await user.click(screen.getByRole('button', { name: /insert merge field/i }))
await waitFor(() => screen.getByText('Your Company'))
await user.click(screen.getByText('Your Company'))
expect(onInsert).toHaveBeenCalledWith('{{sender.name}}')
})
it('renders custom category labels when provided', async () => {
const user = userEvent.setup()
const customFields = [
{ key: '{{custom.field}}', label: 'Custom Field', example: 'Value', category: 'custom' },
]
const customCategories = [{ name: 'custom', label: 'Custom' }]
render(
)
await user.click(screen.getByRole('button', { name: /insert merge field/i }))
await waitFor(() => {
expect(screen.getByText('Custom')).toBeInTheDocument()
expect(screen.getByText('Custom Field')).toBeInTheDocument()
})
})
it('calls onInsert with the custom field key', async () => {
const user = userEvent.setup()
const onInsert = jest.fn()
const customFields = [
{ key: '{{custom.field}}', label: 'My Field', example: 'ex', category: 'custom' },
]
const customCategories = [{ name: 'custom', label: 'Custom' }]
render(
)
await user.click(screen.getByRole('button', { name: /insert merge field/i }))
await waitFor(() => screen.getByText('My Field'))
await user.click(screen.getByText('My Field'))
expect(onInsert).toHaveBeenCalledWith('{{custom.field}}')
})
})
// ---------------------------------------------------------------------------
// MergeFieldPreview (pure function)
// ---------------------------------------------------------------------------
describe('MergeFieldPreview', () => {
it('replaces known merge field tokens with default sample data', () => {
const result = MergeFieldPreview({ content: 'Hello {{recipient.firstName}}' })
expect(result).toBe('Hello John')
})
it('replaces multiple tokens in a single string', () => {
const result = MergeFieldPreview({
content: '{{recipient.firstName}} from {{organization.name}}',
})
expect(result).toBe('John from Acme Corp')
})
it('overrides default sample data with provided sampleData', () => {
const result = MergeFieldPreview({
content: 'Hi {{recipient.firstName}}',
sampleData: { '{{recipient.firstName}}': 'Alice' },
})
expect(result).toBe('Hi Alice')
})
it('returns content unchanged when no tokens match', () => {
const result = MergeFieldPreview({ content: 'Hello world' })
expect(result).toBe('Hello world')
})
it('handles empty string content', () => {
const result = MergeFieldPreview({ content: '' })
expect(result).toBe('')
})
it('replaces sender name token', () => {
const result = MergeFieldPreview({
content: 'From: {{sender.name}}',
sampleData: { '{{sender.name}}': 'My Biz' },
})
expect(result).toBe('From: My Biz')
})
})
// ---------------------------------------------------------------------------
// replaceMergeFields (pure utility)
// ---------------------------------------------------------------------------
describe('replaceMergeFields', () => {
it('replaces recipient firstName token', () => {
const result = replaceMergeFields('Hi {{recipient.firstName}}', { firstName: 'Alice' })
expect(result).toBe('Hi Alice')
})
it('replaces recipient fullName using the name field when firstName/lastName absent', () => {
const result = replaceMergeFields('{{recipient.fullName}}', { name: 'Bob Jones' })
expect(result).toBe('Bob Jones')
})
it('derives firstName from name when not explicitly provided', () => {
const result = replaceMergeFields('{{recipient.firstName}}', { name: 'Bob Jones' })
expect(result).toBe('Bob')
})
it('replaces organization name token', () => {
const result = replaceMergeFields('{{organization.name}}', {}, { name: 'Acme Corp' })
expect(result).toBe('Acme Corp')
})
it('replaces sender tokens', () => {
const result = replaceMergeFields(
'{{sender.name}} / {{sender.contactName}}',
{},
undefined,
{ name: 'My Biz', contactName: 'Jane' }
)
expect(result).toBe('My Biz / Jane')
})
it('replaces recipient email token', () => {
const result = replaceMergeFields('{{recipient.email}}', { email: 'bob@example.com' })
expect(result).toBe('bob@example.com')
})
it('replaces recipient title token', () => {
const result = replaceMergeFields('{{recipient.title}}', { title: 'CTO' })
expect(result).toBe('CTO')
})
it('replaces title with empty string when not provided', () => {
const result = replaceMergeFields('Title: {{recipient.title}}', {})
expect(result).toBe('Title: ')
})
it('returns content unchanged when there are no tokens', () => {
const result = replaceMergeFields('No tokens here', { firstName: 'X' })
expect(result).toBe('No tokens here')
})
it('replaces all occurrences of the same token', () => {
const result = replaceMergeFields(
'{{recipient.firstName}} and {{recipient.firstName}}',
{ firstName: 'Alice' }
)
expect(result).toBe('Alice and Alice')
})
it('replaces organization type token', () => {
const result = replaceMergeFields('{{organization.type}}', {}, { type: 'Enterprise' })
expect(result).toBe('Enterprise')
})
})
// ---------------------------------------------------------------------------
// PreviewDialog
// ---------------------------------------------------------------------------
describe('PreviewDialog', () => {
const defaultProps = {
open: true,
onOpenChange: jest.fn(),
subject: 'Hello {{recipient.firstName}}',
bodyHtml: 'Welcome to our platform!
',
}
beforeEach(() => {
jest.clearAllMocks()
})
it('renders the "Preview Email" heading', () => {
render()
expect(screen.getByRole('heading', { name: 'Preview Email' })).toBeInTheDocument()
})
it('renders the resolved subject line with merge fields substituted', () => {
render()
// Default sample recipient has firstName = 'John'
expect(screen.getByText('Hello John')).toBeInTheDocument()
})
it('renders Desktop, Mobile, and HTML toggle tabs', () => {
render()
expect(screen.getByRole('tab', { name: /desktop/i })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /mobile/i })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /html/i })).toBeInTheDocument()
})
it('defaults to desktop view with an email iframe', () => {
render()
expect(screen.getByTitle('Email preview')).toBeInTheDocument()
})
it('switches to mobile view when Mobile tab is clicked', async () => {
const user = userEvent.setup()
const { container } = render()
await user.click(screen.getByRole('tab', { name: /mobile/i }))
const allDivs = Array.from(container.querySelectorAll('div'))
const mobileContainer = allDivs.find(d => d.className.includes('max-w-[375px]'))
expect(mobileContainer).toBeTruthy()
})
it('switches back to desktop view when Desktop tab is clicked after mobile', async () => {
const user = userEvent.setup()
const { container } = render()
await user.click(screen.getByRole('tab', { name: /mobile/i }))
await user.click(screen.getByRole('tab', { name: /desktop/i }))
const allDivs = Array.from(container.querySelectorAll('div'))
const mobileContainer = allDivs.find(d => d.className.includes('max-w-[375px]'))
expect(mobileContainer).toBeFalsy()
expect(screen.getByTitle('Email preview')).toBeInTheDocument()
})
it('switches to HTML source view when HTML tab is clicked', async () => {
const user = userEvent.setup()
const { container } = render()
await user.click(screen.getByRole('tab', { name: /html/i }))
const pre = container.querySelector('pre')
expect(pre).toBeInTheDocument()
expect(pre?.textContent).toContain('')
})
it('renders the body HTML inside the iframe src document', async () => {
const user = userEvent.setup()
const { container } = render()
await user.click(screen.getByRole('tab', { name: /html/i }))
const pre = container.querySelector('pre')
expect(pre?.textContent).toContain('Welcome to our platform!')
})
it('renders the unsubscribe text in HTML source by default', async () => {
const user = userEvent.setup()
const { container } = render()
await user.click(screen.getByRole('tab', { name: /html/i }))
const pre = container.querySelector('pre')
expect(pre?.textContent).toContain('Unsubscribe from these updates')
})
it('omits unsubscribe block when unsubscribeText is null', async () => {
const user = userEvent.setup()
const { container } = render()
await user.click(screen.getByRole('tab', { name: /html/i }))
const pre = container.querySelector('pre')
expect(pre?.textContent).not.toContain('Unsubscribe')
})
it('renders custom unsubscribe text in HTML source', async () => {
const user = userEvent.setup()
const { container } = render()
await user.click(screen.getByRole('tab', { name: /html/i }))
const pre = container.querySelector('pre')
expect(pre?.textContent).toContain('Opt out')
})
it('renders the select trigger with the default sample recipient name', () => {
render()
// The select trigger shows the currently selected recipient name
expect(screen.getByText('John Smith')).toBeInTheDocument()
})
it('applies recipient merge data to the subject preview', () => {
const recipients = [
{
id: 'r1',
name: 'Alice',
mergeData: { '{{recipient.firstName}}': 'Alice' },
},
]
render()
expect(screen.getByText('Hello Alice')).toBeInTheDocument()
})
it('uses the first custom recipient label for merge field substitution', () => {
const recipients = [
{
id: 'r1',
name: 'Alice',
label: 'Alice (alice@example.com)',
mergeData: { '{{recipient.firstName}}': 'Alice' },
},
{
id: 'r2',
name: 'Bob',
label: 'Bob (bob@example.com)',
mergeData: { '{{recipient.firstName}}': 'Bob' },
},
]
render()
// The first recipient is selected by default — subject shows Alice's firstName
expect(screen.getByText('Hello Alice')).toBeInTheDocument()
})
it('calls onOpenChange(false) when the Close button is clicked', () => {
const onOpenChange = jest.fn()
render()
// There are two "Close" text occurrences: the visible button and an sr-only span
// on the dialog's X button. Click the one that is a visible button element.
const closeButtons = screen.getAllByText('Close')
const visibleButton = closeButtons.find(
(el) => el.tagName === 'BUTTON' && !el.classList.contains('sr-only')
)
expect(visibleButton).toBeTruthy()
fireEvent.click(visibleButton!)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('does not render when open is false', () => {
render()
expect(screen.queryByRole('heading', { name: 'Preview Email' })).not.toBeInTheDocument()
})
it('shows "Subject" label', () => {
render()
expect(screen.getByText('Subject')).toBeInTheDocument()
})
it('shows "Preview as" label', () => {
render()
expect(screen.getByText('Preview as')).toBeInTheDocument()
})
})