161 lines
8.4 KiB
TypeScript
161 lines
8.4 KiB
TypeScript
"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>
|
|
)
|
|
}
|