feat: add React FieldRenderer system for settings and metaboxes

Complete React-based field rendering system that replaces WPCFTO Vue.js
layer while maintaining PHP field configuration compatibility.

Components:
- FieldRenderer: Main renderer with tabs support (metabox) and direct mode (settings)
- FieldTypes: 15+ field types (Text, Number, Select, Radio, Date, etc.)
- RepeaterField: Collapsible repeater with currency label parsing
- DependencyEngine: Show/hide fields based on conditions
- ValidationEngine: Client-side validation with error messages
- SettingsRenderer: Settings page with AJAX save to wp_options

Features:
- Repeater rows collapsed by default with readable currency titles
- Searchable dropdowns using Popover + Command pattern
- Proper label resolution for pre-selected values
- Hidden input sync for WordPress form submission

Also includes:
- FieldConfigBridge: Transform PHP configs to React format
- Updated Settings.php for React-based settings page
- Radio-group UI component
- wp-admin-restore.css for admin panel isolation
This commit is contained in:
dwindown
2026-04-28 16:48:08 +07:00
parent 7a6765a579
commit 622c9f8eb7
206 changed files with 5788 additions and 1612 deletions

View File

@@ -0,0 +1,460 @@
<?php
namespace Formipay\Admin;
use Formipay\Traits\SingletonTrait;
if (!defined('ABSPATH')) exit;
/**
* FieldConfigBridge - Transforms WPCFTO field config arrays to React-ready JSON
*
* Bridges the gap between PHP field configurations and the React FieldRenderer:
* - Collects field configs from filter system
* - Loads saved post meta / option values
* - Transforms PHP config format to React-ready structure
* - Handles dynamic currency field expansion
* - Handles dependency transformation
*/
class FieldConfigBridge {
use SingletonTrait;
/**
* Map CPT types to their filter names
*/
private static $cpt_filter_map = [
'formipay-coupon' => 'formipay/coupon-config',
'formipay-product' => 'formipay/product-config',
'formipay-form' => 'formipay/form-config',
'formipay-access' => 'formipay/access-config',
];
/**
* Get complete field configuration for a post with saved values
*
* @param int $post_id Post ID
* @param string $post_type Post type
* @return array React-ready field configuration
*/
public static function get_config_for_post($post_id, $post_type) {
$filter_name = self::$cpt_filter_map[$post_type] ?? null;
if (!$filter_name) {
return [];
}
// Get field configuration from filters
$fields = apply_filters($filter_name, []);
// Load saved values from post meta
$values = self::load_post_meta_values($post_id, $fields);
// Transform to React-ready format
return self::transform_fields_config($fields, $values, $post_id, $post_type);
}
/**
* Get configuration for settings page (wp_options based)
*
* @param string $option_name Option name
* @return array React-ready field configuration
*/
public static function get_config_for_settings($option_name = 'formipay_settings') {
// Get settings values
$settings = get_option($option_name, []);
// Get field definitions directly from Settings class
$settings_instance = \Formipay\Settings::get_instance();
$tabs_config = $settings_instance->get_settings_fields();
// Transform to React-ready format
return self::transform_settings_config($settings, $tabs_config, $option_name);
}
/**
* Transform settings config to React-ready format
*
* @param array $settings Settings values
* @param array $tabs_config Tab configuration from Settings class
* @param string $option_name Option name
* @return array React-ready configuration
*/
private static function transform_settings_config($settings, $tabs_config, $option_name) {
$tabs = [];
foreach ($tabs_config as $tab_key => $tab_data) {
if (!isset($tab_data['name']) || !isset($tab_data['fields'])) {
continue;
}
$tab = [
'id' => self::slugify($tab_data['name']),
'label' => $tab_data['name'],
'fields' => [],
];
$current_group = null;
foreach ($tab_data['fields'] as $field_name => $field) {
// Handle group_title (section headers)
if (isset($field['type']) && $field['type'] === 'group_title') {
$group_value = $field['group'] ?? '';
if ($group_value === 'started') {
$current_group = [
'type' => 'section',
'label' => $field['label'] ?? '',
'description' => $field['description'] ?? '',
];
} elseif ($group_value === 'ended') {
$current_group = null;
}
continue;
}
// Add section if we have one
if ($current_group !== null) {
$tab['fields'][] = $current_group;
$current_group = null;
}
// Transform field configuration
$transformed_field = self::transform_settings_field($field_name, $field, $settings);
// Add to fields
$tab['fields'][] = $transformed_field;
}
$tabs[] = $tab;
}
return [
'tabs' => $tabs,
'optionName' => $option_name,
'globalCurrencies' => self::get_global_currencies_data(),
'nonce' => wp_create_nonce('formipay-field-config'),
];
}
/**
* Load saved post meta values for all fields
*
* @param int $post_id Post ID
* @param array $fields Field configuration array
* @return array Field values keyed by field name
*/
private static function load_post_meta_values($post_id, $fields) {
$values = [];
// The config structure is: $fields['formipay_{cpt}_settings'][tab_key] = ['name' => ..., 'fields' => [...]]
foreach ($fields as $container_key => $container_data) {
if (!is_array($container_data)) {
continue;
}
foreach ($container_data as $tab_key => $tab_data) {
if (!isset($tab_data['fields']) || !is_array($tab_data['fields'])) {
continue;
}
foreach ($tab_data['fields'] as $field_name => $field) {
// Skip group_title fields
if (isset($field['type']) && $field['type'] === 'group_title') {
continue;
}
$meta_value = get_post_meta($post_id, $field_name, true);
$values[$field_name] = $meta_value !== '' ? $meta_value : ($field['value'] ?? null);
}
}
}
return $values;
}
/**
* Transform fields config to React-ready format
*
* @param array $fields Raw field configuration from PHP
* @param array $values Saved field values
* @param int $post_id Post ID
* @param string $post_type Post type
* @return array React-ready configuration
*/
private static function transform_fields_config($fields, $values, $post_id, $post_type) {
$tabs = [];
// The config structure is: $fields['formipay_{cpt}_settings'][tab_key] = ['name' => ..., 'fields' => [...]]
foreach ($fields as $container_key => $container_data) {
if (!is_array($container_data)) {
continue;
}
foreach ($container_data as $tab_key => $tab_data) {
if (!isset($tab_data['name']) || !isset($tab_data['fields'])) {
continue;
}
$tab = [
'id' => $tab_key,
'label' => $tab_data['name'],
'fields' => [],
];
$current_group = null;
foreach ($tab_data['fields'] as $field_name => $field) {
// Handle group_title (section headers)
if (isset($field['type']) && $field['type'] === 'group_title') {
$group_value = $field['group'] ?? '';
if ($group_value === 'started') {
$current_group = [
'type' => 'section',
'label' => $field['label'] ?? '',
'description' => $field['description'] ?? '',
];
} elseif ($group_value === 'ended') {
$current_group = null;
}
continue;
}
// Add section if we have one
if ($current_group !== null) {
$tab['fields'][] = $current_group;
$current_group = null;
}
// Transform field configuration
$transformed_field = self::transform_single_field($field_name, $field, $values);
// Add to fields
$tab['fields'][] = $transformed_field;
}
$tabs[] = $tab;
}
}
return [
'tabs' => $tabs,
'postId' => $post_id,
'postType' => $post_type,
'globalCurrencies' => self::get_global_currencies_data(),
'nonce' => wp_create_nonce('formipay-field-config'),
];
}
/**
* Transform a single field configuration
*
* @param string $field_name Field name
* @param array $field Field configuration
* @param array $values Saved values
* @return array Transformed field config
*/
private static function transform_single_field($field_name, $field, $values) {
$transformed = [
'name' => $field_name,
'type' => self::transform_field_type($field['type'] ?? 'text'),
'label' => $field['label'] ?? '',
'description' => $field['description'] ?? '',
'value' => $values[$field_name] ?? $field['value'] ?? '',
'required' => !empty($field['required']),
'dependency' => isset($field['dependency']) ? self::transform_dependency($field['dependency']) : null,
];
// Add options for select/radio fields
if (isset($field['options'])) {
$transformed['options'] = $field['options'];
}
// Add subtype info for currency fields
if (isset($field['step'])) {
$transformed['step'] = $field['step'];
}
if (isset($field['min'])) {
$transformed['min'] = $field['min'];
}
if (isset($field['placeholder'])) {
$transformed['placeholder'] = $field['placeholder'];
}
// Handle special field types
if (isset($field['repeater_fields'])) {
$transformed['fields'] = $field['repeater_fields'];
}
// Pass post_type for autocomplete fields (CPTs)
if (isset($field['post_type'])) {
$transformed['post_type'] = $field['post_type'];
}
// Pass object_type for autocomplete fields (e.g., 'user' for WP Users)
if (isset($field['object_type'])) {
$transformed['object_type'] = $field['object_type'];
}
return $transformed;
}
/**
* Transform field type from PHP to React format
*
* @param string $type PHP field type
* @return string React field type
*/
private static function transform_field_type($type) {
$type_map = [
'checkbox' => 'switch', // Use toggle switch UI
'tinymce' => 'editor', // Rich text editor
'editor' => 'editor', // Map 'editor' type to tinymce field
'image' => 'image', // WordPress Media Library picker
'notification_message' => 'notification', // Notification banner component
];
return $type_map[$type] ?? $type;
}
/**
* Transform dependency from PHP to React format
*
* @param array $dependency PHP dependency config
* @return array React dependency config
*/
private static function transform_dependency($dependency) {
// Handle multiple dependencies (AND logic)
if (isset($dependency[0]) && is_array($dependency[0])) {
return [
'mode' => 'and',
'rules' => array_map([self::class, 'transform_single_dependency'], $dependency),
];
}
return self::transform_single_dependency($dependency);
}
/**
* Transform a single dependency rule
*
* @param array $dependency PHP dependency
* @return array React dependency rule
*/
private static function transform_single_dependency($dependency) {
$rule = [
'field' => $dependency['key'] ?? '',
];
$value = $dependency['value'] ?? '';
// Handle special values
if ($value === 'not_empty') {
$rule['operator'] = 'not_empty';
} elseif ($value === 'empty') {
$rule['operator'] = 'empty';
} else {
$rule['value'] = $value;
$rule['operator'] = 'eq';
}
return $rule;
}
/**
* Get global currencies data for React
*
* @return array Currencies data
*/
private static function get_global_currencies_data() {
$currencies = get_global_currency_array();
return array_map(function($currency) {
$parts = explode(':::', $currency['currency']);
return [
'currency' => $currency['currency'],
'code' => $parts[0] ?? '',
'title' => $parts[1] ?? '',
'symbol' => $parts[2] ?? $parts[1] ?? '',
'decimalDigits' => intval($currency['decimal_digits'] ?? 2),
'decimalSymbol' => $currency['decimal_symbol'] ?? '.',
'thousandSeparator' => $currency['thousand_separator'] ?? ',',
'flag' => formipay_get_flag_by_currency($currency['currency']),
];
}, $currencies);
}
/**
* Transform a single settings field configuration
*
* @param string $field_name Field name
* @param array $field Field configuration
* @param array $settings Saved settings values
* @return array Transformed field config
*/
private static function transform_settings_field($field_name, $field, $settings) {
$transformed = [
'name' => $field_name,
'type' => self::transform_field_type($field['type'] ?? 'text'),
'label' => $field['label'] ?? '',
'description' => $field['description'] ?? '',
'value' => $settings[$field_name] ?? $field['value'] ?? '',
'required' => !empty($field['required']),
'dependency' => isset($field['dependency']) ? self::transform_dependency($field['dependency']) : null,
];
// Add options for select/radio/multi_checkbox fields
if (isset($field['options'])) {
$transformed['options'] = $field['options'];
}
// Add options for image_select
if (isset($field['options']) && $field['type'] === 'image_select') {
$transformed['width'] = $field['width'] ?? 100;
$transformed['height'] = $field['height'] ?? 100;
}
// Add hints for hint_textarea
if (isset($field['hints'])) {
$transformed['hints'] = $field['hints'];
}
// Add step/min for number fields
if (isset($field['step'])) {
$transformed['step'] = $field['step'];
}
if (isset($field['min'])) {
$transformed['min'] = $field['min'];
}
if (isset($field['placeholder'])) {
$transformed['placeholder'] = $field['placeholder'];
}
if (isset($field['rows'])) {
$transformed['rows'] = $field['rows'];
}
// Handle repeater fields
if (isset($field['fields'])) {
// Transform sub-fields to include name property
$sub_fields = [];
foreach ($field['fields'] as $sub_field_name => $sub_field) {
$sub_field['name'] = $sub_field_name;
$sub_fields[] = $sub_field;
}
$transformed['fields'] = $sub_fields;
}
return $transformed;
}
/**
* Convert string to URL-safe slug
*
* @param string $text Text to slugify
* @return string URL-safe slug
*/
private static function slugify($text) {
return strtolower(sanitize_title($text));
}
}

