fix wpcfto select and repeater related visibility and validation
This commit is contained in:
@@ -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