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

@@ -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>