fix wpcfto select and repeater related visibility and validation
This commit is contained in:
17
vendor/wpcfto/metaboxes/assets/js/metaboxes.js
vendored
17
vendor/wpcfto/metaboxes/assets/js/metaboxes.js
vendored
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
2
vendor/wpcfto/metaboxes/fields/repeater.php
vendored
2
vendor/wpcfto/metaboxes/fields/repeater.php
vendored
@@ -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>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 '[]'; }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user