feat: build React Form Builder (F2.7-F2.10)

Components:
- FieldPalette: drag-and-drop source for all field types
- FormCanvas: drop target with field list, reordering, CRUD
- FormField: individual field component with actions
- FieldSettingsPanel: edit field properties (label, ID, options, etc.)
- FormFieldOptions: manage select/radio/checkbox options
- FormPreview: live preview of rendered form
- FormFieldPreview: preview individual field types

Features:
- 16 field types (text, email, select, checkbox, radio, etc.)
- Categorized field palette
- Drag-and-drop field reordering
- Per-field settings panel
- Option management for choice fields
- Live form preview
- Save via AJAX

Config:
- fieldTypes.js: field definitions and constants
- Generate unique field IDs
- Field type categories (input, choice, layout, preset, advanced)
This commit is contained in:
dwindown
2026-04-18 11:34:13 +07:00
parent 9b2538bdd9
commit ec1f01ef24
18 changed files with 1434 additions and 4 deletions

View File

@@ -0,0 +1,69 @@
.formipay-field-palette {
padding: 16px;
background: #fff;
border-right: 1px solid #e0e0e0;
overflow-y: auto;
max-height: calc(100vh - 100px);
}
.formipay-field-palette h3 {
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
color: #1e1e1e;
}
.formipay-palette-category {
margin-bottom: 20px;
}
.formipay-palette-category h4 {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: #646970;
}
.formipay-palette-items {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.formipay-palette-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #f0f0f1;
border: 1px solid #dcdcde;
border-radius: 4px;
cursor: grab;
transition: all 0.2s;
user-select: none;
}
.formipay-palette-item:hover {
background: #fff;
border-color: #2271b1;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.formipay-palette-item:active {
cursor: grabbing;
}
.formipay-palette-item svg {
width: 18px;
height: 18px;
fill: #1e1e1e;
}
.formipay-palette-item span {
font-size: 12px;
color: #1e1e1e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,50 @@
/**
* Field Palette - Drag-and-drop source for form fields
*/
import { __ } from '@wordpress/i18n';
import { Icon } from '@wordpress/icons';
import { FIELD_CATEGORIES, getFieldTypesByCategory } from '../../config/fieldTypes';
import './FieldPalette.css';
export default function FieldPalette({ onDragStart }) {
const fieldTypesByCategory = getFieldTypesByCategory();
const categories = Object.values(FIELD_CATEGORIES).sort((a, b) => a.order - b.order);
const handleDragStart = (event, fieldType) => {
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('formipay-field-type', fieldType);
onDragStart?.(fieldType);
};
return (
<div className="formipay-field-palette">
<h3>{ __('Add Field', 'formipay') }</h3>
{categories.map((category) => {
const fields = fieldTypesByCategory[category.label] || [];
if (fields.length === 0) return null;
return (
<div key={category.label} className="formipay-palette-category">
<h4>{ category.label }</h4>
<div className="formipay-palette-items">
{fields.map((field) => (
<div
key={field.type}
className="formipay-palette-item"
draggable
onDragStart={(e) => handleDragStart(e, field.type)}
title={field.label}
>
<Icon icon={field.icon} />
<span>{ field.label }</span>
</div>
))}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,48 @@
.formipay-field-settings-panel {
width: 320px;
background: #fff;
border-left: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
max-height: calc(100vh - 100px);
}
.formipay-settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
}
.formipay-settings-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.formipay-settings-header .field-type-badge {
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
background: #e0e0e0;
border-radius: 2px;
}
.formipay-settings-content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.formipay-settings-content .components-base-control {
margin-bottom: 16px;
}
.formipay-settings-content .components-base-control:last-child {
margin-bottom: 0;
}

View File

@@ -0,0 +1,129 @@
/**
* Field Settings Panel - Edit field properties
*/
import { __ } from '@wordpress/i18n';
import { TextControl, CheckboxControl, SelectControl, TextareaControl } from '@wordpress/components';
import { FIELD_TYPES } from '../../config/fieldTypes';
import FormFieldOptions from './FormFieldOptions';
import './FieldSettingsPanel.css';
export default function FieldSettingsPanel({
field,
open,
onClose,
onUpdate,
}) {
if (!open || !field) {
return null;
}
const fieldConfig = FIELD_TYPES[field.field_type] || FIELD_TYPES.text;
const hasOptions = ['select', 'checkbox', 'radio'].includes(field.field_type);
const hasPlaceholder = [
'text', 'url', 'email', 'tel', 'number', 'date', 'datetime', 'color',
'select', 'checkbox', 'radio', 'hidden', 'textarea', 'country_list'
].includes(field.field_type);
const hasDefaultValue = [
'text', 'url', 'email', 'tel', 'number', 'date', 'datetime', 'color',
'select', 'checkbox', 'radio', 'hidden', 'textarea'
].includes(field.field_type);
const hasDescription = !['hidden'].includes(field.field_type);
const isRequired = !['divider', 'page_break', 'hidden'].includes(field.field_type);
const hasGridColumns = ['radio', 'checkbox'].includes(field.field_type);
const handleChange = (key, value) => {
onUpdate?.({ ...field, [key]: value });
};
return (
<div className="formipay-field-settings-panel">
<div className="formipay-settings-header">
<h3>
<span className="field-type-badge">{ fieldConfig.label }</span>
{ __('Field Settings', 'formipay') }
</h3>
<button
type="button"
className="button-link"
onClick={onClose}
>
{ __('Close', 'formipay') }
</button>
</div>
<div className="formipay-settings-content">
<TextControl
label={ __('Label', 'formipay') }
value={field.label || ''}
onChange={(value) => handleChange('label', value)}
help={ __('The display label for this field', 'formipay') }
/>
<TextControl
label={ __('Field ID', 'formipay') }
value={field.field_id || ''}
onChange={(value) => handleChange('field_id', value)}
help={ __('Unique identifier for this field (used in form data)', 'formipay') }
/>
{ hasPlaceholder && (
<TextControl
label={ __('Placeholder', 'formipay') }
value={field.placeholder || ''}
onChange={(value) => handleChange('placeholder', value)}
/>
) }
{ hasDefaultValue && (
<TextControl
label={ __('Default Value', 'formipay') }
value={field.default_value || ''}
onChange={(value) => handleChange('default_value', value)}
/>
) }
{ hasDescription && (
<TextareaControl
label={ __('Description', 'formipay') }
value={field.description || ''}
onChange={(value) => handleChange('description', value)}
help={ __('Optional help text displayed below the field', 'formipay') }
rows={3}
/>
) }
{ hasOptions && (
<FormFieldOptions
options={field.field_options || []}
onChange={(value) => handleChange('field_options', value)}
fieldType={field.field_type}
/>
) }
{ hasGridColumns && (
<SelectControl
label={ __('Option Grid Columns', 'formipay') }
value={field.option_grid_columns || 1}
options={[
{ label: __('1 Column', 'formipay'), value: 1 },
{ label: __('2 Columns', 'formipay'), value: 2 },
{ label: __('3 Columns', 'formipay'), value: 3 },
{ label: __('4 Columns', 'formipay'), value: 4 },
]}
onChange={(value) => handleChange('option_grid_columns', parseInt(value))}
/>
) }
{ isRequired && (
<CheckboxControl
label={ __('Required Field', 'formipay') }
checked={field.is_required || false}
onChange={(value) => handleChange('is_required', value)}
help={ __('User must fill this field before submitting', 'formipay') }
/>
) }
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
.formipay-form-builder {
display: flex;
flex-direction: column;
height: calc(100vh - 32px);
}
.formipay-builder-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: #fff;
border-bottom: 1px solid #e0e0e0;
}
.formipay-builder-toolbar h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.formipay-builder-actions {
display: flex;
gap: 8px;
}
.formipay-builder-content {
flex: 1;
display: flex;
overflow: hidden;
}

View File

@@ -0,0 +1,114 @@
/**
* Form Builder - Main container component
*/
import { __ } from '@wordpress/i18n';
import { useState, useCallback } from '@wordpress/element';
import FormCanvas from './FormCanvas';
import FieldPalette from './FieldPalette';
import FieldSettingsPanel from './FieldSettingsPanel';
import FormPreview from './FormPreview';
import './FormBuilder.css';
export default function FormBuilder({ formId, initialData = {} }) {
const [fields, setFields] = useState(initialData.fields || []);
const [selectedFieldId, setSelectedFieldId] = useState(null);
const selectedField = fields.find(f => f.field_id === selectedFieldId) || null;
const handleDrop = useCallback((newField) => {
setFields([...fields, newField]);
setSelectedFieldId(newField.field_id);
}, [fields]);
const handleSelectField = useCallback((fieldId) => {
setSelectedFieldId(fieldId);
}, []);
const handleUpdateField = useCallback((fieldId, updates) => {
setFields(fields.map(f => f.field_id === fieldId ? { ...f, ...updates } : f));
}, [fields]);
const handleMoveField = useCallback((newFields) => {
setFields(newFields);
}, []);
const handleDeleteField = useCallback((fieldId) => {
const newFields = fields.filter(f => f.field_id !== fieldId);
setFields(newFields);
if (selectedFieldId === fieldId) {
setSelectedFieldId(null);
}
}, [fields, selectedFieldId]);
const handleSave = useCallback(() => {
// Save form fields via AJAX
const formData = new FormData();
formData.append('action', 'formipay_save_form_fields');
formData.append('post_id', formId);
formData.append('fields', JSON.stringify(fields));
formData.append('_wpnonce', window.formipayAdmin?.nonce || '');
fetch(window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php', {
method: 'POST',
credentials: 'same-origin',
body: formData,
})
.then(response => response.json())
.then(result => {
if (result.success) {
// Show success message
console.log('Form saved successfully');
} else {
console.error('Failed to save form:', result.message);
}
})
.catch(error => {
console.error('Save error:', error);
});
}, [fields, formId]);
return (
<div className="formipay-form-builder">
<div className="formipay-builder-toolbar">
<h2>{ __('Form Builder', 'formipay') }</h2>
<div className="formipay-builder-actions">
<button
type="button"
className="button button-secondary"
onClick={() => setFields([])}
>
{ __('Clear All', 'formipay') }
</button>
<button
type="button"
className="button button-primary"
onClick={handleSave}
>
{ __('Save Form', 'formipay') }
</button>
</div>
</div>
<div className="formipay-builder-content">
<FieldPalette onDragStart={() => {}} />
<FormCanvas
fields={fields}
selectedFieldId={selectedFieldId}
onDrop={handleDrop}
onSelectField={handleSelectField}
onUpdateField={handleUpdateField}
onMoveField={handleMoveField}
onDeleteField={handleDeleteField}
/>
<FormPreview fields={fields} />
<FieldSettingsPanel
field={selectedField}
open={!!selectedField}
onClose={() => setSelectedFieldId(null)}
onUpdate={handleUpdateField}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
.formipay-form-canvas {
flex: 1;
display: flex;
flex-direction: column;
background: #f6f7f7;
min-width: 0;
}
.formipay-canvas-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #fff;
border-bottom: 1px solid #e0e0e0;
}
.formipay-canvas-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1e1e1e;
}
.field-count {
font-size: 12px;
color: #646970;
}
.formipay-canvas-area {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.formipay-canvas-area.is-empty {
display: flex;
align-items: center;
justify-content: center;
}
.formipay-empty-state {
text-align: center;
color: #646970;
}
.formipay-empty-state svg {
display: block;
margin: 0 auto 16px;
fill: #c3c4c7;
}
.formipay-empty-state p {
margin: 0;
font-size: 14px;
}
.formipay-fields-list {
display: flex;
flex-direction: column;
gap: 12px;
}

View File

@@ -0,0 +1,103 @@
/**
* Form Canvas - Drop target for form fields
*/
import { __ } from '@wordpress/i18n';
import { Icon, plus } from '@wordpress/icons';
import { DEFAULT_FIELD_CONFIG, generateFieldId } from '../../config/fieldTypes';
import FormField from './FormField';
import './FormCanvas.css';
export default function FormCanvas({
fields = [],
selectedFieldId,
onDrop,
onSelectField,
onUpdateField,
onMoveField,
onDeleteField,
}) {
const handleDragOver = (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
};
const handleDrop = (event) => {
event.preventDefault();
const fieldType = event.dataTransfer.getData('formipay-field-type');
if (!fieldType) return;
const newField = {
...DEFAULT_FIELD_CONFIG,
field_type: fieldType,
field_id: generateFieldId(fieldType.replace('_', ' ')),
label: fieldType.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()),
};
onDrop?.(newField);
};
const handleReorder = (dragIndex, dropIndex) => {
const newFields = [...fields];
const [draggedField] = newFields.splice(dragIndex, 1);
newFields.splice(dropIndex, 0, draggedField);
onMoveField?.(newFields);
};
return (
<div className="formipay-form-canvas">
<div className="formipay-canvas-header">
<h3>{ __('Form Fields', 'formipay') }</h3>
<span className="field-count">
{ fields.length } { __('fields', 'formipay') }
</span>
</div>
<div
className={`formipay-canvas-area ${fields.length === 0 ? 'is-empty' : ''}`}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{fields.length === 0 ? (
<div className="formipay-empty-state">
<Icon icon={plus} size={48} />
<p>
{ __('Drag fields from the palette to build your form', 'formipay') }
</p>
</div>
) : (
<div className="formipay-fields-list">
{fields.map((field, index) => (
<FormField
key={field.field_id}
field={field}
index={index}
isSelected={selectedFieldId === field.field_id}
onSelect={() => onSelectField?.(field.field_id)}
onUpdate={(updates) => onUpdateField?.(field.field_id, updates)}
onMoveUp={index > 0 ? () => handleReorder(index, index - 1) : null}
onMoveDown={index < fields.length - 1 ? () => handleReorder(index, index + 1) : null}
onDelete={() => onDeleteField?.(field.field_id)}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('formipay-field-index', index.toString());
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}}
onDrop={(e) => {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData('formipay-field-index'));
if (!isNaN(fromIndex) && fromIndex !== index) {
handleReorder(fromIndex, index);
}
}}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
.formipay-field-item {
background: #fff;
border: 1px solid #dcdcde;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.formipay-field-item:hover {
border-color: #a7aaad;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.formipay-field-item.is-selected {
border-color: #2271b1;
box-shadow: 0 0 0 2px rgba(34, 113, 177, 0.2);
}
.formipay-field-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: #f6f7f7;
border-bottom: 1px solid #e0e0e0;
border-radius: 4px 4px 0 0;
}
.formipay-field-info {
display: flex;
align-items: center;
gap: 10px;
}
.field-type-badge {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
background: #e0e0e0;
color: #1e1e1e;
border-radius: 2px;
}
.field-id {
font-size: 11px;
color: #646970;
font-family: monospace;
}
.formipay-field-actions {
display: flex;
gap: 4px;
}
.formipay-field-actions .button-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
border-radius: 2px;
transition: background 0.2s;
}
.formipay-field-actions .button-icon:hover:not(:disabled) {
background: #e0e0e0;
}
.formipay-field-actions .button-icon:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.formipay-field-actions .button-icon svg {
fill: #1e1e1e;
}
.formipay-field-actions .button-danger:hover svg {
fill: #d63638;
}
.formipay-field-content {
padding: 14px;
}
.field-label-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.field-label-row strong {
font-size: 14px;
color: #1e1e1e;
}
.field-label-row em {
color: #a7aaad;
font-style: italic;
}
.required-badge {
display: inline-block;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
color: #d63638;
background: #f6f7f7;
border-radius: 2px;
}
.field-description {
margin: 0 0 8px;
font-size: 12px;
color: #646970;
line-height: 1.4;
}
.field-options-preview {
padding: 8px;
margin-bottom: 8px;
background: #f6f7f7;
border-radius: 2px;
}
.field-options-preview small {
font-size: 11px;
color: #646970;
}
.field-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.field-meta .meta-item {
font-size: 11px;
color: #646970;
}

View File

@@ -0,0 +1,113 @@
/**
* Form Field - Individual field component in canvas
*/
import { __ } from '@wordpress/i18n';
import { Icon, chevronUp, chevronDown, trash } from '@wordpress/icons';
import { FIELD_TYPES } from '../../config/fieldTypes';
import './FormField.css';
export default function FormField({
field,
index,
isSelected,
onSelect,
onUpdate,
onMoveUp,
onMoveDown,
onDelete,
onDragStart,
onDragOver,
onDrop,
}) {
const fieldConfig = FIELD_TYPES[field.field_type] || FIELD_TYPES.text;
const hasOptions = ['select', 'checkbox', 'radio'].includes(field.field_type);
return (
<div
className={`formipay-field-item ${isSelected ? 'is-selected' : ''}`}
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={onSelect}
>
<div className="formipay-field-header">
<div className="formipay-field-info">
<span className="field-type-badge">
{ fieldConfig.label }
</span>
<span className="field-id">
#{field.field_id || index}
</span>
</div>
<div className="formipay-field-actions">
<button
type="button"
className="button-icon"
onClick={(e) => { e.stopPropagation(); onMoveUp?.(); }}
disabled={!onMoveUp}
title={ __('Move Up', 'formipay') }
>
<Icon icon={chevronUp} size={16} />
</button>
<button
type="button"
className="button-icon"
onClick={(e) => { e.stopPropagation(); onMoveDown?.(); }}
disabled={!onMoveDown}
title={ __('Move Down', 'formipay') }
>
<Icon icon={chevronDown} size={16} />
</button>
<button
type="button"
className="button-icon button-danger"
onClick={(e) => { e.stopPropagation(); onDelete?.(); }}
title={ __('Delete', 'formipay') }
>
<Icon icon={trash} size={16} />
</button>
</div>
</div>
<div className="formipay-field-content">
<div className="field-label-row">
<strong>{ field.label || <em>Untitled</em> }</strong>
{ field.is_required && (
<span className="required-badge">
{ __('Required', 'formipay') }
</span>
)}
</div>
{ field.description && (
<p className="field-description">
{ field.description }
</p>
)}
{ hasOptions && field.field_options && field.field_options.length > 0 && (
<div className="field-options-preview">
<small>
{ field.field_options.length } { __('options', 'formipay') }
</small>
</div>
)}
<div className="field-meta">
{ field.placeholder && (
<span className="meta-item">
Placeholder: "{ field.placeholder }"
</span>
)}
{ field.default_value && (
<span className="meta-item">
Default: "{ field.default_value }"
</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
.formipay-field-options {
margin-top: 16px;
padding: 16px;
background: #f6f7f7;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.formipay-options-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.formipay-options-header label {
font-size: 13px;
font-weight: 600;
color: #1e1e1e;
}
.formipay-options-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.formipay-option-item {
display: flex;
gap: 8px;
align-items: flex-start;
}
.formipay-option-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.formipay-option-fields .components-base-control {
margin: 0;
}
.formipay-option-fields .components-base-control__label {
font-size: 11px;
}
.formipay-option-actions {
padding-top: 20px;
}
.formipay-no-options {
padding: 20px;
text-align: center;
background: #fff;
border: 1px dashed #dcdcde;
border-radius: 2px;
}
.formipay-no-options p {
margin: 0;
font-size: 12px;
color: #646970;
}

View File

@@ -0,0 +1,105 @@
/**
* Form Field Options - Manage select/radio/checkbox options
*/
import { __ } from '@wordpress/i18n';
import { TextControl, Button } from '@wordpress/components';
import { Icon as WPIcon, plus } from '@wordpress/icons';
export default function FormFieldOptions({ options = [], onChange, fieldType }) {
const handleAddOption = () => {
const newOption = {
label: '',
value: '',
amount: '',
weight: '',
quantity: false,
};
onChange([...options, newOption]);
};
const handleUpdateOption = (index, updates) => {
const newOptions = [...options];
newOptions[index] = { ...newOptions[index], ...updates };
onChange(newOptions);
};
const handleDeleteOption = (index) => {
const newOptions = options.filter((_, i) => i !== index);
onChange(newOptions);
};
const isMultiSelect = fieldType === 'checkbox';
return (
<div className="formipay-field-options">
<div className="formipay-options-header">
<label>
{ isMultiSelect
? __('Checkbox Options', 'formipay')
: fieldType === 'radio'
? __('Radio Options', 'formipay')
: __('Select Options', 'formipay')
}
</label>
<Button
variant="secondary"
size="compact"
onClick={handleAddOption}
icon={<WPIcon icon={plus} size={16} />}
>
{ __('Add Option', 'formipay') }
</Button>
</div>
<div className="formipay-options-list">
{options.map((option, index) => (
<div key={index} className="formipay-option-item">
<div className="formipay-option-fields">
<TextControl
label={__('Label', 'formipay')}
value={option.label || ''}
onChange={(value) => handleUpdateOption(index, { label: value })}
placeholder={__('Option label', 'formipay')}
/>
<TextControl
label={__('Value', 'formipay')}
value={option.value || ''}
onChange={(value) => handleUpdateOption(index, { value: value })}
placeholder={__('Optional custom value', 'formipay')}
/>
<TextControl
label={__('Amount', 'formipay')}
type="number"
value={option.amount || ''}
onChange={(value) => handleUpdateOption(index, { amount: value })}
placeholder={__('0', 'formipay')}
step="0.01"
/>
</div>
<div className="formipay-option-actions">
<Button
variant="tertiary"
size="compact"
onClick={() => handleDeleteOption(index)}
icon={<WPIcon icon={trash} size={16} />}
label={__('Delete Option', 'formipay')}
/>
</div>
</div>
))}
{options.length === 0 && (
<div className="formipay-no-options">
<p>
{ __('No options added yet. Click "Add Option" to create one.', 'formipay') }
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
.formipay-preview-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.formipay-field-preview {
display: flex;
flex-direction: column;
gap: 6px;
}
.formipay-field-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #1e1e1e;
}
.formipay-field-label .required {
color: #d63638;
margin-left: 2px;
}
.formipay-input,
.formipay-textarea,
.formipay-select {
width: 100%;
padding: 8px 12px;
font-size: 13px;
color: #1e1e1e;
background: #fff;
border: 1px solid #8c8f94;
border-radius: 2px;
}
.formipay-input:focus,
.formipay-textarea:focus,
.formipay-select:focus {
outline: none;
border-color: #2271b1;
box-shadow: 0 0 0 1px #2271b1;
}
.formipay-textarea {
resize: vertical;
min-height: 80px;
}
.formipay-radio-group,
.formipay-checkbox-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.formipay-radio-label,
.formipay-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #1e1e1e;
cursor: not-allowed;
}
.formipay-field-description {
margin: 0;
font-size: 12px;
color: #646970;
}
.formipay-divider {
border: none;
border-top: 1px solid #c3c4c7;
margin: 8px 0;
}
.formipay-page-break {
padding: 12px;
text-align: center;
background: #f6f7f7;
border: 1px dashed #c3c4c7;
border-radius: 2px;
font-size: 12px;
color: #646970;
}

View File

@@ -0,0 +1,142 @@
/**
* Form Field Preview - Preview individual form fields
*/
import { __ } from '@wordpress/i18n';
import './FormFieldPreview.css';
export default function FormFieldPreview({ field }) {
const fieldType = field.field_type || 'text';
const label = field.label || '';
const placeholder = field.placeholder || '';
const defaultValue = field.default_value || '';
const description = field.description || '';
const isRequired = field.is_required || false;
const fieldId = field.field_id || `field_${Math.random().toString(36).slice(2, 11)}`;
const renderFieldInput = () => {
const commonProps = {
id: fieldId,
name: fieldId,
placeholder,
defaultValue,
required: isRequired,
disabled: true,
};
switch (fieldType) {
case 'text':
case 'url':
case 'email':
case 'tel':
case 'number':
case 'date':
case 'datetime':
case 'color':
return (
<input
type={fieldType}
className="formipay-input"
{...commonProps}
/>
);
case 'textarea':
return (
<textarea
className="formipay-textarea"
rows={4}
{...commonProps}
/>
);
case 'select':
return (
<select className="formipay-select" {...commonProps}>
<option value="">{ __('Select an option', 'formipay') }</option>
{field.field_options?.map((option, index) => (
<option
key={index}
value={option.value || option.label}
>
{ option.label }
</option>
))}
</select>
);
case 'radio':
return (
<div className="formipay-radio-group">
{field.field_options?.map((option, index) => (
<label key={index} className="formipay-radio-label">
<input
type="radio"
name={fieldId}
value={option.value || option.label}
disabled={true}
/>
<span>{ option.label }</span>
</label>
))}
</div>
);
case 'checkbox':
return (
<div className="formipay-checkbox-group">
{field.field_options?.map((option, index) => (
<label key={index} className="formipay-checkbox-label">
<input
type="checkbox"
name={`${fieldId}_${index}`}
value={option.value || option.label}
disabled={true}
/>
<span>{ option.label }</span>
</label>
))}
</div>
);
case 'divider':
return <hr className="formipay-divider" />;
case 'page_break':
return <div className="formipay-page-break">{ __('Page Break', 'formipay') }</div>;
case 'country_list':
return (
<select className="formipay-select" {...commonProps}>
<option value="">{ __('Select country', 'formipay') }</option>
<option value="United States">United States</option>
<option value="Indonesia">Indonesia</option>
</select>
);
default:
return <input type="text" className="formipay-input" {...commonProps} />;
}
};
const isLayout = ['divider', 'page_break'].includes(fieldType);
return (
<div className={`formipay-field-preview formipay-field-preview--${fieldType}`}>
{!isLayout && label && (
<label htmlFor={fieldId} className="formipay-field-label">
{ label }
{ isRequired && <span className="required">*</span> }
</label>
)}
{ renderFieldInput() }
{ description && !isLayout && (
<p className="formipay-field-description">
{ description }
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
.formipay-form-preview {
display: flex;
flex-direction: column;
background: #fff;
border-right: 1px solid #e0e0e0;
width: 320px;
}
.formipay-preview-header {
padding: 12px 16px;
background: #f6f7f7;
border-bottom: 1px solid #e0e0e0;
}
.formipay-preview-header h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #1e1e1e;
}
.formipay-preview-content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.formipay-preview-content.is-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
}
.formipay-preview-content.is-empty p {
color: #646970;
font-size: 13px;
margin: 0;
}

View File

@@ -0,0 +1,40 @@
/**
* Form Preview - Live preview of form rendering
*/
import { __ } from '@wordpress/i18n';
import FormFieldPreview from './FormFieldPreview';
import './FormPreview.css';
export default function FormPreview({ fields }) {
if (!fields || fields.length === 0) {
return (
<div className="formipay-form-preview">
<div className="formipay-preview-header">
<h4>{ __('Live Preview', 'formipay') }</h4>
</div>
<div className="formipay-preview-content is-empty">
<p>{ __('Add fields to see the preview', 'formipay') }</p>
</div>
</div>
);
}
return (
<div className="formipay-form-preview">
<div className="formipay-preview-header">
<h4>{ __('Live Preview', 'formipay') }</h4>
</div>
<div className="formipay-preview-content">
<form className="formipay-preview-form">
{fields.map((field, index) => (
<FormFieldPreview
key={field.field_id || index}
field={field}
/>
))}
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
/**
* Form Field Type Definitions
*/
export const FIELD_TYPES = {
text: { label: 'Text', icon: 'text', category: 'input' },
url: { label: 'URL', icon: 'link', category: 'input' },
email: { label: 'Email', icon: 'email', category: 'input' },
tel: { label: 'Telephone', icon: 'phone', category: 'input' },
number: { label: 'Number', icon: 'number', category: 'input' },
date: { label: 'Date', icon: 'calendar', category: 'input' },
datetime: { label: 'Date & Time', icon: 'calendar-alt', category: 'input' },
color: { label: 'Color', icon: 'art', category: 'input' },
select: { label: 'Select Dropdown', icon: 'list-view', category: 'choice' },
checkbox: { label: 'Checkbox', icon: 'checkbox', category: 'choice' },
radio: { label: 'Radio', icon: 'radio', category: 'choice' },
hidden: { label: 'Hidden', icon: 'hidden', category: 'advanced' },
textarea: { label: 'Textarea', icon: 'document', category: 'input' },
divider: { label: 'Divider', icon: 'minus', category: 'layout' },
page_break: { label: 'Page Break', icon: 'page-break', category: 'layout' },
country_list: { label: 'Preset: Country List', icon: 'globe', category: 'preset' },
};
export const FIELD_CATEGORIES = {
input: { label: 'Input Fields', order: 1 },
choice: { label: 'Choice Fields', order: 2 },
layout: { label: 'Layout', order: 3 },
preset: { label: 'Presets', order: 4 },
advanced: { label: 'Advanced', order: 5 },
};
export const DEFAULT_FIELD_CONFIG = {
field_type: 'text',
label: '',
field_id: '',
placeholder: '',
default_value: '',
description: '',
is_required: false,
field_options: [],
option_grid_columns: 1,
};
/**
* Get field types grouped by category
*/
export function getFieldTypesByCategory() {
const grouped = {};
Object.entries(FIELD_TYPES).forEach(([type, config]) => {
if (!grouped[config.category]) {
grouped[config.category] = [];
}
grouped[config.category].push({ type, ...config });
});
return grouped;
}
/**
* Generate unique field ID
*/
export function generateFieldId(label) {
const base = label.toLowerCase().replace(/[^a-z0-9]/g, '_');
const timestamp = Date.now().toString(36);
return `${base}_${timestamp}`;
}

View File

@@ -1,14 +1,32 @@
/** /**
* Forms Page - Placeholder * Forms Page - List view and Form Builder
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import FormBuilder from '../components/formBuilder/FormBuilder';
export default function FormsPage({ initialData }) { export default function FormsPage({ initialData }) {
const [isBuilder, setIsBuilder] = useState(false);
const [selectedFormId, setSelectedFormId] = useState(null);
if (isBuilder) {
return (
<div className="formipay-page-forms">
<FormBuilder
formId={selectedFormId}
initialData={initialData}
/>
</div>
);
}
return ( return (
<div className="formipay-page-formipay-page"> <div className="formipay-page-forms">
<h1>{ __('Forms', 'formipay') }</h1> <div className="formipay-forms-list">
<p>{ __('Page content coming soon...', 'formipay') }</p> <h1>{ __('Forms', 'formipay') }</h1>
<p>{ __('Forms list coming soon. Use the classic editor for now.', 'formipay') }</p>
</div>
</div> </div>
); );
} }