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() }) })