This form could not be loaded
{formLoadError}
import React, { useEffect, useState } from 'react' import { DndContext, closestCenter, pointerWithin, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay, } from '@dnd-kit/core' import { arrayMove, sortableKeyboardCoordinates, } from '@dnd-kit/sortable' import { Type, Mail, AlignLeft, ChevronDown, CheckSquare, CircleDot, Calendar, Binary, Eye, Pencil, ArrowLeft, Plug, Settings, Maximize2, Minimize2, ChevronRight, ChevronLeft, Undo2, Redo2, Copy, Check, X, Palette, Loader2, AlertCircle } from 'lucide-react' import { useFormHistory } from './hooks/useFormHistory' import useStorefrontFonts from './hooks/useStorefrontFonts' import FormSwitcher from './components/shell/FormSwitcher' import ActivePill from './components/shell/ActivePill' import DeviceSwitcher from './components/shell/DeviceSwitcher' import SaveButton from './components/shell/SaveButton' import ZoomDock, { readStoredZoom } from './components/shell/ZoomDock' import PreviewToolbar from './components/shell/PreviewToolbar' import LeftRail, { readRailCollapsed } from './components/shell/LeftRail' import { remapLogicReferences } from './utils/remapLogic' import { logicPatch, logicValue } from './components/settings/logicKeys' import { CanvasContext } from './components/canvas/CanvasContext' import { resolveGlobalSettings, cardStyle, collectFormFonts, loadFormFont } from './utils/resolveFieldStyle' import Canvas from './components/Canvas' import ErrorBoundary from './components/ErrorBoundary' import FieldsPalette from './components/FieldsPalette' import PropertyEditor from './components/PropertyEditor' import StylingCustomizer from './components/StylingCustomizer' import LayersPanel from './components/LayersPanel' import FloatingIcons from './components/FloatingIcons' import SettingsDrawer from './components/SettingsDrawer' import TemplateLoaderDrawer from './components/TemplateLoaderDrawer' import TemplateGalleryScreen from './components/TemplateGalleryScreen' import GridInitialColumnModal from './components/GridInitialColumnModal' import InlineFormBackgroundEditor from './components/InlineFormBackgroundEditor' import ConditionalLogic from './components/ConditionalLogic' import { createFieldWithDefaults, getDefaultSettings, hasNestedColumns, useFormStore } from './store/useFormStore' import { toast } from './store/useToastStore' import { ToastContainer } from './components/Toast' import FormsListScreen from './components/FormsListScreen' import EntriesScreen from './components/EntriesScreen' import { applyFilters, doAction, getFieldType, Slot, useRegistryVersion } from './extensions/registry' import { BuilderContext } from './extensions/BuilderContext' import { getTemplateCatalog } from './extensions/templates' const ICON_MAP = { text: Type, email: Mail, textarea: AlignLeft, number: Binary, select: ChevronDown, checkbox: CheckSquare, radio: CircleDot, date: Calendar, } // Top-level store keys saved with the form besides fields/settings (see handleSave). Extensions that keep form data // in their own store keys add them with the `builder.historyKeys` filter (undo/redo, unsaved-changes tracking). const BASE_HISTORY_KEYS = ['confirmations', 'notifications'] const historyKeys = () => { const extra = applyFilters('builder.historyKeys', []) return [...BASE_HISTORY_KEYS, ...(Array.isArray(extra) ? extra.filter((k) => !BASE_HISTORY_KEYS.includes(k)) : [])] } /** All field ids, including fields nested in container columns (a `columns` array holds definitions, not fields). */ const collectFieldIds = (list) => { const ids = [] ;(list || []).forEach((f) => { ids.push(f.id) if (hasNestedColumns(f)) { Object.values(f.columns).forEach((col) => { ids.push(...collectFieldIds(col)) }) } }) return ids } const buildHistorySnapshot = (state) => { const snapshot = { fields: state.fields, settings: state.settings } historyKeys().forEach((key) => { snapshot[key] = state[key] }) return snapshot } /** * Template import gives every field a new id (idMap: old → new). The field mapping renames top-level fields and * their direct column children; this also renames fields nested deeper (a grid inside a grid column). Other id * references are renamed by the `template.renameFieldIds` filter; logic references by remapLogicReferences. */ function renameNestedFieldIds(fields, idMap) { const mapId = (id) => (typeof id === 'string' && idMap[id]) || id const walk = (f) => { if (!f || typeof f !== 'object') return f let next = { ...f } if (typeof next.id === 'string' && idMap[next.id]) next.id = idMap[next.id] next = applyFilters('template.renameFieldIds', next, mapId, idMap) || next if (hasNestedColumns(next)) { const columns = {} Object.keys(next.columns).forEach((k) => { columns[k] = Array.isArray(next.columns[k]) ? next.columns[k].map(walk) : next.columns[k] }) next.columns = columns } return next } return (fields || []).map(walk) } export default function App() { const fields = useFormStore((state) => state.fields) const setFields = useFormStore((state) => state.setFields) const updateField = useFormStore((state) => state.updateField) const settings = useFormStore((state) => state.settings) const setSettings = useFormStore((state) => state.setSettings) const activeFieldId = useFormStore((state) => state.activeFieldId) const setActiveFieldId = useFormStore((state) => state.setActiveFieldId) const viewMode = useFormStore((state) => state.viewMode) const setViewMode = useFormStore((state) => state.setViewMode) const dragOverContainer = useFormStore((state) => state.dragOverContainer) const dragOverIndex = useFormStore((state) => state.dragOverIndex) const setDragOverContainer = useFormStore((state) => state.setDragOverContainer) const setDragOverIndex = useFormStore((state) => state.setDragOverIndex) const resetDragOver = useFormStore((state) => state.resetDragOver) const resetPreview = useFormStore((state) => state.resetPreview) const isLogicSidebarOpen = useFormStore((state) => state.isLogicSidebarOpen) const setIsLogicSidebarOpen = useFormStore((state) => state.setIsLogicSidebarOpen) const confirmations = useFormStore((state) => state.confirmations) const notifications = useFormStore((state) => state.notifications) const setConfirmations = useFormStore((state) => state.setConfirmations) const setNotifications = useFormStore((state) => state.setNotifications) // Re-render when extensions register tabs, slots or filters after the first render. useRegistryVersion() // Incremented by "Restart" in the preview toolbar (canvases that keep their own state remount with it). const [previewRestartKey, setPreviewRestartKey] = useState(0) const [isLogicClosing, setIsLogicClosing] = useState(false) const logicSidebarRef = React.useRef(null) /** Field logic drawer: writes the four flat keys only and removes the nested conditionalLogic copy (logicPatch). */ const writeFieldLogic = (fieldObj, key, value) => { // ConditionalLogic calls several setters in one handler (enable + conditions): build each write on the latest // stored field, not on the render-time copy, or the second write would undo the first. const latest = findFieldPath(useFormStore.getState().fields, fieldObj.id)?.field const base = latest ? { ...latest, ...(fieldObj.logicConditions !== latest.logicConditions && key === 'enableConditionalLogic' ? { logicConditions: fieldObj.logicConditions } : {}) } : fieldObj const next = logicPatch(base, key, value) updateField(fieldObj.id, { enableConditionalLogic: next.enableConditionalLogic, logicConditions: next.logicConditions, logicMatch: next.logicMatch, logicAction: next.logicAction, conditionalLogic: undefined, }) setIsDirty(true) } const handleCloseLogicDrawer = () => { setIsLogicClosing(true) setTimeout(() => { setIsLogicSidebarOpen(false) setIsLogicClosing(false) }, 250) } useEffect(() => { const handleClickOutside = (event) => { if ( isLogicSidebarOpen && logicSidebarRef.current && !logicSidebarRef.current.contains(event.target) && !event.target.closest('aside') && !event.target.closest('[data-testid="conditional-logic-card"]') && !event.target.closest('[data-testid="conditional-logic-switch"]') && !event.target.closest('[data-testid="conditional-logic-gear"]') ) { handleCloseLogicDrawer() } } document.addEventListener('mousedown', handleClickOutside) return () => { document.removeEventListener('mousedown', handleClickOutside) } }, [isLogicSidebarOpen]) const { canUndo, canRedo, undo, redo, pushState, reset: resetHistory } = useFormHistory({ onRestore: (snapshot) => applyHistorySnapshot(snapshot), enabled: viewMode === 'edit', }) const [isDirty, setIsDirty] = useState(false) const isRestoring = React.useRef(false) const isInitialLoad = React.useRef(true) useEffect(() => { const handler = (e) => { if (isDirty) { e.preventDefault() e.returnValue = '' } } window.addEventListener('beforeunload', handler) return () => window.removeEventListener('beforeunload', handler) }, [isDirty]) useEffect(() => { const unsub = useFormStore.subscribe((state, prevState) => { if (isRestoring.current) return if (state.viewMode !== 'edit') return const fieldsChanged = state.fields !== prevState.fields // Every other top-level key that handleSave persists (Submit Actions, extension keys). const keys = historyKeys() const extraChanged = keys.filter((key) => !BASE_HISTORY_KEYS.includes(key)).some((key) => state[key] !== prevState[key]) const formKeysChanged = state.settings !== prevState.settings || keys.some((key) => state[key] !== prevState[key]) if (!fieldsChanged && !formKeysChanged) return if (isInitialLoad.current) { isInitialLoad.current = false return } setIsDirty(true) const prevIds = JSON.stringify(collectFieldIds(prevState.fields)) const currIds = JSON.stringify(collectFieldIds(state.fields)) const isStructural = extraChanged || prevIds !== currIds || state.confirmations?.length !== prevState.confirmations?.length || state.notifications?.length !== prevState.notifications?.length pushState(buildHistorySnapshot(state), isStructural) }) return unsub }, [pushState]) const applyHistorySnapshot = (snapshot) => { if (!snapshot) return isRestoring.current = true const store = useFormStore.getState() setFields(snapshot.fields) // History snapshots hold the whole settings object: replace it (a merge would keep keys added later). if (snapshot.settings) useFormStore.setState({ settings: snapshot.settings }) if (snapshot.confirmations) store.setConfirmations(snapshot.confirmations) if (snapshot.notifications) store.setNotifications(snapshot.notifications) const extra = {} historyKeys().filter((key) => !BASE_HISTORY_KEYS.includes(key)).forEach((key) => { if (snapshot[key] !== undefined) extra[key] = snapshot[key] }) if (Object.keys(extra).length) useFormStore.setState(extra) // The restored form may not contain the selected field any more. const current = useFormStore.getState() if (current.activeFieldId && !collectFieldIds(snapshot.fields || []).includes(current.activeFieldId)) { setActiveFieldId(null) } doAction('builder.historyRestored', snapshot) setIsDirty(true) setTimeout(() => { isRestoring.current = false }, 0) } /** New form opened or created: undo must not reach back into the previous form. */ const startFreshHistory = () => { resetHistory(buildHistorySnapshot(useFormStore.getState())) } const exitPreviewMode = () => { setViewMode('edit') resetPreview() } const handleRestartPreview = () => { resetPreview() setPreviewRestartKey((k) => k + 1) doAction('preview.restart') toast.success('Preview restarted') } useEffect(() => { const handleKeyDown = (e) => { if (e.key === 'Escape' && viewMode === 'preview') { exitPreviewMode() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [viewMode]) // Screen routing: 'forms' | 'builder' | 'entries' const [currentScreen, setCurrentScreen] = useState(() => { const params = new URLSearchParams(window.location.search) const page = params.get('page') const screenParam = params.get('screen') if (page === 'wpvfb-entries' || screenParam === 'entries') { return 'entries' } if (screenParam === 'builder') { return 'builder' } if (screenParam === 'templates') { return 'templates' } return 'forms' }) const [formsList, setFormsList] = useState([]) const navigateTo = (screen, id = null) => { setCurrentScreen(screen) const params = new URLSearchParams(window.location.search) params.set('screen', screen) if (id) { params.set('id', id) } else { params.delete('id') } const newUrl = `${window.location.pathname}?${params.toString()}` window.history.pushState(null, '', newUrl) } const navigateToForms = () => { setCurrentScreen('forms') const params = new URLSearchParams(window.location.search) params.delete('screen') params.delete('id') params.set('page', (typeof wpvfbSettings !== 'undefined' && wpvfbSettings.formsPageSlug) || 'efe-visual-forms') const newUrl = `${window.location.pathname}?${params.toString()}` window.history.pushState(null, '', newUrl) loadFormsList() } // Form builder state const [formId, setFormId] = useState('') const [formName, setFormName] = useState('My Form') const [tempFormName, setTempFormName] = useState('') // Layout States matching the reference builder const [previewMode, setPreviewMode] = useState('desktop') // 'desktop' | 'tablet' | 'mobile' const canvasContextValue = React.useMemo(() => ({ previewMode }), [previewMode]) const [previewZoom, setPreviewZoom] = useState(() => readStoredZoom()) // 50% to 200%, remembered by the dock const [isLeftRailCollapsed, setIsLeftRailCollapsed] = useState(() => readRailCollapsed()) // Extra details of the last sidebar tab request (requestSidebarTab(tab, extra)), passed to that tab. const [sidebarTabRequest, setSidebarTabRequest] = useState(null) const [showFormBackgroundEditor, setShowFormBackgroundEditor] = useState(false) const [sidebarTab, setSidebarTab] = useState('elements') // 'elements' | 'theme' | 'layers' | extension tab ids const [elementsSubTab, setElementsSubTab] = useState('palette') // 'palette' | 'settings' const [isRightSidebarCollapsed, setIsRightSidebarCollapsed] = useState(false) // Settings Drawer States const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] = useState(false) const [activeSettingsTab, setActiveSettingsTab] = useState('Form Settings') // Modals const [isRenameModalOpen, setIsRenameModalOpen] = useState(false) const [isPublishModalOpen, setIsPublishModalOpen] = useState(false) const [copiedShortcode, setCopiedShortcode] = useState(false) // Templates Drawer const [isTemplatesDrawerOpen, setIsTemplatesDrawerOpen] = useState(false) // Grid Modal const [isGridModalOpen, setIsGridModalOpen] = useState(false) const [pendingGridDrop, setPendingGridDrop] = useState(null) const [loading, setLoading] = useState(false) // Builder canvas: a form is being fetched, or the fetch failed (shown over the canvas with Retry). const [formLoading, setFormLoading] = useState(false) // True once the open form is on the canvas (loaded, created or failed to load): data-builder-ready for tests. const [formReady, setFormReady] = useState(false) const [formLoadError, setFormLoadError] = useState(null) const [saving, setSaving] = useState(false) const [activeDragItem, setActiveDragItem] = useState(null) // Header Enhancements const [isFormActive, setIsFormActive] = useState(true) const [isFullScreen, setIsFullScreen] = useState(false) const [publishPulse, setPublishPulse] = useState(false) const [adminBarHeight, setAdminBarHeight] = useState(0) useEffect(() => { const bar = document.getElementById('wpadminbar') if (bar) { setAdminBarHeight(bar.offsetHeight) } const handleResize = () => { if (bar) setAdminBarHeight(bar.offsetHeight) } window.addEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize) }, []) useEffect(() => { const handleFullscreenChange = () => { setIsFullScreen(!!document.fullscreenElement) } document.addEventListener('fullscreenchange', handleFullscreenChange) return () => document.removeEventListener('fullscreenchange', handleFullscreenChange) }, []) // Lock WordPress Admin viewport when inside Builder to prevent window scroll chaining useEffect(() => { if (currentScreen === 'builder') { document.body.classList.add('wpvfb-builder-active') document.documentElement.classList.add('wpvfb-builder-active') } else { document.body.classList.remove('wpvfb-builder-active') document.documentElement.classList.remove('wpvfb-builder-active') } return () => { document.body.classList.remove('wpvfb-builder-active') document.documentElement.classList.remove('wpvfb-builder-active') } }, [currentScreen]) // Innermost droppable under the pointer (a nested field beats its column, a column beats its container, // which beats the canvas); closest centre when the pointer is outside every droppable (keyboard drags too). const collisionDetection = React.useCallback((args) => { const within = pointerWithin(args) if (within.length) { const area = (c) => { const r = args.droppableRects.get(c.id) return r ? r.width * r.height : Infinity } const others = within.filter((c) => c.id !== args.active.id) const pool = others.length ? others : within return [...pool].sort((a, b) => area(a) - area(b)) } return closestCenter(args) }, []) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ) // Auto-expand/select settings when a field is selected on canvas, or switch back to palette on deselect useEffect(() => { if (activeFieldId) { setSidebarTab('elements') setElementsSubTab('settings') setIsRightSidebarCollapsed(false) } else { setElementsSubTab('palette') } }, [activeFieldId]) // Canvas chrome can ask for a sidebar tab (useFormStore requestSidebarTab). const sidebarRequest = useFormStore((state) => state.sidebarRequest) useEffect(() => { if (!sidebarRequest) return setSidebarTabRequest(sidebarRequest) setSidebarTab(sidebarRequest.tab) setIsRightSidebarCollapsed(false) useFormStore.getState().clearSidebarRequest() }, [sidebarRequest]) // Google fonts load exactly like on the published form (upright weights only), so canvas text == site text. useStorefrontFonts() // Click outside to deselect active field matching the reference builder useEffect(() => { const handleDocumentMouseDown = (e) => { // Don't deselect if clicked inside a field, right sidebar, modal, or popover if ( e.target.closest('[data-field-id]') || e.target.closest('aside') || e.target.closest('[role="dialog"]') || e.target.closest('.field-actions') || e.target.closest('[data-prevent-outside-click]') || e.target.closest('[data-testid="conditional-logic-sidebar"]') || e.target.closest('[data-drawer="conditional-logic"]') || e.target.closest('[data-testid="conditional-logic-card"]') ) { return } setActiveFieldId(null) } document.addEventListener('mousedown', handleDocumentMouseDown) return () => { document.removeEventListener('mousedown', handleDocumentMouseDown) } }, [setActiveFieldId]) const loadFormsList = async () => { if (typeof wpvfbSettings === 'undefined') return setLoading(true) try { const res = await fetch(`${wpvfbSettings.apiUrl}/forms`, { headers: { 'X-WP-Nonce': wpvfbSettings.nonce }, }) const data = await res.json() setFormsList(data || []) } catch (err) { console.error('Failed to load forms list:', err) } finally { setLoading(false) } } useEffect(() => { const params = new URLSearchParams(window.location.search) const page = params.get('page') const screenParam = params.get('screen') const idParam = params.get('id') if (page === 'wpvfb-entries' || screenParam === 'entries') { if (idParam) { handleViewEntries(idParam, '') } else { // Global entries screen listing all forms setCurrentScreen('entries') loadFormsList() } } else if (screenParam === 'templates') { setCurrentScreen('templates') } else if (screenParam === 'builder') { if (idParam) { handleEditForm(idParam, '') } else { navigateToForms() } } else { loadFormsList() } }, []) const handleCreateNewForm = () => { const newId = crypto.randomUUID() setFormId(newId) setFormName('New Form') setFields([]) setConfirmations(null) setNotifications(null) setIsFormActive(true) // A new form starts from the default settings (nothing carries over from the form opened before). useFormStore.setState({ settings: { ...getDefaultSettings(), inputBgColor: '#ffffff' } }) useFormStore.setState(applyFilters('form.initialState', {}, { source: 'new' }) || {}) setViewMode('edit') setSidebarTab('elements') setElementsSubTab('palette') setIsRightSidebarCollapsed(false) setFormLoadError(null) navigateTo('builder', newId) startFreshHistory() setFormReady(true) } const handleEditForm = async (fId, name) => { // Loading a form is not an edit: the store subscriber must not mark it dirty or record history. isRestoring.current = true setFormReady(false) setFormId(fId) if (name) setFormName(name) setLoading(true) setFormLoading(true) setFormLoadError(null) let loaded = false try { if (typeof wpvfbSettings !== 'undefined') { const res = await fetch(`${wpvfbSettings.apiUrl}/forms/${fId}`, { headers: { 'X-WP-Nonce': wpvfbSettings.nonce }, }) let detail = null try { detail = await res.json() } catch (parseErr) { detail = null } if (!res.ok || !detail || typeof detail !== 'object' || (detail.code && detail.message && !detail.fields)) { throw new Error((detail && detail.message) || `The form could not be loaded (HTTP ${res.status}).`) } if (detail.name) setFormName(detail.name) if (detail.fields) setFields(detail.fields) setIsFormActive(detail.is_active === undefined ? true : String(detail.is_active) === '1') // null for forms saved before 2.9.0: defaults load and the drawers migrate the legacy settings keys. setConfirmations(detail.confirmations) setNotifications(detail.notifications) // The saved settings replace the previous form's (keys this builder doesn't edit are kept as saved). useFormStore.setState({ settings: { ...getDefaultSettings(), ...(detail.settings && typeof detail.settings === 'object' ? detail.settings : {}) } }) useFormStore.setState(applyFilters('form.initialState', {}, { source: 'load', detail }) || {}) } else { useFormStore.setState(applyFilters('form.initialState', {}, { source: 'new' }) || {}) } setViewMode('edit') setSidebarTab('elements') setElementsSubTab('palette') setIsRightSidebarCollapsed(false) navigateTo('builder', fId) loaded = true } catch (err) { console.error('Failed to load form details:', err) const message = err && err.message ? err.message : 'The form could not be loaded.' setFormLoadError(message) toast.error(message) navigateTo('builder', fId) } finally { setLoading(false) setFormLoading(false) if (loaded) startFreshHistory() isRestoring.current = false isInitialLoad.current = false setIsDirty(false) setFormReady(true) } } // Entries screen loads its own data; this only routes there (also used from the builder header). const handleViewEntries = (fId, name) => { if (fId) setFormId(fId) if (name) setFormName(name) navigateTo('entries', fId || null) loadFormsList() } // Form picked inside the entries screen: keep the URL (and builder target) in sync. const handleEntriesFormChange = (fId, name, replace = false) => { setFormId(fId) if (name) setFormName(name) const params = new URLSearchParams(window.location.search) params.set('id', fId) const newUrl = `${window.location.pathname}?${params.toString()}` if (replace) window.history.replaceState(null, '', newUrl) else window.history.pushState(null, '', newUrl) } // handleSave can run from a debounced timer (an extension's auto-save) created by an older render, // so it reads the latest form from the store and refs instead of this render's closure. const formIdRef = React.useRef(formId) formIdRef.current = formId const formNameRef = React.useRef(formName) formNameRef.current = formName const formLoadingRef = React.useRef(formLoading) formLoadingRef.current = formLoading const formLoadErrorRef = React.useRef(formLoadError) formLoadErrorRef.current = formLoadError /** Saves the current form. Resolves true on success, false on failure. */ const handleSave = async () => { const latest = useFormStore.getState() // Never write the previous form's content over a form that failed to load (or is still loading). if (formLoadingRef.current || formLoadErrorRef.current) return false if (typeof wpvfbSettings === 'undefined') { toast.warning('WordPress REST API not active. Form state printed in console.') console.log('Form State:', { id: formIdRef.current, name: formNameRef.current, fields: latest.fields, settings: latest.settings }) return false } setSaving(true) try { const res = await fetch(`${wpvfbSettings.apiUrl}/forms`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpvfbSettings.nonce, }, body: JSON.stringify({ id: formIdRef.current, name: formNameRef.current, fields: latest.fields, // Extensions that keep form data in their own store keys write it into the settings here. settings: applyFilters('form.saveSettings', { ...latest.settings }, latest), confirmations: latest.confirmations, notifications: latest.notifications, }), }) const data = await res.json() if (data.success) { // Edits made while the request was in flight stay unsaved. const now = useFormStore.getState() const unchanged = now.fields === latest.fields && now.settings === latest.settings && historyKeys().every((key) => now[key] === latest[key]) if (unchanged) setIsDirty(false) toast.success('Form saved successfully!') setPublishPulse(true) setTimeout(() => setPublishPulse(false), 3000) return true } toast.error('Failed to save form.') return false } catch (err) { console.error('Failed to save form:', err) toast.error('Error saving form.') return false } finally { setSaving(false) } } const originalFieldsRef = React.useRef(null) const handleDragStart = (event) => { const { active } = event setActiveDragItem(active) // Store a deep copy of the current fields to allow clean cancellation / rollback originalFieldsRef.current = JSON.parse(JSON.stringify(fields)) } const handleConfirmGridColumns = (columnCount) => { if (!pendingGridDrop) return const { label, targetContainer, index } = pendingGridDrop const columns = {} for (let i = 0; i < columnCount; i++) { columns[`col-${i}`] = [] } const newField = { id: crypto.randomUUID(), type: 'grid', name: label, label: label, placeholder: '', required: false, columns: columns, conditionalLogic: { enabled: false, match: 'all', action: 'show', rules: [] } } const updated = insertFieldAtPath(fields, applyFilters('field.create', newField, newField), targetContainer || { type: 'top' }, index) setFields(updated) setActiveFieldId(newField.id) setPendingGridDrop(null) setIsGridModalOpen(false) } /** * Where a field lives. Recurses into every container with nested columns (`columns` arrays are definitions and * are skipped). For nested fields `gridId` is the direct parent. */ const findFieldPath = (list, id, parent = null) => { for (let i = 0; i < list.length; i++) { const item = list[i] if (item.id === id) { return parent ? { containerType: 'grid', gridId: parent.gridId, colKey: parent.colKey, index: i, field: item } : { containerType: 'top', gridId: null, colKey: null, index: i, field: item } } if (hasNestedColumns(item)) { for (const colKey of Object.keys(item.columns)) { const found = findFieldPath(item.columns[colKey] || [], id, { gridId: item.id, colKey }) if (found) return found } } } return null } const removeFieldById = (list, id) => { return list .filter(item => item.id !== id) .map(item => { if (hasNestedColumns(item)) { const updatedColumns = {} Object.keys(item.columns).forEach(key => { updatedColumns[key] = removeFieldById(item.columns[key] || [], id) }) return { ...item, columns: updatedColumns } } return item }) } const insertFieldAtPath = (list, field, targetContainer, index) => { if (targetContainer.type === 'top') { const newList = [...list] if (index !== null && index !== undefined) { newList.splice(index, 0, field) } else { newList.push(field) } return newList } else { return list.map(item => { if (item.id === targetContainer.gridId && (hasNestedColumns(item) || !item.columns)) { const updatedColumns = { ...(item.columns || {}) } const colFields = [...(updatedColumns[targetContainer.colKey] || [])] if (index !== null && index !== undefined) { colFields.splice(index, 0, field) } else { colFields.push(field) } updatedColumns[targetContainer.colKey] = colFields return { ...item, columns: updatedColumns } } if (hasNestedColumns(item)) { const updatedColumns = {} Object.keys(item.columns).forEach(key => { updatedColumns[key] = insertFieldAtPath(item.columns[key] || [], field, targetContainer, index) }) return { ...item, columns: updatedColumns } } return item }) } } /** GridColumn droppables use the id `${containerId}__${colKey}`; returns null for anything else. */ const parseColumnDroppableId = (overId, currentFields) => { if (typeof overId !== 'string') return null const sep = overId.indexOf('__') if (sep <= 0) return null const gridId = overId.slice(0, sep) const colKey = overId.slice(sep + 2) const container = findFieldPath(currentFields, gridId)?.field if (!container || !hasNestedColumns(container) || !Object.prototype.hasOwnProperty.call(container.columns, colKey)) { // Containers without columns yet render one default 'col-0' column. if (container && !container.columns && colKey === 'col-0') return { gridId, colKey } return null } return { gridId, colKey } } const parseTargetContainer = (overId, currentFields) => { if (overId === 'canvas-dropzone') { return { type: 'top', gridId: null, colKey: null, index: null } } const column = parseColumnDroppableId(overId, currentFields) if (column) return { type: 'grid', gridId: column.gridId, colKey: column.colKey, index: null } const path = findFieldPath(currentFields, overId) if (path) { return { type: path.containerType, gridId: path.gridId, colKey: path.colKey, index: path.index } } return { type: 'top', gridId: null, colKey: null, index: null } } /** * Current layout box of a field on the canvas: the live box (drop line included) minus the sortable shift * animation (translate), so before/after doesn't flip-flop while neighbours slide. */ const getLayoutRect = (fieldId) => { if (typeof document === 'undefined' || typeof fieldId !== 'string') return null const el = document.querySelector(`[data-preview-area="true"] [data-field-id="${CSS.escape(fieldId)}"]`) if (!el) return null const box = el.getBoundingClientRect() const t = window.getComputedStyle(el).transform let ty = 0 if (t && t !== 'none') { try { ty = new DOMMatrixReadOnly(t).m42 } catch (e) { ty = 0 } } // The sortable shift is the field's target position while dragging (neighbours make room for the dragged // field), so before/after is measured against where the field is drawn; only a shift still animating towards // that target (transition running) is ignored. const settled = el.getAnimations ? el.getAnimations().length === 0 : true return { top: settled ? box.top : box.top - ty, height: box.height } } /** Pointer position during a drag (pointer/touch), else the centre of the dragged rect (keyboard). */ const getDragPointerY = (event) => { const start = event.activatorEvent const startY = start && (typeof start.clientY === 'number' ? start.clientY : start.touches?.[0]?.clientY) if (typeof startY === 'number') return startY + (event.delta?.y || 0) const r = event.active?.rect?.current?.translated return r ? r.top + r.height / 2 : null } /** * Drop target for the current pointer: container + insertion index. * dragOverIndex semantics (read by Canvas/GridColumn to draw the drop line): * - top level: index into the global `fields` array to insert BEFORE (the line is drawn above the field with * that index); "after the last field of this page" = the next same-page field's index, or fields.length. * - container column: index into that column's list (0..n); null = append. * Before/after is decided by the pointer against the hovered field's vertical midpoint (the reference builder calculateHoverIndex). */ const computeDropTarget = (event) => { const { active, over } = event if (!over) return null const currentFields = useFormStore.getState().fields const targetContainer = parseTargetContainer(over.id, currentFields) // the reference builder rules: a grid can go inside a column (a palette grid opens the column-count modal with that column as // target); field types registered with `allowInColumns: false` cannot; a container never goes inside itself. const activeType = active.data.current?.isPalette ? active.data.current.type : findFieldPath(currentFields, active.id)?.field?.type const activeDef = getFieldType(activeType) if (activeDef && activeDef.allowInColumns === false && targetContainer.type === 'grid') { return { blocked: true, reason: activeDef.columnBlockedMessage || `${activeDef.label || activeType} cannot be added inside a Grid column.` } } if (!active.data.current?.isPalette && targetContainer.type === 'grid') { const activeField = findFieldPath(currentFields, active.id)?.field if (activeField && collectFieldIds([activeField]).includes(targetContainer.gridId)) return { blocked: true } } let index = null const overIsField = over.id !== 'canvas-dropzone' && !parseColumnDroppableId(over.id, currentFields) if (!overIsField) { // Pointer over a container gap (canvas or column): insert before the first child whose midpoint is below it. const pointerY = getDragPointerY(event) let children = [] if (targetContainer.type === 'top') { // Only the fields drawn on the canvas (an extension may show a subset of the form). const shown = applyFilters('canvas.visibleFields', currentFields, useFormStore.getState()) children = shown.map((f) => ({ id: f.id, index: currentFields.indexOf(f) })) } else { const container = findFieldPath(currentFields, targetContainer.gridId)?.field children = ((container?.columns || {})[targetContainer.colKey] || []).map((f, i) => ({ id: f.id, index: i })) } if (pointerY !== null && children.length) { for (const child of children) { if (child.id === active.id) continue const rect = getLayoutRect(child.id) if (rect && pointerY < rect.top + rect.height / 2) { index = child.index; break } } if (index === null && targetContainer.type === 'top') index = currentFields.length } } if (overIsField) { const path = findFieldPath(currentFields, over.id) if (path) { const pointerY = getDragPointerY(event) // dnd-kit's over.rect is measured at drag start and goes stale once the drop line moves fields. const rect = getLayoutRect(over.id) || over.rect const after = pointerY !== null && rect ? pointerY > rect.top + rect.height / 2 : false if (!after) { index = path.index } else if (path.containerType === 'top') { // After the hovered field = before the next field drawn on the canvas (or at the end). const shown = applyFilters('canvas.visibleFields', currentFields, useFormStore.getState()) let next = currentFields.length for (let i = path.index + 1; i < currentFields.length; i++) { if (shown.includes(currentFields[i])) { next = i; break } } index = next } else { index = path.index + 1 } } } return { targetContainer, index } } const sameContainer = (a, b) => !!a && !!b && a.type === b.type && (a.type === 'top' || (a.gridId === b.gridId && a.colKey === b.colKey)) // Why the current drop position is refused (shown as a notice if the field is released there). const blockedDropRef = React.useRef(null) const applyDropTarget = (event) => { const target = computeDropTarget(event) blockedDropRef.current = target && target.blocked ? target.reason || 'blocked' : null if (!target || target.blocked) { resetDragOver() return } const state = useFormStore.getState() if (!sameContainer(state.dragOverContainer, target.targetContainer)) setDragOverContainer(target.targetContainer) if (state.dragOverIndex !== target.index) setDragOverIndex(target.index) } // onDragOver only fires when the hovered droppable changes; onDragMove keeps before/after current while the // pointer moves across the same field. const onDragOver = (event) => applyDropTarget(event) const onDragMove = (event) => applyDropTarget(event) const countSubmitFields = (list) => { let n = 0 ;(list || []).forEach((f) => { if (f.type === 'submit') n++ if (hasNestedColumns(f)) Object.values(f.columns).forEach((col) => { n += countSubmitFields(col) }) }) return n } const handleDragEnd = (event) => { const { active } = event setActiveDragItem(null) if (blockedDropRef.current && blockedDropRef.current !== 'blocked') { toast.warning(blockedDropRef.current) } blockedDropRef.current = null // Read the drop target from the store: the last onDragMove may not have re-rendered yet. const { dragOverContainer, dragOverIndex, fields } = useFormStore.getState() // Ensure we have active coordinates to insert or reorder if (dragOverContainer) { // Case 1: Dragging a palette item if (active.data.current?.isPalette) { const fieldType = active.data.current.type const fieldLabel = active.data.current.label // One submit button per form (the reference builder rule). if (fieldType === 'submit' && countSubmitFields(fields) > 0) { toast.warning('This form already has a Submit button. A form can only have one.') resetDragOver() originalFieldsRef.current = null return } if (fieldType === 'grid') { setPendingGridDrop({ label: fieldLabel, targetContainer: dragOverContainer, index: dragOverIndex }) setIsGridModalOpen(true) resetDragOver() originalFieldsRef.current = null return } // Same defaults as click-to-add (consent text, choices, registered field type defaults, …). const newField = createFieldWithDefaults({ type: fieldType, name: fieldLabel, ...(['checkbox', 'radio', 'select'].includes(fieldType) ? { options: [ { label: 'First Choice', value: 'first_choice' }, { label: 'Second Choice', value: 'second_choice' }, { label: 'Third Choice', value: 'third_choice' } ] } : {}), }) const updated = insertFieldAtPath(fields, newField, dragOverContainer, dragOverIndex) if (findFieldPath(updated, newField.id)) { setFields(updated) setActiveFieldId(newField.id) } } else { // Case 2: Reordering existing field. dragOverIndex is an insertion index in the original list, so // removing the dragged field first shifts later slots of the same container up by one. const activePath = findFieldPath(fields, active.id) if (activePath) { const activeContainer = { type: activePath.containerType, gridId: activePath.gridId, colKey: activePath.colKey } let targetIndex = dragOverIndex if (sameContainer(activeContainer, dragOverContainer) && dragOverIndex !== null && activePath.index < dragOverIndex) { targetIndex = dragOverIndex - 1 } const cleaned = removeFieldById(fields, active.id) const updated = insertFieldAtPath(cleaned, activePath.field, dragOverContainer, targetIndex) // Never drop the field when the target container vanished (e.g. it was the dragged field itself). if (findFieldPath(updated, active.id)) { setFields(updated) } } } } resetDragOver() originalFieldsRef.current = null } const handleDragCancel = () => { blockedDropRef.current = null setActiveDragItem(null) resetDragOver() originalFieldsRef.current = null } const renderDragOverlay = () => { if (!activeDragItem) return null if (activeDragItem.data.current?.isPalette) { const type = activeDragItem.data.current.type const label = activeDragItem.data.current.label const Icon = ICON_MAP[type] || getFieldType(type)?.icon || Type return (
{formLoadError}
Provide a new administration label for this form.
Embed this form inside your WordPress content.
Copy this shortcode and paste it directly inside any WordPress block, post, page, or sidebar widget:
{`[wpvfb id="${formId}"]`}
Conditional logic allows you to change what the user sees depending on the fields they select.