fix wpcfto select and repeater related visibility and validation

This commit is contained in:
dwindown
2025-08-29 19:27:50 +07:00
parent ccb2b1aea1
commit 255da46509
14 changed files with 495 additions and 233 deletions

View File

@@ -596,11 +596,6 @@ jQuery(function($){
}
}
$(document).on('change', '#customer_data select', function() {
var value = $(this).val();
$(this).attr('data-current-value', value);
});
$(document).on('click', '.delete-preview-field', function(e){
e.preventDefault();
$(this).parents('.preview-field').remove();
@@ -760,40 +755,40 @@ jQuery(function($){
$(this).closest('.child-field-title').toggleClass('option-detail-opened');
});
var all_checkbox = $('[type="checkbox"]');
if(all_checkbox.length > 0){
$.each(all_checkbox, function(a,b){
if($(b).val() == 'yes'){
$(b).val('no').trigger('click');
}
});
}
// var all_checkbox = $('[type="checkbox"]');
// if(all_checkbox.length > 0){
// $.each(all_checkbox, function(a,b){
// if($(b).val() == 'yes'){
// $(b).val('no').trigger('click');
// }
// });
// }
function modify_payment_box_behavior() {
// function modify_payment_box_behavior() {
var allbox = $('#payments multi_checkbox');
var checkbox_input = $('.payments-payment input[type=checkbox]');
var checked_value = [];
if(checkbox_input.length > 0){
$.each(checkbox_input, function(x, y){
if($(y).is(':checked')){
checked_value.push($(y).val());
$('[data-field=wpcfto_addon_option_payment_'+$(y).val()+']').show();
}else{
$('[data-field=wpcfto_addon_option_payment_'+$(y).val()+']').hide();
}
});
}
// var allbox = $('#payments multi_checkbox');
// var checkbox_input = $('.payments-payment input[type=checkbox]');
// var checked_value = [];
// if(checkbox_input.length > 0){
// $.each(checkbox_input, function(x, y){
// if($(y).is(':checked')){
// checked_value.push($(y).val());
// $('[data-field=wpcfto_addon_option_payment_'+$(y).val()+']').show();
// }else{
// $('[data-field=wpcfto_addon_option_payment_'+$(y).val()+']').hide();
// }
// });
// }
}
// }
setTimeout(() => {
modify_payment_box_behavior();
}, 500);
// setTimeout(() => {
// modify_payment_box_behavior();
// }, 500);
$(document).on('click', '.payments-payment input[type=checkbox]', function(){
modify_payment_box_behavior();
});
// $(document).on('click', '.payments-payment input[type=checkbox]', function(){
// modify_payment_box_behavior();
// });
$(document).on('mouseover', 'span.grab', function(){
$(this).css('pointer', 'grab');
@@ -807,9 +802,14 @@ jQuery(function($){
$(this).closest('.child-fields-wrapper').find('.the_title').text($(this).val());
});
$(document).on('change', '#customer_data select', function() {
var value = $(this).val();
$(this).attr('data-current-value', value);
});
});
jQuery(function($){
// jQuery(function($){
// setTimeout(() => {
// var autocomplete_fields = $('.wpcfto-box .autocomplete');
@@ -831,116 +831,116 @@ jQuery(function($){
// }, 500);
// });
$( document ).on( 'click', '.add-thumbnail', function( event ) {
// $( document ).on( 'click', '.add-thumbnail', function( event ) {
var gallery_items_frame;
const $el = $( this );
var target_field = $el.attr('data-field');
var target_id = $el.siblings('.'+target_field+'-id');
var target_url = $el.siblings('.'+target_field+'-url');
var selected = target_id.val();
var able_multiple = $el.attr('data-able-multiple');
// var gallery_items_frame;
// const $el = $( this );
// var target_field = $el.attr('data-field');
// var target_id = $el.siblings('.'+target_field+'-id');
// var target_url = $el.siblings('.'+target_field+'-url');
// var selected = target_id.val();
// var able_multiple = $el.attr('data-able-multiple');
event.preventDefault();
// event.preventDefault();
if ( gallery_items_frame ) {
// if ( gallery_items_frame ) {
// Select the attachment when the frame opens
gallery_items_frame.on( 'open', function() {
var selection = gallery_items_frame.state().get( 'selection' );
selection.reset( selected ? [ wp.media.attachment( selected ) ] : [] );
});
// // Select the attachment when the frame opens
// gallery_items_frame.on( 'open', function() {
// var selection = gallery_items_frame.state().get( 'selection' );
// selection.reset( selected ? [ wp.media.attachment( selected ) ] : [] );
// });
// Open the modal.
gallery_items_frame.open();
// // Open the modal.
// gallery_items_frame.open();
return;
}
// return;
// }
// Create the media frame.
gallery_items_frame = wp.media.frames.gallery_items = wp.media({
// Set the title of the modal.
title: 'Choose or upload media',
button: {
text: 'Select'
},
states: [
new wp.media.controller.Library({
title: 'Choose or upload media',
filterable: 'all',
multiple: able_multiple
})
]
});
// // Create the media frame.
// gallery_items_frame = wp.media.frames.gallery_items = wp.media({
// // Set the title of the modal.
// title: 'Choose or upload media',
// button: {
// text: 'Select'
// },
// states: [
// new wp.media.controller.Library({
// title: 'Choose or upload media',
// filterable: 'all',
// multiple: able_multiple
// })
// ]
// });
// Select the attachment when the frame opens
gallery_items_frame.on( 'open', function() {
var selection = gallery_items_frame.state().get( 'selection' );
selection.reset( selected ? [ wp.media.attachment( selected ) ] : [] );
});
// // Select the attachment when the frame opens
// gallery_items_frame.on( 'open', function() {
// var selection = gallery_items_frame.state().get( 'selection' );
// selection.reset( selected ? [ wp.media.attachment( selected ) ] : [] );
// });
gallery_items_frame.on( 'select', function() {
attachment = gallery_items_frame.state().get('selection').first().toJSON();
target_id.val( attachment.id );
target_url.val( attachment.url );
if(target_id.val() !== ''){
// $el.removeClass('text-white').addClass('text-info d-none');
if($el.hasClass('btn')){
$el.siblings('i').hide();
}else{
$el.hide();
}
$el.siblings('img').removeClass('d-none').attr('src', attachment.url).show();
}else{
// $el.removeClass('text-info d-none').addClass('text-white');
if($el.hasClass('btn')){
$el.siblings('i').show();
}else{
$el.show();
}
$el.siblings('img').addClass('d-none').hide();
}
});
// gallery_items_frame.on( 'select', function() {
// attachment = gallery_items_frame.state().get('selection').first().toJSON();
// target_id.val( attachment.id );
// target_url.val( attachment.url );
// if(target_id.val() !== ''){
// // $el.removeClass('text-white').addClass('text-info d-none');
// if($el.hasClass('btn')){
// $el.siblings('i').hide();
// }else{
// $el.hide();
// }
// $el.siblings('img').removeClass('d-none').attr('src', attachment.url).show();
// }else{
// // $el.removeClass('text-info d-none').addClass('text-white');
// if($el.hasClass('btn')){
// $el.siblings('i').show();
// }else{
// $el.show();
// }
// $el.siblings('img').addClass('d-none').hide();
// }
// });
// Open the modal.
gallery_items_frame.open();
// // Open the modal.
// gallery_items_frame.open();
});
// });
$( document ).on( 'click', '.trumbowyg-button-group:has(.trumbowyg-insertImage-button)', function( event ) {
// $( document ).on( 'click', '.trumbowyg-button-group:has(.trumbowyg-insertImage-button)', function( event ) {
var gallery_items_frame;
// var gallery_items_frame;
event.preventDefault();
// event.preventDefault();
// Create the media frame.
gallery_items_frame = wp.media.frames.gallery_items = wp.media({
// Set the title of the modal.
title: 'Choose or upload media',
button: {
text: 'Select'
},
states: [
new wp.media.controller.Library({
title: 'Choose or upload media',
filterable: 'all',
multiple: false
})
]
});
// // Create the media frame.
// gallery_items_frame = wp.media.frames.gallery_items = wp.media({
// // Set the title of the modal.
// title: 'Choose or upload media',
// button: {
// text: 'Select'
// },
// states: [
// new wp.media.controller.Library({
// title: 'Choose or upload media',
// filterable: 'all',
// multiple: false
// })
// ]
// });
gallery_items_frame.on( 'select', function() {
attachment = gallery_items_frame.state().get('selection').first().toJSON();
var target_input_url = $('.trumbowyg-modal.trumbowyg-fixed-top .trumbowyg-input-html input');
var target_confirm = $('.trumbowyg-modal.trumbowyg-fixed-top .trumbowyg-modal-submit');
target_input_url.val( attachment.url );
target_confirm.trigger('click');
// gallery_items_frame.on( 'select', function() {
// attachment = gallery_items_frame.state().get('selection').first().toJSON();
// var target_input_url = $('.trumbowyg-modal.trumbowyg-fixed-top .trumbowyg-input-html input');
// var target_confirm = $('.trumbowyg-modal.trumbowyg-fixed-top .trumbowyg-modal-submit');
// target_input_url.val( attachment.url );
// target_confirm.trigger('click');
});
// });
// Open the modal.
gallery_items_frame.open();
// // Open the modal.
// gallery_items_frame.open();
});
// });
});
// });

View File

@@ -1,13 +1,13 @@
function numberFormat(nStr) {
nStr = parseFloat(nStr).toFixed(2);
var x = nStr.split('.');
var x1 = x[0];
var x2 = x.length > 1 ? '.' + x[1] : '';
var rgx = /(\d+)(\d{3})/;
while (rgx.test(x1)) {
x1 = x1.replace(rgx, '$1' + ',' + '$2');
}
return x1 + x2;
nStr = parseFloat(nStr).toFixed(2);
var x = nStr.split('.');
var x1 = x[0];
var x2 = x.length > 1 ? '.' + x[1] : '';
var rgx = /(\d+)(\d{3})/;
while (rgx.test(x1)) {
x1 = x1.replace(rgx, '$1' + ',' + '$2');
}
return x1 + x2;
}
function processPostsReport(data) {
@@ -38,32 +38,32 @@ jQuery(function($){
});
(function() {
var supportsPassive = false;
try {
var opts = Object.defineProperty({}, 'passive', {
get: function() {
supportsPassive = true;
}
});
window.addEventListener("testPassive", null, opts);
window.removeEventListener("testPassive", null, opts);
} catch (e) {}
if (!supportsPassive) return;
var origAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function(type, listener, options) {
// Only patch touchstart and touchmove if options is not explicitly passive
if (
(type === 'touchstart' || type === 'touchmove') &&
(options === undefined || options === false || (typeof options === 'object' && !options.passive))
) {
options = options || {};
if (typeof options === 'object') {
options.passive = true;
}
var supportsPassive = false;
try {
var opts = Object.defineProperty({}, 'passive', {
get: function() {
supportsPassive = true;
}
return origAddEventListener.call(this, type, listener, options);
};
})();
});
window.addEventListener("testPassive", null, opts);
window.removeEventListener("testPassive", null, opts);
} catch (e) {}
if (!supportsPassive) return;
var origAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function(type, listener, options) {
// Only patch touchstart and touchmove if options is not explicitly passive
if (
(type === 'touchstart' || type === 'touchmove') &&
(options === undefined || options === false || (typeof options === 'object' && !options.passive))
) {
options = options || {};
if (typeof options === 'object') {
options.passive = true;
}
}
return origAddEventListener.call(this, type, listener, options);
};
})();

View File

@@ -1216,7 +1216,11 @@ class Form {
'placeholder' => esc_html__( '-- Choose related field', 'formipay' )
]
],
'nonce' => wp_create_nonce('formipay-form-editor')
'nonce' => wp_create_nonce('formipay-form-editor'),
'multicurrency' => formipay_is_multi_currency_active(),
'all_currencies' => formipay_currency_as_options(),
'global_selected_currencies' => formipay_global_currency_options(),
'default_currency' => formipay_default_currency()
] );
wp_enqueue_media();

View File

@@ -0,0 +1,10 @@
<?php
namespace Formipay\Integration;
use Formipay\Traits\SingletonTrait;
use Formipay\Payment\Payment;
// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) exit;
class ExchangeRateAPI extends Payment {
}

View File

@@ -59,6 +59,24 @@ abstract class Payment {
'submenu' => __( 'General', 'formipay' ),
'group' => 'started'
),
'allowed_currencies' => array(
'type' => 'multi_checkbox',
'searchable' => true,
'required' => true,
'label' => __( 'Allowed Currencies', 'formipay' ),
'options' => formipay_global_currency_options(),
'submenu' => __( 'General', 'formipay' ),
'description' => __( 'Activate multicurrency and set more than one currency to enable this option. Default, only default currency is allowed.', 'formipay' )
),
'default_currencies' => array(
'type' => 'select',
'searchable' => true,
'required' => true,
'label' => __( 'Default Currency', 'formipay' ),
'options' => formipay_global_currency_options(),
'submenu' => __( 'General', 'formipay' ),
'description' => __( 'First apply currency before buyer select.', 'formipay' )
),
'payment_section_title' => array(
'type' => 'text',
'label' => __( 'Payment Section Title', 'formipay' ),
@@ -102,10 +120,10 @@ abstract class Payment {
);
$channels['payment_tablet_columns'] = array(
'type' => 'number',
'label' => __( 'Tablet View', 'formipay' ),
'submenu' => __( 'General', 'formipay' ),
'value' => 3
'type' => 'number',
'label' => __( 'Tablet View', 'formipay' ),
'submenu' => __( 'General', 'formipay' ),
'value' => 3
);
$channels['payment_mobile_columns'] = array(

View File

@@ -57,6 +57,30 @@ class Settings {
'type' => 'checkbox',
'label' => __( 'Enable Multi Currency', 'formipay' )
],
'enable_auto_exchangerate' => [
'type' => 'checkbox',
'label' => __( 'Enable Auto Exchange Rate', 'formipay' ),
'dependency' => [
'key' => 'enable_multicurrency',
'value' => 'not_empty'
]
],
'enable_auto_exchangerate_apikey' => [
'type' => 'text',
'label' => __( 'Auto Exchange Rate API Key', 'formipay' ),
'required' => true,
'dependency' => [
[
'key' => 'enable_multicurrency',
'value' => 'not_empty'
],
[
'key' => 'enable_auto_exchangerate',
'value' => 'not_empty'
]
],
'dependencies' => '&&'
],
'multicurrencies' => [
'type' => 'repeater',
'label' => __( 'Currencies', 'formipay' ),
@@ -94,13 +118,12 @@ class Settings {
'options' => $payment_checkboxes,
'submenu' => __( 'General', 'formipay' ),
),
'payment_gateways_select' => array(
'type' => 'multiselect',
'label' => __( 'Payment Gateways', 'formipay' ),
'options' => $payment_checkboxes,
'exchange_rate' => array(
'type' => 'number',
'label' => __( 'Manual Exchange Rate', 'formipay' ),
'description' => __( 'This value is the exchange rate of default currency against this currency. If this currency selected, total order will be multiplied to this value. <b>This override the value from ExchangeRatePI if enabled</b>', 'formipay' ),
'submenu' => __( 'General', 'formipay' ),
'placeholder' => 'Select related Payments'
),
)
],
'required' => true,
'dependency' => [

View File

@@ -76,4 +76,41 @@ Developed by Dwindi Ramadhana.
== Privacy ==
Formipay collects and processes user data in accordance with GDPR. Please review and customize the included privacy policy template for your site.
Formipay collects and processes user data in accordance with GDPR. Please review and customize the included privacy policy template for your site.
== Mermaid Multi-Currency Implementation ==
flowchart TD
GS[Global Settings]
GS -->|Toggle: MultiCurrency ON/OFF| MODE{Mode}
%% SINGLE-CURRENCY MODE
MODE -->|OFF| SC[SingleCurrency Mode]
SC -->|GS Default Currency only| FS1[Form Settings]
SC -->|GS Default Currency only| PS1[Product Settings]
FS1 --> CO1[Checkout]
PS1 --> CO1
CO1 --> ORD1[Order Stored :: currency = GS default]
ORD1 --> RPT1[Reports for single currency]
%% MULTI-CURRENCY MODE
MODE -->|ON| MC[MultiCurrency Mode]
GS -->|Enabled Currencies + Rates| FS[Form Settings]
GS -->|Enabled Currencies + Rates| PS[Product Settings xN]
FS -->|FS.allowed ⊆ GS.enabled\nFS.default ∈ FS.allowed| CK[Checkout]
PS -->|Per Product:\nBase currency default GS\nManual overrides optional\nDerive from base via GS toggle| CK
CK -->|Compute CheckoutAllowed =\nFS.allowed ∩ as ProductSupported p| ALLOWED{CheckoutAllowed empty?}
ALLOWED -->|Yes| BLOCK[Block checkout + Admin diagnostic:\nEnable derive / add manual prices /\nadjust FS.allowed / remove product]
ALLOWED -->|No| CUR[Buyer selects currency ∈ CheckoutAllowed]
CUR --> PAY[Filter payment gateways by selected currency]
PAY --> TOT[Compute totals:\nManual price → else derive via GS]
TOT --> ORD[Persist Order:\norder_currency, total_in_order_currency,\nfx_rate_used, report_total_in_GS_base]
ORD --> RPT[Reports:\nSum in GS base with percurrency breakdown option]
%% CATALOG (when MC=ON)
MC --> CAT[Catalog]
CAT -->|Query: currency=USD,IDR,AUTO| RESOLVE[Resolve display currency AUTO→pref/GS default]
RESOLVE --> FILT[Filter products strictly:\nshow only products supporting the display currency\n derive ok if product toggle is ON]

View File

@@ -126,9 +126,18 @@
if ($boxChild.hasClass('repeater')) {
// Repeater parent label (the field label for the repeater itself)
const parentLabel = ($boxChild.find('.wpcfto-field-aside__label span:first-child').first().text() || '').trim();
// checker for the parent itself
if($boxChild.find('.wpcfto-repeater-single').length == 0){
// Determine if this repeater is required.
// We prefer presence of the hidden proxy input (rendered only when required).
// Fallback: a data attribute marker if used by templates.
const isRequiredRepeater = (
$boxChild.find('.wpcfto-required-proxy').length > 0 ||
$boxChild.is('[data-required="true"]')
);
// Parent-level empty check: only flag when the repeater itself is required
const hasRows = $boxChild.find('.wpcfto-repeater-single').length > 0;
if (isRequiredRepeater && !hasRows) {
invalid.push({
id: fieldId,
tab: tabTitle,
@@ -137,6 +146,7 @@
});
}
// Child-level checks: scan only inputs that explicitly declare [required]
$boxChild.find('.wpcfto-repeater-single').each(function (idx) {
const $item = $(this);
@@ -156,6 +166,7 @@
}
if (!repeaterLabel) repeaterLabel = `Item #${idx+1}`;
// Only required child fields should be considered invalid when empty
$item.find('input, textarea, select').filter('[required]').each(function () {
const $f = $(this);
if (isRequiredEmpty($f)) {

View File

@@ -1,4 +1,3 @@
window.validationMixin = {
methods: {
// --- Helpers
@@ -24,6 +23,12 @@ window.validationMixin = {
if (Array.isArray(a) || Array.isArray(b)) return JSON.stringify(a) === JSON.stringify(b);
return String(a) == String(b);
},
_coalesceValue() {
// Prefer prop field_value if present; otherwise fall back to instance `value`
if (typeof this.field_value !== 'undefined') return this.field_value;
if (typeof this.value !== 'undefined') return this.value;
return undefined;
},
// --- Visibility according to dependencies
isVisible() {
@@ -56,7 +61,7 @@ window.validationMixin = {
const visible = this.isVisible();
const required = 'required' in this.fields && this.fields.required === true;
const type = this.fields.type || '';
const value = this.field_value;
const value = this._coalesceValue();
let filled;
if (!required) {
@@ -66,8 +71,18 @@ window.validationMixin = {
filled = true;
} else if (type === 'checkbox') {
filled = value === 1 || value === true || value === '1' || value === 'true';
} else if (type === 'repeater' && required) {
filled = this.fields.value && this.fields.value.length > 0;
} else if (type === 'repeater') {
if (!required) {
filled = true; // optional repeater: always valid
} else {
// Prefer the component's live repeater array; fallback to value/field_value
const list = Array.isArray(this.repeater)
? this.repeater
: (Array.isArray(value)
? value
: (this.fields && Array.isArray(this.fields.value) ? this.fields.value : []));
filled = list.length > 0;
}
} else if (Array.isArray(value)) {
filled = value.length > 0;
} else if (typeof value === 'number') {
@@ -95,6 +110,11 @@ window.validationMixin = {
if (typeof this.validateField === 'function') {
this.validateField();
}
},
value() {
if (typeof this.validateField === 'function') {
this.validateField();
}
}
},

View File

@@ -21,5 +21,5 @@ $field = "data['{$section_name}']['fields']['{$field_name}']";
:field_id="'<?php echo esc_attr( $field_id ); ?>'"
:field_value="<?php echo esc_attr( $field_value ); ?>"
:field_data='<?php echo esc_attr( htmlspecialchars( wp_json_encode( $field_data ) ) ); ?>'
@wpcfto-get-value="<?php echo esc_attr( $field_value ); ?> = $event">
@wpcfto-get-value="$set(<?php echo esc_attr( $field ); ?>, 'value', $event)">
</wpcfto_autocomplete>

View File

@@ -23,5 +23,5 @@ wp_enqueue_script('my-super-component', STM_WPCFTO_URL . '/metaboxes/general_com
v-bind:field_id="'<?php echo esc_attr($field_id); ?>'"
v-bind:field_value="<?php echo esc_attr($field_value); ?>"
v-bind:field_data='<?php echo str_replace("'", "", json_encode($field_data)); ?>'
@wpcfto-get-value="$set(<?php echo esc_attr($field) ?>, 'value', $event)"
@wpcfto-get-value="$set(<?php echo esc_attr($field); ?>, 'value', $event)"
></wpcfto_repeater>

View File

@@ -59,9 +59,10 @@
</ul>
<input type="hidden"
:name="field_name"
v-model="value"
:required="fields.required === true"
:name="field_name"
:value="serializedValue"
:required="fields && fields.required === true"
:disabled="!(fields && fields.required === true) && (!value || (Array.isArray(value) && value.length === 0))"
/>
</div>
</div>
@@ -70,6 +71,11 @@
</div>
`,
computed: {
serializedValue() {
const v = this.value;
if (Array.isArray(v)) return v.join(',');
return v || '';
},
computedPlaceholder() {
// Default placeholder template or fallback
const template = formipay_admin?.config?.autocomplete?.placeholder || 'Search {field_label}...';
@@ -92,6 +98,9 @@
} else {
this.limit = 5; // default limit
}
if (!this.field_value) {
this.value = [];
}
},
mounted() {
this.$nextTick(() => {

View File

@@ -34,15 +34,22 @@ Vue.component('wpcfto_repeater', {
<div class="wpcfto-field-content">
<!-- Required proxy: forces validation when repeater is empty -->
<input
v-if="fields && fields.required === true"
class="wpcfto-required-proxy"
type="text"
:name="field_name + '__required__'"
:name="field_id + '__required__'"
:value="(repeater && repeater.length ? 'ok' : '')"
:required="fields && fields.required === true && (!repeater || repeater.length === 0)"
:disabled="!(fields && fields.required === true && (!repeater || repeater.length === 0))"
:required="!repeater || repeater.length === 0"
:disabled="repeater && repeater.length > 0"
tabindex="-1" readonly
style="position:absolute; left:-9999px; top:auto; width:1px; height:1px; opacity:0; pointer-events:none;"
/>
<!-- Hidden data carrier to persist repeater rows via standard form submit -->
<input
type="hidden"
:name="field_name"
:value="jsonValue"
/>
<div v-for="(area, area_key) in repeater" class="wpcfto-repeater-single" :class="'wpcfto-repeater_' + field_name + '_' + area_key">
<div class="wpcfto_group_title" @click="toggleArea(area)" v-html="getGroupTitle(area, area_key)"></div>
<div class="repeater_inner" :class="{ closed: area.closed_tab }">
@@ -94,44 +101,74 @@ Vue.component('wpcfto_repeater', {
item.closed_tab = true;
});
}
// Normalize rows to include only declared fields (copy defaults if missing)
if (this.fields && this.fields.fields) {
this.repeater = this.repeater.map((row) => {
const normalized = { closed_tab: true };
Object.keys(this.fields.fields).forEach((fname) => {
if (typeof row[fname] !== 'undefined') {
normalized[fname] = row[fname];
} else {
const def = this.fields.fields[fname] && this.fields.fields[fname].value;
if (typeof def !== 'undefined') normalized[fname] = def;
}
});
return normalized;
});
}
// Initial validation state
if (typeof this.validateField === 'function') this.validateField();
// Block Save when repeater is required but empty (length check only)
const handler = (e) => {
// Only enforce when this component is in DOM
if (!this.$el || !document.body.contains(this.$el)) return;
const emptyRequired = !!(this.fields && this.fields.required === true && (!this.repeater || this.repeater.length === 0));
if (emptyRequired) {
e.preventDefault();
e.stopPropagation();
this.focusSelf();
}
};
try {
const btns = document.querySelectorAll('.wpcfto_save_settings, .wpcfto_save_metabox');
btns.forEach(btn => btn.addEventListener('click', handler, true));
this.__wpcftoSaveHandler = handler;
} catch(e) {}
// Also prevent <form> submission if invalid (works for both settings + metabox)
try {
const form = this.$el.closest('form');
if (form) {
const onSubmit = (e) => {
const emptyRequired = !!(this.fields && this.fields.required === true && (!this.repeater || this.repeater.length === 0));
if (emptyRequired) {
e.preventDefault();
e.stopPropagation();
this.focusSelf();
}
};
form.addEventListener('submit', onSubmit, true);
this.__wpcftoFormSubmitHandler = onSubmit;
}
} catch(e) {}
if (this.fields && this.fields.required === true) {
// Block Save when repeater is required but empty (length check only)
const handler = (e) => {
// Only enforce when this component is in DOM
if (!this.$el || !document.body.contains(this.$el)) return;
const emptyRequired = !this.repeater || this.repeater.length === 0;
if (emptyRequired) {
e.preventDefault();
e.stopPropagation();
this.focusSelf();
}
};
try {
const btns = document.querySelectorAll('.wpcfto_save_settings, .wpcfto_save_metabox');
btns.forEach(btn => btn.addEventListener('click', handler, true));
this.__wpcftoSaveHandler = handler;
} catch(e) {}
// Also prevent <form> submission if invalid (works for both settings + metabox)
try {
const form = this.$el.closest('form');
if (form) {
const onSubmit = (e) => {
const emptyRequired = !this.repeater || this.repeater.length === 0;
if (emptyRequired) {
e.preventDefault();
e.stopPropagation();
this.focusSelf();
}
};
form.addEventListener('submit', onSubmit, true);
this.__wpcftoFormSubmitHandler = onSubmit;
}
} catch(e) {}
}
if (typeof this.validateField === 'function') this.validateField();
},
beforeDestroy: function () {
if (this.__teardown) this.__teardown();
if (this.__wpcftoSaveHandler) {
try {
const btns = document.querySelectorAll('.wpcfto_save_settings, .wpcfto_save_metabox');
btns.forEach(btn => btn.removeEventListener('click', this.__wpcftoSaveHandler, true));
} catch(e) {}
this.__wpcftoSaveHandler = null;
}
if (this.__wpcftoFormSubmitHandler) {
try {
const form = this.$el && this.$el.closest ? this.$el.closest('form') : null;
if (form) form.removeEventListener('submit', this.__wpcftoFormSubmitHandler, true);
} catch(e) {}
this.__wpcftoFormSubmitHandler = null;
}
},
methods: {
focusSelf() {
@@ -154,28 +191,41 @@ Vue.component('wpcfto_repeater', {
}).filter(Boolean).join(' → ');
},
validateField() {
let isValid = true;
if ('required' in this.fields && this.fields.required === true) {
isValid = !!(this.fields.value.length > 0);
// If this repeater is not required, always treat as valid and clean any invalid markers
if (!(this.fields && this.fields.required === true)) {
try { this.$el.classList.remove('wpcfto-invalid'); } catch(e){}
try { this.$el.removeAttribute('aria-invalid'); } catch(e){}
try {
const fid = this.field_id || (this.fields && this.fields.field_id);
if (fid) this.$root.$emit('field-validation', { fieldId: fid, isValid: true });
} catch(e) {}
return true;
}
// Repeater-level required: valid iff it has at least one row
let isValid = true;
if (this.fields && this.fields.required === true) {
const len = (this.repeater && Array.isArray(this.repeater)) ? this.repeater.length : 0;
isValid = len > 0;
}
if (!isValid) {
try { this.$el.classList.add('wpcfto-invalid'); } catch(e){}
try { this.$el.setAttribute('aria-invalid','true'); } catch(e){}
// Tell the global collector (validationMixin) about our state
// Notify global validator (validationMixin listener)
try {
const fid = this.field_id || (this.fields && this.fields.field_id);
if (fid) this.$root.$emit('field-validation', { fieldId: fid, isValid: false });
} catch(e) {}
return false;
}
// valid state
try { this.$el.classList.remove('wpcfto-invalid'); } catch(e){}
try { this.$el.removeAttribute('aria-invalid'); } catch(e){}
try {
const fid = this.field_id || (this.fields && this.fields.field_id);
if (fid) this.$root.$emit('field-validation', { fieldId: fid, isValid: true });
} catch(e) {}
return true;
},
// This method validates all visible required fields in all rows before adding a new row
@@ -382,10 +432,40 @@ Vue.component('wpcfto_repeater', {
repeater: {
deep: true,
handler: function handler(repeater) {
this.$emit('wpcfto-get-value', repeater);
// Build a clean payload with only declared inner fields
const payload = (Array.isArray(repeater) ? repeater : []).map((row) => {
const out = {};
if (this.fields && this.fields.fields) {
Object.keys(this.fields.fields).forEach((fname) => {
if (typeof row[fname] !== 'undefined') out[fname] = row[fname];
});
}
return out;
});
this.$emit('wpcfto-get-value', payload);
this.validateField();
}
}
},
computed: {
jsonValue() {
const list = Array.isArray(this.repeater) ? this.repeater : [];
const fieldsDef = (this.fields && this.fields.fields) ? this.fields.fields : null;
const payload = list.map((row) => {
const out = {};
if (fieldsDef) {
Object.keys(fieldsDef).forEach((fname) => {
if (typeof row[fname] !== 'undefined') out[fname] = row[fname];
else {
const def = fieldsDef[fname] && fieldsDef[fname].value;
if (typeof def !== 'undefined') out[fname] = def;
}
});
}
return out;
});
try { return JSON.stringify(payload); } catch(e) { return '[]'; }
}
}
});

View File

@@ -11,7 +11,8 @@ Vue.component('wpcfto_select', {
dropdownOpen: false,
// local reactive copy so we don't mutate props
localOptions: {}, // {value: label}
localSearchable: false
localSearchable: false,
runtimeSubmenuClass: ''
};
},
computed: {
@@ -68,6 +69,48 @@ Vue.component('wpcfto_select', {
this.localSearchable = !!(this.fields && this.fields.searchable);
this.value = this.field_value;
// Attach submenu section class expected by initSubmenu()
let submenuClass, slug;
try {
const tabEl = this.$el.closest('.wpcfto-tab');
const tabId = tabEl && tabEl.getAttribute ? tabEl.getAttribute('id') : null;
const raw = (this.fields && this.fields.submenu) ? String(this.fields.submenu) : '';
slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
// initSubmenu() expects classes like `${tabId}_${slug}`
submenuClass = tabId ? (tabId + '_' + slug) : slug;
if (submenuClass) {
this.runtimeSubmenuClass = submenuClass;
}
} catch (e) {}
// Ensure submenu discovery works even if initSubmenu ran before this component mounted
try {
const active = document.querySelector('.wpcfto-submenus .active');
const activeKey = active ? active.getAttribute('data-submenu') : null;
if (submenuClass) {
// Also set data-submenu attr for selectors that rely on attributes
this.$el.setAttribute('data-submenu', submenuClass);
// if (activeKey && (activeKey === submenuClass || activeKey === slug)) {
// this.$el.style.removeProperty('display');
// this.$el.style.removeProperty('visibility');
// }
// no direct style changes; visibility is handled by initSubmenu()
}
} catch (e) {}
// Listen for submenu changes to unhide this field when needed
try {
this.__onSubmenuClick = (ev) => {
const btn = ev.target.closest('[data-submenu]');
if (!btn) return;
const key = btn.getAttribute('data-submenu');
if (key && submenuClass && (key === submenuClass || key === slug)) {
// nothing needed; class binding ensures we have the right class and initSubmenu will reveal us
}
};
document.addEventListener('click', this.__onSubmenuClick, true);
} catch (e) {}
// Register for external control (optional but handy)
try {
window.wpcftoSelectRegistry = window.wpcftoSelectRegistry || {};
@@ -77,6 +120,10 @@ Vue.component('wpcfto_select', {
document.addEventListener('click', this.handleClickOutside);
},
beforeDestroy() {
if (this.__onSubmenuClick) {
try { document.removeEventListener('click', this.__onSubmenuClick, true); } catch(e) {}
this.__onSubmenuClick = null;
}
document.removeEventListener('click', this.handleClickOutside);
if (window.wpcftoSelectRegistry) {
delete window.wpcftoSelectRegistry[this.field_id];
@@ -162,7 +209,10 @@ Vue.component('wpcfto_select', {
}
},
template: `
<div class="wpcfto_generic_field wpcfto_generic_field__select" :class="{ open: dropdownOpen }" :data-field="field_id">
<div
class="wpcfto_generic_field wpcfto_generic_field_select"
:class="['columns-' + (fields && fields.columns ? fields.columns : 1), runtimeSubmenuClass, { open: dropdownOpen }]"
:data-field="field_id">
<wpcfto_fields_aside_before :fields="fields" :field_label="field_label" :required="fields.required === true"></wpcfto_fields_aside_before>