View File

@@ -13,6 +13,9 @@ class ReactAdmin {
add_action( 'admin_enqueue_scripts', [$this, 'enqueue_assets'] );
add_filter( 'formipay/admin/data', [$this, 'localize_data'] );
// AJAX endpoint for field configuration
add_action( 'wp_ajax_formipay-get-field-config', [$this, 'ajax_get_field_config'] );
}
public function enqueue_assets() {
@@ -40,18 +43,16 @@ class ReactAdmin {
$dependencies = $assets_file['dependencies'] ?? [];
// Filter out icon build dependencies - they're bundled, not separate scripts
$original_count = count($dependencies);
$dependencies = array_values(array_filter($dependencies, function($dep) {
return strpos($dep, 'wp-icons/build/') === false;
}));
error_log('[Formipay] Filtered dependencies: ' . $original_count . ' -> ' . count($dependencies));
$version = $assets_file['version'] ?? FORMIPAY_VERSION;
wp_enqueue_style(
'formipay-admin-style',
$build_url . '/admin.css',
[],
['wp-admin', 'colors', 'dashicons', 'common', 'forms', 'admin-menu', 'dashboard', 'list-tables', 'edit', 'revisions', 'media', 'themes', 'about', 'nav-menus'],
$version
);
@@ -72,10 +73,6 @@ class ReactAdmin {
'siteUrl' => site_url(),
]);
// Debug logging
error_log('[Formipay] Enqueuing React assets on screen: ' . $screen->id);
error_log('[Formipay] Page data: ' . wp_json_encode($data));
wp_localize_script('formipay-admin', 'formipayAdmin', $data);
}
@@ -161,4 +158,27 @@ class ReactAdmin {
}
/**
* AJAX handler for getting field configuration
*/
public function ajax_get_field_config() {
check_ajax_referer( 'formipay-admin', '_wpnonce', '', true );
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('Unauthorized', 'formipay')]);
}
$post_id = isset($_REQUEST['post_id']) ? intval($_REQUEST['post_id']) : 0;
$post_type = isset($_REQUEST['post_type']) ? sanitize_text_field($_REQUEST['post_type']) : '';
if (!$post_id || !$post_type) {
wp_send_json_error(['message' => __('Invalid request', 'formipay')]);
}
$config = FieldConfigBridge::get_config_for_post($post_id, $post_type);
wp_send_json_success($config);
}
}