import { render, screen, fireEvent } from '@testing-library/react'
import { TextBlockEditor } from '../text-block'
import { MetricsBlockEditor } from '../metrics-block'
import { DividerBlockEditor } from '../divider-block'
import { ButtonBlockEditor } from '../button-block'
import { ImageBlockEditor } from '../image-block'
import { SpacerBlockEditor } from '../spacer-block'
import { SocialBlockEditor } from '../social-block'
import { HeaderBlockEditor } from '../header-block'
import { FooterBlockEditor } from '../footer-block'
import type {
TextBlock,
MetricsBlock,
DividerBlock,
CTABlock,
ImageBlock,
SpacerBlock,
SocialBlock,
HeaderBlock,
FooterBlock,
} from '../../types'
// ---------------------------------------------------------------------------
// Shared test fixtures
// ---------------------------------------------------------------------------
const baseTextBlock: TextBlock = {
id: 'text-1',
type: 'text',
content: '
Hello world
',
}
const baseMetricsBlock: MetricsBlock = {
id: 'metrics-1',
type: 'metrics',
title: 'Key Metrics',
metrics: [
{ id: 'metric-a', label: 'Revenue', value: '$1,200', change: '+10%', changeType: 'positive' },
{ id: 'metric-b', label: 'Users', value: '340', change: '-5%', changeType: 'negative' },
],
columns: 2,
}
const baseDividerBlock: DividerBlock = {
id: 'divider-1',
type: 'divider',
dividerStyle: 'solid',
color: '#d1d5db',
thickness: 1,
width: 100,
}
const baseButtonBlock: CTABlock = {
id: 'cta-1',
type: 'cta',
text: 'Click Me',
url: 'https://example.com',
buttonColor: '#2563eb',
textColor: '#ffffff',
borderRadius: 6,
paddingH: 24,
paddingV: 12,
alignment: 'center',
}
const baseImageBlock: ImageBlock = {
id: 'image-1',
type: 'image',
url: 'https://example.com/image.png',
alt: 'Test image',
alignment: 'center',
width: 100,
}
const baseSpacerBlock: SpacerBlock = {
id: 'spacer-1',
type: 'spacer',
height: 48,
}
const baseSocialBlock: SocialBlock = {
id: 'social-1',
type: 'social',
links: [
{ id: 'link-linkedin', platform: 'linkedin', url: 'https://linkedin.com/in/test' },
{ id: 'link-twitter', platform: 'twitter', url: 'https://twitter.com/test' },
{ id: 'link-website', platform: 'website', url: '' },
],
iconSize: 24,
alignment: 'center',
}
const baseHeaderBlock: HeaderBlock = {
id: 'header-1',
type: 'header',
companyName: 'Acme Corp',
alignment: 'center',
}
const baseFooterBlock: FooterBlock = {
id: 'footer-1',
type: 'footer',
companyName: 'Acme Corp',
address: '123 Main St, Springfield',
showUnsubscribe: true,
unsubscribeUrl: 'https://example.com/unsubscribe',
alignment: 'center',
}
// ---------------------------------------------------------------------------
// TextBlockEditor
// ---------------------------------------------------------------------------
describe('TextBlockEditor', () => {
it('renders content in view mode via dangerouslySetInnerHTML', () => {
const { container } = render(
)
expect(container.querySelector('.prose')).toBeInTheDocument()
})
it('renders contentEditable div in editing mode without renderEditor', () => {
const { container } = render(
)
expect(container.querySelector('[contenteditable="true"]')).toBeInTheDocument()
})
it('applies fontSize style from block data', () => {
const block: TextBlock = { ...baseTextBlock, fontSize: 18 }
const { container } = render(
)
const el = container.querySelector('.prose') as HTMLElement
expect(el.style.fontSize).toBe('18px')
})
it('applies fontFamily style from block data', () => {
const block: TextBlock = { ...baseTextBlock, fontFamily: 'Georgia, serif' }
const { container } = render(
)
const el = container.querySelector('.prose') as HTMLElement
expect(el.style.fontFamily).toBe('Georgia, serif')
})
it('applies textColor style from block data', () => {
const block: TextBlock = { ...baseTextBlock, textColor: '#ff0000' }
const { container } = render(
)
const el = container.querySelector('.prose') as HTMLElement
expect(el.style.color).toBe('rgb(255, 0, 0)')
})
it('calls onChange with updated content on blur in default editing mode', () => {
const onChange = jest.fn()
const { container } = render(
)
const editable = container.querySelector('[contenteditable="true"]') as HTMLElement
fireEvent.blur(editable)
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ type: 'text', id: 'text-1' })
)
})
it('uses renderEditor when provided in editing mode', () => {
const renderEditor = jest.fn(() => )
render(
)
expect(screen.getByTestId('custom-editor')).toBeInTheDocument()
expect(renderEditor).toHaveBeenCalledWith(
expect.objectContaining({
content: baseTextBlock.content,
placeholder: 'Write your content...',
})
)
})
it('renderEditor onChange callback updates block content', () => {
const onChange = jest.fn()
let capturedOnChange: (html: string) => void = () => {}
const renderEditor = jest.fn(({ onChange: cb }: { onChange: (html: string) => void }) => {
capturedOnChange = cb
return
})
render(
)
capturedOnChange('Updated
')
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ content: 'Updated
' })
)
})
it('falls back to empty string for content in editing mode when content is empty', () => {
const block: TextBlock = { ...baseTextBlock, content: '' }
const { container } = render(
)
expect(container.querySelector('[contenteditable="true"]')).toBeInTheDocument()
})
it('defaults to isEditing=true when prop is omitted', () => {
const { container } = render(
)
expect(container.querySelector('[contenteditable="true"]')).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// MetricsBlockEditor
// ---------------------------------------------------------------------------
describe('MetricsBlockEditor', () => {
it('renders metric values and labels in view mode', () => {
render()
expect(screen.getByText('$1,200')).toBeInTheDocument()
expect(screen.getByText('Revenue')).toBeInTheDocument()
expect(screen.getByText('340')).toBeInTheDocument()
expect(screen.getByText('Users')).toBeInTheDocument()
})
it('renders section title in view mode when provided', () => {
render()
expect(screen.getByText('Key Metrics')).toBeInTheDocument()
})
it('does not render title in view mode when omitted', () => {
const block: MetricsBlock = { ...baseMetricsBlock, title: undefined }
render()
expect(screen.queryByRole('heading')).not.toBeInTheDocument()
})
it('renders change indicator for positive metric in view mode', () => {
render()
expect(screen.getByText('+10%')).toBeInTheDocument()
})
it('renders change indicator for negative metric in view mode', () => {
render()
expect(screen.getByText('-5%')).toBeInTheDocument()
})
it('does not render change when metric has no change field', () => {
const block: MetricsBlock = {
...baseMetricsBlock,
metrics: [{ id: 'metric-c', label: 'Sessions', value: '100' }],
}
render()
expect(screen.getByText('Sessions')).toBeInTheDocument()
expect(screen.getByText('100')).toBeInTheDocument()
})
it('renders section title input and columns select in editing mode', () => {
render()
expect(screen.getByPlaceholderText('Key Metrics')).toBeInTheDocument()
expect(screen.getByText('Add Metric')).toBeInTheDocument()
})
it('calls onChange when title input changes in editing mode', () => {
const onChange = jest.fn()
render()
const titleInput = screen.getByPlaceholderText('Key Metrics')
fireEvent.change(titleInput, { target: { value: 'Updated Title' } })
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Updated Title' })
)
})
it('calls onChange with new metric when Add Metric is clicked', () => {
const onChange = jest.fn()
render()
fireEvent.click(screen.getByText('Add Metric'))
expect(onChange).toHaveBeenCalledTimes(1)
const updated: MetricsBlock = onChange.mock.calls[0][0]
expect(updated.metrics).toHaveLength(3)
expect(updated.metrics[2].label).toBe('New Metric')
})
it('calls onChange with metric removed when remove button is clicked', () => {
const onChange = jest.fn()
render()
// Two metrics — two remove buttons; first button should be enabled
const removeButtons = screen.getAllByRole('button').filter((btn) =>
btn.querySelector('svg') !== null && !btn.textContent?.includes('Add')
)
fireEvent.click(removeButtons[0])
expect(onChange).toHaveBeenCalledTimes(1)
const updated: MetricsBlock = onChange.mock.calls[0][0]
expect(updated.metrics).toHaveLength(1)
})
it('disables remove button when only one metric remains', () => {
const block: MetricsBlock = {
...baseMetricsBlock,
metrics: [{ id: 'only', label: 'Solo', value: '1' }],
}
render()
const removeButtons = screen.getAllByRole('button', { hidden: true }).filter(
(btn) => btn.hasAttribute('disabled')
)
expect(removeButtons.length).toBeGreaterThan(0)
})
it('calls onChange when a metric label input changes', () => {
const onChange = jest.fn()
render()
const labelInputs = screen.getAllByPlaceholderText('Label')
fireEvent.change(labelInputs[0], { target: { value: 'ARR' } })
expect(onChange).toHaveBeenCalledTimes(1)
const updated: MetricsBlock = onChange.mock.calls[0][0]
expect(updated.metrics[0].label).toBe('ARR')
})
it('calls onChange when a metric value input changes', () => {
const onChange = jest.fn()
render()
const valueInputs = screen.getAllByPlaceholderText('Value')
fireEvent.change(valueInputs[0], { target: { value: '$9,999' } })
expect(onChange).toHaveBeenCalledTimes(1)
const updated: MetricsBlock = onChange.mock.calls[0][0]
expect(updated.metrics[0].value).toBe('$9,999')
})
it('defaults to isEditing=true when prop is omitted', () => {
render()
expect(screen.getByText('Add Metric')).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// DividerBlockEditor
// ---------------------------------------------------------------------------
describe('DividerBlockEditor', () => {
it('renders an hr element in view mode for solid style', () => {
const { container } = render(
)
expect(container.querySelector('hr')).toBeInTheDocument()
})
it('applies correct border-top style to hr in view mode', () => {
const { container } = render(
)
const hr = container.querySelector('hr') as HTMLElement
expect(hr.style.borderTop).toContain('solid')
})
it('renders a spacer div (no hr) when dividerStyle is "space"', () => {
const block: DividerBlock = { ...baseDividerBlock, dividerStyle: 'space' }
const { container } = render(
)
expect(container.querySelector('hr')).not.toBeInTheDocument()
const spacer = container.querySelector('div') as HTMLElement
expect(spacer.style.height).toBe('32px')
})
it('renders with dashed style', () => {
const block: DividerBlock = { ...baseDividerBlock, dividerStyle: 'dashed' }
const { container } = render(
)
const hr = container.querySelector('hr') as HTMLElement
expect(hr.style.borderTop).toContain('dashed')
})
it('renders with dotted style', () => {
const block: DividerBlock = { ...baseDividerBlock, dividerStyle: 'dotted' }
const { container } = render(
)
const hr = container.querySelector('hr') as HTMLElement
expect(hr.style.borderTop).toContain('dotted')
})
it('applies width percentage to hr', () => {
const block: DividerBlock = { ...baseDividerBlock, width: 80 }
const { container } = render(
)
const hr = container.querySelector('hr') as HTMLElement
expect(hr.style.width).toBe('80%')
})
it('renders a preview hr in editing mode', () => {
const { container } = render(
)
// Editing mode wraps the view-mode render, so hr should still appear
expect(container.querySelector('hr')).toBeInTheDocument()
})
it('uses default color when color is not provided', () => {
const block: DividerBlock = { ...baseDividerBlock, color: undefined }
const { container } = render(
)
const hr = container.querySelector('hr') as HTMLElement
// jsdom normalizes hex colors to rgb in computed styles
expect(hr.style.borderTop).toMatch(/rgb\(209,\s*213,\s*219\)|#d1d5db/)
})
})
// ---------------------------------------------------------------------------
// ButtonBlockEditor
// ---------------------------------------------------------------------------
describe('ButtonBlockEditor', () => {
it('renders the button text from block data', () => {
render()
expect(screen.getByText('Click Me')).toBeInTheDocument()
})
it('falls back to "Button" text when block.text is empty', () => {
const block: CTABlock = { ...baseButtonBlock, text: '' }
render()
expect(screen.getByText('Button')).toBeInTheDocument()
})
it('applies button background color from block data', () => {
const { container } = render()
const span = container.querySelector('span[style]') as HTMLElement
expect(span.style.backgroundColor).toBe('rgb(37, 99, 235)')
})
it('applies text color from block data', () => {
const { container } = render()
const span = container.querySelector('span[style]') as HTMLElement
expect(span.style.color).toBe('rgb(255, 255, 255)')
})
it('applies border radius from block data', () => {
const { container } = render()
const span = container.querySelector('span[style]') as HTMLElement
expect(span.style.borderRadius).toBe('6px')
})
it('uses center alignment class by default', () => {
const { container } = render()
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('text-center')
})
it('applies left alignment class', () => {
const block: CTABlock = { ...baseButtonBlock, alignment: 'left' }
const { container } = render()
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('text-left')
})
it('applies right alignment class', () => {
const block: CTABlock = { ...baseButtonBlock, alignment: 'right' }
const { container } = render()
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('text-right')
})
it('uses default buttonColor when not provided', () => {
const block: CTABlock = { ...baseButtonBlock, buttonColor: undefined }
const { container } = render()
const span = container.querySelector('span[style]') as HTMLElement
expect(span.style.backgroundColor).toBe('rgb(37, 99, 235)')
})
})
// ---------------------------------------------------------------------------
// ImageBlockEditor
// ---------------------------------------------------------------------------
describe('ImageBlockEditor', () => {
it('renders img element when url is provided', () => {
const { container } = render(
)
const img = container.querySelector('img') as HTMLImageElement
expect(img).toBeInTheDocument()
expect(img.src).toBe('https://example.com/image.png')
expect(img.alt).toBe('Test image')
})
it('renders placeholder when url is empty', () => {
const block: ImageBlock = { ...baseImageBlock, url: '' }
render()
expect(screen.getByText('Click to set image URL')).toBeInTheDocument()
})
it('renders "No image set" placeholder text in view mode when url is empty', () => {
const block: ImageBlock = { ...baseImageBlock, url: '' }
render()
expect(screen.getByText('No image set')).toBeInTheDocument()
})
it('renders caption when provided', () => {
const block: ImageBlock = { ...baseImageBlock, caption: 'A beautiful photo' }
render()
expect(screen.getByText('A beautiful photo')).toBeInTheDocument()
})
it('does not render caption element when caption is absent', () => {
render()
expect(screen.queryByRole('paragraph')).not.toBeInTheDocument()
})
it('wraps image in an anchor when linkUrl is set in view mode', () => {
const block: ImageBlock = { ...baseImageBlock, linkUrl: 'https://linked.com' }
const { container } = render(
)
const anchor = container.querySelector('a') as HTMLAnchorElement
expect(anchor).toBeInTheDocument()
expect(anchor.href).toBe('https://linked.com/')
})
it('does not wrap image in anchor in editing mode even when linkUrl is set', () => {
const block: ImageBlock = { ...baseImageBlock, linkUrl: 'https://linked.com' }
const { container } = render(
)
expect(container.querySelector('a')).not.toBeInTheDocument()
})
it('applies center alignment class by default', () => {
const { container } = render(
)
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('text-center')
})
it('applies left alignment class', () => {
const block: ImageBlock = { ...baseImageBlock, alignment: 'left' }
const { container } = render(
)
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('text-left')
})
it('applies width percentage to img element', () => {
const block: ImageBlock = { ...baseImageBlock, width: 50 }
const { container } = render(
)
const img = container.querySelector('img') as HTMLElement
expect(img.style.width).toBe('50%')
})
})
// ---------------------------------------------------------------------------
// SpacerBlockEditor
// ---------------------------------------------------------------------------
describe('SpacerBlockEditor', () => {
it('renders a plain div with correct height in view mode', () => {
const { container } = render(
)
const spacer = container.querySelector('div') as HTMLElement
expect(spacer.style.height).toBe('48px')
})
it('renders editing mode wrapper with height label', () => {
render(
)
expect(screen.getByText('48px')).toBeInTheDocument()
})
it('defaults to 32px height when height is 0', () => {
const block: SpacerBlock = { ...baseSpacerBlock, height: 0 }
const { container } = render(
)
const spacer = container.querySelector('div') as HTMLElement
// height of 0 is falsy so falls back to 32
expect(spacer.style.height).toBe('32px')
})
it('renders dashed border container in editing mode', () => {
const { container } = render(
)
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('border-dashed')
})
it('defaults to isEditing=true when prop is omitted', () => {
render()
expect(screen.getByText('48px')).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// SocialBlockEditor
// ---------------------------------------------------------------------------
describe('SocialBlockEditor', () => {
it('renders icons for each link in the block', () => {
const { container } = render(
)
// 3 links → 3 icon wrapper elements (span or a)
const iconWrappers = container.querySelectorAll('[class*="inline-flex"]')
expect(iconWrappers.length).toBe(3)
})
it('renders spans (not anchors) for links in editing mode', () => {
const { container } = render(
)
expect(container.querySelectorAll('a')).toHaveLength(0)
expect(container.querySelectorAll('span.inline-flex').length).toBeGreaterThan(0)
})
it('renders spans for links with empty url even in view mode', () => {
// link-website has url: '' so should render span not anchor
const { container } = render(
)
// linkedin and twitter have URLs → 2 anchors; website has no url → 1 span
const anchors = container.querySelectorAll('a')
expect(anchors).toHaveLength(2)
})
it('renders anchors with correct hrefs for links with URLs in view mode', () => {
const { container } = render(
)
const anchors = container.querySelectorAll('a') as NodeListOf
const hrefs = Array.from(anchors).map((a) => a.href)
expect(hrefs).toContain('https://linkedin.com/in/test')
expect(hrefs).toContain('https://twitter.com/test')
})
it('applies justify-center class for center alignment', () => {
const { container } = render(
)
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('justify-center')
})
it('applies justify-start class for left alignment', () => {
const block: SocialBlock = { ...baseSocialBlock, alignment: 'left' }
const { container } = render()
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('justify-start')
})
it('applies justify-end class for right alignment', () => {
const block: SocialBlock = { ...baseSocialBlock, alignment: 'right' }
const { container } = render()
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('justify-end')
})
it('renders empty links array without crashing', () => {
const block: SocialBlock = { ...baseSocialBlock, links: [] }
const { container } = render()
expect(container.querySelector('div')).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// HeaderBlockEditor
// ---------------------------------------------------------------------------
describe('HeaderBlockEditor', () => {
it('renders company name', () => {
render()
expect(screen.getByText('Acme Corp')).toBeInTheDocument()
})
it('renders logo image when logoUrl is provided', () => {
const block: HeaderBlock = { ...baseHeaderBlock, logoUrl: 'https://example.com/logo.png' }
const { container } = render()
const img = container.querySelector('img') as HTMLImageElement
expect(img).toBeInTheDocument()
expect(img.src).toBe('https://example.com/logo.png')
expect(img.alt).toBe('Acme Corp')
})
it('renders placeholder icon when logoUrl is absent', () => {
const { container } = render()
expect(container.querySelector('img')).not.toBeInTheDocument()
// The placeholder wrapper div should be present
expect(container.querySelector('.rounded.bg-gray-100')).toBeInTheDocument()
})
it('applies center alignment class', () => {
const { container } = render()
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('text-center')
})
it('applies left alignment class', () => {
const block: HeaderBlock = { ...baseHeaderBlock, alignment: 'left' }
const { container } = render()
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('text-left')
})
it('applies right alignment class', () => {
const block: HeaderBlock = { ...baseHeaderBlock, alignment: 'right' }
const { container } = render()
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('text-right')
})
it('renders without logoUrl field (undefined)', () => {
const block: HeaderBlock = { ...baseHeaderBlock, logoUrl: undefined }
render()
expect(screen.getByText('Acme Corp')).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// FooterBlockEditor
// ---------------------------------------------------------------------------
describe('FooterBlockEditor', () => {
it('renders company name', () => {
render()
expect(screen.getByText('Acme Corp')).toBeInTheDocument()
})
it('renders address when provided', () => {
render()
expect(screen.getByText('123 Main St, Springfield')).toBeInTheDocument()
})
it('does not render address element when address is absent', () => {
const block: FooterBlock = { ...baseFooterBlock, address: undefined }
render()
expect(screen.queryByText('123 Main St, Springfield')).not.toBeInTheDocument()
})
it('renders unsubscribe link when showUnsubscribe is true', () => {
render()
expect(screen.getByText('Unsubscribe')).toBeInTheDocument()
})
it('does not render unsubscribe link when showUnsubscribe is false', () => {
const block: FooterBlock = { ...baseFooterBlock, showUnsubscribe: false }
render()
expect(screen.queryByText('Unsubscribe')).not.toBeInTheDocument()
})
it('unsubscribe link href uses unsubscribeUrl when provided in view mode', () => {
const { container } = render(
)
const anchor = container.querySelector('a.underline') as HTMLAnchorElement
expect(anchor.href).toBe('https://example.com/unsubscribe')
})
it('unsubscribe link href falls back to "#" when unsubscribeUrl is absent', () => {
const block: FooterBlock = { ...baseFooterBlock, unsubscribeUrl: undefined }
const { container } = render(
)
const anchor = container.querySelector('a.underline') as HTMLAnchorElement
expect(anchor.getAttribute('href')).toBe('#')
})
it('prevents default click on unsubscribe link in editing mode', () => {
render()
const anchor = screen.getByText('Unsubscribe')
const event = createClickEvent()
anchor.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
it('applies center alignment class', () => {
const { container } = render()
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('text-center')
})
it('applies left alignment class', () => {
const block: FooterBlock = { ...baseFooterBlock, alignment: 'left' }
const { container } = render()
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('text-left')
})
it('applies right alignment class', () => {
const block: FooterBlock = { ...baseFooterBlock, alignment: 'right' }
const { container } = render()
const wrapper = container.querySelector('div[class]') as HTMLElement
expect(wrapper.className).toContain('text-right')
})
})
// ---------------------------------------------------------------------------
// Helper: create a real MouseEvent with bubbles so dispatchEvent registers handler
// ---------------------------------------------------------------------------
function createClickEvent(): MouseEvent {
return new MouseEvent('click', { bubbles: true, cancelable: true })
}