import React, { useEffect, useMemo, useRef, useState } from 'react' import { Plus, Pencil, Trash2, Copy, Download, Upload, Search, ListFilter, FileText, FileJson, CheckCircle2, AlertTriangle, RotateCcw, X, ChevronUp, ChevronDown, Check, } from 'lucide-react' import { toast } from '../store/useToastStore' import { apiRequest, hasApi } from '../utils/api' import { downloadBlob, slugify } from '../utils/entries' import { applyFilters, Slot, useRegistryVersion } from '../extensions/registry' const PAGE_SIZES = [10, 25, 50] const EXPORT_VERSION = '1.0' const GENERATOR = 'EFE Visual Forms' // WP admin styles form controls with higher specificity than Tailwind utilities, hence the "!" overrides. const SELECT_CLS = '!px-3.5 !pr-8 !py-2 !text-sm !font-semibold !border !border-slate-300 !rounded-xl !bg-white !text-slate-800 !shadow-sm !min-h-[38px] !leading-5 !max-w-none focus:!ring-2 focus:!ring-slate-900 focus:!outline-none cursor-pointer' const BTN_SECONDARY = 'inline-flex items-center gap-1.5 bg-white border border-slate-300 hover:bg-slate-50 text-slate-800 font-semibold px-3.5 py-2 rounded-xl text-sm shadow-sm transition-colors shrink-0 cursor-pointer' const BTN_PRIMARY = 'inline-flex items-center gap-1.5 bg-gray-900 hover:bg-black text-white font-semibold px-4 py-2 rounded-xl text-sm shadow-sm transition-colors shrink-0 cursor-pointer' const ICON_BTN = 'w-8 h-8 rounded-lg bg-white border border-slate-200 hover:border-slate-300 hover:bg-slate-50 text-slate-600 hover:text-slate-950 shadow-sm inline-flex items-center justify-center transition-all shrink-0 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed' const isActive = (f) => String(f.is_active) === '1' || f.is_active === true const countFields = (fields) => { let list = fields if (typeof list === 'string') { try { list = JSON.parse(list) } catch (e) { list = [] } } return Array.isArray(list) ? list.length : 0 } /** Reads a single-form or bundle export; returns {forms:[...]} or throws a user-facing message. */ export function parseImportPayload(parsed) { const invalid = 'Invalid JSON file or unsupported schema. Must contain a fields array.' if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(invalid) let items if (Array.isArray(parsed.forms)) items = parsed.forms else if (parsed.form && typeof parsed.form === 'object') items = [parsed.form] else items = [parsed] // settings may be an empty array: PHP encodes an empty settings map as []. if (!items.length || items.some((f) => !f || typeof f !== 'object' || !Array.isArray(f.fields) || (f.settings !== undefined && f.settings !== null && typeof f.settings !== 'object'))) { throw new Error(invalid) } return { forms: items, bundle: Array.isArray(parsed.forms) } } const toExportForm = (detail) => ({ name: detail.name || 'Untitled Form', fields: Array.isArray(detail.fields) ? detail.fields : [], settings: detail.settings && typeof detail.settings === 'object' && !Array.isArray(detail.settings) ? detail.settings : {}, confirmations: Array.isArray(detail.confirmations) ? detail.confirmations : [], notifications: Array.isArray(detail.notifications) ? detail.notifications : [], }) function Switch({ checked, disabled, onChange, label }) { return ( ) } function ConfirmModal({ title, message, confirmLabel, busy, onCancel, onConfirm }) { return (

{title}

