diff --git a/public/js/components/Form/Form.js b/public/js/components/Form/Form.js
new file mode 100644
index 000000000..2257eb289
--- /dev/null
+++ b/public/js/components/Form/Form.js
@@ -0,0 +1,137 @@
+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);
+
+ if (c.lcType == 'checkbox' && name.substr(-1) == ']' && name.indexOf('[')) {
+ name = name.substr(0, name.lastIndexOf('['));
+ if (!a[name])
+ a[name] = [];
+ a[name].push(c);
+ }
+
+ return a;
+ }, {});
+ }
+ },
+ methods: {
+ _sendFeedbackToInput(inputs, feedback, valid) {
+ if (inputs.length) {
+ inputs.forEach(input => input.setFeedback(valid, feedback));
+ return false;
+ }
+ if (this.$fhcAlert) {
+ this.$fhcAlert[valid ? 'alertSuccess' : 'alertError'](feedback);
+ return false;
+ }
+ return true;
+ },
+ setFeedback(valid, feedback) {
+ 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(true, data);
+ 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(
+ false,
+ result.response.data.retval
+ );
+ 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..1fc318523
--- /dev/null
+++ b/public/js/components/Form/Input.js
@@ -0,0 +1,278 @@
+import FhcFragment from "../Fragment.js";
+
+let _uuid = {};
+
+export default {
+ inheritAttrs: false,
+ components: {
+ FhcFragment
+ },
+ inject: [
+ '$registerToForm'
+ ],
+ props: {
+ bsFeedback: Boolean,
+ noAutoClass: Boolean,
+ noFeedback: Boolean,
+ inputGroup: Boolean,
+ type: String,
+ name: String,
+ containerClass: [String, Array, Object]
+ },
+ data() {
+ return {
+ valid: undefined,
+ feedback: []
+ }
+ },
+ computed: {
+ hasContainer() {
+ if (!this.bsFeedback)
+ return true;
+ if (this.containerClass)
+ return true;
+ if (this.autoContainerClass)
+ return true;
+ return false;
+ },
+ acc() {
+ if (!this.containerClass)
+ return {};
+ if (typeof this.containerClass === 'string' || this.containerClass instanceof String)
+ return this.containerClass.split(' ').reduce((a,c) => {a[c] = true; return a}, {});
+ if (Array.isArray(this.containerClass))
+ return this.containerClass.reduce((a,c) => {a[c] = true; return a}, {});
+ return this.containerClass;
+ },
+ autoContainerClass() {
+ if (this.noAutoClass)
+ return this.acc;
+
+ const acc = {...this.acc};
+
+ if (this.inputGroup)
+ acc['input-group-item'] = true;
+
+ if (this.lcType == 'radio' || this.lcType == 'checkbox')
+ acc['form-check'] = true;
+
+ if (this.inputGroup && acc['form-check']) {
+ acc['input-group-item'] = false;
+ acc['form-check'] = false;
+ acc['input-group-text'] = true;
+ }
+ return acc;
+ },
+ 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';
+ case 'uploadimage':
+ return 'UploadImage';
+ case 'uploadfile':
+ case 'uploaddms':
+ return 'UploadDms';
+ 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 'color':
+ if (!c.includes('form-control-color'))
+ classes.push('form-control-color');
+ case 'text':
+ case 'number':
+ case 'password':
+ 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(valid, feedback) {
+ if (!feedback)
+ feedback = [];
+ 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"));
+ } else if (this.tag == 'UploadImage' && !this._.components.UploadImage) {
+ this._.components.UploadImage = Vue.defineAsyncComponent(() => import("./Upload/Image.js"));
+ } else if (this.tag == 'UploadDms' && !this._.components.UploadDms) {
+ this._.components.UploadDms = Vue.defineAsyncComponent(() => import("./Upload/Dms.js"));
+ }
+ }
+ },
+ beforeMount() {
+ this._loadComponents();
+ },
+ beforeUpdate() {
+ this._loadComponents();
+ },
+ mounted() {
+ if (this.$registerToForm)
+ this.$registerToForm(this);
+ },
+ template: `
+
+
+
+
+
+ {if(v) a.push(k);return a}, []), ...($attrs['input-class-name'] ? $attrs['input-class-name'].split(' ') : [])].join(' ')"
+ @update:model-value="clearValidation"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{msg}}
+
+
+
+ `
+}
\ No newline at end of file
diff --git a/public/js/components/Form/Upload/Dms.js b/public/js/components/Form/Upload/Dms.js
new file mode 100644
index 000000000..da37a2105
--- /dev/null
+++ b/public/js/components/Form/Upload/Dms.js
@@ -0,0 +1,87 @@
+export default {
+ emits: [
+ 'update:modelValue'
+ ],
+ props: {
+ modelValue: {
+ type: [ FileList, Array ],
+ required: true
+ },
+ multiple: Boolean,
+ id: String,
+ name: String,
+ inputClass: [String, Array, Object],
+ noList: Boolean
+ },
+ methods: {
+ stringifyFile(file) {
+ return JSON.stringify({
+ lastModified: file.lastModified,
+ lastModifiedDate: file.lastModifiedDate,
+ name: file.name,
+ size: file.size,
+ type: file.type
+ });
+ },
+ addFiles(event) {
+ if (!this.multiple)
+ return this.$emit('update:modelValue', event.target.files);
+
+ const dt = new DataTransfer();
+ const doubles = [];
+ for (var file of this.modelValue) {
+ dt.items.add(file);
+ doubles.push(this.stringifyFile(file));
+ }
+ for (var file of event.target.files) {
+ // NOTE(chris): deep check (with FileReader) would require an async function so we only check the basic attributes
+ if (doubles.indexOf(this.stringifyFile(file)) < 0)
+ dt.items.add(file);
+ }
+ this.$emit('update:modelValue', dt.files);
+ },
+ removeFile(id) {
+ const fileToRemove = Array.from(this.modelValue)[id];
+
+ const dt = new DataTransfer();
+ for (var file of this.modelValue) {
+ if (file !== fileToRemove)
+ dt.items.add(file);
+ }
+ this.$emit('update:modelValue', dt.files);
+ }
+ },
+ watch: {
+ modelValue(n) {
+ if (n instanceof FileList)
+ return this.$refs.upload.files = n;
+
+ const dt = new DataTransfer();
+ const dms = [];
+ for (var file of n) {
+ if (file instanceof File) {
+ dt.items.add(file);
+ } else {
+ const dmsFile = new File([JSON.stringify(file)], file.name, {
+ type: 'application/x.fhc-dms+json'
+ });
+ dt.items.add(dmsFile);
+ }
+ }
+ this.$emit('update:modelValue', dt.files);
+ }
+ },
+ template: `
+
`
+}
\ No newline at end of file
diff --git a/public/js/components/Form/Upload/Image.js b/public/js/components/Form/Upload/Image.js
new file mode 100644
index 000000000..b0b8d5b78
--- /dev/null
+++ b/public/js/components/Form/Upload/Image.js
@@ -0,0 +1,62 @@
+export default {
+ emits: [
+ 'update:modelValue'
+ ],
+ props: {
+ modelValue: String
+ },
+ computed: {
+ valueAsBase64DataString() {
+ if (!this.modelValue || this.modelValue.substring(0, 10) == 'data:image')
+ return this.modelValue;
+ return 'data:image/jpeg;charset=utf-8;base64,' + this.modelValue;
+ }
+ },
+ methods: {
+ openUploadDialog() {
+ this.$refs.fileInput.click();
+ },
+ pickFile() {
+ let file = this.$refs.fileInput.files;
+ if (file && file[0]) {
+ let reader = new FileReader();
+ reader.onload = e => {
+ this.$emit('update:modelValue', e.target.result);
+ }
+ reader.readAsDataURL(file[0]);
+ }
+ },
+ deleteImage() {
+ this.$emit('update:modelValue', '');
+ }
+ },
+ template: `
+ `
+}
\ No newline at end of file