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