import { render, screen, fireEvent } from '@testing-library/react'
import { BlockRenderer } from '../BlockRenderer'
import type {
TextBlock,
MetricsBlock,
DividerBlock,
CTABlock,
ImageBlock,
SpacerBlock,
SocialBlock,
HeaderBlock,
FooterBlock,
Block,
} from '../types'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const textBlock: TextBlock = {
id: 'text-1',
type: 'text',
content: '
Hello dispatch
',
}
const metricsBlock: MetricsBlock = {
id: 'metrics-1',
type: 'metrics',
title: 'Dispatch Metrics',
metrics: [
{ id: 'm1', label: 'ARR', value: '$5k', changeType: 'positive' },
],
columns: 2,
}
const dividerBlock: DividerBlock = {
id: 'divider-1',
type: 'divider',
dividerStyle: 'solid',
color: '#d1d5db',
thickness: 1,
width: 100,
}
const ctaBlock: CTABlock = {
id: 'cta-1',
type: 'cta',
text: 'Dispatch CTA',
url: 'https://example.com',
buttonColor: '#2563eb',
textColor: '#ffffff',
borderRadius: 6,
paddingH: 24,
paddingV: 12,
alignment: 'center',
}
const imageBlock: ImageBlock = {
id: 'image-1',
type: 'image',
url: 'https://example.com/img.png',
alt: 'dispatch img',
alignment: 'center',
width: 100,
}
const spacerBlock: SpacerBlock = {
id: 'spacer-1',
type: 'spacer',
height: 40,
}
const socialBlock: SocialBlock = {
id: 'social-1',
type: 'social',
links: [{ id: 'sl1', platform: 'linkedin', url: 'https://linkedin.com/test' }],
iconSize: 24,
alignment: 'center',
}
const headerBlock: HeaderBlock = {
id: 'header-1',
type: 'header',
companyName: 'DispatchCo',
alignment: 'center',
}
const footerBlock: FooterBlock = {
id: 'footer-1',
type: 'footer',
companyName: 'DispatchCo',
showUnsubscribe: false,
alignment: 'center',
}
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
function renderBlock(block: Block, editing = false) {
return render(
)
}
// ---------------------------------------------------------------------------
// Dispatches to correct editor
// ---------------------------------------------------------------------------
describe('BlockRenderer — dispatches to correct block editor', () => {
it('renders TextBlockEditor for text block (view mode shows .prose)', () => {
const { container } = renderBlock(textBlock, false)
expect(container.querySelector('.prose')).toBeInTheDocument()
})
it('renders TextBlockEditor for text block (editing mode shows contentEditable)', () => {
const { container } = renderBlock(textBlock, true)
expect(container.querySelector('[contenteditable="true"]')).toBeInTheDocument()
})
it('renders MetricsBlockEditor for metrics block — shows title', () => {
renderBlock(metricsBlock, false)
expect(screen.getByText('Dispatch Metrics')).toBeInTheDocument()
})
it('renders MetricsBlockEditor for metrics block — shows metric value', () => {
renderBlock(metricsBlock, false)
expect(screen.getByText('$5k')).toBeInTheDocument()
})
it('renders DividerBlockEditor for divider block — shows hr element', () => {
const { container } = renderBlock(dividerBlock, false)
expect(container.querySelector('hr')).toBeInTheDocument()
})
it('renders ButtonBlockEditor for cta block — shows button text', () => {
renderBlock(ctaBlock, false)
expect(screen.getByText('Dispatch CTA')).toBeInTheDocument()
})
it('renders ImageBlockEditor for image block — shows img element', () => {
const { container } = renderBlock(imageBlock, false)
expect(container.querySelector('img')).toBeInTheDocument()
})
it('renders SpacerBlockEditor for spacer block — shows correct height in editing mode', () => {
renderBlock(spacerBlock, true)
expect(screen.getByText('40px')).toBeInTheDocument()
})
it('renders SocialBlockEditor for social block — shows social icon wrapper', () => {
const { container } = renderBlock(socialBlock, false)
expect(container.querySelector('div')).toBeInTheDocument()
// LinkedIn link present since url is set
expect(container.querySelector('a')).toBeInTheDocument()
})
it('renders HeaderBlockEditor for header block — shows company name', () => {
renderBlock(headerBlock, false)
expect(screen.getByText('DispatchCo')).toBeInTheDocument()
})
it('renders FooterBlockEditor for footer block — shows company name', () => {
renderBlock(footerBlock, false)
// Footer renders company name
expect(screen.getByText('DispatchCo')).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// editing prop is forwarded correctly
// ---------------------------------------------------------------------------
describe('BlockRenderer — editing prop forwarding', () => {
it('text block: editing=false renders view mode (.prose)', () => {
const { container } = renderBlock(textBlock, false)
expect(container.querySelector('.prose')).toBeInTheDocument()
expect(container.querySelector('[contenteditable="true"]')).not.toBeInTheDocument()
})
it('text block: editing=true renders edit mode (contentEditable)', () => {
const { container } = renderBlock(textBlock, true)
expect(container.querySelector('[contenteditable="true"]')).toBeInTheDocument()
})
it('spacer block: editing=false renders plain div without label text', () => {
const { container } = renderBlock(spacerBlock, false)
// view mode renders a plain height div, no "px" label
expect(screen.queryByText('40px')).not.toBeInTheDocument()
const spacer = container.querySelector('div') as HTMLElement
expect(spacer.style.height).toBe('40px')
})
it('spacer block: editing=true shows height label', () => {
renderBlock(spacerBlock, true)
expect(screen.getByText('40px')).toBeInTheDocument()
})
it('image block: editing=true shows placeholder when url empty', () => {
const emptyImage: ImageBlock = { ...imageBlock, url: '' }
render(
)
expect(screen.getByText('Click to set image URL')).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// onChange is forwarded through to child editors
// ---------------------------------------------------------------------------
describe('BlockRenderer — onChange forwarding', () => {
it('text block: onChange is called when contentEditable blurs', () => {
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('metrics block: onChange is called when title input changes', () => {
const onChange = jest.fn()
render(
)
const titleInput = screen.getByPlaceholderText('Key Metrics')
fireEvent.change(titleInput, { target: { value: 'New Title' } })
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ type: 'metrics', title: 'New Title' })
)
})
})
// ---------------------------------------------------------------------------
// renderTextEditor prop is forwarded to TextBlockEditor
// ---------------------------------------------------------------------------
describe('BlockRenderer — renderTextEditor prop', () => {
it('renders custom editor when renderTextEditor is provided for text block', () => {
const renderTextEditor = jest.fn(() => )
render(
)
expect(screen.getByTestId('custom-rich-editor')).toBeInTheDocument()
expect(renderTextEditor).toHaveBeenCalledWith(
expect.objectContaining({
content: textBlock.content,
})
)
})
it('renderTextEditor is ignored for non-text block types', () => {
const renderTextEditor = jest.fn(() => )
render(
)
// DividerBlockEditor should render instead
expect(screen.queryByTestId('custom-rich-editor')).not.toBeInTheDocument()
const { container } = render(
)
expect(container.querySelector('hr')).toBeInTheDocument()
expect(renderTextEditor).not.toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// mergeFields prop does not cause errors
// ---------------------------------------------------------------------------
describe('BlockRenderer — mergeFields prop', () => {
it('renders without error when mergeFields is empty', () => {
expect(() => renderBlock(textBlock, false)).not.toThrow()
})
it('renders without error when mergeFields has items', () => {
expect(() =>
render(
)
).not.toThrow()
})
})