import { render, screen, fireEvent } from '@testing-library/react' import { BlockPropertyPanel } from '../BlockPropertyPanel' import type { TextBlock, DividerBlock, CTABlock, ImageBlock, SpacerBlock, SocialBlock, HeaderBlock, FooterBlock, Block, } from '../../types' // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const textBlock: TextBlock = { id: 'text-1', type: 'text', content: '

Hello

', fontSize: 16, lineHeight: 1.6, textColor: '#1f2937', } 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: 'Buy Now', url: 'https://buy.example.com', buttonColor: '#2563eb', textColor: '#ffffff', borderRadius: 6, paddingH: 24, paddingV: 12, alignment: 'center', } const imageBlock: ImageBlock = { id: 'image-1', type: 'image', url: 'https://img.example.com/pic.jpg', alt: 'My image', alignment: 'center', width: 100, } const spacerBlock: SpacerBlock = { id: 'spacer-1', type: 'spacer', height: 32, } const socialBlock: SocialBlock = { id: 'social-1', type: 'social', links: [ { id: 'sl1', platform: 'linkedin', url: 'https://linkedin.com' }, ], iconSize: 24, alignment: 'center', } const headerBlock: HeaderBlock = { id: 'header-1', type: 'header', companyName: 'PropCo', logoUrl: '', alignment: 'center', } const footerBlock: FooterBlock = { id: 'footer-1', type: 'footer', companyName: 'PropCo', address: '1 Main St', showUnsubscribe: true, unsubscribeUrl: 'https://unsub.example.com', alignment: 'center', } function renderPanel(block: Block, onChange = jest.fn()) { return { ...render(), onChange } } // --------------------------------------------------------------------------- // Renders type label header // --------------------------------------------------------------------------- describe('BlockPropertyPanel — type label header', () => { it('shows "text Settings" heading for text block', () => { renderPanel(textBlock) expect(screen.getByText('text Settings')).toBeInTheDocument() }) it('shows "divider Settings" heading for divider block', () => { renderPanel(dividerBlock) expect(screen.getByText('divider Settings')).toBeInTheDocument() }) it('shows "cta Settings" heading for cta block', () => { renderPanel(ctaBlock) expect(screen.getByText('cta Settings')).toBeInTheDocument() }) it('shows "image Settings" heading for image block', () => { renderPanel(imageBlock) expect(screen.getByText('image Settings')).toBeInTheDocument() }) it('shows "spacer Settings" heading for spacer block', () => { renderPanel(spacerBlock) expect(screen.getByText('spacer Settings')).toBeInTheDocument() }) it('shows "social Settings" heading for social block', () => { renderPanel(socialBlock) expect(screen.getByText('social Settings')).toBeInTheDocument() }) it('shows "header Settings" heading for header block', () => { renderPanel(headerBlock) expect(screen.getByText('header Settings')).toBeInTheDocument() }) it('shows "footer Settings" heading for footer block', () => { renderPanel(footerBlock) expect(screen.getByText('footer Settings')).toBeInTheDocument() }) }) // --------------------------------------------------------------------------- // Common spacing / background section (present for all block types) // --------------------------------------------------------------------------- describe('BlockPropertyPanel — common spacing section', () => { it('renders Spacing heading for text block', () => { renderPanel(textBlock) expect(screen.getByText('Spacing')).toBeInTheDocument() }) it('renders Pad Top input for any block', () => { renderPanel(spacerBlock) expect(screen.getByText('Pad Top')).toBeInTheDocument() }) it('renders Pad Bottom input for any block', () => { renderPanel(ctaBlock) expect(screen.getByText('Pad Bottom')).toBeInTheDocument() }) it('renders Background label for any block', () => { renderPanel(headerBlock) expect(screen.getByText('Background')).toBeInTheDocument() }) it('calls onChange with updated paddingTop when Pad Top input changes', () => { const onChange = jest.fn() render() // Both Pad Top and Pad Bottom show "0"; take the first (Pad Top) const padTopInput = screen.getAllByDisplayValue('0')[0] fireEvent.change(padTopInput, { target: { value: '12' } }) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ style: expect.objectContaining({ paddingTop: 12 }), }) ) }) it('calls onChange with updated paddingBottom when Pad Bottom input changes', () => { const onChange = jest.fn() render() const inputs = screen.getAllByDisplayValue('0') // Second "0" input is Pad Bottom fireEvent.change(inputs[1], { target: { value: '8' } }) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ style: expect.objectContaining({ paddingBottom: 8 }), }) ) }) it('calls onChange with updated backgroundColor from background text input', () => { const onChange = jest.fn() render() const bgInput = screen.getByPlaceholderText('transparent') fireEvent.change(bgInput, { target: { value: '#eeeeee' } }) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ style: expect.objectContaining({ backgroundColor: '#eeeeee' }), }) ) }) }) // --------------------------------------------------------------------------- // TextSettings — correct property editors rendered // --------------------------------------------------------------------------- describe('BlockPropertyPanel — TextSettings for text block', () => { it('renders Font Size input', () => { renderPanel(textBlock) expect(screen.getByText('Font Size (px)')).toBeInTheDocument() }) it('renders Line Height input', () => { renderPanel(textBlock) expect(screen.getByText('Line Height')).toBeInTheDocument() }) it('renders Text Color label', () => { renderPanel(textBlock) expect(screen.getAllByText('Text Color').length).toBeGreaterThan(0) }) it('renders Font Family label', () => { renderPanel(textBlock) expect(screen.getAllByText('Font Family').length).toBeGreaterThan(0) }) it('calls onChange with updated fontSize when Font Size input changes', () => { const onChange = jest.fn() render() const fontSizeInput = screen.getByDisplayValue('16') fireEvent.change(fontSizeInput, { target: { value: '20' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'text', fontSize: 20 }) ) }) it('calls onChange with updated lineHeight when Line Height input changes', () => { const onChange = jest.fn() render() const lineHeightInput = screen.getByDisplayValue('1.6') fireEvent.change(lineHeightInput, { target: { value: '2' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'text', lineHeight: 2 }) ) }) it('calls onChange with updated textColor when text color input changes', () => { const onChange = jest.fn() render() const colorInputs = screen.getAllByDisplayValue('#1f2937') const textInput = colorInputs.find( (el) => (el as HTMLInputElement).type !== 'color' ) as HTMLInputElement fireEvent.change(textInput, { target: { value: '#ff0000' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'text', textColor: '#ff0000' }) ) }) it('does not render DividerSettings for text block', () => { renderPanel(textBlock) expect(screen.queryByText('Thickness (px)')).not.toBeInTheDocument() }) }) // --------------------------------------------------------------------------- // DividerSettings // --------------------------------------------------------------------------- describe('BlockPropertyPanel — DividerSettings for divider block', () => { it('renders Style select', () => { renderPanel(dividerBlock) expect(screen.getByText('Style')).toBeInTheDocument() }) it('renders Thickness input', () => { renderPanel(dividerBlock) expect(screen.getByText('Thickness (px)')).toBeInTheDocument() }) it('renders Width input', () => { renderPanel(dividerBlock) // There are potentially two "Width" labels but divider shows "Width (%)" expect(screen.getByText('Width (%)')).toBeInTheDocument() }) it('calls onChange with updated thickness', () => { const onChange = jest.fn() render() const thicknessInput = screen.getByDisplayValue('1') fireEvent.change(thicknessInput, { target: { value: '3' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'divider', thickness: 3 }) ) }) it('calls onChange with updated width percentage', () => { const onChange = jest.fn() render() const widthInput = screen.getByDisplayValue('100') fireEvent.change(widthInput, { target: { value: '80' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'divider', width: 80 }) ) }) it('calls onChange with updated color from text input', () => { const onChange = jest.fn() render() const colorInputs = screen.getAllByDisplayValue('#d1d5db') const textInput = colorInputs.find( (el) => (el as HTMLInputElement).type !== 'color' ) as HTMLInputElement fireEvent.change(textInput, { target: { value: '#000000' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'divider', color: '#000000' }) ) }) it('does not render TextSettings for divider block', () => { renderPanel(dividerBlock) expect(screen.queryByText('Font Size (px)')).not.toBeInTheDocument() }) }) // --------------------------------------------------------------------------- // ButtonSettings (CTABlock) // --------------------------------------------------------------------------- describe('BlockPropertyPanel — ButtonSettings for cta block', () => { it('renders Button Text input', () => { renderPanel(ctaBlock) expect(screen.getByText('Button Text')).toBeInTheDocument() }) it('renders URL input', () => { renderPanel(ctaBlock) expect(screen.getByText('URL')).toBeInTheDocument() }) it('renders Button Color label', () => { renderPanel(ctaBlock) expect(screen.getByText('Button Color')).toBeInTheDocument() }) it('renders Border Radius input', () => { renderPanel(ctaBlock) expect(screen.getByText('Border Radius (px)')).toBeInTheDocument() }) it('renders Pad H and Pad V inputs', () => { renderPanel(ctaBlock) expect(screen.getByText('Pad H')).toBeInTheDocument() expect(screen.getByText('Pad V')).toBeInTheDocument() }) it('shows button text value in input', () => { renderPanel(ctaBlock) expect(screen.getByDisplayValue('Buy Now')).toBeInTheDocument() }) it('calls onChange with updated text when Button Text input changes', () => { const onChange = jest.fn() render() const textInput = screen.getByDisplayValue('Buy Now') fireEvent.change(textInput, { target: { value: 'Shop Now' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'cta', text: 'Shop Now' }) ) }) it('calls onChange with updated URL when URL input changes', () => { const onChange = jest.fn() render() const urlInput = screen.getByDisplayValue('https://buy.example.com') fireEvent.change(urlInput, { target: { value: 'https://new.example.com' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'cta', url: 'https://new.example.com' }) ) }) it('calls onChange with updated borderRadius', () => { const onChange = jest.fn() render() const radiusInput = screen.getByDisplayValue('6') fireEvent.change(radiusInput, { target: { value: '12' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'cta', borderRadius: 12 }) ) }) }) // --------------------------------------------------------------------------- // ImageSettings // --------------------------------------------------------------------------- describe('BlockPropertyPanel — ImageSettings for image block', () => { it('renders Image URL label', () => { renderPanel(imageBlock) expect(screen.getByText('Image URL')).toBeInTheDocument() }) it('renders Alt Text label', () => { renderPanel(imageBlock) expect(screen.getByText('Alt Text')).toBeInTheDocument() }) it('renders Caption label', () => { renderPanel(imageBlock) expect(screen.getByText('Caption')).toBeInTheDocument() }) it('renders Link URL label', () => { renderPanel(imageBlock) expect(screen.getByText('Link URL (wraps image)')).toBeInTheDocument() }) it('renders Width (%) input', () => { renderPanel(imageBlock) expect(screen.getByText('Width (%)')).toBeInTheDocument() }) it('shows image URL value in input', () => { renderPanel(imageBlock) expect(screen.getByDisplayValue('https://img.example.com/pic.jpg')).toBeInTheDocument() }) it('calls onChange with updated URL', () => { const onChange = jest.fn() render() const urlInput = screen.getByDisplayValue('https://img.example.com/pic.jpg') fireEvent.change(urlInput, { target: { value: 'https://new.img.com/x.png' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'image', url: 'https://new.img.com/x.png' }) ) }) it('calls onChange with updated alt text', () => { const onChange = jest.fn() render() const altInput = screen.getByDisplayValue('My image') fireEvent.change(altInput, { target: { value: 'Updated alt' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'image', alt: 'Updated alt' }) ) }) it('calls onChange with updated width percentage', () => { const onChange = jest.fn() render() const widthInput = screen.getByDisplayValue('100') fireEvent.change(widthInput, { target: { value: '75' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'image', width: 75 }) ) }) }) // --------------------------------------------------------------------------- // SpacerSettings // --------------------------------------------------------------------------- describe('BlockPropertyPanel — SpacerSettings for spacer block', () => { it('renders Height (px) label', () => { renderPanel(spacerBlock) expect(screen.getByText('Height (px)')).toBeInTheDocument() }) it('shows spacer height value in input', () => { renderPanel(spacerBlock) expect(screen.getByDisplayValue('32')).toBeInTheDocument() }) it('calls onChange with updated height', () => { const onChange = jest.fn() render() const heightInput = screen.getByDisplayValue('32') fireEvent.change(heightInput, { target: { value: '64' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'spacer', height: 64 }) ) }) it('does not render text-specific inputs for spacer', () => { renderPanel(spacerBlock) expect(screen.queryByText('Font Size (px)')).not.toBeInTheDocument() expect(screen.queryByText('Button Text')).not.toBeInTheDocument() }) }) // --------------------------------------------------------------------------- // SocialSettings // --------------------------------------------------------------------------- describe('BlockPropertyPanel — SocialSettings for social block', () => { it('renders Alignment label', () => { renderPanel(socialBlock) expect(screen.getByText('Alignment')).toBeInTheDocument() }) it('renders Icon Size label', () => { renderPanel(socialBlock) expect(screen.getByText('Icon Size')).toBeInTheDocument() }) it('renders Links label', () => { renderPanel(socialBlock) expect(screen.getByText('Links')).toBeInTheDocument() }) it('renders Add Link button', () => { renderPanel(socialBlock) expect(screen.getByText('Add Link')).toBeInTheDocument() }) it('calls onChange when Add Link is clicked', () => { const onChange = jest.fn() render() fireEvent.click(screen.getByText('Add Link')) expect(onChange).toHaveBeenCalledTimes(1) const updated = onChange.mock.calls[0][0] as SocialBlock expect(updated.links).toHaveLength(2) }) it('calls onChange when remove link button is clicked', () => { const onChange = jest.fn() render() // The Trash2 icon button — there is one link so one remove button const removeButton = screen.getAllByRole('button').find( (btn) => btn.querySelector('svg') !== null && !btn.textContent?.includes('Add') ) as HTMLElement fireEvent.click(removeButton) expect(onChange).toHaveBeenCalledTimes(1) const updated = onChange.mock.calls[0][0] as SocialBlock expect(updated.links).toHaveLength(0) }) it('shows existing link URL in input', () => { renderPanel(socialBlock) expect(screen.getByDisplayValue('https://linkedin.com')).toBeInTheDocument() }) it('calls onChange when link URL input changes', () => { const onChange = jest.fn() render() const urlInput = screen.getByDisplayValue('https://linkedin.com') fireEvent.change(urlInput, { target: { value: 'https://new-linkedin.com' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'social', links: expect.arrayContaining([ expect.objectContaining({ url: 'https://new-linkedin.com' }), ]), }) ) }) it('calls onChange with updated iconSize', () => { const onChange = jest.fn() render() const iconSizeInput = screen.getByDisplayValue('24') fireEvent.change(iconSizeInput, { target: { value: '32' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'social', iconSize: 32 }) ) }) }) // --------------------------------------------------------------------------- // HeaderSettings // --------------------------------------------------------------------------- describe('BlockPropertyPanel — HeaderSettings for header block', () => { it('renders Company Name label', () => { renderPanel(headerBlock) expect(screen.getByText('Company Name')).toBeInTheDocument() }) it('renders Logo URL label', () => { renderPanel(headerBlock) expect(screen.getByText('Logo URL')).toBeInTheDocument() }) it('shows company name value in input', () => { renderPanel(headerBlock) expect(screen.getByDisplayValue('PropCo')).toBeInTheDocument() }) it('calls onChange with updated companyName', () => { const onChange = jest.fn() render() const nameInput = screen.getByDisplayValue('PropCo') fireEvent.change(nameInput, { target: { value: 'NewCo' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'header', companyName: 'NewCo' }) ) }) it('calls onChange with updated logoUrl', () => { const onChange = jest.fn() render() // logoUrl input has placeholder "https://..." const logoInputs = screen.getAllByPlaceholderText('https://...') fireEvent.change(logoInputs[0], { target: { value: 'https://cdn.example.com/logo.png' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'header', logoUrl: 'https://cdn.example.com/logo.png' }) ) }) }) // --------------------------------------------------------------------------- // FooterSettings // --------------------------------------------------------------------------- describe('BlockPropertyPanel — FooterSettings for footer block', () => { it('renders Company Name label', () => { renderPanel(footerBlock) // The footer has its own Company Name, header might share the name const labels = screen.getAllByText('Company Name') expect(labels.length).toBeGreaterThan(0) }) it('renders Address label', () => { renderPanel(footerBlock) expect(screen.getByText('Address')).toBeInTheDocument() }) it('shows address value in input', () => { renderPanel(footerBlock) expect(screen.getByDisplayValue('1 Main St')).toBeInTheDocument() }) it('renders "Show unsubscribe link" checkbox', () => { renderPanel(footerBlock) expect(screen.getByText('Show unsubscribe link')).toBeInTheDocument() }) it('renders Unsubscribe URL input when showUnsubscribe is true', () => { renderPanel(footerBlock) expect(screen.getByText('Unsubscribe URL')).toBeInTheDocument() }) it('does not render Unsubscribe URL input when showUnsubscribe is false', () => { const block: FooterBlock = { ...footerBlock, showUnsubscribe: false } renderPanel(block) expect(screen.queryByText('Unsubscribe URL')).not.toBeInTheDocument() }) it('calls onChange with updated companyName', () => { const onChange = jest.fn() render() const nameInput = screen.getByDisplayValue('PropCo') fireEvent.change(nameInput, { target: { value: 'Updated Co' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'footer', companyName: 'Updated Co' }) ) }) it('calls onChange when showUnsubscribe checkbox is toggled off', () => { const onChange = jest.fn() render() const checkbox = screen.getByRole('checkbox') fireEvent.click(checkbox) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'footer', showUnsubscribe: false }) ) }) it('calls onChange when showUnsubscribe checkbox is toggled on from false state', () => { const onChange = jest.fn() const block: FooterBlock = { ...footerBlock, showUnsubscribe: false } render() const checkbox = screen.getByRole('checkbox') fireEvent.click(checkbox) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'footer', showUnsubscribe: true }) ) }) it('calls onChange with updated address', () => { const onChange = jest.fn() render() const addressInput = screen.getByDisplayValue('1 Main St') fireEvent.change(addressInput, { target: { value: '2 New Rd' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'footer', address: '2 New Rd' }) ) }) it('calls onChange with updated unsubscribeUrl', () => { const onChange = jest.fn() render() const unsubInput = screen.getByDisplayValue('https://unsub.example.com') fireEvent.change(unsubInput, { target: { value: 'https://new-unsub.com' } }) expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ type: 'footer', unsubscribeUrl: 'https://new-unsub.com' }) ) }) })