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

View File

@@ -1,13 +1,13 @@
function numberFormat(nStr) { function numberFormat(nStr) {
nStr = parseFloat(nStr).toFixed(2); nStr = parseFloat(nStr).toFixed(2);
var x = nStr.split('.'); var x = nStr.split('.');
var x1 = x[0]; var x1 = x[0];
var x2 = x.length > 1 ? '.' + x[1] : ''; var x2 = x.length > 1 ? '.' + x[1] : '';
var rgx = /(\d+)(\d{3})/; var rgx = /(\d+)(\d{3})/;
while (rgx.test(x1)) { while (rgx.test(x1)) {
x1 = x1.replace(rgx, '$1' + ',' + '$2'); x1 = x1.replace(rgx, '$1' + ',' + '$2');
} }
return x1 + x2; return x1 + x2;
} }
function processPostsReport(data) { function processPostsReport(data) {
@@ -38,32 +38,32 @@ jQuery(function($){
}); });
(function() { (function() {
var supportsPassive = false; var supportsPassive = false;
try { try {
var opts = Object.defineProperty({}, 'passive', { var opts = Object.defineProperty({}, 'passive', {
get: function() { get: function() {
supportsPassive = true; 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;
}
} }
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' ) '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(); 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' ), 'submenu' => __( 'General', 'formipay' ),
'group' => 'started' '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( 'payment_section_title' => array(
'type' => 'text', 'type' => 'text',
'label' => __( 'Payment Section Title', 'formipay' ), 'label' => __( 'Payment Section Title', 'formipay' ),
@@ -102,10 +120,10 @@ abstract class Payment {
); );
$channels['payment_tablet_columns'] = array( $channels['payment_tablet_columns'] = array(
'type' => 'number', 'type' => 'number',
'label' => __( 'Tablet View', 'formipay' ), 'label' => __( 'Tablet View', 'formipay' ),
'submenu' => __( 'General', 'formipay' ), 'submenu' => __( 'General', 'formipay' ),
'value' => 3 'value' => 3
); );
$channels['payment_mobile_columns'] = array( $channels['payment_mobile_columns'] = array(

View File

@@ -57,6 +57,30 @@ class Settings {
'type' => 'checkbox', 'type' => 'checkbox',
'label' => __( 'Enable Multi Currency', 'formipay' ) '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' => [ 'multicurrencies' => [
'type' => 'repeater', 'type' => 'repeater',
'label' => __( 'Currencies', 'formipay' ), 'label' => __( 'Currencies', 'formipay' ),
@@ -94,13 +118,12 @@ class Settings {
'options' => $payment_checkboxes, 'options' => $payment_checkboxes,
'submenu' => __( 'General', 'formipay' ), 'submenu' => __( 'General', 'formipay' ),
), ),
'payment_gateways_select' => array( 'exchange_rate' => array(
'type' => 'multiselect', 'type' => 'number',
'label' => __( 'Payment Gateways', 'formipay' ), 'label' => __( 'Manual Exchange Rate', 'formipay' ),
'options' => $payment_checkboxes, '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' ), 'submenu' => __( 'General', 'formipay' ),
'placeholder' => 'Select related Payments' )
),
], ],
'required' => true, 'required' => true,
'dependency' => [ 'dependency' => [

View File

@@ -76,4 +76,41 @@ Developed by Dwindi Ramadhana.
== Privacy == == 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')) { if ($boxChild.hasClass('repeater')) {
// Repeater parent label (the field label for the repeater itself) // Repeater parent label (the field label for the repeater itself)
const parentLabel = ($boxChild.find('.wpcfto-field-aside__label span:first-child').first().text() || '').trim(); const parentLabel = ($boxChild.find('.wpcfto-field-aside__label span:first-child').first().text() || '').trim();
// checker for the parent itself // Determine if this repeater is required.
if($boxChild.find('.wpcfto-repeater-single').length == 0){ // 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({ invalid.push({
id: fieldId, id: fieldId,
tab: tabTitle, tab: tabTitle,
@@ -137,6 +146,7 @@
}); });
} }
// Child-level checks: scan only inputs that explicitly declare [required]
$boxChild.find('.wpcfto-repeater-single').each(function (idx) { $boxChild.find('.wpcfto-repeater-single').each(function (idx) {
const $item = $(this); const $item = $(this);
@@ -156,6 +166,7 @@
} }
if (!repeaterLabel) repeaterLabel = `Item #${idx+1}`; 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 () { $item.find('input, textarea, select').filter('[required]').each(function () {
const $f = $(this); const $f = $(this);
if (isRequiredEmpty($f)) { if (isRequiredEmpty($f)) {

View File

@@ -1,4 +1,3 @@
window.validationMixin = { window.validationMixin = {
methods: { methods: {
// --- Helpers // --- Helpers
@@ -24,6 +23,12 @@ window.validationMixin = {
if (Array.isArray(a) || Array.isArray(b)) return JSON.stringify(a) === JSON.stringify(b); if (Array.isArray(a) || Array.isArray(b)) return JSON.stringify(a) === JSON.stringify(b);
return String(a) == String(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 // --- Visibility according to dependencies
isVisible() { isVisible() {
@@ -56,7 +61,7 @@ window.validationMixin = {
const visible = this.isVisible(); const visible = this.isVisible();
const required = 'required' in this.fields && this.fields.required === true; const required = 'required' in this.fields && this.fields.required === true;
const type = this.fields.type || ''; const type = this.fields.type || '';
const value = this.field_value; const value = this._coalesceValue();
let filled; let filled;
if (!required) { if (!required) {
@@ -66,8 +71,18 @@ window.validationMixin = {
filled = true; filled = true;
} else if (type === 'checkbox') { } else if (type === 'checkbox') {
filled = value === 1 || value === true || value === '1' || value === 'true'; filled = value === 1 || value === true || value === '1' || value === 'true';
} else if (type === 'repeater' && required) { } else if (type === 'repeater') {
filled = this.fields.value && this.fields.value.length > 0; 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)) { } else if (Array.isArray(value)) {
filled = value.length > 0; filled = value.length > 0;
} else if (typeof value === 'number') { } else if (typeof value === 'number') {
@@ -95,6 +110,11 @@ window.validationMixin = {
if (typeof this.validateField === 'function') { if (typeof this.validateField === 'function') {
this.validateField(); 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_id="'<?php echo esc_attr( $field_id ); ?>'"
:field_value="<?php echo esc_attr( $field_value ); ?>" :field_value="<?php echo esc_attr( $field_value ); ?>"
:field_data='<?php echo esc_attr( htmlspecialchars( wp_json_encode( $field_data ) ) ); ?>' :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> </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_id="'<?php echo esc_attr($field_id); ?>'"
v-bind:field_value="<?php echo esc_attr($field_value); ?>" v-bind:field_value="<?php echo esc_attr($field_value); ?>"
v-bind:field_data='<?php echo str_replace("'", "", json_encode($field_data)); ?>' 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> ></wpcfto_repeater>

View File

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

View File

@@ -34,15 +34,22 @@ Vue.component('wpcfto_repeater', {
<div class="wpcfto-field-content"> <div class="wpcfto-field-content">
<!-- Required proxy: forces validation when repeater is empty --> <!-- Required proxy: forces validation when repeater is empty -->
<input <input
v-if="fields && fields.required === true"
class="wpcfto-required-proxy" class="wpcfto-required-proxy"
type="text" type="text"
:name="field_name + '__required__'" :name="field_id + '__required__'"
:value="(repeater && repeater.length ? 'ok' : '')" :value="(repeater && repeater.length ? 'ok' : '')"
:required="fields && fields.required === true && (!repeater || repeater.length === 0)" :required="!repeater || repeater.length === 0"
:disabled="!(fields && fields.required === true && (!repeater || repeater.length === 0))" :disabled="repeater && repeater.length > 0"
tabindex="-1" readonly tabindex="-1" readonly
style="position:absolute; left:-9999px; top:auto; width:1px; height:1px; opacity:0; pointer-events:none;" 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 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="wpcfto_group_title" @click="toggleArea(area)" v-html="getGroupTitle(area, area_key)"></div>
<div class="repeater_inner" :class="{ closed: area.closed_tab }"> <div class="repeater_inner" :class="{ closed: area.closed_tab }">
@@ -94,44 +101,74 @@ Vue.component('wpcfto_repeater', {
item.closed_tab = true; 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 // Initial validation state
if (typeof this.validateField === 'function') this.validateField(); if (typeof this.validateField === 'function') this.validateField();
// Block Save when repeater is required but empty (length check only) if (this.fields && this.fields.required === true) {
const handler = (e) => { // Block Save when repeater is required but empty (length check only)
// Only enforce when this component is in DOM const handler = (e) => {
if (!this.$el || !document.body.contains(this.$el)) return; // Only enforce when this component is in DOM
const emptyRequired = !!(this.fields && this.fields.required === true && (!this.repeater || this.repeater.length === 0)); if (!this.$el || !document.body.contains(this.$el)) return;
if (emptyRequired) { const emptyRequired = !this.repeater || this.repeater.length === 0;
e.preventDefault(); if (emptyRequired) {
e.stopPropagation(); e.preventDefault();
this.focusSelf(); e.stopPropagation();
} this.focusSelf();
}; }
try { };
const btns = document.querySelectorAll('.wpcfto_save_settings, .wpcfto_save_metabox'); try {
btns.forEach(btn => btn.addEventListener('click', handler, true)); const btns = document.querySelectorAll('.wpcfto_save_settings, .wpcfto_save_metabox');
this.__wpcftoSaveHandler = handler; btns.forEach(btn => btn.addEventListener('click', handler, true));
} catch(e) {} this.__wpcftoSaveHandler = handler;
// Also prevent <form> submission if invalid (works for both settings + metabox) } catch(e) {}
try { // Also prevent <form> submission if invalid (works for both settings + metabox)
const form = this.$el.closest('form'); try {
if (form) { const form = this.$el.closest('form');
const onSubmit = (e) => { if (form) {
const emptyRequired = !!(this.fields && this.fields.required === true && (!this.repeater || this.repeater.length === 0)); const onSubmit = (e) => {
if (emptyRequired) { const emptyRequired = !this.repeater || this.repeater.length === 0;
e.preventDefault(); if (emptyRequired) {
e.stopPropagation(); e.preventDefault();
this.focusSelf(); e.stopPropagation();
} this.focusSelf();
}; }
form.addEventListener('submit', onSubmit, true); };
this.__wpcftoFormSubmitHandler = onSubmit; form.addEventListener('submit', onSubmit, true);
} this.__wpcftoFormSubmitHandler = onSubmit;
} catch(e) {} }
} catch(e) {}
}
if (typeof this.validateField === 'function') this.validateField(); if (typeof this.validateField === 'function') this.validateField();
}, },
beforeDestroy: function () { 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: { methods: {
focusSelf() { focusSelf() {
@@ -154,28 +191,41 @@ Vue.component('wpcfto_repeater', {
}).filter(Boolean).join(' → '); }).filter(Boolean).join(' → ');
}, },
validateField() { validateField() {
let isValid = true; // If this repeater is not required, always treat as valid and clean any invalid markers
if ('required' in this.fields && this.fields.required === true) { if (!(this.fields && this.fields.required === true)) {
isValid = !!(this.fields.value.length > 0); 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) { if (!isValid) {
try { this.$el.classList.add('wpcfto-invalid'); } catch(e){} try { this.$el.classList.add('wpcfto-invalid'); } catch(e){}
try { this.$el.setAttribute('aria-invalid','true'); } 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 { try {
const fid = this.field_id || (this.fields && this.fields.field_id); const fid = this.field_id || (this.fields && this.fields.field_id);
if (fid) this.$root.$emit('field-validation', { fieldId: fid, isValid: false }); if (fid) this.$root.$emit('field-validation', { fieldId: fid, isValid: false });
} catch(e) {} } catch(e) {}
return false; return false;
} }
// valid state
try { this.$el.classList.remove('wpcfto-invalid'); } catch(e){} try { this.$el.classList.remove('wpcfto-invalid'); } catch(e){}
try { this.$el.removeAttribute('aria-invalid'); } catch(e){} try { this.$el.removeAttribute('aria-invalid'); } catch(e){}
try { try {
const fid = this.field_id || (this.fields && this.fields.field_id); const fid = this.field_id || (this.fields && this.fields.field_id);
if (fid) this.$root.$emit('field-validation', { fieldId: fid, isValid: true }); if (fid) this.$root.$emit('field-validation', { fieldId: fid, isValid: true });
} catch(e) {} } catch(e) {}
return true; return true;
}, },
// This method validates all visible required fields in all rows before adding a new row // This method validates all visible required fields in all rows before adding a new row
@@ -382,10 +432,40 @@ Vue.component('wpcfto_repeater', {
repeater: { repeater: {
deep: true, deep: true,
handler: function handler(repeater) { 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(); 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, dropdownOpen: false,
// local reactive copy so we don't mutate props // local reactive copy so we don't mutate props
localOptions: {}, // {value: label} localOptions: {}, // {value: label}
localSearchable: false localSearchable: false,
runtimeSubmenuClass: ''
}; };
}, },
computed: { computed: {
@@ -68,6 +69,48 @@ Vue.component('wpcfto_select', {
this.localSearchable = !!(this.fields && this.fields.searchable); this.localSearchable = !!(this.fields && this.fields.searchable);
this.value = this.field_value; 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) // Register for external control (optional but handy)
try { try {
window.wpcftoSelectRegistry = window.wpcftoSelectRegistry || {}; window.wpcftoSelectRegistry = window.wpcftoSelectRegistry || {};
@@ -77,6 +120,10 @@ Vue.component('wpcfto_select', {
document.addEventListener('click', this.handleClickOutside); document.addEventListener('click', this.handleClickOutside);
}, },
beforeDestroy() { beforeDestroy() {
if (this.__onSubmenuClick) {
try { document.removeEventListener('click', this.__onSubmenuClick, true); } catch(e) {}
this.__onSubmenuClick = null;
}
document.removeEventListener('click', this.handleClickOutside); document.removeEventListener('click', this.handleClickOutside);
if (window.wpcftoSelectRegistry) { if (window.wpcftoSelectRegistry) {
delete window.wpcftoSelectRegistry[this.field_id]; delete window.wpcftoSelectRegistry[this.field_id];
@@ -162,7 +209,10 @@ Vue.component('wpcfto_select', {
} }
}, },
template: ` 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> <wpcfto_fields_aside_before :fields="fields" :field_label="field_label" :required="fields.required === true"></wpcfto_fields_aside_before>