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

@@ -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 '[]'; }
}
}
});