From a0635840b7260299f9974d47690e60bf6632113e Mon Sep 17 00:00:00 2001 From: cgfhtw Date: Wed, 29 Nov 2023 12:14:45 +0100 Subject: [PATCH] New Validation --- public/js/components/Form/Form.js | 129 +++++ public/js/components/Form/Input.js | 207 +++++++ public/js/components/Form/Validation.js | 98 +--- public/js/components/Fragment.js | 5 + .../Stv/Studentenverwaltung/List/New.js | 548 ++++++++++++------ 5 files changed, 723 insertions(+), 264 deletions(-) create mode 100644 public/js/components/Form/Form.js create mode 100644 public/js/components/Form/Input.js create mode 100644 public/js/components/Fragment.js diff --git a/public/js/components/Form/Form.js b/public/js/components/Form/Form.js new file mode 100644 index 000000000..bf9e318c0 --- /dev/null +++ b/public/js/components/Form/Form.js @@ -0,0 +1,129 @@ +import FhcFragment from "../Fragment.js"; + +export default { + components: { + FhcFragment + }, + provide() { + return { + $registerToForm: component => { + if (this.inputs.indexOf(component) < 0) + this.inputs.push(component); + } + }; + }, + props: { + tag: { + type: String, + default: 'form' + } + }, + data() { + return { + inputs: [] + } + }, + computed: { + sortedInputs() { + return this.inputs.reduce((a,c) => { + let name = c.name || '_default'; + if (!a[name]) + a[name] = []; + a[name].push(c); + return a; + }, {}); + } + }, + methods: { + _sendFeedbackToInput(inputs, feedback, valid) { + if (inputs.length) { + inputs.forEach(input => input.setFeedback(feedback, valid)); + return false; + } + if (this.$fhcAlert) { + this.$fhcAlert[valid ? 'alertSuccess' : 'alertError'](feedback); + return false; + } + return true; + }, + setFeedback(feedback, valid) { + if (Array.isArray(feedback)) { + let remaining = feedback.filter(fb => + this._sendFeedbackToInput( + this.sortedInputs['_default'] || [], + fb, + valid + ) + ); + return remaining.length ? remaining : null; + } + if (typeof feedback === 'object') { + let remaining = Object.entries(feedback).filter(([name, fb]) => + this._sendFeedbackToInput( + this.sortedInputs[name.split('.')[0] + name.split('.').slice(1).map(p => `[${p}]`).join("")] || this.sortedInputs['_default'] || [], + fb, + valid + ) + ); + return remaining.length ? Object.fromEntries(remaining) : null; + } + + let remaining = this._sendFeedbackToInput( + this.sortedInputs['_default'] || [], + feedback, + valid + ); + return remaining ? feedback : null; + }, + clearValidation() { + this.inputs.forEach(input => input.clearValidation()); + }, + send(promise) { + return new Promise((resolve, reject) => { + promise.then(result => { + if (result?.status == 200 && result.data) { + if (typeof result.data !== 'object' || !result.data.hasOwnProperty('retval')) + // TODO(chris): IMPLEMENT! Error in API + return reject(result); + if (result.data.error) + // TODO(chris): IMPLEMENT! Error in API + return reject(result); + const data = result.data.retval; + // TODO(chris): check for something better/add new standardized return value + if (result.data.code == 1) + this.setFeedback(data, true); + return resolve(data); + } + // TODO(chris): IMPLEMENT! Wrong result object + reject(result); + }).catch(result => { + if (result?.response?.status == 400 && result.response.data) { + if (typeof result.response.data !== 'object' || !result.response.data.hasOwnProperty('retval')) + // TODO(chris): IMPLEMENT! Error in API + return reject(result); + this.clearValidation(); + const remaining = this.setFeedback( + result.response.data.retval, + false + ); + if (remaining) { + result.response.data.retval = remaining; + return reject(result); + } + } else if (result?.response?.status == 500) { + if (this.$fhcAlert) + this.$fhcAlert.handleSystemError(result); + else + return reject(result); + } else { + return reject(result); + } + }); + }); + } + }, + template: ` + + + ` +} \ No newline at end of file diff --git a/public/js/components/Form/Input.js b/public/js/components/Form/Input.js new file mode 100644 index 000000000..6b3ed5eb1 --- /dev/null +++ b/public/js/components/Form/Input.js @@ -0,0 +1,207 @@ +import FhcFragment from "../Fragment.js"; + +let _uuid = {}; + +export default { + inheritAttrs: false, + components: { + FhcFragment + }, + inject: [ + '$registerToForm' + ], + props: { + bsFeedback: Boolean, + noAutoClass: Boolean, + type: String, + name: String + }, + data() { + return { + valid: undefined, + feedback: [] + } + }, + computed: { + lcType() { + if (!this.type) + return 'text'; + return this.type.toLowerCase(); + }, + tag() { + switch (this.lcType) { + case 'textarea': + case 'select': + return this.lcType; + case 'datepicker': + return 'VueDatePicker'; + case 'autocomplete': + return 'PvAutocomplete'; + default: + return 'input'; + } + }, + validationClass() { + const classes = []; + if (this.valid) + classes.push('is-valid'); + else if (this.valid === false) + classes.push('is-invalid'); + + if (!this.noAutoClass) { + let c = this.$attrs.class ? this.$attrs.class.split(' ') : []; + switch (this.lcType) { + // TODO(chris): complete list! + case 'select': + if (!c.includes('form-select')) + classes.push('form-select'); + break; + case 'range': + if (!c.includes('form-range')) + classes.push('form-range'); + break; + case 'radio': + case 'checkbox': + // TODO(chris): maybe different handling? + if (!c.includes('form-check-input') && !c.includes('btn-check')) + classes.push('form-check-input'); + break; + case 'autocomplete': + case 'datepicker': + classes.push('p-0'); + classes.push('border-0'); + case 'text': + case 'textarea': + if (!c.includes('form-control')) + classes.push('form-control'); + break; + } + } + + return classes; + }, + feedbackClass() { + if (!this.feedback || this.feedback === true) + return ''; + if (!this.bsFeedback) + return { + 'valid-tooltip': this.valid === true, + 'invalid-tooltip': this.valid === false + }; + return { + 'valid-feedback': this.valid === true, + 'invalid-feedback': this.valid === false + }; + }, + modelValueCmp: { + get() { + return this.$attrs.modelValue; + }, + set(v) { + this.$emit('update:modelValue', v); + } + }, + idCmp() { + let uuid = this.$attrs.id; + if (this.lcType == 'datepicker') + uuid = this.$attrs.uid; + if (!uuid && this.$attrs.label) + uuid = 'fhc-form-input'; + if (!uuid) + return undefined; + if (this.lcType == 'datepicker') + uuid = 'dp-input-' + uuid; + if (_uuid[uuid] === undefined) + _uuid[uuid] = 0; + return uuid + '-' + (_uuid[uuid]++); + } + }, + methods: { + clearValidation() { + this.valid = undefined; + this.feedback = []; + }, + setFeedback(feedback, valid) { + if (!Array.isArray(feedback)) + feedback = [feedback]; + this.valid = valid; + this.feedback = feedback; + }, + _loadComponents() { + if (this.tag == 'VueDatePicker' && !this._.components.VueDatePicker) { + this._.components.VueDatePicker = Vue.defineAsyncComponent(() => import("../vueDatepicker.js.php")); + } else if (this.tag == 'PvAutocomplete' && !this._.components.PvAutocomplete) { + this._.components.PvAutocomplete = Vue.defineAsyncComponent(() => import(FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + "/public/js/components/primevue/autocomplete/autocomplete.esm.min.js"));; + } + } + }, + beforeMount() { + this._loadComponents(); + }, + beforeUpdate() { + this._loadComponents(); + }, + mounted() { + if (this.$registerToForm) + this.$registerToForm(this); + // TODO(chris): wrap check in div? + }, + template: ` + + + + + + + + + + + + + + +
+ +
+ +
+ ` +} \ No newline at end of file diff --git a/public/js/components/Form/Validation.js b/public/js/components/Form/Validation.js index 3b4e69112..51ea3c689 100644 --- a/public/js/components/Form/Validation.js +++ b/public/js/components/Form/Validation.js @@ -1,84 +1,36 @@ +const FEEDBACK_DEFAULT = { + success: [], + danger: [] +}; export default { - props: { - name: String - }, + inject: [ + '$registerToForm' + ], data() { return { - isValid: false, - isInvalid: false, - invalidFeedback: '' + feedback: FEEDBACK_DEFAULT }; }, methods: { - reset() { - this.isValid = false; - this.isInvalid = false; - this.invalidFeedback = ''; + clearValidation() { + this.feedback = FEEDBACK_DEFAULT; }, - setValid() { - this.isValid = true; - }, - setInvalid(feedback) { - this.invalidFeedback = feedback?.detail || ''; - this.isInvalid = true; + setFeedback(feedback, valid) { + if (!Array.isArray(feedback)) + feedback = [feedback]; + this.feedback[valid ? 'success' : 'danger'] = feedback; } }, - render() { - if (!this.$slots.default) - return Vue.h('div', { - 'data-fhc-form-error': true, - class: { - 'alert': true, - 'alert-danger': true, - 'd-none': !this.isInvalid - }, - role: 'alert', - onFhcFormReset: this.reset, - onFhcFormError: this.setInvalid - }, (this.invalidFeedback || []).map((txt, i) => Vue.h('p', { - key: i, - class: i+1 == (this.invalidFeedback || []).length ? 'mb-0' : '' - }, txt))); - - const res = this.$slots.default(); - const orig = res.shift(); - - let options = { - 'data-fhc-form-validate': this.name, - onFhcFormReset: this.reset, - onFhcFormValidate: this.setValid, - onFhcFormInvalidate: this.setInvalid, - onInput: this.reset, - class: { - 'form-control': orig.type !== 'select' && orig.props.type !== 'checkbox' && orig.props.type !== 'radio' && !(orig.props?.class || '').split(' ').includes('form-control'), - 'form-select': orig.type === 'select' && !(orig.props?.class || '').split(' ').includes('form-select'), - 'is-valid': this.isValid, - 'is-invalid': this.isInvalid - } - }; - if (orig.type.__name == 'VueDatePicker') { - options.class['p-0'] = true; - options.inputClassName = 'border-0'; - } - res.unshift(Vue.cloneVNode(orig, options)); - - if (this.isInvalid && this.invalidFeedback && this.$slots.default) { - /* NOTE(chris): use bootstraps 'feedback' */ - /*res.push(Vue.h('div', { - class: 'invalid-feedback' - }, this.invalidFeedback));*/ - - /* NOTE(chris): use bootstraps 'tooltip' */ - res.push(Vue.h('div', { - class: 'invalid-tooltip' - }, this.invalidFeedback)); - } - /* NOTE(chris): use bootstraps 'feedback' */ - /*return res;*/ - - /* NOTE(chris): use bootstraps 'tooltip' */ - return Vue.h('div', { - class: 'position-relative' - }, res); + mounted() { + if (this.$registerToForm) + this.$registerToForm(this); }, + template: ` + + ` }; \ No newline at end of file diff --git a/public/js/components/Fragment.js b/public/js/components/Fragment.js new file mode 100644 index 000000000..4d4af87c7 --- /dev/null +++ b/public/js/components/Fragment.js @@ -0,0 +1,5 @@ +export default { + render() { + return (this.$slots && this.$slots.default) ? this.$slots.default() : null; + } +}; \ No newline at end of file diff --git a/public/js/components/Stv/Studentenverwaltung/List/New.js b/public/js/components/Stv/Studentenverwaltung/List/New.js index 3709acb5f..8df462909 100644 --- a/public/js/components/Stv/Studentenverwaltung/List/New.js +++ b/public/js/components/Stv/Studentenverwaltung/List/New.js @@ -1,7 +1,9 @@ import {CoreRESTClient} from '../../../../RESTClient.js'; import BsModal from '../../../Bootstrap/Modal.js'; -import FhcFormValidation from '../../../Form/Validation.js'; -import VueDatePicker from '../../../vueDatepicker.js.php'; +import FhcForm from '../../../Form/Form.js'; +import FormValidation from '../../../Form/Validation.js'; +import FormInput from '../../../Form/Input.js'; +import { useForm } from '../../../../composables/Form.js'; import accessibility from '../../../../directives/accessibility.js'; var _uuid = 0; @@ -20,8 +22,9 @@ const FORMDATA_DEFAULT = { export default { components: { BsModal, - FhcFormValidation, - VueDatePicker + FhcForm, + FormValidation, + FormInput }, directives: { accessibility @@ -95,9 +98,9 @@ export default { this.formData = FORMDATA_DEFAULT; this.person = null; this.suggestions = []; - this.$fhcAlert.resetFormValidation(this.$refs.form) + this.$refs.form.clearValidation(); }, - loadSuggestions() { + loadSuggestions() {console.log('loadSuggestions'); if (this.abortController.suggestions) this.abortController.suggestions.abort(); if (this.person !== null) @@ -129,21 +132,22 @@ export default { return; this.abortController.places = new AbortController(); - CoreRESTClient - .get('components/stv/address/getPlaces/' + this.formData.address.plz, undefined, { - signal: this.abortController.places.signal - }) - .then(result => CoreRESTClient.getData(result.data) || []) + this.$refs.form + .send(CoreRESTClient.get( + 'components/stv/address/getPlaces/' + this.formData.address.plz, + undefined, + { + signal: this.abortController.places.signal + } + )) .then(result => { - this.places = result; + this.places = result }) .catch(error => { - if (error.code == 'ERR_BAD_REQUEST') { - return this.$fhcAlert.handleFormValidation(error, this.$refs.form); - } - // NOTE(chris): repeat request if (error.code != "ERR_CANCELED") window.setTimeout(this.loadPlaces, 100); + else + console.error(error); }); }, loadStudienplaene() { @@ -180,26 +184,20 @@ export default { if (this.person === null) return this.person = 0; - this.$fhcAlert.resetFormValidation(this.$refs.form); + //this.$fhcAlert.resetFormValidation(this.$refs.form); const data = {...this.formData, ...(this.person || {})}; if (data.studiengang_kz === undefined) data.studiengang_kz = this.studiengangKz; if (data.studiensemester_kurzbz === undefined) data.studiensemester_kurzbz = this.studiensemesterKurzbz; - CoreRESTClient - .post('components/stv/student/add', data) - .then(result => result.data) - .then(result => { - if (CoreRESTClient.isError(result)) - throw new Error(CoreRESTClient.getError(result)); - return CoreRESTClient.getData(result); - }) + this.$refs.form + .send(CoreRESTClient.post('components/stv/student/add', data)) .then(result => { this.$fhcAlert.alertSuccess('Gespeichert'); this.$refs.modal.hide(); }) - .catch(this.$fhcAlert.handleFormValidation(this.$refs.form)); + .catch(console.error); } }, created() { @@ -211,35 +209,59 @@ export default { this.semester = result; }) .catch(this.$fhcAlert.handleSystemError); - }, template: ` -
+