<?php
|
namespace JVBase\meta;
|
|
use JVBase\forms\TaxonomySelector;
|
use JVBase\forms\PostSelector;
|
use DateTime;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Renders the form fields for managing the meta
|
*/
|
class MetaForm
|
{
|
protected int $max_file_size = 5242880;
|
|
/* ========== MAIN RENDER METHOD ========== */
|
public function return(string $name, mixed $value, array $config, bool $showHidden = false)
|
{
|
return $this->render($name, $value, $config, $showHidden, true);
|
}
|
public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false): mixed
|
{
|
$out = '';
|
|
if (jvbCheck('hidden', $config) && !$showHidden) {
|
return $out;
|
}
|
|
if (!array_key_exists('type', $config)) {
|
return $out;
|
}
|
|
// Handle hidden display type
|
if (array_key_exists('display', $config) && $config['display'] === 'hidden') {
|
$out = '<input type="hidden" name="' . $name . '" value="' . $value . '" />';
|
if (!$return) {
|
echo $out;
|
}
|
return $out;
|
}
|
|
ob_start();
|
|
// Try custom function overrides first
|
$type = array_map('ucfirst', explode('_', $config['type']));
|
$type = implode('', $type);
|
$method = 'render' . $type . 'Field';
|
|
$nameTemp = implode('', array_map('ucfirst', explode('_', $name)));
|
$nameMethod = 'render' . $nameTemp . 'Field';
|
|
if (function_exists($nameMethod)) {
|
call_user_func($nameMethod, $value, $config);
|
} elseif (function_exists($method)) {
|
call_user_func($method, $value, $config);
|
} elseif (method_exists($this, $method)) {
|
$this->$method($name, $value, $config);
|
}
|
|
$out = ob_get_clean();
|
|
do_action('jvbRenderFormField', $name, $config, $value);
|
$out = apply_filters('jvbFilterRenderFormField', $out, $name, $config, $value);
|
|
if (!$return) {
|
echo $out;
|
}
|
return $out;
|
}
|
|
/* ========== HELPER METHODS ========== */
|
|
/**
|
* Prepare common field data
|
*/
|
protected function prepareFieldData(string $name, mixed $value, array $field): array
|
{
|
return [
|
'name' => array_key_exists('group', $field) ? $field['group'] . '::' . $name : $name,
|
'value' => isset($field['value']) ? $field['value'] : $value,
|
'id' => (array_key_exists('base', $field) ? esc_attr($field['base']) : '') . esc_attr($name),
|
];
|
}
|
|
/**
|
* Build common HTML attributes for inputs
|
*/
|
protected function buildInputAttributes(string $name, array $field): string
|
{
|
$attrs = [];
|
|
// Conditional rendering
|
if (array_key_exists('condition', $field)) {
|
$attrs['conditional'] = $this->handleConditionalField($field);
|
}
|
|
// Accessibility
|
if (!empty($field['description'])) {
|
$attrs['aria-describedby'] = $name . '-help';
|
}
|
|
// Common attributes
|
$common = ['placeholder', 'autocomplete', 'pattern', 'minlength', 'maxlength', 'min', 'max', 'step'];
|
foreach ($common as $attr) {
|
if (array_key_exists($attr, $field)) {
|
$attrs[$attr] = $field[$attr];
|
}
|
}
|
|
// Required
|
if (!empty($field['required'])) {
|
$attrs['required'] = true;
|
}
|
|
// Build attribute string
|
$attrString = '';
|
foreach ($attrs as $key => $val) {
|
if ($key === 'conditional') {
|
$attrString .= ' ' . $val; // Already formatted
|
} elseif ($val === true) {
|
$attrString .= ' ' . $key;
|
} else {
|
$attrString .= ' ' . $key . '="' . esc_attr($val) . '"';
|
}
|
}
|
|
return $attrString;
|
}
|
|
/**
|
* Build validation data attributes
|
*/
|
protected function buildValidationAttributes(array $field): string
|
{
|
$attrs = [];
|
|
if (!empty($field['pattern'])) {
|
$attrs['data-pattern'] = $field['pattern'];
|
}
|
|
if (!empty($field['validate'])) {
|
$attrs['data-validate'] = $field['validate'];
|
}
|
|
if (isset($field['min'])) {
|
$attrs['data-min'] = $field['min'];
|
}
|
|
if (isset($field['max'])) {
|
$attrs['data-max'] = $field['max'];
|
}
|
|
if (isset($field['minlength'])) {
|
$attrs['data-minlength'] = $field['minlength'];
|
}
|
|
if (isset($field['maxlength'])) {
|
$attrs['data-maxlength'] = $field['maxlength'];
|
}
|
|
if (!empty($field['validation_message'])) {
|
$attrs['data-validation-message'] = $field['validation_message'];
|
}
|
$attrs['data-type'] = $field['type'];
|
|
$attrString = '';
|
foreach ($attrs as $key => $val) {
|
$attrString .= ' ' . $key . '="' . esc_attr($val) . '"';
|
}
|
|
return $attrString;
|
}
|
|
/* ========== GENERIC FIELD WRAPPER ========== */
|
|
/**
|
* Render a standard input field with validation wrapper
|
*/
|
protected function renderStandardInput(string $name, mixed $value, array $field, string $inputType = 'text'): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$inputAttrs = $this->buildInputAttributes($name, $field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
|
?>
|
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
<?= $validationAttrs ?>>
|
|
<?php $this->renderLabel($name, $field); ?>
|
|
<div class="field-input-wrapper">
|
<input
|
type="<?= esc_attr($inputType) ?>"
|
id="<?= esc_attr($data['id']) ?>"
|
name="<?= esc_attr($data['name']) ?>"
|
value="<?= esc_attr($data['value']) ?>"
|
<?= $inputAttrs ?>
|
>
|
<span class="validation-icon success" hidden aria-hidden="true">
|
<?= jvbIcon('check-circle') ?>
|
</span>
|
<span class="validation-icon error" hidden aria-hidden="true">
|
<?= jvbIcon('x-circle') ?>
|
</span>
|
</div>
|
|
<span class="validation-message" hidden role="alert"></span>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
protected function renderComplexFieldWrapper(string $name, array $field, callable $renderContent): void
|
{
|
$data = $this->prepareFieldData($name, $field['value'] ?? '', $field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
|
|
// Additional data attributes for complex fields
|
$dataAttrs = '';
|
if (array_key_exists('data', $field) && !empty($field['data'])) {
|
foreach ($field['data'] as $key => $val) {
|
$dataAttrs .= ($val === '') ? ' data-' . $key : ' data-' . $key . '="' . esc_attr($val) . '"';
|
}
|
}
|
|
?>
|
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
<?= $validationAttrs ?>
|
<?= $dataAttrs ?>
|
<?= $describedBy ?>>
|
|
<?php if (!empty($field['label']) && (!isset($field['show_label']) || $field['show_label'])) : ?>
|
<h3 class="field-label"><?= esc_html($field['label']) ?></h3>
|
<?php endif; ?>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
|
<div class="field-content">
|
<?php $renderContent($name, $data, $field); ?>
|
</div>
|
|
<span class="validation-message" hidden role="alert"></span>
|
</div>
|
<?php
|
}
|
|
/**
|
* Render field label with optional character count
|
*/
|
protected function renderLabel(string $name, array $field): void
|
{
|
?>
|
<label for="<?= esc_attr($name) ?>">
|
<?= esc_html($field['label']) ?>
|
<?php if (!empty($field['required'])) : ?>
|
<span class="required" aria-label="required">*</span>
|
<?php endif; ?>
|
<?php if (!empty($field['limit'])) : ?>
|
<span class="char-count" data-limit="<?= esc_attr($field['limit']) ?>">
|
<span class="current">0</span>/<?= esc_attr($field['limit']) ?>
|
</span>
|
<?php endif; ?>
|
</label>
|
<?php
|
}
|
|
/**
|
* Render hint and description
|
*/
|
protected function renderHintAndDescription(array $field, string $name): void
|
{
|
if (array_key_exists('hint', $field)) {
|
$this->renderHint($field['hint']);
|
}
|
|
if (array_key_exists('description', $field)) {
|
$this->renderDescription($field['description'], $name);
|
}
|
}
|
|
/* ========== SIMPLE INPUT FIELD TYPES ========== */
|
|
public function renderTextField(string $name, mixed $value, array $field): void
|
{
|
$this->renderStandardInput($name, $value, $field, $field['input_type'] ?? 'text');
|
}
|
|
public function renderEmailField(string $name, mixed $value, array $field): void
|
{
|
$field['validate'] = 'email'; // Auto-add email validation
|
$this->renderStandardInput($name, $value, $field, 'email');
|
}
|
|
private function renderUrlField(string $name, mixed $value, array $field): void
|
{
|
$field['validate'] = 'url'; // Auto-add URL validation
|
$this->renderStandardInput($name, $value, $field, 'url');
|
}
|
|
private function renderTelField(string $name, mixed $value, array $field): void
|
{
|
$field['validate'] = 'phone'; // Auto-add phone validation
|
$this->renderStandardInput($name, $value, $field, 'tel');
|
}
|
|
private function renderDateField(string $name, mixed $value, array $field): void
|
{
|
$format = !empty($field['format']) ? $field['format'] : 'Y-m-d';
|
|
// Format the date if we have a value
|
if (!empty($value)) {
|
$date = DateTime::createFromFormat($format, $value);
|
if ($date) {
|
$value = $date->format('Y-m-d'); // HTML date input requires Y-m-d format
|
}
|
}
|
|
$this->renderStandardInput($name, $value, $field, 'date');
|
}
|
|
private function renderTimeField(string $name, mixed $value, array $field): void
|
{
|
$this->renderStandardInput($name, $value, $field, 'time');
|
}
|
|
private function renderDatetimeField(string $name, mixed $value, array $field): void
|
{
|
$this->renderStandardInput($name, $value, $field, 'datetime-local');
|
}
|
|
/* ========== TEXTAREA FIELD ========== */
|
|
public function renderTextareaField(string $name, mixed $value, array $field): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$inputAttrs = $this->buildInputAttributes($name, $field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
|
$rows = isset($field['rows']) ? (int)$field['rows'] : 4;
|
$quill = (array_key_exists('quill', $field) && $field['quill'] == true) ? ' data-editor="true"' : '';
|
|
if ($quill !== '') {
|
$allowImages = array_key_exists('allowImage', $field);
|
$quill .= ($allowImages) ? ' data-allowimage="true"' : ' data-allowimage="false"';
|
}
|
|
// Handle array values
|
if (is_array($value)) {
|
$value = implode(', ', $value);
|
}
|
|
?>
|
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
<?= $validationAttrs ?>>
|
|
<?php $this->renderLabel($name, $field); ?>
|
|
<div class="field-input-wrapper">
|
<textarea
|
id="<?= esc_attr($data['id']) ?>"
|
name="<?= esc_attr($data['name']) ?>"
|
rows="<?= esc_attr($rows) ?>"
|
<?= $quill ?>
|
<?= $inputAttrs ?>
|
><?= esc_textarea($data['value']) ?></textarea>
|
<span class="validation-icon success" hidden aria-hidden="true">
|
<?= jvbIcon('check-circle') ?>
|
</span>
|
<span class="validation-icon error" hidden aria-hidden="true">
|
<?= jvbIcon('x-circle') ?>
|
</span>
|
</div>
|
|
<span class="validation-message" hidden role="alert"></span>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
/* ========== NUMBER FIELD ========== */
|
|
private function renderNumberField(string $name, mixed $value, array $field): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
|
|
$min = isset($field['min']) ? (float)$field['min'] : 0;
|
$max = isset($field['max']) ? (float)$field['max'] : 100;
|
$step = isset($field['step']) ? (float)$field['step'] : 1;
|
|
// Handle custom data attributes
|
$customData = '';
|
if (array_key_exists('data', $field) && !empty($field['data'])) {
|
foreach ($field['data'] as $key => $v) {
|
$customData .= ($v === '') ? ' data-' . $key : ' data-' . $key . '="' . $v . '"';
|
}
|
}
|
|
if (empty($value)) {
|
$value = $field['default'] ?? 0;
|
}
|
|
$autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="' . $field['autocomplete'] . '"' : '';
|
|
?>
|
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?> row"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
<?= $validationAttrs ?>>
|
|
<?php $this->renderLabel($name, $field); ?>
|
|
<div class="quantity" <?= $customData ?>>
|
<button type="button"
|
class="decrease"
|
title="<?= array_key_exists('remove', $field) ? $field['remove'] : 'Decrease amount' ?>"
|
aria-label="Decrease <?= esc_attr($field['label']) ?>">
|
<?= jvbIcon('minus') ?>
|
</button>
|
|
<input type="number"
|
id="<?= esc_attr($data['id']) ?>"
|
name="<?= esc_attr($data['name']) ?>"
|
value="<?= esc_attr($value) ?>"
|
min="<?= esc_attr($min) ?>"
|
max="<?= esc_attr($max) ?>"
|
step="<?= esc_attr($step) ?>"
|
class="quantity-input"
|
<?= $describedBy ?>
|
<?= $autocomplete ?>
|
<?= !empty($field['required']) ? 'required' : '' ?>>
|
|
<button type="button"
|
class="increase"
|
title="<?= array_key_exists('add', $field) ? $field['add'] : 'Increase amount' ?>"
|
aria-label="Increase <?= esc_attr($field['label']) ?>">
|
<?= jvbIcon('add') ?>
|
</button>
|
</div>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
/* ========== SELECT, RADIO, CHECKBOX FIELDS ========== */
|
|
private function renderSelectField(string $name, mixed $value, array $field): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$inputAttrs = $this->buildInputAttributes($name, $field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
|
?>
|
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
<?= $validationAttrs ?>>
|
|
<?php $this->renderLabel($name, $field); ?>
|
|
<div class="field-input-wrapper">
|
<select
|
id="<?= esc_attr($data['id']) ?>"
|
name="<?= esc_attr($data['name']) ?>"
|
<?= $inputAttrs ?>
|
>
|
<?php foreach ($field['options'] as $key => $label) : ?>
|
<option value="<?= esc_attr($key) ?>" <?php selected($value, $key); ?>>
|
<?= esc_html($label) ?>
|
</option>
|
<?php endforeach; ?>
|
</select>
|
<span class="validation-icon success" hidden aria-hidden="true">
|
<?= jvbIcon('check-circle') ?>
|
</span>
|
<span class="validation-icon error" hidden aria-hidden="true">
|
<?= jvbIcon('x-circle') ?>
|
</span>
|
</div>
|
|
<span class="validation-message" hidden role="alert"></span>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
private function renderRadioField(string $name, mixed $value, array $field): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
|
?>
|
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
<?= $validationAttrs ?>>
|
|
<fieldset>
|
<legend><?= esc_html($field['label']) ?>
|
<?php if (!empty($field['required'])) : ?>
|
<span class="required" aria-label="required">*</span>
|
<?php endif; ?>
|
</legend>
|
|
<?php foreach ($field['options'] as $key => $label) : ?>
|
<label class="radio-option">
|
<input
|
type="radio"
|
name="<?= esc_attr($data['name']) ?>"
|
value="<?= esc_attr($key) ?>"
|
<?php checked($value, $key); ?>
|
<?= !empty($field['required']) ? 'required' : '' ?>
|
>
|
<span><?= esc_html($label) ?></span>
|
</label>
|
<?php endforeach; ?>
|
</fieldset>
|
|
<span class="validation-message" hidden role="alert"></span>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
private function renderCheckboxField(string $name, mixed $value, array $field): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
|
if (!is_array($value)) {
|
$value = !empty($value) ? [$value] : [];
|
}
|
|
?>
|
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
<?= $validationAttrs ?>>
|
|
<fieldset>
|
<legend><?= esc_html($field['label']) ?>
|
<?php if (!empty($field['required'])) : ?>
|
<span class="required" aria-label="required">*</span>
|
<?php endif; ?>
|
</legend>
|
|
<?php foreach ($field['options'] as $key => $label) : ?>
|
<input
|
type="checkbox"
|
id="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>"
|
name="<?= esc_attr($data['name']) ?>[]"
|
value="<?= esc_attr($key) ?>"
|
<?php checked(in_array($key, $value)); ?>
|
>
|
<label class="checkbox-option" for="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>">
|
<span><?= esc_html($label) ?></span>
|
</label>
|
<?php endforeach; ?>
|
</fieldset>
|
|
<span class="validation-message" hidden role="alert"></span>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
private function renderTrueFalseField(string $name, mixed $value, array $field): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
|
|
?>
|
<div class="field true-false <?= esc_attr($name) ?> row btw"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
<?= $validationAttrs ?>>
|
|
<label class="toggle-switch row" <?= $describedBy ?>>
|
<input
|
type="checkbox"
|
name="<?= esc_attr($data['name']) ?>"
|
value="1"
|
<?= ($value) ? ' checked' : '' ?>
|
<?= !empty($field['required']) ? 'required' : '' ?>
|
>
|
<div class="slider"></div>
|
<span class="toggle-label">
|
<?php if (!empty($field['required'])) : ?>
|
<span class="required" aria-label="required">*</span>
|
<?php endif; ?>
|
|
<?= esc_html($field['label']) ?></span>
|
</label>
|
<span class="validation-message" hidden role="alert"></span>
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
|
|
/* ========== REPEATER FIELD ========== */
|
|
private function renderRepeaterField(string $name, mixed $value, array $field): void
|
{
|
if (array_key_exists('group', $field)) {
|
$name = $field['group'] . '::' . $name;
|
}
|
|
$this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use ($value) {
|
$values = is_array($value) ? $value : [];
|
$rowLabel = $field['row_label'] ?? '';
|
$rowTitle = $field['new_row'] ?? 'New Item';
|
$addLabel = $field['add_label'] ?? 'Add Item';
|
|
?>
|
<div class="repeater-items" data-label="<?= esc_attr($rowLabel) ?>">
|
<?php
|
if (!empty($values)) {
|
foreach ($values as $index => $row) {
|
$this->renderRepeaterRow($field['fields'], $row, $index, $name, $rowTitle);
|
}
|
}
|
?>
|
</div>
|
|
<template class="<?= uniqid('repeaterTemplate') ?>">
|
<?php $this->renderRepeaterRow($field['fields'], [], '', $name, $rowTitle); ?>
|
</template>
|
|
<button type="button" class="add-repeater-row button secondary">
|
<?= jvbIcon('plus', ['title' => 'Add']) ?>
|
<span><?= esc_html($addLabel) ?></span>
|
</button>
|
<?php
|
});
|
}
|
|
private function renderRepeaterRow(array $fields, array $values, int|string $index, string $base_name, string $rowTitle): void
|
{
|
$display_number = is_string($index) ? $index : ($index + 1);
|
?>
|
<div class="repeater-row" data-index="<?= esc_attr($index) ?>">
|
<details <?= is_string($index) ? 'open' : '' ?>>
|
<summary class="repeater-row-header row btw">
|
<span class="drag-handle"><?= jvbIcon('grab') ?></span>
|
<span class="row-number">#<?= esc_html($display_number) ?></span>
|
<span class="row-title"><?= esc_html($this->getRowTitle($fields, $values, $rowTitle)) ?></span>
|
<button type="button" class="remove-row" title="Remove">
|
<?= jvbIcon('delete', ['title' => 'Remove']) ?>
|
</button>
|
</summary>
|
<div class="repeater-row-content">
|
<?php
|
foreach ($fields as $slug => $field) {
|
$field_name = ($base_name === '') ? $slug : sprintf('%s:%s:%s', $base_name, $index, $slug);
|
$field_value = $values[$slug] ?? '';
|
$this->render($field_name, $field_value, $field);
|
}
|
?>
|
</div>
|
</details>
|
</div>
|
<?php
|
}
|
|
private function getRowTitle(array $fields, array $values, string $rowTitle): string
|
{
|
// Try to find the first text field or textarea value to use as title
|
foreach ($fields as $slug => $field) {
|
if (in_array($field['type'], ['text', 'textarea']) &&
|
isset($values[$slug]) &&
|
!empty($values[$slug])) {
|
return $values[$slug];
|
}
|
}
|
return $rowTitle;
|
}
|
|
/* ========== GROUP FIELD ========== */
|
|
protected function renderGroupField(string $name, mixed $value, array $field): void
|
{
|
if (!array_key_exists('fields', $field) || empty($field['fields'])) {
|
return;
|
}
|
|
$values = is_array($value) ? $value : [];
|
$original = $name;
|
|
if (array_key_exists('group', $field)) {
|
$name = $field['group'] . '::' . $name;
|
}
|
|
$hidden = (array_key_exists('mode', $field) && $field['mode'] === 'hidden');
|
|
if ($hidden) {
|
// Simplified render for hidden groups
|
$this->renderGroupFields($name, $values, $field);
|
return;
|
}
|
|
// Standard fieldset render
|
$conditional = $this->handleConditionalField($field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
|
|
?>
|
<fieldset class="field group <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
<?= $validationAttrs ?>
|
<?= $describedBy ?>>
|
<legend><?= esc_html($field['label']) ?></legend>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
|
<div class="group-fields <?= esc_attr($original) ?>">
|
<?php $this->renderGroupFields($name, $values, $field); ?>
|
</div>
|
|
<span class="validation-message" hidden role="alert"></span>
|
</fieldset>
|
<?php
|
}
|
|
/**
|
* Render individual fields within a group
|
* Reusable for both standard and hidden group modes
|
*/
|
private function renderGroupFields(string $groupName, array $values, array $field): void
|
{
|
foreach ($field['fields'] as $field_name => $config) {
|
// Set the group context for proper field naming
|
$config['group'] = $groupName;
|
|
// Get the value for this specific field
|
$field_value = $values[$field_name] ?? '';
|
|
// Handle conditional fields within the group
|
if (isset($config['condition'])) {
|
$condition_field = $config['condition']['field'];
|
if (!str_contains($condition_field, '::')) {
|
$config['condition']['field'] = $groupName . '::' . $condition_field;
|
}
|
}
|
|
$this->render($field_name, $field_value, $config);
|
}
|
}
|
|
/* ========== UPLOAD FIELD ========== */
|
|
private function renderUploadField(string $name, mixed $value, array $field): void
|
{
|
// Merge with defaults
|
$config = array_merge([
|
'subtype' => 'image',
|
'accepted_types' => null,
|
'multiple' => false,
|
'limit' => 0,
|
'mode' => 'direct',
|
'destination' => 'meta',
|
'max_size' => null,
|
'convert' => 'webp',
|
'quality' => 80,
|
'create_thumbnails' => true,
|
], $field);
|
|
// Validate destination config
|
if (in_array($config['destination'], ['post', 'post_group']) && empty($config['content'])) {
|
error_log("Upload field '{$name}' has destination '{$config['destination']}' but no content defined");
|
return;
|
}
|
|
if (array_key_exists('group', $field)) {
|
$name = $field['group'] . '::' . $name;
|
}
|
|
// Prepare upload configuration
|
$acceptedTypes = $this->getAllowedTypes($config);
|
$acceptExtensions = $this->getMimeExtensions($acceptedTypes);
|
$acceptAttr = implode(',', $acceptExtensions);
|
$attachmentIds = $this->parseAttachmentIds($value);
|
$fieldType = $config['multiple'] ? 'gallery' : 'image';
|
|
// Build data attributes for uploader.js
|
$uploadData = [
|
'data-subtype' => $config['subtype'],
|
'data-mode' => $config['mode'],
|
'data-destination' => $config['destination'],
|
'data-multiple' => $config['multiple'] ? 'true' : 'false',
|
'data-limit' => $config['limit'],
|
'data-convert' => $config['convert'],
|
'data-quality' => $config['quality'],
|
];
|
|
if (!empty($config['content'])) {
|
$uploadData['data-content'] = $config['content'];
|
}
|
|
$this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use (
|
$config, $acceptAttr, $attachmentIds, $fieldType, $uploadData, $value
|
) {
|
?>
|
<div class="upload-field-wrapper <?= esc_attr($fieldType) ?>"
|
<?php foreach ($uploadData as $attr => $val) : ?>
|
<?= $attr ?>="<?= esc_attr($val) ?>"
|
<?php endforeach; ?>>
|
|
<!-- Preview Area -->
|
<div class="upload-preview-area">
|
<?php $this->renderUploadPreviews($attachmentIds, $config); ?>
|
</div>
|
|
<!-- Upload Area -->
|
<div class="file-upload-container">
|
<div class="file-upload-wrapper">
|
<input type="file"
|
name="<?= esc_attr($data['name']) ?>_temp"
|
accept="<?= esc_attr($acceptAttr) ?>"
|
<?= $config['multiple'] ? 'multiple' : '' ?>>
|
<p class="file-upload-text">
|
<strong>Click to upload</strong> or drag and drop<br>
|
<?= esc_html($this->getUploadInstructions($config)) ?>
|
</p>
|
</div>
|
<div class="file-error"></div>
|
</div>
|
|
<!-- Hidden input for storing the IDs -->
|
<input type="hidden"
|
name="<?= esc_attr($data['name']) ?>"
|
value="<?= esc_attr($value) ?>"
|
<?= !empty($field['required']) ? 'required' : '' ?>>
|
</div>
|
<?php
|
});
|
}
|
|
/**
|
* Render upload preview items
|
*/
|
private function renderUploadPreviews(array $attachmentIds, array $config): void
|
{
|
if (empty($attachmentIds)) {
|
return;
|
}
|
|
foreach ($attachmentIds as $id) {
|
if ($config['subtype'] === 'image') {
|
$url = wp_get_attachment_image_url($id, 'thumbnail');
|
if ($url) {
|
echo '<div class="upload-item" data-id="' . esc_attr($id) . '">';
|
echo '<img src="' . esc_url($url) . '" alt="">';
|
echo '<button type="button" class="remove-preview">' .
|
jvbIcon('trash', ['title' => 'Remove']) . '</button>';
|
echo '</div>';
|
}
|
}
|
// Add other subtypes (video, document) as needed
|
}
|
}
|
|
/**
|
* Get upload instruction text based on config
|
*/
|
private function getUploadInstructions(array $config): string
|
{
|
$extensions = $this->getMimeExtensions($this->getAllowedTypes($config));
|
$extList = implode(', ', array_map('strtoupper', $extensions));
|
$maxSize = $config['max_size'] ?? $this->max_file_size;
|
$maxSizeMB = round($maxSize / 1048576, 1);
|
|
return "{$extList} (max. {$maxSizeMB}MB)";
|
}
|
|
/* ========== TAXONOMY/USER SELECTOR FIELDS ========== */
|
|
private function renderTaxonomyField(string $name, mixed $value, array $field): void
|
{
|
if (array_key_exists('group', $field)) {
|
$name = $field['group'] . '::' . $name;
|
}
|
|
$this->renderSelectorField($name, $value, $field, 'taxonomy');
|
}
|
|
private function renderUserField(string $name, mixed $value, array $field): void
|
{
|
if (array_key_exists('group', $field)) {
|
$name = $field['group'] . '::' . $name;
|
}
|
|
$this->renderSelectorField($name, $value, $field, 'post');
|
}
|
|
/**
|
* Generic selector field renderer
|
* Handles both taxonomy and post selectors with consistent structure
|
*/
|
private function renderSelectorField(string $name, mixed $value, array $field, string $type): void
|
{
|
$conditional = $this->handleConditionalField($field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
|
|
// Parse selected values
|
$selected = ($value === '') ? [] : (is_array($value) ? $value : explode(',', $value));
|
|
// Create selector instance
|
if ($type === 'taxonomy') {
|
$taxonomy = $field['taxonomy'];
|
$selectorConfig = [
|
'multiple' => $field['multiple'] ?? true,
|
'placeholder' => $field['placeholder'] ?? 'Search terms...',
|
'noResults' => 'No terms found',
|
'onClose' => 'updateMetaFormTaxonomy'
|
];
|
$selector = new TaxonomySelector($taxonomy, $selectorConfig);
|
$icon = $taxonomy;
|
} else {
|
$postType = $field['post_type'];
|
$selectorConfig = [
|
'multiple' => $field['multiple'] ?? true,
|
'placeholder' => $field['placeholder'] ?? 'Search posts...',
|
'noResults' => 'No posts found',
|
'shop_id' => $field['shop_id'] ?? null,
|
'onClose' => 'updateMetaFormPost'
|
];
|
$selector = new PostSelector($postType, $selectorConfig);
|
$icon = $postType;
|
}
|
|
$containerId = $name . '-' . $type . '-selector';
|
|
?>
|
<div class="field <?= esc_attr($type) ?>-selector <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
<?= $validationAttrs ?>
|
<?= $describedBy ?>>
|
|
<div class="field-group-header row btw">
|
<label class="toggle row">
|
<?= jvbIcon($icon) ?>
|
<span><?= esc_html($field['label'] ?? ucfirst($type)) ?></span>
|
</label>
|
<button type="button"
|
class="add-item-btn button secondary"
|
title="Add <?= esc_attr(ucfirst($type)) ?>">
|
<?= jvbIcon('add', ['title' => 'Add ' . ucfirst($type)]) ?>
|
</button>
|
</div>
|
|
<?= $selector->render($selected, $containerId) ?>
|
|
<!-- Hidden input for form submission -->
|
<input type="hidden"
|
class="<?= esc_attr($type) ?>-selector-input"
|
name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>"
|
data-<?= esc_attr($type) ?>="<?= esc_attr($field[$type === 'taxonomy' ? 'taxonomy' : 'post_type']) ?>"
|
value="<?= esc_attr(is_array($selected) ? implode(',', $selected) : $value) ?>"
|
<?= !empty($field['required']) ? 'required' : '' ?>>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
|
<span class="validation-message" hidden role="alert"></span>
|
</div>
|
<?php
|
}
|
|
/* ========== LOCATION FIELD ========== */
|
|
protected function renderLocationField(string $name, mixed $value, array $field): void
|
{
|
$googleMaps = JVB()->connect('maps');
|
if (!$googleMaps->isSetUp()) {
|
echo '<div class="notice notice-warning"><p>Google Maps not configured. Please configure in Integrations settings.</p></div>';
|
return;
|
}
|
|
// Parse stored data
|
if (is_string($value)) {
|
$value = maybe_unserialize($value);
|
}
|
$stored_data = is_array($value) ? $value : [];
|
|
$address = $stored_data['address'] ?? '';
|
$lat = $stored_data['lat'] ?? '';
|
$lng = $stored_data['lng'] ?? '';
|
$street = $stored_data['street'] ?? '';
|
|
if (array_key_exists('group', $field)) {
|
$name = $field['group'] . '::' . $name;
|
}
|
|
// Prepare JavaScript configuration
|
$field_id = esc_attr($name);
|
$map_id = $field_id . '_map';
|
$js_config = [
|
'fieldId' => $field_id,
|
'initialCoords' => (!empty($lat) && !empty($lng)) ? [
|
'lat' => (float)$lat,
|
'lng' => (float)$lng
|
] : null
|
];
|
|
$this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use (
|
$stored_data, $street, $address, $lat, $lng, $map_id, $js_config
|
) {
|
?>
|
<div class="location-field-wrapper"
|
data-location-field-init="<?= esc_attr(json_encode($js_config)) ?>">
|
|
<?php if (!empty($street)) : ?>
|
<p class="current-location">
|
<strong>Current location:</strong> <?= esc_html($street) ?>
|
</p>
|
<?php endif; ?>
|
|
<label for="<?= esc_attr($data['id']) ?>">Address</label>
|
<input type="text"
|
id="<?= esc_attr($data['id']) ?>"
|
name="<?= esc_attr($data['name']) ?>[address]"
|
value="<?= esc_attr($address) ?>"
|
placeholder="Enter an address"
|
class="location-search-input"
|
autocomplete="off"
|
<?= !empty($field['required']) ? 'required' : '' ?>>
|
|
<div id="<?= esc_attr($map_id) ?>" class="location-map" style="height: 300px;"></div>
|
|
<!-- Hidden fields for lat/lng -->
|
<input type="hidden" name="<?= esc_attr($data['name']) ?>[lat]" value="<?= esc_attr($lat) ?>" class="location-lat">
|
<input type="hidden" name="<?= esc_attr($data['name']) ?>[lng]" value="<?= esc_attr($lng) ?>" class="location-lng">
|
<input type="hidden" name="<?= esc_attr($data['name']) ?>[street]" value="<?= esc_attr($stored_data['street'] ?? '') ?>" class="location-street">
|
<input type="hidden" name="<?= esc_attr($data['name']) ?>[city]" value="<?= esc_attr($stored_data['city'] ?? '') ?>" class="location-city">
|
<input type="hidden" name="<?= esc_attr($data['name']) ?>[province]" value="<?= esc_attr($stored_data['province'] ?? '') ?>" class="location-province">
|
<input type="hidden" name="<?= esc_attr($data['name']) ?>[postal]" value="<?= esc_attr($stored_data['postal'] ?? '') ?>" class="location-postal">
|
<input type="hidden" name="<?= esc_attr($data['name']) ?>[country]" value="<?= esc_attr($stored_data['country'] ?? '') ?>" class="location-country">
|
</div>
|
<?php
|
});
|
}
|
|
/* ========== HTML FIELD ========== */
|
|
protected function renderHtmlField(string $name, mixed $value, array $field): void
|
{
|
$method_name = $field['content'];
|
$content = '';
|
|
if (method_exists($this, $method_name)) {
|
$content = $this->$method_name();
|
}
|
|
if ($content === '') {
|
return;
|
}
|
|
echo sprintf(
|
'<div class="html-field-container" data-field-type="html" data-field="%s">%s</div>',
|
esc_attr($name),
|
$content
|
);
|
}
|
|
/* ========== UTILITY METHODS ========== */
|
|
private function handleConditionalField(array $field):string
|
{
|
if (empty($field['condition'])) {
|
return '';
|
}
|
|
$condition = $field['condition'];
|
return sprintf(
|
'data-depends-on="%s" data-depends-value="%s" data-depends-operator="%s"',
|
esc_attr($field['condition']['field']),
|
esc_attr($field['condition']['value']),
|
esc_attr($field['condition']['operator'] ?? '==')
|
);
|
}
|
|
protected function renderHint(string $hint): void
|
{
|
?>
|
<span class="hint"><?= esc_html($hint) ?></span>
|
<?php
|
}
|
|
protected function renderDescription(string $description, string $name): void
|
{
|
?>
|
<p class="description" id="<?= esc_attr($name) ?>-help">
|
<?= wp_kses_post($description) ?>
|
</p>
|
<?php
|
}
|
|
protected function getAllowedTypes(array $config): array
|
{
|
if (!empty($config['accepted_types'])) {
|
return $config['accepted_types'];
|
}
|
|
// Default types based on subtype
|
$defaults = [
|
'image' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
'video' => ['video/mp4', 'video/webm', 'video/ogg'],
|
'document' => ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
'any' => ['image/*', 'video/*', 'application/pdf']
|
];
|
|
return $defaults[$config['subtype']] ?? $defaults['image'];
|
}
|
|
protected function getMimeExtensions(array $mimeTypes): array
|
{
|
$extensions = [];
|
foreach ($mimeTypes as $mime) {
|
if (str_contains($mime, '*')) {
|
continue; // Skip wildcards
|
}
|
$ext = str_replace(['image/', 'video/', 'application/'], '', $mime);
|
$extensions[] = '.' . $ext;
|
}
|
return $extensions;
|
}
|
|
protected function parseAttachmentIds(mixed $value): array
|
{
|
if (empty($value)) {
|
return [];
|
}
|
|
if (is_array($value)) {
|
return array_filter($value, 'is_numeric');
|
}
|
|
if (is_string($value)) {
|
return array_filter(explode(',', $value), 'is_numeric');
|
}
|
|
return is_numeric($value) ? [$value] : [];
|
}
|
|
}
|