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,47 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createFormStore, type FormFieldInput, type FormState, type FormField } from '@/lib/state/formStore'
|
||||
|
||||
const text = (name: string, label = name): FormFieldInput => ({ type: 'text', name, label, required: false })
|
||||
|
||||
describe('form store', () => {
|
||||
it('필드 추가/삭제/이동/업데이트와 action URL, spam 옵션을 관리한다', () => {
|
||||
const useForm = createFormStore()
|
||||
|
||||
// 초기
|
||||
let s: FormState = useForm.getState()
|
||||
expect(s.fields.length).toBe(0)
|
||||
expect(s.actionUrl).toBe('')
|
||||
expect(s.spam.honeypotFieldName).toBe('_hp')
|
||||
|
||||
// 추가
|
||||
useForm.getState().addField(text('name', 'Name'))
|
||||
useForm.getState().addField(text('email', 'Email'))
|
||||
useForm.getState().addField({ type: 'select', name: 'plan', label: 'Plan', required: true, options: ['A','B'] })
|
||||
s = useForm.getState()
|
||||
expect(s.fields.map((f: FormField) => f.name)).toEqual(['name','email','plan'])
|
||||
|
||||
// 이동 (0 -> 2)
|
||||
useForm.getState().moveField(0, 2)
|
||||
s = useForm.getState()
|
||||
expect(s.fields.map((f: FormField) => f.name)).toEqual(['email','plan','name'])
|
||||
|
||||
// 업데이트
|
||||
useForm.getState().updateField(1, { label: 'Pricing Plan', required: false })
|
||||
s = useForm.getState()
|
||||
expect(s.fields[1].label).toBe('Pricing Plan')
|
||||
expect(s.fields[1].required).toBe(false)
|
||||
|
||||
// 삭제
|
||||
useForm.getState().removeField(0)
|
||||
s = useForm.getState()
|
||||
expect(s.fields.map((f: FormField) => f.name)).toEqual(['plan','name'])
|
||||
|
||||
// action & spam
|
||||
useForm.getState().setActionUrl('https://example.com/form')
|
||||
useForm.getState().setSpam({ honeypotFieldName: '_trap', minSubmitSeconds: 3 })
|
||||
s = useForm.getState()
|
||||
expect(s.actionUrl).toBe('https://example.com/form')
|
||||
expect(s.spam.honeypotFieldName).toBe('_trap')
|
||||
expect(s.spam.minSubmitSeconds).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
|
||||
export type BaseField = {
|
||||
name: string
|
||||
label: string
|
||||
required?: boolean
|
||||
pattern?: string
|
||||
maxLength?: number
|
||||
}
|
||||
|
||||
export type TextField = BaseField & { type: 'text' | 'email' | 'tel' | 'date' | 'textarea' }
|
||||
export type CheckboxField = BaseField & { type: 'checkbox' }
|
||||
export type RadioField = BaseField & { type: 'radio'; options: string[] }
|
||||
export type SelectField = BaseField & { type: 'select'; options: string[] }
|
||||
|
||||
export type FormField = TextField | CheckboxField | RadioField | SelectField
|
||||
|
||||
export type FormFieldInput = FormField
|
||||
|
||||
export type FormState = {
|
||||
fields: FormField[]
|
||||
actionUrl: string
|
||||
spam: { honeypotFieldName: string; minSubmitSeconds: number }
|
||||
addField: (f: FormFieldInput) => void
|
||||
moveField: (from: number, to: number) => void
|
||||
removeField: (index: number) => void
|
||||
updateField: (index: number, patch: Partial<FormField>) => void
|
||||
setActionUrl: (url: string) => void
|
||||
setSpam: (s: { honeypotFieldName?: string; minSubmitSeconds?: number }) => void
|
||||
}
|
||||
|
||||
export function createFormStore() {
|
||||
return createStore<FormState>((set) => ({
|
||||
fields: [],
|
||||
actionUrl: '',
|
||||
spam: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
addField: (f) => set((st) => ({ fields: [...st.fields, f] })),
|
||||
moveField: (from, to) =>
|
||||
set((st) => {
|
||||
const arr = [...st.fields]
|
||||
if (from < 0 || from >= arr.length || to < 0 || to >= arr.length) return st
|
||||
const [spliced] = arr.splice(from, 1)
|
||||
arr.splice(to, 0, spliced)
|
||||
return { fields: arr }
|
||||
}),
|
||||
removeField: (index) =>
|
||||
set((st) => {
|
||||
if (index < 0 || index >= st.fields.length) return st
|
||||
const arr = st.fields.slice()
|
||||
arr.splice(index, 1)
|
||||
return { fields: arr }
|
||||
}),
|
||||
updateField: (index, patch) =>
|
||||
set((st) => {
|
||||
if (index < 0 || index >= st.fields.length) return st
|
||||
const next = st.fields.slice()
|
||||
next[index] = { ...next[index], ...patch } as FormField
|
||||
return { fields: next }
|
||||
}),
|
||||
setActionUrl: (url) => set({ actionUrl: url }),
|
||||
setSpam: (s) =>
|
||||
set((st) => ({
|
||||
spam: {
|
||||
honeypotFieldName: s.honeypotFieldName ?? st.spam.honeypotFieldName,
|
||||
minSubmitSeconds: s.minSubmitSeconds ?? st.spam.minSubmitSeconds,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createBuilderStore, type Section } from '@/lib/state/store'
|
||||
|
||||
describe('builder store (zustand)', () => {
|
||||
it('섹션 추가/이동/삭제와 로케일 변경을 지원한다', () => {
|
||||
const useStore = createBuilderStore()
|
||||
const s1: Section = { type: 'hero', props: { heading: 'H1' } }
|
||||
const s2: Section = { type: 'faq', props: { items: [] } }
|
||||
const s3: Section = { type: 'cta', props: { text: 'Go' } }
|
||||
|
||||
// 초기 상태
|
||||
expect(useStore.getState().locale).toBe('en')
|
||||
expect(useStore.getState().sections.length).toBe(0)
|
||||
|
||||
// 추가
|
||||
useStore.getState().addSection(s1)
|
||||
useStore.getState().addSection(s2)
|
||||
useStore.getState().addSection(s3)
|
||||
expect(useStore.getState().sections.map(s => s.type)).toEqual(['hero','faq','cta'])
|
||||
|
||||
// 이동 (index 0 -> 2)
|
||||
useStore.getState().moveSection(0, 2)
|
||||
expect(useStore.getState().sections.map(s => s.type)).toEqual(['faq','cta','hero'])
|
||||
|
||||
// 삭제 (index 1: 'cta')
|
||||
useStore.getState().removeSection(1)
|
||||
expect(useStore.getState().sections.map(s => s.type)).toEqual(['faq','hero'])
|
||||
|
||||
// 로케일 변경
|
||||
useStore.getState().setLocale('ko')
|
||||
expect(useStore.getState().locale).toBe('ko')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
|
||||
export type HeroSection = { type: 'hero'; props: { heading?: string; subheading?: string } }
|
||||
export type FaqSection = { type: 'faq'; props: { items: Array<{ q: string; a: string }> } }
|
||||
export type CtaSection = { type: 'cta'; props: { text: string; href?: string } }
|
||||
export type FeaturesSection = { type: 'features'; props: { items: string[] } }
|
||||
export type TestimonialsSection = { type: 'testimonials'; props: { items: Array<{ author: string; quote: string }> } }
|
||||
|
||||
export type Section = HeroSection | FaqSection | CtaSection | FeaturesSection | TestimonialsSection
|
||||
|
||||
export type BuilderState = {
|
||||
locale: string
|
||||
sections: Section[]
|
||||
addSection: (s: Section) => void
|
||||
moveSection: (from: number, to: number) => void
|
||||
removeSection: (index: number) => void
|
||||
setLocale: (loc: string) => void
|
||||
}
|
||||
|
||||
export function createBuilderStore() {
|
||||
return createStore<BuilderState>((set, get) => ({
|
||||
locale: 'en',
|
||||
sections: [],
|
||||
addSection: (s) => set((st) => ({ sections: [...st.sections, s] })),
|
||||
moveSection: (from, to) =>
|
||||
set((st) => {
|
||||
const arr = [...st.sections]
|
||||
if (from < 0 || from >= arr.length || to < 0 || to >= arr.length) return st
|
||||
const [spliced] = arr.splice(from, 1)
|
||||
arr.splice(to, 0, spliced)
|
||||
return { sections: arr }
|
||||
}),
|
||||
removeSection: (index) =>
|
||||
set((st) => {
|
||||
if (index < 0 || index >= st.sections.length) return st
|
||||
const arr = st.sections.slice()
|
||||
arr.splice(index, 1)
|
||||
return { sections: arr }
|
||||
}),
|
||||
setLocale: (loc) => set({ locale: loc }),
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user