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