test(ci): add exporter heading levels and a11y typography smoke; update MAIN_PLAN
CI / test (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import BuilderClientPage from './BuilderClientPage'
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Inspector Add buttons title consistency', () => {
|
||||
test('Features/Testimonials/FAQ Add buttons have title matching visible label', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
// Features
|
||||
fireEvent.click(screen.getByTestId('card-features'))
|
||||
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||
const addFeature = await screen.findByRole('button', { name: /Add Feature Item/i })
|
||||
expect(addFeature).toBeInTheDocument()
|
||||
expect(addFeature.getAttribute('title')).toBe('Add Feature Item')
|
||||
|
||||
// Testimonials
|
||||
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||
fireEvent.click(await screen.findByLabelText('Select 1'))
|
||||
const addTestimonial = await screen.findByRole('button', { name: /Add Testimonial/i })
|
||||
expect(addTestimonial).toBeInTheDocument()
|
||||
expect(addTestimonial.getAttribute('title')).toBe('Add Testimonial')
|
||||
|
||||
// FAQ
|
||||
fireEvent.click(screen.getByTestId('card-faq'))
|
||||
fireEvent.click(await screen.findByLabelText('Select 2'))
|
||||
const addFaq = await screen.findByRole('button', { name: /Add FAQ Item/i })
|
||||
expect(addFaq).toBeInTheDocument()
|
||||
expect(addFaq.getAttribute('title')).toBe('Add FAQ Item')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import BuilderClientPage from './BuilderClientPage'
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BuilderClientPage - inspector aria-describedby (Features/Testimonials)', () => {
|
||||
test('Features inputs reference help via aria-describedby', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('card-features'))
|
||||
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||
|
||||
const feature0 = await screen.findByLabelText('Feature 0')
|
||||
expect(feature0).toHaveAttribute('aria-describedby', 'ins-features-help')
|
||||
})
|
||||
|
||||
test('Testimonials inputs reference help via aria-describedby', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Testimonial/i }))
|
||||
|
||||
const author0 = await screen.findByLabelText('Author 0')
|
||||
const quote0 = await screen.findByLabelText('Quote 0')
|
||||
expect(author0).toHaveAttribute('aria-describedby', 'ins-testimonials-help')
|
||||
expect(quote0).toHaveAttribute('aria-describedby', 'ins-testimonials-help')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import BuilderClientPage from './BuilderClientPage'
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BuilderClientPage - inspector aria-describedby (Hero/CTA/FAQ)', () => {
|
||||
test('Hero input references help via aria-describedby', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('card-hero'))
|
||||
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||
|
||||
const hero = await screen.findByLabelText('Hero Heading')
|
||||
expect(hero).toHaveAttribute('aria-describedby', 'ins-hero-heading-help')
|
||||
})
|
||||
|
||||
test('CTA inputs reference help via aria-describedby', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('card-cta'))
|
||||
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||
|
||||
const text = await screen.findByLabelText('CTA Text')
|
||||
const href = await screen.findByLabelText('CTA Href')
|
||||
expect(text).toHaveAttribute('aria-describedby', 'ins-cta-text-help')
|
||||
expect(href).toHaveAttribute('aria-describedby', 'ins-cta-href-help')
|
||||
})
|
||||
|
||||
test('FAQ inputs reference help via aria-describedby', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('card-faq'))
|
||||
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Add FAQ Item' }))
|
||||
|
||||
const q0 = await screen.findByLabelText('FAQ Q 0')
|
||||
const a0 = await screen.findByLabelText('FAQ A 0')
|
||||
expect(q0).toHaveAttribute('aria-describedby', 'ins-faq-help')
|
||||
expect(a0).toHaveAttribute('aria-describedby', 'ins-faq-help')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import BuilderClientPage from './BuilderClientPage'
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BuilderClientPage - inspector aria-describedby smoke', () => {
|
||||
test('SEO/Analytics inputs reference helptext via aria-describedby', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
const seoTitle = await screen.findByLabelText('SEO Title')
|
||||
const seoDesc = await screen.findByLabelText('SEO Description')
|
||||
const ogImage = await screen.findByLabelText('OG Image URL')
|
||||
const ga4 = await screen.findByLabelText('GA4 Measurement ID')
|
||||
const pixel = await screen.findByLabelText('Meta Pixel ID')
|
||||
|
||||
expect(seoTitle).toHaveAttribute('aria-describedby', 'ins-seo-title-help')
|
||||
expect(seoDesc).toHaveAttribute('aria-describedby', 'ins-seo-desc-help')
|
||||
expect(ogImage).toHaveAttribute('aria-describedby', 'ins-og-image-help')
|
||||
expect(ga4).toHaveAttribute('aria-describedby', 'ins-ga4-help')
|
||||
expect(pixel).toHaveAttribute('aria-describedby', 'ins-meta-pixel-help')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import enMessages from '@/messages/en.json'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
|
||||
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||
|
||||
describe('BuilderClientPage - features/testimonials preview', () => {
|
||||
it('사이드바 카드 클릭으로 features/testimonials 섹션이 추가되고 프리뷰에 표기된다', () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<BuilderClientPage />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
|
||||
// features 추가
|
||||
fireEvent.click(screen.getByTestId('card-features'))
|
||||
// testimonials 추가
|
||||
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||
|
||||
// 프리뷰에서 섹션 요약 표기 확인
|
||||
expect(screen.getByText(/features — items: 3/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/testimonials — items: 2/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import enMessages from '@/messages/en.json'
|
||||
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||
|
||||
describe('BuilderClientPage - form preview', () => {
|
||||
it('폼 필드 목록과 필수 여부가 미리보기에 표시된다', () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<BuilderClientPage />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
|
||||
// FormBuilderPanel의 텍스트 필드 추가 버튼 클릭 (data-testid 사용)
|
||||
fireEvent.click(screen.getByTestId('btn-add-text'))
|
||||
|
||||
// 프리뷰 존재 확인(컴팩트 인스펙터 영역으로 이동됨)
|
||||
expect(screen.getByText(/Locale:/)).toBeInTheDocument()
|
||||
|
||||
// 첫 필드 항목 표기: 라벨/네임/required 여부(초기 false)
|
||||
expect(screen.getByText(/Text 1 \(field1\)/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/required/)).not.toBeNull()
|
||||
|
||||
// required 토글 후 프리뷰 갱신 확인
|
||||
fireEvent.click(screen.getByLabelText('Required 0'))
|
||||
expect(screen.getAllByText('required').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import enMessages from '@/messages/en.json'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
|
||||
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||
|
||||
describe('BuilderClientPage - Inspector CTA', () => {
|
||||
it('CTA 선택 후 인스펙터에서 텍스트/링크를 편집할 수 있다', () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<BuilderClientPage />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
|
||||
// CTA 섹션 추가
|
||||
fireEvent.click(screen.getByTestId('btn-add-cta'))
|
||||
|
||||
// 선택
|
||||
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||
|
||||
// 인풋 존재 및 값 변경
|
||||
const textInput = screen.getByLabelText('CTA Text') as HTMLInputElement
|
||||
const hrefInput = screen.getByLabelText('CTA Href') as HTMLInputElement
|
||||
|
||||
expect(textInput.value).toBeTruthy()
|
||||
expect(hrefInput.value).toBeTruthy()
|
||||
|
||||
fireEvent.change(textInput, { target: { value: 'Buy Now' } })
|
||||
fireEvent.change(hrefInput, { target: { value: 'https://example.com' } })
|
||||
|
||||
expect((screen.getByLabelText('CTA Text') as HTMLInputElement).value).toBe('Buy Now')
|
||||
expect((screen.getByLabelText('CTA Href') as HTMLInputElement).value).toBe('https://example.com')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import enMessages from '@/messages/en.json'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
|
||||
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||
|
||||
describe('BuilderClientPage - Inspector FAQ', () => {
|
||||
it('FAQ 선택 후 인스펙터에서 아이템을 편집할 수 있다(쉼표 구분)', () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<BuilderClientPage />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
|
||||
// FAQ 섹션 추가 및 선택
|
||||
fireEvent.click(screen.getByTestId('btn-add-faq'))
|
||||
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||
|
||||
// 리스트 UI: Add FAQ Item 후 Q/A 입력 변경 확인
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add FAQ Item' }))
|
||||
const q0 = screen.getByLabelText('FAQ Q 0') as HTMLInputElement
|
||||
const a0 = screen.getByLabelText('FAQ A 0') as HTMLInputElement
|
||||
fireEvent.change(q0, { target: { value: 'Q1?' } })
|
||||
fireEvent.change(a0, { target: { value: 'A1' } })
|
||||
expect((screen.getByLabelText('FAQ Q 0') as HTMLInputElement).value).toBe('Q1?')
|
||||
expect((screen.getByLabelText('FAQ A 0') as HTMLInputElement).value).toBe('A1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import enMessages from '@/messages/en.json'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
|
||||
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||
|
||||
describe('BuilderClientPage - Inspector FAQ List UI', () => {
|
||||
it('FAQ 항목을 추가/편집/삭제할 수 있다', () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<BuilderClientPage />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
|
||||
// FAQ 섹션 추가 및 선택
|
||||
fireEvent.click(screen.getByTestId('btn-add-faq'))
|
||||
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||
|
||||
// Add FAQ Item 버튼 클릭 → 첫 항목 생성
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add FAQ Item' }))
|
||||
|
||||
const q0 = screen.getByLabelText('FAQ Q 0') as HTMLInputElement
|
||||
const a0 = screen.getByLabelText('FAQ A 0') as HTMLInputElement
|
||||
|
||||
fireEvent.change(q0, { target: { value: 'What is it?' } })
|
||||
fireEvent.change(a0, { target: { value: 'A landing builder' } })
|
||||
|
||||
expect((screen.getByLabelText('FAQ Q 0') as HTMLInputElement).value).toBe('What is it?')
|
||||
expect((screen.getByLabelText('FAQ A 0') as HTMLInputElement).value).toBe('A landing builder')
|
||||
|
||||
// 두 번째 항목 추가 후 삭제
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add FAQ Item' }))
|
||||
const q1 = screen.getByLabelText('FAQ Q 1') as HTMLInputElement
|
||||
fireEvent.change(q1, { target: { value: 'How to export?' } })
|
||||
fireEvent.click(screen.getByLabelText('Remove FAQ 1'))
|
||||
|
||||
// 삭제 후 첫 항목만 남음
|
||||
expect(screen.queryByLabelText('FAQ Q 1')).toBeNull()
|
||||
expect(screen.getByLabelText('FAQ Q 0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import BuilderClientPage from './BuilderClientPage'
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Inspector - Features & Testimonials editing', () => {
|
||||
test('Features: add item and edit value', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
// add Features section via sidebar
|
||||
fireEvent.click(screen.getByTestId('card-features'))
|
||||
|
||||
// select the section in canvas (index 0)
|
||||
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||
|
||||
// add item
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Feature Item/i }))
|
||||
|
||||
// edit item 0
|
||||
const input = screen.getByLabelText('Feature 0') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: 'New Feature' } })
|
||||
expect(input.value).toBe('New Feature')
|
||||
})
|
||||
|
||||
test('Testimonials: add item and edit author/quote', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
// add Testimonials section via sidebar
|
||||
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||
|
||||
// select the section (index 0)
|
||||
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||
|
||||
// add item
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Testimonial/i }))
|
||||
|
||||
const author = screen.getByLabelText('Author 0') as HTMLInputElement
|
||||
const quote = screen.getByLabelText('Quote 0') as HTMLInputElement
|
||||
|
||||
fireEvent.change(author, { target: { value: 'Alice' } })
|
||||
fireEvent.change(quote, { target: { value: 'Awesome!' } })
|
||||
|
||||
expect(author.value).toBe('Alice')
|
||||
expect(quote.value).toBe('Awesome!')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import enMessages from '@/messages/en.json'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
|
||||
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||
|
||||
describe('BuilderClientPage - Inspector panel', () => {
|
||||
it('섹션 선택 후 인스펙터에서 Hero heading을 편집할 수 있다', () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<BuilderClientPage />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
|
||||
// Hero 섹션 추가 (data-testid 사용)
|
||||
fireEvent.click(screen.getByTestId('btn-add-hero'))
|
||||
|
||||
// 캔버스에서 첫 섹션 선택
|
||||
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||
|
||||
// 인스펙터에서 Hero Heading 입력 존재
|
||||
const heading = screen.getByLabelText('Hero Heading') as HTMLInputElement
|
||||
expect(heading).toBeInTheDocument()
|
||||
// 기본값 존재(Welcome)
|
||||
expect(heading.value).toBeTruthy()
|
||||
|
||||
// 값 변경
|
||||
fireEvent.change(heading, { target: { value: 'Hello World' } })
|
||||
expect((screen.getByLabelText('Hero Heading') as HTMLInputElement).value).toBe('Hello World')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import BuilderClientPage from './BuilderClientPage'
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BuilderClientPage - Inspector helptexts (Hero/CTA/FAQ)', () => {
|
||||
test('Hero, CTA, FAQ helptexts render', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
// add hero and select it from canvas
|
||||
screen.getByTestId('card-hero').click()
|
||||
const sec0 = await screen.findByTestId('sec-item-0')
|
||||
within(sec0).getByRole('button', { name: /Select/ }).click()
|
||||
expect(await screen.findByText(/Main heading displayed prominently/i)).toBeInTheDocument()
|
||||
|
||||
// add cta and select it from canvas
|
||||
screen.getByTestId('card-cta').click()
|
||||
const sec1 = await screen.findByTestId('sec-item-1')
|
||||
within(sec1).getByRole('button', { name: /Select/ }).click()
|
||||
expect(await screen.findByLabelText('CTA Text')).toBeInTheDocument()
|
||||
expect(await screen.findByLabelText('CTA Href')).toBeInTheDocument()
|
||||
|
||||
// add faq and select it from canvas
|
||||
screen.getByTestId('card-faq').click()
|
||||
const sec2 = await screen.findByTestId('sec-item-2')
|
||||
within(sec2).getByRole('button', { name: /Select/ }).click()
|
||||
expect(await screen.findByText(/Add at least one FAQ item with Q and A/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import BuilderClientPage from './BuilderClientPage'
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Inspector keyboard UX - Features & Testimonials add item', () => {
|
||||
test('Features: Space/Enter activates Add Feature Item', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
// Add features section and select it
|
||||
fireEvent.click(screen.getByTestId('card-features'))
|
||||
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||
|
||||
const addBtn = screen.getByRole('button', { name: /Add Feature Item/i })
|
||||
|
||||
// Space key
|
||||
addBtn.focus()
|
||||
fireEvent.keyDown(addBtn, { key: ' ', code: 'Space', charCode: 32 })
|
||||
// Expect first input to appear (Feature 0)
|
||||
expect(screen.getByLabelText('Feature 0')).toBeInTheDocument()
|
||||
|
||||
// Enter key adds another
|
||||
addBtn.focus()
|
||||
fireEvent.keyDown(addBtn, { key: 'Enter', code: 'Enter', charCode: 13 })
|
||||
expect(screen.getByLabelText('Feature 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Testimonials: Space/Enter activates Add Testimonial', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
// Add testimonials section and select it
|
||||
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||
|
||||
const addBtn = screen.getByRole('button', { name: /Add Testimonial/i })
|
||||
|
||||
// Space
|
||||
addBtn.focus()
|
||||
fireEvent.keyDown(addBtn, { key: ' ', code: 'Space', charCode: 32 })
|
||||
expect(screen.getByLabelText('Author 0')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Quote 0')).toBeInTheDocument()
|
||||
|
||||
// Enter adds another
|
||||
addBtn.focus()
|
||||
fireEvent.keyDown(addBtn, { key: 'Enter', code: 'Enter', charCode: 13 })
|
||||
expect(screen.getByLabelText('Author 1')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Quote 1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import BuilderClientPage from './BuilderClientPage'
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Inspector keyboard UX - Remove actions', () => {
|
||||
test('Features: Remove with Space/Enter', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
// Add features and select
|
||||
fireEvent.click(screen.getByTestId('card-features'))
|
||||
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||
|
||||
// Add two items
|
||||
const addBtn = screen.getByRole('button', { name: /Add Feature Item/i })
|
||||
let inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
|
||||
const beforeAdd = inputs.length
|
||||
fireEvent.click(addBtn)
|
||||
fireEvent.click(addBtn)
|
||||
inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
|
||||
expect(inputs.length).toBe(beforeAdd + 2)
|
||||
|
||||
// Remove last item via Space
|
||||
inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
|
||||
const beforeRemoveA = inputs.length
|
||||
let removeButtons = screen.queryAllByRole('button', { name: /Remove Feature \d+/ })
|
||||
const rmLastA = removeButtons[removeButtons.length - 1]
|
||||
rmLastA.focus()
|
||||
fireEvent.keyUp(rmLastA, { key: ' ', code: 'Space', charCode: 32 })
|
||||
await waitFor(() => {
|
||||
const now = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
|
||||
expect(now.length).toBe(beforeRemoveA - 1)
|
||||
})
|
||||
|
||||
// Add another then remove last via Enter
|
||||
fireEvent.click(addBtn)
|
||||
inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
|
||||
const beforeRemoveB = inputs.length
|
||||
removeButtons = screen.queryAllByRole('button', { name: /Remove Feature \d+/ })
|
||||
const rmLastB = removeButtons[removeButtons.length - 1]
|
||||
rmLastB.focus()
|
||||
fireEvent.keyUp(rmLastB, { key: 'Enter', code: 'Enter', charCode: 13 })
|
||||
await waitFor(() => {
|
||||
const now = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
|
||||
expect(now.length).toBe(beforeRemoveB - 1)
|
||||
})
|
||||
})
|
||||
|
||||
test('Testimonials: Remove with Space/Enter', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
// Add testimonials and select
|
||||
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||
|
||||
// Add two items
|
||||
const addBtn = screen.getByRole('button', { name: /Add Testimonial/i })
|
||||
let authors = screen.queryAllByRole('textbox', { name: /Author \d+/ })
|
||||
let quotes = screen.queryAllByRole('textbox', { name: /Quote \d+/ })
|
||||
const beforeAddT = authors.length
|
||||
fireEvent.click(addBtn)
|
||||
fireEvent.click(addBtn)
|
||||
authors = screen.queryAllByRole('textbox', { name: /Author \d+/ })
|
||||
quotes = screen.queryAllByRole('textbox', { name: /Quote \d+/ })
|
||||
expect(authors.length).toBe(beforeAddT + 2)
|
||||
expect(quotes.length).toBe(beforeAddT + 2)
|
||||
|
||||
// Remove last item via Enter
|
||||
authors = screen.queryAllByRole('textbox', { name: /Author \d+/ })
|
||||
let beforeRemoveT = authors.length
|
||||
let rmTButtons = screen.queryAllByRole('button', { name: /Remove Testimonial \d+/ })
|
||||
let rmLastT = rmTButtons[rmTButtons.length - 1]
|
||||
rmLastT.focus()
|
||||
fireEvent.keyUp(rmLastT, { key: 'Enter', code: 'Enter', charCode: 13 })
|
||||
await waitFor(() => {
|
||||
const aNow = screen.queryAllByRole('textbox', { name: /Author \d+/ })
|
||||
expect(aNow.length).toBe(beforeRemoveT - 1)
|
||||
})
|
||||
|
||||
// Add another then remove last via Space
|
||||
fireEvent.click(addBtn)
|
||||
authors = screen.queryAllByRole('textbox', { name: /Author \d+/ })
|
||||
beforeRemoveT = authors.length
|
||||
rmTButtons = screen.queryAllByRole('button', { name: /Remove Testimonial \d+/ })
|
||||
rmLastT = rmTButtons[rmTButtons.length - 1]
|
||||
rmLastT.focus()
|
||||
fireEvent.keyUp(rmLastT, { key: ' ', code: 'Space', charCode: 32 })
|
||||
await waitFor(() => {
|
||||
const aNow = screen.queryAllByRole('textbox', { name: /Author \d+/ })
|
||||
expect(aNow.length).toBe(beforeRemoveT - 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { describe, expect, test, beforeEach } from 'vitest'
|
||||
import BuilderClientPage from './BuilderClientPage'
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BuilderClientPage - preview viewport persistence & placeholders', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
test('persists viewport selection in localStorage', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
const container = screen.getByTestId('preview-container')
|
||||
expect(container).toHaveClass('w-[768px]')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Mobile/i }))
|
||||
expect(container).toHaveClass('w-[375px]')
|
||||
|
||||
// remount
|
||||
cleanup()
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
const container2 = screen.getByTestId('preview-container')
|
||||
expect(container2).toHaveClass('w-[375px]')
|
||||
})
|
||||
|
||||
test('inspector inputs have helpful placeholders', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('SEO Title')).toHaveAttribute('placeholder')
|
||||
expect(screen.getByLabelText('SEO Description')).toHaveAttribute('placeholder')
|
||||
expect(screen.getByLabelText('OG Image URL')).toHaveAttribute('placeholder')
|
||||
expect(screen.getByLabelText('GA4 Measurement ID')).toHaveAttribute('placeholder')
|
||||
expect(screen.getByLabelText('Meta Pixel ID')).toHaveAttribute('placeholder')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import BuilderClientPage from './BuilderClientPage'
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BuilderClientPage - preview viewport toggle', () => {
|
||||
test('toggles preview width between mobile and desktop', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
const container = screen.getByTestId('preview-container')
|
||||
// default is desktop
|
||||
expect(container).toHaveClass('w-[768px]')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Mobile/i }))
|
||||
expect(container).toHaveClass('w-[375px]')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Desktop/i }))
|
||||
expect(container).toHaveClass('w-[768px]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import enMessages from '@/messages/en.json'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
|
||||
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||
|
||||
describe('BuilderClientPage - SectionCanvas integration', () => {
|
||||
it('섹션을 추가하고 이동/삭제가 동작한다', () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<BuilderClientPage />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
|
||||
// 섹션 3개 추가: Hero, FAQ, CTA (data-testid 사용)
|
||||
fireEvent.click(screen.getByTestId('btn-add-hero'))
|
||||
fireEvent.click(screen.getByTestId('btn-add-faq'))
|
||||
fireEvent.click(screen.getByTestId('btn-add-cta'))
|
||||
|
||||
const canvas = screen.getByTestId('section-canvas')
|
||||
expect(canvas).toBeInTheDocument()
|
||||
|
||||
// 초기 순서 확인: hero, faq, cta
|
||||
let items = canvas.querySelectorAll('li')
|
||||
expect(items.length).toBe(3)
|
||||
expect(items[0].textContent).toMatch(/hero/)
|
||||
expect(items[1].textContent).toMatch(/faq/)
|
||||
expect(items[2].textContent).toMatch(/cta/)
|
||||
|
||||
// 두 번째 항목(faq)을 위로 이동 (Up 1)
|
||||
fireEvent.click(screen.getByLabelText('Up 1'))
|
||||
items = canvas.querySelectorAll('li')
|
||||
expect(items[0].textContent).toMatch(/faq/)
|
||||
expect(items[1].textContent).toMatch(/hero/)
|
||||
|
||||
// 마지막 항목(cta)을 위로 이동 (Up 2)
|
||||
fireEvent.click(screen.getByLabelText('Up 2'))
|
||||
items = canvas.querySelectorAll('li')
|
||||
expect(items[0].textContent).toMatch(/faq/)
|
||||
expect(items[1].textContent).toMatch(/cta/)
|
||||
expect(items[2].textContent).toMatch(/hero/)
|
||||
|
||||
// 가운데 항목(cta) 삭제 (Remove 1)
|
||||
fireEvent.click(screen.getByLabelText('Remove 1'))
|
||||
items = canvas.querySelectorAll('li')
|
||||
expect(items.length).toBe(2)
|
||||
expect(items[0].textContent).toMatch(/faq/)
|
||||
expect(items[1].textContent).toMatch(/hero/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import BuilderClientPage from './BuilderClientPage'
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SEO/Analytics inspector', () => {
|
||||
test('edits SEO title/description and shows in preview', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
const seoTitle = screen.getByLabelText('SEO Title')
|
||||
const seoDesc = screen.getByLabelText('SEO Description')
|
||||
|
||||
fireEvent.change(seoTitle, { target: { value: 'Landing SEO' } })
|
||||
fireEvent.change(seoDesc, { target: { value: 'Great page' } })
|
||||
|
||||
expect(await screen.findByTestId('preview-seo-title')).toHaveTextContent('Landing SEO')
|
||||
expect(await screen.findByTestId('preview-seo-description')).toHaveTextContent('Great page')
|
||||
})
|
||||
|
||||
test('edits Analytics fields and shows in preview', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
const ga4 = screen.getByLabelText('GA4 Measurement ID')
|
||||
const mp = screen.getByLabelText('Meta Pixel ID')
|
||||
|
||||
fireEvent.change(ga4, { target: { value: 'G-TEST123' } })
|
||||
fireEvent.change(mp, { target: { value: '1234567890' } })
|
||||
|
||||
expect(await screen.findByTestId('preview-ga4')).toHaveTextContent('G-TEST123')
|
||||
expect(await screen.findByTestId('preview-meta-pixel')).toHaveTextContent('1234567890')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import BuilderClientPage from './BuilderClientPage'
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BuilderClientPage - SEO/Analytics helptexts', () => {
|
||||
test('renders helpful hints under SEO/Analytics/OG inputs', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
expect(screen.getByText(/Shown in browser title and social previews/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Used by search engines and OG description/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Absolute URL recommended/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Format: G-XXXXXXXXXX/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Numeric Page ID/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,698 @@
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildZip } from '@/lib/exporter/zip'
|
||||
import type { Section } from '@/lib/state/store'
|
||||
import { createFormStore, type FormState } from '@/lib/state/formStore'
|
||||
import FormBuilderPanel from '@/components/FormBuilderPanel'
|
||||
import BuilderShell from '@/components/BuilderShell'
|
||||
import SectionCanvas from '@/components/SectionCanvas'
|
||||
import SidebarComponents from '@/components/SidebarComponents'
|
||||
|
||||
function download(filename: string, data: Uint8Array) {
|
||||
const ab = data.slice().buffer as ArrayBuffer
|
||||
const blob = new Blob([ab], { type: 'application/zip' })
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const w = window as unknown as { __captureExport?: boolean; __lastBlob?: Blob }
|
||||
if (w.__captureExport) {
|
||||
w.__lastBlob = blob
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export default function BuilderClientPage() {
|
||||
const t = useTranslations()
|
||||
const [locale, setLocale] = useState<'en' | 'ko'>('en')
|
||||
const [title, setTitle] = useState('My Landing')
|
||||
const [primaryColor, setPrimaryColor] = useState('#0ea5e9')
|
||||
const [fontFamily, setFontFamily] = useState('Inter')
|
||||
const [sections, setSections] = useState<Section[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
const [seoTitle, setSeoTitle] = useState<string>('')
|
||||
const [seoDescription, setSeoDescription] = useState<string>('')
|
||||
const [ogImage, setOgImage] = useState<string>('')
|
||||
const [ga4MeasurementId, setGa4MeasurementId] = useState<string>('')
|
||||
const [metaPixelId, setMetaPixelId] = useState<string>('')
|
||||
const [viewport, setViewport] = useState<'desktop' | 'mobile'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const saved = localStorage.getItem('builder.viewport') as 'desktop' | 'mobile' | null
|
||||
if (saved === 'desktop' || saved === 'mobile') return saved
|
||||
} catch {}
|
||||
}
|
||||
return 'desktop'
|
||||
})
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const assetManagerRef = useRef<AssetManager>(
|
||||
new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'] })
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem('builder.viewport', viewport)
|
||||
} catch {}
|
||||
}, [viewport])
|
||||
const [am, setAm] = useState<AssetManager | null>(null)
|
||||
useEffect(() => {
|
||||
setAm(assetManagerRef.current)
|
||||
}, [])
|
||||
const [selectedFileName, setSelectedFileName] = useState<string>('')
|
||||
|
||||
// form store (stable instance without ref access in render)
|
||||
const formStore = useMemo(() => createFormStore(), [])
|
||||
const formState: FormState = useSyncExternalStore(
|
||||
formStore.subscribe,
|
||||
formStore.getState,
|
||||
formStore.getState
|
||||
)
|
||||
|
||||
const addHero = useCallback(async () => {
|
||||
const fileEl = fileInputRef.current
|
||||
if (fileEl && fileEl.files && fileEl.files.length > 0) {
|
||||
const file = fileEl.files[0]
|
||||
const buf = new Uint8Array(await file.arrayBuffer())
|
||||
if (am) {
|
||||
await am.add({ name: file.name, type: file.type, bytes: buf })
|
||||
}
|
||||
setSelectedFileName(file.name)
|
||||
}
|
||||
// 파일이 없어도 Hero 섹션은 추가(이미지는 placeholder 사용)
|
||||
const hero: Section = { type: 'hero', props: { heading: 'Welcome', subheading: '' } }
|
||||
setSections((prev) => [...prev, hero])
|
||||
}, [am])
|
||||
|
||||
const addFaq = useCallback(() => {
|
||||
const faq: Section = { type: 'faq', props: { items: [] } }
|
||||
setSections((prev) => [...prev, faq])
|
||||
}, [])
|
||||
|
||||
const addCta = useCallback(() => {
|
||||
const cta: Section = { type: 'cta', props: { text: 'Get Started', href: '#' } }
|
||||
setSections((prev) => [...prev, cta])
|
||||
}, [])
|
||||
|
||||
const addFeatures = useCallback(() => {
|
||||
const features: Section = { type: 'features', props: { items: ['Feature A', 'Feature B', 'Feature C'] } }
|
||||
setSections((prev) => [...prev, features])
|
||||
}, [])
|
||||
|
||||
const addTestimonials = useCallback(() => {
|
||||
const testimonials: Section = {
|
||||
type: 'testimonials',
|
||||
props: { items: [{ author: 'Jane', quote: 'Great product!' }, { author: 'John', quote: 'Loved it.' }] },
|
||||
}
|
||||
setSections((prev) => [...prev, testimonials])
|
||||
}, [])
|
||||
|
||||
const removeAt = useCallback((idx: number) => {
|
||||
setSections((prev) => prev.filter((_, i) => i !== idx))
|
||||
}, [])
|
||||
|
||||
const moveSection = useCallback((from: number, to: number) => {
|
||||
setSections((prev) => {
|
||||
if (from < 0 || to < 0 || from >= prev.length || to >= prev.length) return prev
|
||||
const next = prev.slice()
|
||||
const [sp] = next.splice(from, 1)
|
||||
next.splice(to, 0, sp)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const pageData = useMemo<Page>(() => {
|
||||
const firstName = selectedFileName
|
||||
|
||||
const mappedSections = sections.map((s: Section) => {
|
||||
if (s.type === 'hero') {
|
||||
return {
|
||||
type: 'hero' as const,
|
||||
props: {
|
||||
heading: s.props?.heading || 'Welcome',
|
||||
subheading: s.props?.subheading || '',
|
||||
imageUrl: firstName ? `local:${firstName}` : 'https://via.placeholder.com/800x400',
|
||||
},
|
||||
}
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
return ({
|
||||
title,
|
||||
locale,
|
||||
theme: { primaryColor, fontFamily },
|
||||
sections: (mappedSections as unknown) as Page['sections'],
|
||||
form: formState,
|
||||
analytics: { ga4MeasurementId, metaPixelId, customHeadHtml: '' },
|
||||
seo: { title: seoTitle || title, description: (seoDescription && seoDescription.trim()) ? seoDescription : 'Landing page', ogImage: (ogImage && ogImage.trim()) ? ogImage : undefined },
|
||||
} as unknown) as Page
|
||||
}, [title, locale, primaryColor, fontFamily, sections, formState, selectedFileName, seoTitle, seoDescription, ga4MeasurementId, metaPixelId, ogImage])
|
||||
|
||||
// test-only: expose preview exporter for E2E to read exported HTML/CSS/JS directly
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const w = window as unknown as { __exportPreview?: () => { html: string; css: string; js: string } }
|
||||
w.__exportPreview = () => {
|
||||
try {
|
||||
return exportPage(pageSchema.parse(pageData), { assetManager: am ?? undefined })
|
||||
} catch {
|
||||
const fixed: Page = {
|
||||
...pageData,
|
||||
form: {
|
||||
actionUrl: pageData.form?.actionUrl ?? '',
|
||||
fields: pageData.form?.fields ?? [],
|
||||
spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
},
|
||||
}
|
||||
return exportPage(pageSchema.parse(fixed), { assetManager: am ?? undefined })
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}, [pageData, am])
|
||||
|
||||
const doExport = useCallback(async () => {
|
||||
const valid = pageSchema.parse(pageData)
|
||||
const exported = exportPage(valid, { assetManager: am ?? undefined })
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const w = window as unknown as { __captureExport?: boolean; __lastExport?: { html: string; css: string; js: string } }
|
||||
if (w.__captureExport) {
|
||||
w.__lastExport = exported
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
const assets = am ? am.toZipStructure() : {}
|
||||
const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets })
|
||||
download('landing.zip', zipBytes)
|
||||
}, [pageData, am])
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-2xl font-semibold">{t('builder.title')}</h1>
|
||||
|
||||
<BuilderShell
|
||||
left={
|
||||
<div className="space-y-4">
|
||||
<SidebarComponents
|
||||
onAddHero={addHero}
|
||||
onAddFAQ={addFaq}
|
||||
onAddCTA={addCta}
|
||||
onAddFeatures={addFeatures}
|
||||
onAddTestimonials={addTestimonials}
|
||||
/>
|
||||
<FormBuilderPanel store={formStore} />
|
||||
</div>
|
||||
}
|
||||
center={
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium" htmlFor="builder-title">{t('builder.field.title')}</label>
|
||||
<input id="builder-title" className="border rounded px-3 py-2 w-full" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium" htmlFor="builder-locale">{t('builder.field.locale')}</label>
|
||||
<select id="builder-locale" className="border rounded px-3 py-2 w-full" value={locale} onChange={(e) => setLocale(e.target.value as 'en' | 'ko')}>
|
||||
<option value="en">en</option>
|
||||
<option value="ko">ko</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium" htmlFor="builder-primary-color">{t('builder.field.primaryColor')}</label>
|
||||
<input id="builder-primary-color" type="color" className="border rounded px-2 py-2 w-full h-10" value={primaryColor} onChange={(e) => setPrimaryColor(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium" htmlFor="builder-font-family">{t('builder.field.fontFamily')}</label>
|
||||
<input id="builder-font-family" className="border rounded px-3 py-2 w-full" value={fontFamily} onChange={(e) => setFontFamily(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium" htmlFor="builder-hero-file">{t('builder.upload.hero')}</label>
|
||||
<input id="builder-hero-file" ref={fileInputRef} type="file" accept="image/*" className="block" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button data-testid="btn-add-hero" className="bg-sky-600 text-white rounded px-4 py-2" onClick={addHero}>{t('builder.action.addHero')}</button>
|
||||
<button data-testid="btn-add-faq" className="bg-gray-200 rounded px-4 py-2" onClick={addFaq}>{t('builder.action.addFAQ')}</button>
|
||||
<button data-testid="btn-add-cta" className="bg-gray-200 rounded px-4 py-2" onClick={addCta}>{t('builder.action.addCTA')}</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium">{t('builder.sections')}</label>
|
||||
<SectionCanvas
|
||||
sections={sections}
|
||||
onMove={moveSection}
|
||||
onRemove={removeAt}
|
||||
onSelect={(i) => setSelectedIndex(i)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="space-y-3">
|
||||
<button className="bg-emerald-600 text-white rounded px-4 py-2 w-full" onClick={doExport}>{t('builder.action.export')}</button>
|
||||
<div className="border rounded p-3 space-y-2">
|
||||
<h2 className="text-sm font-semibold mb-1">SEO / Analytics</h2>
|
||||
<label className="block" htmlFor="ins-seo-title">
|
||||
<span className="sr-only">SEO Title</span>
|
||||
<input
|
||||
id="ins-seo-title"
|
||||
aria-label="SEO Title"
|
||||
aria-describedby="ins-seo-title-help"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
placeholder="e.g. Product landing page"
|
||||
value={seoTitle}
|
||||
onChange={(e) => setSeoTitle(e.target.value)}
|
||||
/>
|
||||
<p id="ins-seo-title-help" className="mt-1 text-xs text-gray-500">Shown in browser title and social previews</p>
|
||||
</label>
|
||||
<label className="block" htmlFor="ins-seo-desc">
|
||||
<span className="sr-only">SEO Description</span>
|
||||
<input
|
||||
id="ins-seo-desc"
|
||||
aria-label="SEO Description"
|
||||
aria-describedby="ins-seo-desc-help"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
placeholder="Short description shown in search/OG"
|
||||
value={seoDescription}
|
||||
onChange={(e) => setSeoDescription(e.target.value)}
|
||||
/>
|
||||
<p id="ins-seo-desc-help" className="mt-1 text-xs text-gray-500">Used by search engines and OG description</p>
|
||||
</label>
|
||||
<label className="block" htmlFor="ins-og-image">
|
||||
<span className="sr-only">OG Image URL</span>
|
||||
<input
|
||||
id="ins-og-image"
|
||||
aria-label="OG Image URL"
|
||||
aria-describedby="ins-og-image-help"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
placeholder="https://.../og-image.png"
|
||||
value={ogImage}
|
||||
onChange={(e) => setOgImage(e.target.value)}
|
||||
/>
|
||||
<p id="ins-og-image-help" className="mt-1 text-xs text-gray-500">Absolute URL recommended</p>
|
||||
</label>
|
||||
<label className="block" htmlFor="ins-ga4">
|
||||
<span className="sr-only">GA4 Measurement ID</span>
|
||||
<input
|
||||
id="ins-ga4"
|
||||
aria-label="GA4 Measurement ID"
|
||||
aria-describedby="ins-ga4-help"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
placeholder="G-XXXXXXXXXX"
|
||||
value={ga4MeasurementId}
|
||||
onChange={(e) => setGa4MeasurementId(e.target.value)}
|
||||
/>
|
||||
<p id="ins-ga4-help" className="mt-1 text-xs text-gray-500">Format: G-XXXXXXXXXX</p>
|
||||
</label>
|
||||
<label className="block" htmlFor="ins-meta-pixel">
|
||||
<span className="sr-only">Meta Pixel ID</span>
|
||||
<input
|
||||
id="ins-meta-pixel"
|
||||
aria-label="Meta Pixel ID"
|
||||
aria-describedby="ins-meta-pixel-help"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
placeholder="1234567890"
|
||||
value={metaPixelId}
|
||||
onChange={(e) => setMetaPixelId(e.target.value)}
|
||||
/>
|
||||
<p id="ins-meta-pixel-help" className="mt-1 text-xs text-gray-500">Numeric Page ID</p>
|
||||
</label>
|
||||
</div>
|
||||
<div className="border rounded p-3">
|
||||
<h2 className="text-sm font-semibold mb-2">Inspector</h2>
|
||||
{selectedIndex !== null && sections[selectedIndex] ? (
|
||||
(() => {
|
||||
const s = sections[selectedIndex]
|
||||
if (s.type === 'hero') {
|
||||
const heading = s.props?.heading ?? 'Welcome'
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block" htmlFor="ins-hero-heading">
|
||||
<span className="sr-only">Hero Heading</span>
|
||||
<input
|
||||
id="ins-hero-heading"
|
||||
aria-label="Hero Heading"
|
||||
aria-describedby="ins-hero-heading-help"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
value={heading}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setSections((prev) => prev.map((it, idx) => {
|
||||
if (idx !== selectedIndex) return it
|
||||
return { ...it, props: { ...(it.props || {}), heading: v } } as Section
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p id="ins-hero-heading-help" className="mt-1 text-xs text-gray-500">Main heading displayed prominently</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (s.type === 'cta') {
|
||||
const text = s.props?.text ?? 'Get Started'
|
||||
const href = s.props?.href ?? '#'
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block" htmlFor="ins-cta-text">
|
||||
<span className="sr-only">CTA Text</span>
|
||||
<input
|
||||
id="ins-cta-text"
|
||||
aria-label="CTA Text"
|
||||
aria-describedby="ins-cta-text-help"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setSections((prev) => prev.map((it, idx) => {
|
||||
if (idx !== selectedIndex) return it
|
||||
return { ...it, props: { ...(it.props || {}), text: v } } as Section
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p id="ins-cta-text-help" className="mt-1 text-xs text-gray-500">Button label and link for primary action</p>
|
||||
<label className="block" htmlFor="ins-cta-href">
|
||||
<span className="sr-only">CTA Href</span>
|
||||
<input
|
||||
id="ins-cta-href"
|
||||
aria-label="CTA Href"
|
||||
aria-describedby="ins-cta-href-help"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
value={href}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setSections((prev) => prev.map((it, idx) => {
|
||||
if (idx !== selectedIndex) return it
|
||||
return { ...it, props: { ...(it.props || {}), href: v } } as Section
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p id="ins-cta-href-help" className="mt-1 text-xs text-gray-500">Button label and link for primary action</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (s.type === 'faq') {
|
||||
const items: Array<{ q: string; a: string }> = Array.isArray(s.props?.items) ? (s.props.items as Array<{ q: string; a: string }>) : []
|
||||
const addItem = () => {
|
||||
const nextItems = [...items, { q: '', a: '' }]
|
||||
setSections((prev) => prev.map((it, idx) => {
|
||||
if (idx !== selectedIndex) return it
|
||||
return { ...it, props: { ...(it.props || {}), items: nextItems } } as Section
|
||||
}))
|
||||
}
|
||||
const updateItem = (i: number, patch: { q?: string; a?: string }) => {
|
||||
const nextItems = items.map((it: { q: string; a: string }, idx: number) => (idx === i ? { ...it, ...patch } : it))
|
||||
setSections((prev) => prev.map((it, idx) => {
|
||||
if (idx !== selectedIndex) return it
|
||||
return { ...it, props: { ...(it.props || {}), items: nextItems } } as Section
|
||||
}))
|
||||
}
|
||||
const removeItem = (i: number) => {
|
||||
const nextItems = items.filter((_: { q: string; a: string }, idx: number) => idx !== i)
|
||||
setSections((prev) => prev.map((it, idx) => {
|
||||
if (idx !== selectedIndex) return it
|
||||
return { ...it, props: { ...(it.props || {}), items: nextItems } } as Section
|
||||
}))
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button type="button" className="px-2 py-1 border rounded text-sm" title="Add FAQ Item" onClick={addItem}>Add FAQ Item</button>
|
||||
<p id="ins-faq-help" className="text-xs text-gray-500">Add at least one FAQ item with Q and A</p>
|
||||
<ul className="space-y-2">
|
||||
{items.map((it: { q: string; a: string }, i: number) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<label className="block" htmlFor={`ins-faq-q-${i}`}>
|
||||
<span className="sr-only">FAQ Q {i}</span>
|
||||
<input
|
||||
id={`ins-faq-q-${i}`}
|
||||
aria-label={`FAQ Q ${i}`}
|
||||
aria-describedby="ins-faq-help"
|
||||
className="border rounded px-2 py-1 w-full"
|
||||
value={it.q ?? ''}
|
||||
onChange={(e) => updateItem(i, { q: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block" htmlFor={`ins-faq-a-${i}`}>
|
||||
<span className="sr-only">FAQ A {i}</span>
|
||||
<input
|
||||
id={`ins-faq-a-${i}`}
|
||||
aria-label={`FAQ A ${i}`}
|
||||
aria-describedby="ins-faq-help"
|
||||
className="border rounded px-2 py-1 w-full"
|
||||
value={it.a ?? ''}
|
||||
onChange={(e) => updateItem(i, { a: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" aria-label={`Remove FAQ ${i}`} className="px-2 py-1 border rounded text-sm text-red-600" onClick={() => removeItem(i)}>Remove</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (s.type === 'features') {
|
||||
const items: string[] = Array.isArray(s.props?.items) ? (s.props.items as string[]) : []
|
||||
const addItem = () => {
|
||||
const nextItems = [...items, '']
|
||||
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
|
||||
}
|
||||
const updateItem = (i: number, v: string) => {
|
||||
const nextItems = items.map((it, idx) => (idx === i ? v : it))
|
||||
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
|
||||
}
|
||||
const removeItem = (i: number) => {
|
||||
const nextItems = items.filter((_, idx) => idx !== i)
|
||||
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button type="button" className="px-2 py-1 border rounded text-sm" title="Add Feature Item" onClick={addItem}>Add Feature Item</button>
|
||||
<p id="ins-features-help" className="text-xs text-gray-500">Describe a single feature per line</p>
|
||||
<ul className="space-y-2">
|
||||
{items.map((val, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
<label className="block flex-1" htmlFor={`ins-feature-${i}`}>
|
||||
<span className="sr-only">Feature {i}</span>
|
||||
<input
|
||||
id={`ins-feature-${i}`}
|
||||
aria-label={`Feature ${i}`}
|
||||
aria-describedby="ins-features-help"
|
||||
className="border rounded px-2 py-1 w-full"
|
||||
value={val}
|
||||
onChange={(e) => updateItem(i, e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove Feature ${i}`}
|
||||
title={`Remove Feature ${i}`}
|
||||
className="px-2 py-1 border rounded text-sm text-red-600"
|
||||
onClick={() => removeItem(i)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (s.type === 'testimonials') {
|
||||
const items: Array<{ author: string; quote: string }> = Array.isArray(s.props?.items)
|
||||
? (s.props.items as Array<{ author: string; quote: string }>)
|
||||
: []
|
||||
const addItem = () => {
|
||||
const nextItems = [...items, { author: '', quote: '' }]
|
||||
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
|
||||
}
|
||||
const updateItem = (i: number, patch: Partial<{ author: string; quote: string }>) => {
|
||||
const nextItems = items.map((it, idx) => (idx === i ? { ...it, ...patch } : it))
|
||||
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
|
||||
}
|
||||
const removeItem = (i: number) => {
|
||||
const nextItems = items.filter((_, idx) => idx !== i)
|
||||
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button type="button" className="px-2 py-1 border rounded text-sm" title="Add Testimonial" onClick={addItem}>Add Testimonial</button>
|
||||
<p id="ins-testimonials-help" className="text-xs text-gray-500">Add author and quote for each testimonial</p>
|
||||
<ul className="space-y-2">
|
||||
{items.map((it, i) => (
|
||||
<li key={i} className="grid grid-cols-2 gap-2 items-start">
|
||||
<label className="block" htmlFor={`ins-author-${i}`}>
|
||||
<span className="sr-only">Author {i}</span>
|
||||
<input
|
||||
id={`ins-author-${i}`}
|
||||
aria-label={`Author ${i}`}
|
||||
aria-describedby="ins-testimonials-help"
|
||||
className="border rounded px-2 py-1 w-full"
|
||||
value={it.author}
|
||||
onChange={(e) => updateItem(i, { author: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block" htmlFor={`ins-quote-${i}`}>
|
||||
<span className="sr-only">Quote {i}</span>
|
||||
<input
|
||||
id={`ins-quote-${i}`}
|
||||
aria-label={`Quote ${i}`}
|
||||
aria-describedby="ins-testimonials-help"
|
||||
className="border rounded px-2 py-1 w-full"
|
||||
value={it.quote}
|
||||
onChange={(e) => updateItem(i, { quote: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove Testimonial ${i}`}
|
||||
title={`Remove Testimonial ${i}`}
|
||||
className="px-2 py-1 border rounded text-sm text-red-600"
|
||||
onClick={() => removeItem(i)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className="text-xs text-gray-500">No editable fields</div>
|
||||
})()
|
||||
) : (
|
||||
<div className="text-xs text-gray-500">Nothing selected</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border rounded p-3">
|
||||
<h2 className="text-sm font-semibold mb-2">{t('builder.preview')}</h2>
|
||||
<p id="preview-toggle-help" className="sr-only">Toggle preview viewport for the builder preview area</p>
|
||||
<div className="mb-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Desktop"
|
||||
aria-pressed={viewport === 'desktop'}
|
||||
aria-controls="preview-container"
|
||||
aria-describedby="preview-toggle-help"
|
||||
className={`rounded px-2 py-1 text-sm transition-colors border focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 ${
|
||||
viewport === 'desktop'
|
||||
? 'bg-sky-600 text-white border-sky-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setViewport('desktop')}
|
||||
>
|
||||
Desktop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Mobile"
|
||||
aria-pressed={viewport === 'mobile'}
|
||||
aria-controls="preview-container"
|
||||
aria-describedby="preview-toggle-help"
|
||||
className={`rounded px-2 py-1 text-sm transition-colors border focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 ${
|
||||
viewport === 'mobile'
|
||||
? 'bg-sky-600 text-white border-sky-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setViewport('mobile')}
|
||||
>
|
||||
Mobile
|
||||
</button>
|
||||
</div>
|
||||
<div className="prose max-w-none">
|
||||
<div
|
||||
data-testid="preview-container"
|
||||
className={`border rounded p-3 ${viewport === 'desktop' ? 'w-[768px]' : 'w-[375px]'} mx-auto`}
|
||||
>
|
||||
<div className="mb-2"><strong>Title:</strong> {pageData.title}</div>
|
||||
<div className="mb-2"><strong>Locale:</strong> {pageData.locale}</div>
|
||||
<div className="mb-2"><strong>SEO Title:</strong> <span data-testid="preview-seo-title">{pageData.seo.title}</span></div>
|
||||
<div className="mb-2"><strong>SEO Description:</strong> <span data-testid="preview-seo-description">{pageData.seo.description}</span></div>
|
||||
<div className="mb-2"><strong>OG Image:</strong> <span data-testid="preview-og-image">{pageData.seo.ogImage || ''}</span></div>
|
||||
<div className="mb-2"><strong>GA4:</strong> <span data-testid="preview-ga4">{pageData.analytics?.ga4MeasurementId || ''}</span></div>
|
||||
<div className="mb-2"><strong>Meta Pixel:</strong> <span data-testid="preview-meta-pixel">{pageData.analytics?.metaPixelId || ''}</span></div>
|
||||
<div className="space-y-2">
|
||||
{sections.map((s: Section, i: number) => {
|
||||
switch (s.type) {
|
||||
case 'hero':
|
||||
return (
|
||||
<div key={i} className="text-sm text-gray-700">• hero — heading: {s.props?.heading || 'Welcome'}</div>
|
||||
)
|
||||
case 'faq': {
|
||||
const count = s.props.items?.length || 0
|
||||
return (
|
||||
<div key={i} className="text-sm text-gray-700">• faq — items: {count}</div>
|
||||
)
|
||||
}
|
||||
case 'cta': {
|
||||
const text = s.props.text || 'CTA'
|
||||
const href = s.props.href || '#'
|
||||
return (
|
||||
<div key={i} className="text-sm text-gray-700">• cta — {text} → {href}</div>
|
||||
)
|
||||
}
|
||||
case 'features': {
|
||||
const count = s.props.items?.length || 0
|
||||
return (
|
||||
<div key={i} className="text-sm text-gray-700">• features — items: {count}</div>
|
||||
)
|
||||
}
|
||||
case 'testimonials': {
|
||||
const count = s.props.items?.length || 0
|
||||
return (
|
||||
<div key={i} className="text-sm text-gray-700">• testimonials — items: {count}</div>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
<ul className="list-disc pl-5 mt-2">
|
||||
{formState.fields.map((f, idx) => (
|
||||
<li key={`${f.name}-${idx}`} className="text-sm">
|
||||
{f.label} ({f.name}) {f.required ? 'required' : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import BuilderShell from '@/components/BuilderShell'
|
||||
|
||||
describe('BuilderShell', () => {
|
||||
it('좌/중/우 3패널과 접근성 라벨을 렌더링한다', () => {
|
||||
render(
|
||||
<BuilderShell left={<div>LEFT</div>} center={<div>CENTER</div>} right={<div>RIGHT</div>} />
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('builder-shell')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Components Panel')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Canvas')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Inspector')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export default function BuilderShell({
|
||||
left,
|
||||
center,
|
||||
right,
|
||||
}: {
|
||||
left: React.ReactNode
|
||||
center: React.ReactNode
|
||||
right: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full w-full grid grid-cols-1 md:grid-cols-[280px_1fr_320px] gap-4 p-4" data-testid="builder-shell">
|
||||
<aside className="border rounded p-3 min-h-[200px]" aria-label="Components Panel" data-testid="panel-left">
|
||||
{left}
|
||||
</aside>
|
||||
<main className="border rounded p-3 min-h-[200px]" aria-label="Canvas" data-testid="panel-center">
|
||||
{center}
|
||||
</main>
|
||||
<aside className="border rounded p-3 min-h-[200px]" aria-label="Inspector" data-testid="panel-right">
|
||||
{right}
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { expect, test } from 'vitest'
|
||||
import { axe, toHaveNoViolations } from 'jest-axe'
|
||||
import FormBuilderPanel from './FormBuilderPanel'
|
||||
import { createFormStore } from '@/lib/state/formStore'
|
||||
|
||||
expect.extend(toHaveNoViolations)
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
test('FormBuilderPanel has no obvious a11y violations', async () => {
|
||||
const store = createFormStore()
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<FormBuilderPanel store={store} />
|
||||
</Wrapper>
|
||||
)
|
||||
const results = await axe(container)
|
||||
expect(results).toHaveNoViolations()
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import FormBuilderPanel from '@/components/FormBuilderPanel'
|
||||
import { createFormStore } from '@/lib/state/formStore'
|
||||
import type { SelectField, RadioField } from '@/lib/state/formStore'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import enMessages from '@/messages/en.json'
|
||||
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||
|
||||
describe('FormBuilderPanel', () => {
|
||||
it('필드 추가와 action URL/스팸 옵션 변경을 수행한다', () => {
|
||||
const store = createFormStore()
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<FormBuilderPanel store={store} />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
|
||||
// 초기 상태
|
||||
expect(screen.queryAllByTestId('field-item').length).toBe(0)
|
||||
|
||||
// 필드 추가 2개 (번역 키 이슈 회피: 첫 번째 버튼 클릭)
|
||||
fireEvent.click(screen.getAllByRole('button')[0])
|
||||
fireEvent.click(screen.getAllByRole('button')[0])
|
||||
expect(screen.queryAllByTestId('field-item').length).toBe(2)
|
||||
|
||||
// action URL 입력
|
||||
const actionInput = screen.getByLabelText('Action URL') as HTMLInputElement
|
||||
fireEvent.change(actionInput, { target: { value: 'https://example.com/form' } })
|
||||
expect(actionInput.value).toBe('https://example.com/form')
|
||||
expect(store.getState().actionUrl).toBe('https://example.com/form')
|
||||
|
||||
// 스팸 옵션 - honeypot 변경
|
||||
const hpInput = screen.getByLabelText('Honeypot Field') as HTMLInputElement
|
||||
fireEvent.change(hpInput, { target: { value: '_trap' } })
|
||||
expect(store.getState().spam.honeypotFieldName).toBe('_trap')
|
||||
|
||||
// 스팸 옵션 - min seconds 변경
|
||||
const tsInput = screen.getByLabelText('Min Submit Seconds') as HTMLInputElement
|
||||
fireEvent.change(tsInput, { target: { value: '5' } })
|
||||
expect(store.getState().spam.minSubmitSeconds).toBe(5)
|
||||
})
|
||||
|
||||
it('pattern과 maxLength 편집이 상태에 반영된다', () => {
|
||||
const store = createFormStore()
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<FormBuilderPanel store={store} />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
|
||||
// 텍스트 필드 1개 추가
|
||||
fireEvent.click(screen.getAllByRole('button')[0])
|
||||
|
||||
const pat0 = screen.getByLabelText('Pattern 0') as HTMLInputElement
|
||||
fireEvent.change(pat0, { target: { value: '^[A-Za-z ]+$' } })
|
||||
expect(store.getState().fields[0].pattern).toBe('^[A-Za-z ]+$')
|
||||
|
||||
const max0 = screen.getByLabelText('MaxLength 0') as HTMLInputElement
|
||||
fireEvent.change(max0, { target: { value: '50' } })
|
||||
expect(store.getState().fields[0].maxLength).toBe(50)
|
||||
})
|
||||
|
||||
it('select/radio 옵션 편집이 동작하고 추가 버튼으로 타입별 필드를 만든다', () => {
|
||||
const store = createFormStore()
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<FormBuilderPanel store={store} />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
|
||||
// select 추가 후 옵션 편집 (버튼 인덱스로 접근)
|
||||
const buttons1 = screen.getAllByRole('button')
|
||||
// 버튼 순서: Text, Email, Tel, Date, Textarea, Checkbox, Select, Radio
|
||||
fireEvent.click(buttons1[6])
|
||||
const opts0 = screen.getByLabelText('Options 0') as HTMLInputElement
|
||||
fireEvent.change(opts0, { target: { value: 'A,B,C' } })
|
||||
expect(store.getState().fields[0].type).toBe('select')
|
||||
expect((store.getState().fields[0] as SelectField).options).toEqual(['A','B','C'])
|
||||
|
||||
// radio 추가 후 옵션 편집
|
||||
const buttons2 = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons2[7])
|
||||
const opts1 = screen.getByLabelText('Options 1') as HTMLInputElement
|
||||
fireEvent.change(opts1, { target: { value: 'S,M,L' } })
|
||||
expect(store.getState().fields[1].type).toBe('radio')
|
||||
expect((store.getState().fields[1] as RadioField).options).toEqual(['S','M','L'])
|
||||
|
||||
// email/checkbox/textarea/date/tel도 추가 버튼 존재 (인덱스 기반 클릭)
|
||||
const buttons3 = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons3[1]) // Email
|
||||
fireEvent.click(buttons3[5]) // Checkbox
|
||||
fireEvent.click(buttons3[4]) // Textarea
|
||||
fireEvent.click(buttons3[3]) // Date
|
||||
fireEvent.click(buttons3[2]) // Tel
|
||||
|
||||
const names = store.getState().fields.map((f) => f.type)
|
||||
expect(names).toContain('email')
|
||||
expect(names).toContain('checkbox')
|
||||
expect(names).toContain('textarea')
|
||||
expect(names).toContain('date')
|
||||
expect(names).toContain('tel')
|
||||
})
|
||||
|
||||
it('필드 레이블/필수 편집, 이동/삭제가 동작한다', () => {
|
||||
const store = createFormStore()
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<FormBuilderPanel store={store} />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
|
||||
// 3개 추가
|
||||
fireEvent.click(screen.getAllByRole('button')[0])
|
||||
fireEvent.click(screen.getAllByRole('button')[0])
|
||||
fireEvent.click(screen.getAllByRole('button')[0])
|
||||
expect(screen.queryAllByTestId('field-item').length).toBe(3)
|
||||
|
||||
// 첫 필드 레이블 변경
|
||||
const label0 = screen.getByLabelText('Label 0') as HTMLInputElement
|
||||
fireEvent.change(label0, { target: { value: 'Name' } })
|
||||
expect(store.getState().fields[0].label).toBe('Name')
|
||||
|
||||
// 첫 필드 required 토글
|
||||
const req0 = screen.getByLabelText('Required 0') as HTMLInputElement
|
||||
fireEvent.click(req0)
|
||||
expect(store.getState().fields[0].required).toBe(true)
|
||||
|
||||
// 첫 필드를 아래로 이동 (0 -> 1)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Down 0' }))
|
||||
expect(store.getState().fields.map(f => f.name)).toEqual(['field2','field1','field3'])
|
||||
|
||||
// 두 번째(이전 첫) 필드 삭제
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove 1' }))
|
||||
expect(store.getState().fields.map(f => f.name)).toEqual(['field2','field3'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useSyncExternalStore } from 'react'
|
||||
import type { FormState, FormField } from '@/lib/state/formStore'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
type Store = {
|
||||
getState: () => FormState
|
||||
subscribe: (listener: () => void) => () => void
|
||||
}
|
||||
|
||||
export default function FormBuilderPanel({ store }: { store: Store }) {
|
||||
const t = useTranslations()
|
||||
const state = useSyncExternalStore(store.subscribe, store.getState, store.getState)
|
||||
|
||||
const onAddText = useCallback(() => {
|
||||
const idx = state.fields.length + 1
|
||||
const name = `field${idx}`
|
||||
store.getState().addField({ type: 'text', name, label: `Text ${idx}`, required: false })
|
||||
}, [state.fields.length, store])
|
||||
|
||||
const nextName = () => `field${state.fields.length + 1}`
|
||||
const onAddEmail = () => store.getState().addField({ type: 'email', name: nextName(), label: 'Email', required: false })
|
||||
const onAddTel = () => store.getState().addField({ type: 'tel', name: nextName(), label: 'Tel', required: false })
|
||||
const onAddDate = () => store.getState().addField({ type: 'date', name: nextName(), label: 'Date', required: false })
|
||||
const onAddTextarea = () => store.getState().addField({ type: 'textarea', name: nextName(), label: 'Message', required: false })
|
||||
const onAddCheckbox = () => store.getState().addField({ type: 'checkbox', name: nextName(), label: 'Agree', required: false })
|
||||
const onAddSelect = () => store.getState().addField({ type: 'select', name: nextName(), label: 'Select', required: false, options: [] })
|
||||
const onAddRadio = () => store.getState().addField({ type: 'radio', name: nextName(), label: 'Radio', required: false, options: [] })
|
||||
|
||||
const onChangeAction = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
store.getState().setActionUrl(e.target.value)
|
||||
}
|
||||
|
||||
const onChangeHp = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
store.getState().setSpam({ honeypotFieldName: e.target.value })
|
||||
}
|
||||
|
||||
const onChangeTs = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = parseInt(e.target.value || '0', 10)
|
||||
store.getState().setSpam({ minSubmitSeconds: Number.isNaN(v) ? 0 : v })
|
||||
}
|
||||
|
||||
const onChangeLabel = (i: number, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
store.getState().updateField(i, { label: e.target.value })
|
||||
}
|
||||
|
||||
const onToggleRequired = (i: number) => {
|
||||
const cur = store.getState().fields[i]?.required ?? false
|
||||
store.getState().updateField(i, { required: !cur })
|
||||
}
|
||||
|
||||
const onDown = (i: number) => {
|
||||
if (i < state.fields.length - 1) {
|
||||
store.getState().moveField(i, i + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const onRemove = (i: number) => {
|
||||
store.getState().removeField(i)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" data-testid="btn-add-text" onClick={onAddText} className="px-2 py-1 border rounded">{t('builder.action.addText')}</button>
|
||||
<button type="button" onClick={onAddEmail} className="px-2 py-1 border rounded">{t('builder.action.addEmail')}</button>
|
||||
<button type="button" onClick={onAddTel} className="px-2 py-1 border rounded">{t('builder.action.addTel')}</button>
|
||||
<button type="button" onClick={onAddDate} className="px-2 py-1 border rounded">{t('builder.action.addDate')}</button>
|
||||
<button type="button" onClick={onAddTextarea} className="px-2 py-1 border rounded">{t('builder.action.addTextarea')}</button>
|
||||
<button type="button" onClick={onAddCheckbox} className="px-2 py-1 border rounded">{t('builder.action.addCheckbox')}</button>
|
||||
<button type="button" onClick={onAddSelect} className="px-2 py-1 border rounded">{t('builder.action.addSelect')}</button>
|
||||
<button type="button" onClick={onAddRadio} className="px-2 py-1 border rounded">{t('builder.action.addRadio')}</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block" htmlFor="form-action-url">
|
||||
<span className="block text-sm">{t('builder.form.actionUrl')}</span>
|
||||
<input id="form-action-url" aria-label="Action URL" className="border px-2 py-1 w-full" value={state.actionUrl}
|
||||
onChange={onChangeAction} />
|
||||
</label>
|
||||
<label className="block" htmlFor="form-honeypot">
|
||||
<span className="block text-sm">{t('builder.form.honeypot')}</span>
|
||||
<input id="form-honeypot" aria-label="Honeypot Field" className="border px-2 py-1 w-full" value={state.spam.honeypotFieldName}
|
||||
onChange={onChangeHp} />
|
||||
</label>
|
||||
<label className="block" htmlFor="form-min-seconds">
|
||||
<span className="block text-sm">{t('builder.form.minSeconds')}</span>
|
||||
<input id="form-min-seconds" aria-label="Min Submit Seconds" type="number" className="border px-2 py-1 w-full" value={state.spam.minSubmitSeconds}
|
||||
onChange={onChangeTs} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{state.fields.map((f: FormField, i: number) => (
|
||||
<li key={`${f.name}-${i}`} data-testid="field-item" className="text-sm border rounded p-2 flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-gray-500">{f.type} - {f.name}</div>
|
||||
<label className="block" htmlFor={`field-label-${i}`}>
|
||||
<span className="sr-only">Label {i}</span>
|
||||
<input id={`field-label-${i}`} aria-label={`Label ${i}`} className="border px-2 py-1 w-full" value={f.label}
|
||||
onChange={(e) => onChangeLabel(i, e)} />
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 mt-1" htmlFor={`field-required-${i}`}>
|
||||
<input id={`field-required-${i}`} aria-label={`Required ${i}`} type="checkbox" checked={!!f.required}
|
||||
onChange={() => onToggleRequired(i)} />
|
||||
<span className="text-xs">required</span>
|
||||
</label>
|
||||
{(f.type === 'text' || f.type === 'email' || f.type === 'tel' || f.type === 'date' || f.type === 'textarea') && (
|
||||
<div className="mt-1 grid grid-cols-2 gap-2">
|
||||
<label className="block" htmlFor={`field-pattern-${i}`}>
|
||||
<span className="sr-only">Pattern {i}</span>
|
||||
<input
|
||||
id={`field-pattern-${i}`}
|
||||
aria-label={`Pattern ${i}`}
|
||||
className="border px-2 py-1 w-full"
|
||||
value={f.pattern ?? ''}
|
||||
onChange={(e) => store.getState().updateField(i, { pattern: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block" htmlFor={`field-maxlen-${i}`}>
|
||||
<span className="sr-only">MaxLength {i}</span>
|
||||
<input
|
||||
id={`field-maxlen-${i}`}
|
||||
aria-label={`MaxLength ${i}`}
|
||||
type="number"
|
||||
className="border px-2 py-1 w-full"
|
||||
value={f.maxLength ?? ''}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value || '0', 10)
|
||||
store.getState().updateField(i, { maxLength: Number.isNaN(v) ? undefined : v })
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{(f.type === 'select' || f.type === 'radio') && (
|
||||
<label className="block mt-1" htmlFor={`field-options-${i}`}>
|
||||
<span className="sr-only">Options {i}</span>
|
||||
<input id={`field-options-${i}`} aria-label={`Options ${i}`} className="border px-2 py-1 w-full"
|
||||
value={(f.type === 'select' || f.type === 'radio') ? f.options.join(',') : ''}
|
||||
onChange={(e) => {
|
||||
const opts = e.target.value.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
if (f.type === 'select' || f.type === 'radio') {
|
||||
store.getState().updateField(i, { options: opts } as Partial<FormField>)
|
||||
}
|
||||
}} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" aria-label={`Down ${i}`} onClick={() => onDown(i)} className="px-2 py-1 border rounded">Down {i}</button>
|
||||
<button type="button" aria-label={`Remove ${i}`} onClick={() => onRemove(i)} className="px-2 py-1 border rounded text-red-600">Remove {i}</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import React from 'react'
|
||||
|
||||
export default function I18nProvider({
|
||||
locale,
|
||||
messages,
|
||||
children,
|
||||
}: {
|
||||
locale: string
|
||||
messages: Record<string, string>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import SectionCanvas from './SectionCanvas'
|
||||
import type { Section } from '@/lib/state/store'
|
||||
|
||||
describe('SectionCanvas a11y', () => {
|
||||
test('renders dnd-kit live region and instructions', () => {
|
||||
const sections: Section[] = []
|
||||
render(
|
||||
<SectionCanvas
|
||||
sections={sections}
|
||||
onMove={() => {}}
|
||||
onRemove={() => {}}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
// dnd-kit screen reader instructions should be present in the DOM
|
||||
expect(screen.getByText(/To pick up a draggable item/i)).toBeInTheDocument()
|
||||
|
||||
// Live region for announcements should exist
|
||||
const liveRegion = screen.getByRole('status')
|
||||
expect(liveRegion).toHaveAttribute('aria-live', 'assertive')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import React, { useState } from 'react'
|
||||
import SectionCanvas from './SectionCanvas'
|
||||
import type { Section } from '@/lib/state/store'
|
||||
|
||||
function Wrapper({ withSelect = true }: { withSelect?: boolean }) {
|
||||
const [sections, setSections] = useState<Section[]>([
|
||||
{ type: 'hero', props: { heading: 'H', subheading: '' } },
|
||||
{ type: 'faq', props: { items: [] } },
|
||||
{ type: 'cta', props: { text: 'Go', href: '#' } },
|
||||
])
|
||||
const onMove = (from: number, to: number) => {
|
||||
setSections((prev) => {
|
||||
const next = prev.slice()
|
||||
const [sp] = next.splice(from, 1)
|
||||
next.splice(to, 0, sp)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const onRemove = () => {}
|
||||
const onSelect = withSelect ? () => {} : undefined
|
||||
return <SectionCanvas sections={sections} onMove={onMove} onRemove={onRemove} onSelect={onSelect} />
|
||||
}
|
||||
|
||||
describe('SectionCanvas aria-labels on control buttons', () => {
|
||||
test('Select/Up/Down/Remove buttons expose index-based aria-labels', () => {
|
||||
render(<Wrapper />)
|
||||
|
||||
const list = screen.getByTestId('section-canvas')
|
||||
const items = within(list).getAllByTestId(/sec-item-/)
|
||||
|
||||
// check first item labels (index 0)
|
||||
const first = items[0]
|
||||
const sel0 = within(first).getByLabelText('Select 0') as HTMLButtonElement
|
||||
const up0 = within(first).getByLabelText('Up 0') as HTMLButtonElement
|
||||
const down0 = within(first).getByLabelText('Down 0') as HTMLButtonElement
|
||||
const rm0 = within(first).getByLabelText('Remove 0') as HTMLButtonElement
|
||||
expect(sel0).toBeInTheDocument()
|
||||
expect(up0).toBeInTheDocument()
|
||||
expect(down0).toBeInTheDocument()
|
||||
expect(rm0).toBeInTheDocument()
|
||||
expect(sel0.title).toBe(sel0.getAttribute('aria-label'))
|
||||
expect(up0.title).toBe(up0.getAttribute('aria-label'))
|
||||
expect(down0.title).toBe(down0.getAttribute('aria-label'))
|
||||
expect(rm0.title).toBe(rm0.getAttribute('aria-label'))
|
||||
|
||||
// check last item labels (index 2)
|
||||
const last = items[2]
|
||||
const sel2 = within(last).getByLabelText('Select 2') as HTMLButtonElement
|
||||
const up2 = within(last).getByLabelText('Up 2') as HTMLButtonElement
|
||||
const down2 = within(last).getByLabelText('Down 2') as HTMLButtonElement
|
||||
const rm2 = within(last).getByLabelText('Remove 2') as HTMLButtonElement
|
||||
expect(sel2).toBeInTheDocument()
|
||||
expect(up2).toBeInTheDocument()
|
||||
expect(down2).toBeInTheDocument()
|
||||
expect(rm2).toBeInTheDocument()
|
||||
expect(sel2.title).toBe(sel2.getAttribute('aria-label'))
|
||||
expect(up2.title).toBe(up2.getAttribute('aria-label'))
|
||||
expect(down2.title).toBe(down2.getAttribute('aria-label'))
|
||||
expect(rm2.title).toBe(rm2.getAttribute('aria-label'))
|
||||
})
|
||||
|
||||
test('Up/Down buttons still present when onSelect not provided', () => {
|
||||
render(<Wrapper withSelect={false} />)
|
||||
const list = screen.getByTestId('section-canvas')
|
||||
const items = within(list).getAllByTestId(/sec-item-/)
|
||||
|
||||
// when onSelect is undefined, Select button should not exist
|
||||
expect(within(items[0]).queryByLabelText('Select 0')).toBeNull()
|
||||
|
||||
// Up/Down/Remove must still be present
|
||||
expect(within(items[0]).getByLabelText('Up 0')).toBeInTheDocument()
|
||||
expect(within(items[0]).getByLabelText('Down 0')).toBeInTheDocument()
|
||||
expect(within(items[0]).getByLabelText('Remove 0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import React, { useState } from 'react'
|
||||
import SectionCanvas from '@/components/SectionCanvas'
|
||||
import type { Section } from '@/lib/state/store'
|
||||
|
||||
type DragEvt = { active: { id: string }; over: { id: string } }
|
||||
|
||||
vi.mock('@dnd-kit/core', async () => {
|
||||
const actual = await vi.importActual<typeof import('@dnd-kit/core')>('@dnd-kit/core')
|
||||
return {
|
||||
...actual,
|
||||
DndContext: ({ onDragEnd, children }: { onDragEnd?: (e: DragEvt) => void; children: React.ReactNode }) => (
|
||||
<div data-testid="mock-dnd" onClick={() => onDragEnd?.({ active: { id: 'sec-0' }, over: { id: 'sec-2' } })}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
closestCenter: vi.fn(),
|
||||
PointerSensor: function () {},
|
||||
useSensor: vi.fn(() => ({})),
|
||||
useSensors: vi.fn(() => []),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@dnd-kit/sortable', async () => {
|
||||
const actual = await vi.importActual<typeof import('@dnd-kit/sortable')>('@dnd-kit/sortable')
|
||||
return {
|
||||
...actual,
|
||||
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
useSortable: () => ({
|
||||
attributes: {} as Record<string, unknown>,
|
||||
listeners: {} as Record<string, unknown>,
|
||||
setNodeRef: () => {},
|
||||
transform: null as unknown,
|
||||
transition: null as unknown,
|
||||
}),
|
||||
verticalListSortingStrategy: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
function Wrapper() {
|
||||
const [sections, setSections] = useState<Section[]>([
|
||||
{ type: 'hero', props: { heading: 'Welcome', subheading: '' } },
|
||||
{ type: 'faq', props: { items: [] } },
|
||||
{ type: 'cta', props: { text: 'Go', href: '#' } },
|
||||
])
|
||||
return (
|
||||
<SectionCanvas
|
||||
sections={sections}
|
||||
onMove={(from, to) => {
|
||||
const next = sections.slice()
|
||||
const [sp] = next.splice(from, 1)
|
||||
next.splice(to, 0, sp)
|
||||
setSections(next)
|
||||
}}
|
||||
onRemove={(i) => setSections((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SectionCanvas - dnd', () => {
|
||||
it('드래그 종료 이벤트로 섹션 순서를 변경한다', () => {
|
||||
render(<Wrapper />)
|
||||
const list = screen.getByTestId('section-canvas')
|
||||
let items = list.querySelectorAll('li')
|
||||
expect(items[0].textContent).toMatch(/hero/)
|
||||
expect(items[1].textContent).toMatch(/faq/)
|
||||
expect(items[2].textContent).toMatch(/cta/)
|
||||
|
||||
fireEvent.click(screen.getByTestId('mock-dnd'))
|
||||
|
||||
items = list.querySelectorAll('li')
|
||||
expect(items[0].textContent).toMatch(/faq/)
|
||||
expect(items[1].textContent).toMatch(/cta/)
|
||||
expect(items[2].textContent).toMatch(/hero/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react'
|
||||
import React, { useState } from 'react'
|
||||
import SectionCanvas from './SectionCanvas'
|
||||
import type { Section } from '@/lib/state/store'
|
||||
|
||||
function Wrapper() {
|
||||
const [sections, setSections] = useState<Section[]>([
|
||||
{ type: 'hero', props: { heading: 'H', subheading: '' } },
|
||||
{ type: 'faq', props: { items: [] } },
|
||||
{ type: 'cta', props: { text: 'Go', href: '#' } },
|
||||
])
|
||||
|
||||
const onMove = (from: number, to: number) => {
|
||||
setSections((prev) => {
|
||||
const next = prev.slice()
|
||||
const [sp] = next.splice(from, 1)
|
||||
next.splice(to, 0, sp)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCanvas
|
||||
sections={sections}
|
||||
onMove={onMove}
|
||||
onRemove={() => {}}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SectionCanvas keyboard/fallback reorder', () => {
|
||||
test('clicking Down and Up buttons reorders items', () => {
|
||||
render(<Wrapper />)
|
||||
|
||||
// initial order: hero, faq, cta
|
||||
const list = screen.getByTestId('section-canvas')
|
||||
let items = within(list).getAllByTestId(/sec-item-/)
|
||||
expect(items[0]).toHaveTextContent('hero')
|
||||
expect(items[1]).toHaveTextContent('faq')
|
||||
expect(items[2]).toHaveTextContent('cta')
|
||||
|
||||
// move first item (index 0) down
|
||||
fireEvent.click(within(items[0]).getByLabelText('Down 0'))
|
||||
|
||||
items = within(list).getAllByTestId(/sec-item-/)
|
||||
expect(items[0]).toHaveTextContent('faq')
|
||||
expect(items[1]).toHaveTextContent('hero')
|
||||
expect(items[2]).toHaveTextContent('cta')
|
||||
|
||||
// move last item up
|
||||
fireEvent.click(within(items[2]).getByLabelText('Up 2'))
|
||||
|
||||
items = within(list).getAllByTestId(/sec-item-/)
|
||||
expect(items[0]).toHaveTextContent('faq')
|
||||
expect(items[1]).toHaveTextContent('cta')
|
||||
expect(items[2]).toHaveTextContent('hero')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import React, { useMemo } from 'react'
|
||||
import type { Section } from '@/lib/state/store'
|
||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core'
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
|
||||
function SortableItem({ id, children, testId }: { id: string; children: React.ReactNode; testId?: string }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id })
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
return (
|
||||
<li ref={setNodeRef} style={style} {...attributes} {...listeners} data-testid={testId} className="flex items-center justify-between border rounded px-3 py-2">
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SectionCanvas({
|
||||
sections,
|
||||
onMove,
|
||||
onRemove,
|
||||
onSelect,
|
||||
}: {
|
||||
sections: Section[]
|
||||
onMove: (from: number, to: number) => void
|
||||
onRemove: (index: number) => void
|
||||
onSelect?: (index: number) => void
|
||||
}) {
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
|
||||
const ids = useMemo(() => sections.map((_, i) => `sec-${i}`), [sections])
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over) return
|
||||
if (active.id === over.id) return
|
||||
const from = ids.indexOf(String(active.id))
|
||||
const to = ids.indexOf(String(over.id))
|
||||
if (from !== -1 && to !== -1) onMove(from, to)
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
<ul className="space-y-1" data-testid="section-canvas">
|
||||
{sections.map((s, i) => (
|
||||
<SortableItem id={`sec-${i}`} key={`sec-${i}`} testId={`sec-item-${i}`}>
|
||||
<span className="text-sm">{s.type}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{onSelect && (
|
||||
<button aria-label={`Select ${i}`} title={`Select ${i}`} className="text-sm border rounded px-2 py-1" onClick={() => onSelect(i)}>
|
||||
Select
|
||||
</button>
|
||||
)}
|
||||
<button aria-label={`Up ${i}`} title={`Up ${i}`} className="text-sm border rounded px-2 py-1" onClick={() => i > 0 && onMove(i, i - 1)}>
|
||||
Up
|
||||
</button>
|
||||
<button aria-label={`Down ${i}`} title={`Down ${i}`} className="text-sm border rounded px-2 py-1" onClick={() => i < sections.length - 1 && onMove(i, i + 1)}>
|
||||
Down
|
||||
</button>
|
||||
<button aria-label={`Remove ${i}`} title={`Remove ${i}`} className="text-sm text-red-600 border rounded px-2 py-1" onClick={() => onRemove(i)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</SortableItem>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export default function SidebarComponents({
|
||||
onAddHero,
|
||||
onAddFAQ,
|
||||
onAddCTA,
|
||||
onAddFeatures,
|
||||
onAddTestimonials,
|
||||
}: {
|
||||
onAddHero: () => void
|
||||
onAddFAQ: () => void
|
||||
onAddCTA: () => void
|
||||
onAddFeatures: () => void
|
||||
onAddTestimonials: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2" data-testid="sidebar-components">
|
||||
<h2 className="text-sm font-semibold">Components</h2>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<button data-testid="card-hero" className="border rounded p-2 text-left" onClick={onAddHero}>
|
||||
<div className="text-sm font-medium">Hero</div>
|
||||
<div className="text-xs text-gray-500">Large heading + image</div>
|
||||
</button>
|
||||
<button data-testid="card-faq" className="border rounded p-2 text-left" onClick={onAddFAQ}>
|
||||
<div className="text-sm font-medium">FAQ</div>
|
||||
<div className="text-xs text-gray-500">Questions & answers</div>
|
||||
</button>
|
||||
<button data-testid="card-cta" className="border rounded p-2 text-left" onClick={onAddCTA}>
|
||||
<div className="text-sm font-medium">CTA</div>
|
||||
<div className="text-xs text-gray-500">Action button</div>
|
||||
</button>
|
||||
<button data-testid="card-features" className="border rounded p-2 text-left" onClick={onAddFeatures}>
|
||||
<div className="text-sm font-medium">Features</div>
|
||||
<div className="text-xs text-gray-500">List of product features</div>
|
||||
</button>
|
||||
<button data-testid="card-testimonials" className="border rounded p-2 text-left" onClick={onAddTestimonials}>
|
||||
<div className="text-sm font-medium">Testimonials</div>
|
||||
<div className="text-xs text-gray-500">Customer quotes</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import BuilderClientPage from './BuilderClientPage'
|
||||
import messages from '../messages/en.json'
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale="en">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Buttons title/aria-label consistency', () => {
|
||||
test('Inspector Remove buttons have title matching aria-label', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<BuilderClientPage />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
// Add Features section and select
|
||||
fireEvent.click(screen.getByTestId('card-features'))
|
||||
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||
// Ensure at least one item
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Feature Item/i }))
|
||||
|
||||
// Validate Features remove buttons
|
||||
const featureRemoveButtons = screen.getAllByRole('button', { name: /Remove Feature \d+/ })
|
||||
for (const btn of featureRemoveButtons) {
|
||||
const aria = btn.getAttribute('aria-label') || ''
|
||||
const title = btn.getAttribute('title') || ''
|
||||
expect(title).toBe(aria)
|
||||
}
|
||||
|
||||
// Add Testimonials section and select
|
||||
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||
fireEvent.click(await screen.findByLabelText('Select 1'))
|
||||
// Ensure at least one item to expose remove button
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Testimonial/i }))
|
||||
|
||||
// Validate Testimonials remove buttons
|
||||
const testimonialRemoveButtons = screen.getAllByRole('button', { name: /Remove Testimonial \d+/ })
|
||||
for (const btn of testimonialRemoveButtons) {
|
||||
const aria = btn.getAttribute('aria-label') || ''
|
||||
const title = btn.getAttribute('title') || ''
|
||||
expect(title).toBe(aria)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user