{message}

) } function SortHead({ label, sortKey, sort, onSort, className = '' }) { return ( ) } /** * Forms list. Extensions add type filters (`formsList.typeFilters` filter: [{ value, label, match(form) }]), badges * next to a form name (slot `formsList.badges`, receives { form }) and import details (slot `formsList.importDetails`, * receives { form }). */ export default function FormsListScreen({ forms, loading, onReload, onCreateForm, onEditForm, onViewEntries, onManageEntries }) { useRegistryVersion() const typeFilters = applyFilters('formsList.typeFilters', []) || [] const [rows, setRows] = useState(forms || []) useEffect(() => { setRows(forms || []) }, [forms]) const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState('all') // all | active | inactive const [typeFilter, setTypeFilter] = useState('all') // 'all' or an extension filter value const [sort, setSort] = useState({ key: 'created_at', dir: 'desc' }) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(10) const [selected, setSelected] = useState([]) const [bulkAction, setBulkAction] = useState('') const [busyId, setBusyId] = useState(null) const [togglingIds, setTogglingIds] = useState([]) const [deleteTarget, setDeleteTarget] = useState(null) // form row | 'bulk' const [deleting, setDeleting] = useState(false) const [copiedId, setCopiedId] = useState(null) // Import modal const [importOpen, setImportOpen] = useState(false) const [importFile, setImportFile] = useState(null) const [importParsed, setImportParsed] = useState(null) const [importName, setImportName] = useState('') const [importError, setImportError] = useState(null) const [importing, setImporting] = useState(false) const [dragging, setDragging] = useState(false) const fileInputRef = useRef(null) const statusCounts = useMemo(() => ({ all: rows.length, active: rows.filter(isActive).length, inactive: rows.filter((f) => !isActive(f)).length, }), [rows]) const typeCounts = { all: rows.length } typeFilters.forEach((t) => { typeCounts[t.value] = rows.filter((f) => t.match(f)).length }) const activeTypeFilter = typeFilters.find((t) => t.value === typeFilter) || null const filtered = useMemo(() => { const q = search.trim().toLowerCase() const list = rows.filter((f) => { if (q && !String(f.name || '').toLowerCase().includes(q)) return false if (statusFilter === 'active' && !isActive(f)) return false if (statusFilter === 'inactive' && isActive(f)) return false if (activeTypeFilter && !activeTypeFilter.match(f)) return false return true }) const val = (f) => { switch (sort.key) { case 'name': return String(f.name || '').toLowerCase() case 'status': return isActive(f) ? 1 : 0 case 'entries': return Number(f.entries_count) || 0 default: return String(f.created_at || '') } } return list .map((f, i) => ({ f, i })) .sort((a, b) => { const av = val(a.f), bv = val(b.f) if (av === bv) return a.i - b.i return (av > bv ? 1 : -1) * (sort.dir === 'asc' ? 1 : -1) }) .map((x) => x.f) }, [rows, search, statusFilter, activeTypeFilter, sort]) const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)) useEffect(() => { if (page > totalPages) setPage(totalPages) }, [page, totalPages]) const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize) // Drop selections that are no longer visible (deleted or filtered away). useEffect(() => { const ids = new Set(filtered.map((f) => f.id)) setSelected((s) => (s.every((id) => ids.has(id)) ? s : s.filter((id) => ids.has(id)))) }, [filtered]) const resetPaging = () => { setPage(1) } const onSort = (key) => setSort((s) => ({ key, dir: s.key === key && s.dir === 'asc' ? 'desc' : 'asc' })) const allOnPageSelected = pageRows.length > 0 && pageRows.every((f) => selected.includes(f.id)) const someOnPageSelected = pageRows.some((f) => selected.includes(f.id)) && !allOnPageSelected const selectAllRef = useRef(null) useEffect(() => { if (selectAllRef.current) selectAllRef.current.indeterminate = someOnPageSelected }, [someOnPageSelected]) const toggleSelectAll = (checked) => { const ids = pageRows.map((f) => f.id) setSelected((s) => (checked ? Array.from(new Set([...s, ...ids])) : s.filter((id) => !ids.includes(id)))) } const toggleRow = (id, checked) => setSelected((s) => (checked ? [...s, id] : s.filter((x) => x !== id))) const handleToggleActive = async (form) => { const next = !isActive(form) setTogglingIds((t) => [...t, form.id]) setRows((r) => r.map((f) => (f.id === form.id ? { ...f, is_active: next ? '1' : '0' } : f))) try { await apiRequest(`/forms/${form.id}/active`, { method: 'POST', body: { active: next } }) toast.success(next ? `"${form.name}" is now active` : `"${form.name}" is now inactive`) } catch (err) { setRows((r) => r.map((f) => (f.id === form.id ? { ...f, is_active: next ? '0' : '1' } : f))) toast.error(`Could not update status: ${err.message}`) } finally { setTogglingIds((t) => t.filter((id) => id !== form.id)) } } const handleDuplicate = async (form) => { setBusyId(form.id) try { const res = await apiRequest(`/forms/${form.id}/duplicate`, { method: 'POST', body: {} }) toast.success(`Duplicated as "${res.name}"`) await onReload() } catch (err) { toast.error(`Could not duplicate form: ${err.message}`) } finally { setBusyId(null) } } const fetchExportForm = async (id) => toExportForm(await apiRequest(`/forms/${id}`)) const handleExportOne = async (form) => { setBusyId(form.id) try { const payload = { version: EXPORT_VERSION, generator: GENERATOR, exportedAt: new Date().toISOString(), form: await fetchExportForm(form.id), } downloadBlob(JSON.stringify(payload, null, 2), `${slugify(form.name)}-wpvfb.json`, 'application/json') toast.success(`Exported "${form.name}"`) } catch (err) { toast.error(`Could not export form: ${err.message}`) } finally { setBusyId(null) } } const handleBulkExport = async () => { try { const list = [] for (const id of selected) list.push(await fetchExportForm(id)) const payload = { version: EXPORT_VERSION, bundle: true, generator: GENERATOR, exportedAt: new Date().toISOString(), formsCount: list.length, forms: list, } downloadBlob(JSON.stringify(payload, null, 2), `wpvfb-forms-${new Date().toISOString().slice(0, 10)}.json`, 'application/json') toast.success(`Exported ${list.length} form${list.length === 1 ? '' : 's'}`) } catch (err) { toast.error(`Could not export forms: ${err.message}`) } } const handleBulkApply = () => { if (!selected.length || !bulkAction) return if (bulkAction === 'delete') setDeleteTarget('bulk') if (bulkAction === 'export') handleBulkExport() } const handleConfirmDelete = async () => { setDeleting(true) try { if (deleteTarget === 'bulk') { const res = await apiRequest('/forms/bulk-delete', { method: 'POST', body: { ids: selected } }) toast.success(`Deleted ${res.deleted} form${res.deleted === 1 ? '' : 's'}`) setSelected([]) setBulkAction('') } else { await apiRequest(`/forms/${deleteTarget.id}`, { method: 'DELETE' }) toast.success(`Deleted "${deleteTarget.name}"`) } setDeleteTarget(null) await onReload() } catch (err) { toast.error(`Could not delete: ${err.message}`) } finally { setDeleting(false) } } const resetImport = () => { setImportFile(null); setImportParsed(null); setImportName(''); setImportError(null) if (fileInputRef.current) fileInputRef.current.value = '' } const parseFile = (file) => { if (!file) return resetImport() setImportFile(file) const reader = new FileReader() reader.onload = (e) => { try { const parsed = parseImportPayload(JSON.parse(String(e.target.result))) setImportParsed(parsed) if (!parsed.bundle) setImportName(`${parsed.forms[0].name || 'Imported Form'} (Imported)`) } catch (err) { setImportError(err instanceof SyntaxError ? 'Invalid JSON file or unsupported schema. Must contain a fields array.' : err.message) } } reader.onerror = () => setImportError('Failed to read file.') reader.readAsText(file) } const handleConfirmImport = async () => { if (!importParsed) return setImporting(true) try { const body = importParsed.bundle ? { forms: importParsed.forms } : { form: importParsed.forms[0], name: importName.trim() } const res = await apiRequest('/forms/import', { method: 'POST', body }) toast.success(res.forms.length === 1 ? `Imported "${res.forms[0].name}"` : `Imported ${res.forms.length} forms`) setImportOpen(false) resetImport() await onReload() } catch (err) { setImportError(err.message) } finally { setImporting(false) } } const copyShortcode = (id) => { const text = `[wpvfb id="${id}"]` if (navigator.clipboard) navigator.clipboard.writeText(text).catch(() => {}) setCopiedId(id) setTimeout(() => setCopiedId(null), 1500) } const clearFilters = () => { setSearch(''); setStatusFilter('all'); setTypeFilter('all'); setPage(1) } const firstLoad = loading && rows.length === 0 const tabCls = (on) => `inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-sm font-semibold transition-all cursor-pointer ${on ? 'bg-white text-slate-950 font-bold shadow-sm' : 'text-slate-600 hover:text-slate-900'}` return (
{/* Header */}

Forms

Create, customize, and manage all forms on your site.

{/* Controls */}
{typeFilters.length > 0 && ( )}
{/* Table / empty state */}
{!firstLoad && rows.length === 0 ? (

No forms yet

Create your first form to start collecting submissions, or import one from a JSON export.

) : (
{firstLoad ? ( Array.from({ length: 6 }).map((_, i) => ( )) ) : filtered.length === 0 ? ( ) : pageRows.map((form) => { const active = isActive(form) const isSel = selected.includes(form.id) return ( ) })}
toggleSelectAll(e.target.checked)} className="cursor-pointer" /> Shortcode Actions

No forms found

{search ? 'No forms match your search query.' : 'No forms found matching the selected filters.'}

toggleRow(form.id, e.target.checked)} className="cursor-pointer" /> handleToggleActive(form)} label={`${active ? 'Deactivate' : 'Activate'} ${form.name}`} />
{Number(form.entries_count) || 0} {form.created_at ? new Date(String(form.created_at).replace(' ', 'T')).toLocaleDateString() : 'N/A'}
)}
{/* Pagination */} {rows.length > 0 && (
{filtered.length} {filtered.length === 1 ? 'form' : 'forms'}
{[['First', 1], ['Prev', page - 1]].map(([label, to]) => ( ))} {page} / {totalPages} {[['Next', page + 1], ['Last', totalPages]].map(([label, to]) => ( ))}
)}
{deleteTarget && ( setDeleteTarget(null)} onConfirm={handleConfirmDelete} /> )} {importOpen && (

Import Form from JSON

Upload a form export file to create a new form.

{ e.preventDefault(); setDragging(true) }} onDragLeave={(e) => { e.preventDefault(); setDragging(false) }} onDrop={(e) => { e.preventDefault(); setDragging(false); if (e.dataTransfer.files && e.dataTransfer.files[0]) parseFile(e.dataTransfer.files[0]) }} onClick={() => fileInputRef.current && fileInputRef.current.click()} className={`border-2 border-dashed rounded-2xl p-6 flex flex-col items-center justify-center text-center cursor-pointer transition-all ${dragging ? 'border-slate-900 bg-slate-50' : importParsed ? 'border-emerald-300 bg-emerald-50/40' : 'border-slate-300 hover:border-slate-400 bg-slate-50/50'}`} > { if (e.target.files && e.target.files[0]) parseFile(e.target.files[0]) }} /> {importParsed ? (
Valid form JSON detected {importFile && importFile.name} ({importFile ? (importFile.size / 1024).toFixed(1) : 0} KB)
) : (
Click or drag & drop a JSON file here Supports single-form and bundle exports
)}
{importError && (
{importError}
)} {importParsed && (
{importParsed.bundle ? (
{importParsed.forms.length} forms will be imported:
    {importParsed.forms.slice(0, 8).map((f, i) =>
  • {f.name || 'Imported Form'}
  • )} {importParsed.forms.length > 8 &&
  • and {importParsed.forms.length - 8} more
  • }
) : ( <>
setImportName(e.target.value)} placeholder="Enter form name" className="w-full !px-3 !py-2 !text-sm !border !border-slate-300 !rounded-lg !bg-white !text-slate-900 !min-h-[36px] focus:!ring-2 focus:!ring-slate-900 focus:!outline-none" />
{countFields(importParsed.forms[0].fields)} fields
)}
)}
)}
) }