Explorar o código

Set up Form component & some form elements components

Bubka hai 1 ano
pai
achega
1d51cb3e31

+ 14 - 4
resources/js_vue3/app.js

@@ -42,13 +42,23 @@ app.use(Notifications)
 import ResponsiveWidthWrapper from '@/layouts/ResponsiveWidthWrapper.vue'
 import ResponsiveWidthWrapper from '@/layouts/ResponsiveWidthWrapper.vue'
 import FormWrapper from '@/layouts/FormWrapper.vue'
 import FormWrapper from '@/layouts/FormWrapper.vue'
 import Footer from '@/layouts/Footer.vue'
 import Footer from '@/layouts/Footer.vue'
+import VueButton           from '@/components/formElements/Button.vue'
+import FieldError       from '@/components/formElements/FieldError.vue'
+import FormField        from '@/components/formElements/FormField.vue'
+import FormPasswordField        from '@/components/formElements/FormPasswordField.vue'
+import FormButtons      from '@/components/formElements/FormButtons.vue'
 
 
 // Components registration
 // Components registration
 app
 app
-    .component('font-awesome-icon', FontAwesomeIcon)
-    .component('responsive-width-wrapper', ResponsiveWidthWrapper)
-    .component('form-wrapper', FormWrapper)
-    .component('vue-footer', Footer)
+    .component('FontAwesomeIcon', FontAwesomeIcon)
+    .component('ResponsiveWidthWrapper', ResponsiveWidthWrapper)
+    .component('FormWrapper', FormWrapper)
+    .component('VueFooter', Footer)
+    .component('VueButton', VueButton)
+    .component('FieldError', FieldError)
+    .component('FormField', FormField)
+    .component('FormPasswordField', FormPasswordField)
+    .component('FormButtons', FormButtons)
 
 
 // App mounting
 // App mounting
 app.mount('#app')
 app.mount('#app')

+ 34 - 0
resources/js_vue3/components/formElements/Button.vue

@@ -0,0 +1,34 @@
+<script setup>
+    const props = defineProps({
+        color: {
+            type: String,
+            default: 'is-link'
+        },
+        nativeType: {
+            type: String,
+            default: 'submit'
+        },
+        isLoading: {
+            type: Boolean,
+            default: false
+        },
+        isDisabled: {
+            type: Boolean,
+            default: false
+        }
+    })
+</script>
+
+<template>
+    <button 
+        :type="nativeType"
+        :disabled="isLoading || isDisabled"
+        :class="{
+            'button': true,
+            [`${color}`]: true,
+            'is-loading': isLoading,
+        }"
+        v-on:click="$emit('click')">
+        <slot />
+    </button>
+</template>

+ 18 - 0
resources/js_vue3/components/formElements/FieldError.vue

@@ -0,0 +1,18 @@
+<script setup>
+    const props = defineProps({
+        form: {
+            type: Object,
+            required: true
+        },
+        field: {
+            type: String,
+            required: true
+        }
+    })
+</script>
+
+<template>
+    <div role="alert">
+        <p :id="'valError' + field[0].toUpperCase() + field.toLowerCase().slice(1)" class="help is-danger" v-if="form.errors.has(field)" v-html="form.errors.get(field)" />
+    </div>
+</template>

+ 318 - 0
resources/js_vue3/components/formElements/Form.js

@@ -0,0 +1,318 @@
+import { httpClientFactory } from '@/services/httpClientFactory'
+import Errors   from './FormErrors'
+
+class Form {
+    /**
+     * Create a new form instance.
+     *
+     * @param {Object} data
+     */
+    constructor (data = {}) {
+        this.axios = httpClientFactory('web')
+        this.isBusy = false
+        this.isDisabled = false
+        // this.successful = false
+        this.errors = new Errors()
+        this.originalData = this.deepCopy(data)
+
+        Object.assign(this, data)
+    }
+
+    /**
+     * Fill form data.
+     *
+     * @param {Object} data
+     */
+    fill (data) {
+        this.keys().forEach(key => {
+            this[key] = data[key]
+        })
+    }
+
+    /**
+     * Update original form data.
+     */
+    setOriginal () {
+      Object.keys(this)
+        .filter(key => !Form.ignore.includes(key))
+        .forEach(key => {
+            this.originalData[key] = this.deepCopy(this[key])
+        })
+    }
+
+    /**
+     * Fill form data.
+     *
+     * @param {Object} data
+     */
+    fillWithKeyValueObject (data) {
+        this.keys().forEach(key => {
+            const keyValueObject = data.find(s => s.key === key.toString())
+            if(keyValueObject != undefined) {
+                this[key] = keyValueObject.value
+            }
+        })
+    }
+
+    /**
+     * Get the form data.
+     *
+     * @return {Object}
+     */
+    data () {
+        return this.keys().reduce((data, key) => (
+            { ...data, [key]: this[key] }
+        ), {})
+    }
+
+    /**
+     * Get the form data keys.
+     *
+     * @return {Array}
+     */
+    keys () {
+        return Object.keys(this)
+            .filter(key => !Form.ignore.includes(key))
+    }
+
+    /**
+     * Start processing the form.
+     */
+    startProcessing () {
+        this.errors.clear()
+        this.isBusy = true
+        // this.successful = false
+    }
+
+    /**
+     * Finish processing the form.
+     */
+    finishProcessing () {
+        this.isBusy = false
+        // this.successful = true
+    }
+
+    /**
+     * Clear the form errors.
+     */
+    clear () {
+        this.errors.clear()
+        // this.successful = false
+    }
+
+    /**
+     * Reset the form fields.
+     */
+    reset () {
+      Object.keys(this)
+        .filter(key => !Form.ignore.includes(key))
+        .forEach(key => {
+          this[key] = this.deepCopy(this.originalData[key])
+        })
+    }
+
+    /**
+     * Submit the form via a GET request.
+     *
+     * @param  {String} url
+     * @param  {Object} config (axios config)
+     * @return {Promise}
+     */
+    get (url, config = {}) {
+        return this.submit('get', url, config)
+    }
+
+    /**
+     * Submit the form via a POST request.
+     *
+     * @param  {String} url
+     * @param  {Object} config (axios config)
+     * @return {Promise}
+     */
+    post (url, config = {}) {
+        return this.submit('post', url, config)
+    }
+
+    /**
+     * Submit the form via a PATCH request.
+     *
+     * @param  {String} url
+     * @param  {Object} config (axios config)
+     * @return {Promise}
+     */
+    patch (url, config = {}) {
+        return this.submit('patch', url, config)
+    }
+
+    /**
+     * Submit the form via a PUT request.
+     *
+     * @param  {String} url
+     * @param  {Object} config (axios config)
+     * @return {Promise}
+     */
+    put (url, config = {}) {
+        return this.submit('put', url, config)
+    }
+
+    /**
+     * Submit the form via a DELETE request.
+     *
+     * @param  {String} url
+     * @param  {Object} config (axios config)
+     * @return {Promise}
+     */
+    delete (url, config = {}) {
+        return this.submit('delete', url, config)
+    }
+
+    /**
+     * Submit the form data via an HTTP request.
+     *
+     * @param  {String} method (get, post, patch, put)
+     * @param  {String} url
+     * @param  {Object} config (axios config)
+     * @return {Promise}
+     */
+    submit (method, url, config = {}) {
+        this.startProcessing()
+
+        const data = method === 'get'
+            ? { params: this.data() }
+            : this.data()
+
+        return new Promise((resolve, reject) => {
+            // (Form.axios || axios).request({ url: this.route(url), method, data, ...config })
+            this.axios.request({ url: this.route(url), method, data, ...config })
+                .then(response => {
+                    this.finishProcessing()
+
+                    resolve(response)
+                })
+                .catch(error => {
+                    this.isBusy = false
+
+                    if (error.response) {
+                        this.errors.set(this.extractErrors(error.response))
+                    }
+
+                    reject(error)
+                })
+        })
+    }
+
+    /**
+     * Submit the form data via an HTTP request.
+     *
+     * @param  {String} method (get, post, patch, put)
+     * @param  {String} url
+     * @param  {Object} config (axios config)
+     * @return {Promise}
+     */
+    upload (url, formData, config = {}) {
+        this.startProcessing()
+
+        return new Promise((resolve, reject) => {
+            // (Form.axios || axios).request({ url: this.route(url), method, data, ...config })
+            this.axios.request({ url: this.route(url), method: 'post', data: formData, header: {'Content-Type' : 'multipart/form-data'}, ...config })
+                .then(response => {
+                    this.finishProcessing()
+
+                    resolve(response)
+                })
+                .catch(error => {
+                    this.isBusy = false
+
+                    if (error.response) {
+                        this.errors.set(this.extractErrors(error.response))
+                    }
+
+                    reject(error)
+                })
+        })
+    }
+
+    /**
+     * Extract the errors from the response object.
+     *
+     * @param  {Object} response
+     * @return {Object}
+     */
+    extractErrors (response) {
+        if (!response.data || typeof response.data !== 'object') {
+            return { error: Form.errorMessage }
+        }
+
+        if (response.data.errors) {
+            return { ...response.data.errors }
+        }
+
+        if (response.data.message) {
+            return { error: response.data.message }
+        }
+
+        return { ...response.data }
+    }
+
+    /**
+     * Get a named route.
+     *
+     * @param  {String} name
+     * @return {Object} parameters
+     * @return {String}
+     */
+    route (name, parameters = {}) {
+        let url = name
+
+        if (Form.routes.hasOwnProperty(name)) {
+            url = decodeURI(Form.routes[name])
+        }
+
+        if (typeof parameters !== 'object') {
+            parameters = { id: parameters }
+        }
+
+        Object.keys(parameters).forEach(key => {
+            url = url.replace(`{${key}}`, parameters[key])
+        })
+
+        return url
+    }
+
+    /**
+     * Clear errors on keydown.
+     *
+     * @param {KeyboardEvent} event
+     */
+    onKeydown (event) {
+        if (event.target.name) {
+            this.errors.clear(event.target.name)
+        }
+    }
+
+    /**
+     * Deep copy the given object.
+     *
+     * @param  {Object} obj
+     * @return {Object}
+     */
+    deepCopy (obj) {
+        if (obj === null || typeof obj !== 'object') {
+            return obj
+        }
+    
+        const copy = Array.isArray(obj) ? [] : {}
+    
+        Object.keys(obj).forEach(key => {
+            copy[key] = this.deepCopy(obj[key])
+        })
+    
+        return copy
+    }
+}
+
+Form.routes = {}
+Form.errorMessage = 'Something went wrong. Please try again.'
+Form.ignore = ['isBusy', 'isDisabled', 'errors', 'originalData']
+
+export default Form

+ 51 - 0
resources/js_vue3/components/formElements/FormButtons.vue

@@ -0,0 +1,51 @@
+<script setup>
+    const props = defineProps({
+            showCancelButton: {
+                type: Boolean,
+                default: false
+            },
+            isBusy: {
+                type: Boolean,
+                default: false
+            },
+            isDisabled: {
+                type: Boolean,
+                default: false
+            },
+            caption: {
+                type: String,
+                default: 'Submit'
+            },
+            cancelLandingView: {
+                type: String,
+                default: ''
+            },
+            color: {
+                type: String,
+                default: 'is-link'
+            },
+            submitId: {
+                type: String,
+                default: 'btnSubmit'
+            },
+            cancelId: {
+                type: String,
+                default: 'btnCancel'
+            },
+    })
+</script>
+
+<template>
+    <div class="field is-grouped">
+        <div class="control">
+            <VueButton :id="submitId" :color="color" :isLoading="isBusy" :disabled="isDisabled" >
+                {{ caption }}
+            </VueButton>
+        </div>
+        <div class="control" v-if="showCancelButton">
+            <RouterLink :id="cancelId" :to="{ name: cancelLandingView }" class="button is-text">
+                {{ $t('commons.cancel') }}
+            </RouterLink>
+        </div>
+    </div>
+</template>

+ 141 - 0
resources/js_vue3/components/formElements/FormErrors.js

@@ -0,0 +1,141 @@
+
+export default class Errors {
+  /**
+   * Create a new error bag instance.
+   */
+  constructor () {
+    this.errors = {}
+  }
+
+  /**
+   * Set the errors object or field error messages.
+   *
+   * @param {Object|String} field
+   * @param {Array|String|undefined} messages
+   */
+  set (field, messages) {
+    if (typeof field === 'object') {
+      this.errors = field
+    } else {
+      this.set({ ...this.errors, [field]: arrayWrap(messages) })
+    }
+  }
+
+  /**
+   * Get all the errors.
+   *
+   * @return {Object}
+   */
+  all () {
+    return this.errors
+  }
+
+  /**
+   * Determine if there is an error for the given field.
+   *
+   * @param  {String} field
+   * @return {Boolean}
+   */
+  has (field) {
+    return this.errors.hasOwnProperty(field)
+  }
+
+  /**
+   * Determine if there are any errors for the given fields.
+   *
+   * @param  {...String} fields
+   * @return {Boolean}
+   */
+  hasAny (...fields) {
+    return fields.some(field => this.has(field))
+  }
+
+  /**
+   * Determine if there are any errors.
+   *
+   * @return {Boolean}
+   */
+  any () {
+    return Object.keys(this.errors).length > 0
+  }
+
+  /**
+   * Get the first error message for the given field.
+   *
+   * @param  String} field
+   * @return {String|undefined}
+   */
+  get (field) {
+    if (this.has(field)) {
+      return this.getAll(field)[0]
+    }
+  }
+
+  /**
+   * Get all the error messages for the given field.
+   *
+   * @param  {String} field
+   * @return {Array}
+   */
+  getAll (field) {
+    return arrayWrap(this.errors[field] || [])
+  }
+
+  /**
+   * Get the error message for the given fields.
+   *
+   * @param  {...String} fields
+   * @return {Array}
+   */
+  only (...fields) {
+    const messages = []
+
+    fields.forEach(field => {
+      const message = this.get(field)
+
+      if (message) {
+        messages.push(message)
+      }
+    })
+
+    return messages
+  }
+
+  /**
+   * Get all the errors in a flat array.
+   *
+   * @return {Array}
+   */
+  flatten () {
+    return Object.values(this.errors).reduce((a, b) => a.concat(b), [])
+  }
+
+  /**
+   * Clear one or all error fields.
+   *
+   * @param {String|undefined} field
+   */
+  clear (field) {
+    const errors = {}
+
+    if (field) {
+      Object.keys(this.errors).forEach(key => {
+        if (key !== field) {
+          errors[key] = this.errors[key]
+        }
+      })
+    }
+
+    this.set(errors)
+  }
+}
+
+/**
+ * If the given value is not an array, wrap it in one.
+ *
+ * @param  {Any} value
+ * @return {Array}
+ */
+function arrayWrap (value) {
+  return Array.isArray(value) ? value : [value]
+}

+ 88 - 0
resources/js_vue3/components/formElements/FormField.vue

@@ -0,0 +1,88 @@
+<template>
+    <div class="field" :class="{ 'pt-3' : hasOffset }">
+        <label :for="inputId" class="label" v-html="label"></label>
+        <div class="control">
+            <input 
+                :disabled="isDisabled" 
+                :id="inputId" 
+                :type="inputType" 
+                class="input" 
+                v-model="form[fieldName]" 
+                :placeholder="placeholder" 
+                v-bind="$attrs" 
+                v-on:change="$emit('field-changed', form[fieldName])"
+                :maxlength="this.maxLength" 
+            />
+        </div>
+        <FieldError :form="form" :field="fieldName" />
+        <p class="help" v-html="help" v-if="help"></p>
+    </div> 
+</template>
+
+<script>
+    import { useIdGenerator } from '../../composables/helpers'
+
+    export default {
+        name: 'FormField',
+        inheritAttrs: false,
+
+        setup(props) {
+            const { inputId } = useIdGenerator(props.inputType, props.fieldName)
+            return { inputId }
+        },
+        
+        data() {
+            return {
+
+            }
+        },
+
+        props: {
+            label: {
+                type: String,
+                default: ''
+            },
+
+            fieldName: {
+                type: String,
+                default: '',
+                required: true
+            },
+
+            inputType: {
+                type: String,
+                default: 'text'
+            },
+
+            form: {
+                type: Object,
+                required: true
+            },
+
+            placeholder: {
+                type: String,
+                default: ''
+            },
+
+            help: {
+                type: String,
+                default: ''
+            },
+
+            hasOffset: {
+                type: Boolean,
+                default: false
+            },
+
+            isDisabled: {
+                type: Boolean,
+                default: false
+            },
+
+            maxLength: {
+                type: Number,
+                default: null
+            }
+        }
+    }
+</script>

+ 139 - 0
resources/js_vue3/components/formElements/FormPasswordField.vue

@@ -0,0 +1,139 @@
+<template>
+    <div class="field" :class="{ 'pt-3' : hasOffset }">
+        <label :for="inputId" class="label" v-html="label"></label>
+        <div class="control has-icons-right">
+            <input
+                :disabled="isDisabled"
+                :id="inputId"
+                :type="currentType" 
+                class="input" 
+                v-model="form[fieldName]" 
+                :placeholder="placeholder" 
+                v-bind="$attrs" 
+                v-on:change="$emit('field-changed', form[fieldName])"
+                v-on:keyup="checkCapsLock"
+            />
+            <span v-if="currentType == 'password'" role="button" id="btnTogglePassword" tabindex="0" class="icon is-small is-right is-clickable" @keyup.enter="setFieldType('text')" @click="setFieldType('text')" :title="$t('auth.forms.reveal_password')">
+                <font-awesome-icon :icon="['fas', 'eye-slash']" />
+            </span>
+            <span v-else role="button" id="btnTogglePassword" tabindex="0" class="icon is-small is-right is-clickable" @keyup.enter="setFieldType('password')" @click="setFieldType('password')" :title="$t('auth.forms.hide_password')">
+                <font-awesome-icon :icon="['fas', 'eye']" />
+            </span>
+        </div>
+        <p class="help is-warning" v-if="hasCapsLockOn" v-html="$t('auth.forms.caps_lock_is_on')" />
+        <FieldError :form="form" :field="fieldName" />
+        <p class="help" v-html="help" v-if="help"></p>
+        <div v-if="showRules" class="columns is-mobile is-size-7 mt-0">
+            <div class="column is-one-third">
+                <span class="has-text-weight-semibold">{{ $t("auth.forms.mandatory_rules") }}</span><br />
+                <span class="is-underscored" id="valPwdIsLongEnough" :class="{'is-dot' : IsLongEnough}"></span>{{ $t('auth.forms.is_long_enough') }}<br/>
+            </div>
+            <div class="column">
+                <span class="has-text-weight-semibold">{{ $t("auth.forms.optional_rules_you_should_follow") }}</span><br />
+                <span class="is-underscored" id="valPwdHasLowerCase" :class="{'is-dot' : hasLowerCase}"></span>{{ $t('auth.forms.has_lower_case') }}<br/>
+                <span class="is-underscored" id="valPwdHasUpperCase" :class="{'is-dot' : hasUpperCase}"></span>{{ $t('auth.forms.has_upper_case') }}<br/>
+                <span class="is-underscored" id="valPwdHasSpecialChar" :class="{'is-dot' : hasSpecialChar}"></span>{{ $t('auth.forms.has_special_char') }}<br/>
+                <span class="is-underscored" id="valPwdHasNumber" :class="{'is-dot' : hasNumber}"></span>{{ $t('auth.forms.has_number') }}
+            </div>
+        </div>
+    </div> 
+</template>
+
+<script>
+    import { useIdGenerator } from '../../composables/helpers'
+
+    export default {
+        name: 'FormPasswordField',
+        inheritAttrs: false,
+
+        setup(props) {
+            const { inputId } = useIdGenerator('password', props.fieldName)
+            return { inputId }
+        },
+        
+        data() {
+            return {
+                currentType: this.inputType,
+                hasCapsLockOn: false,
+            }
+        },
+
+        computed: {
+            hasLowerCase() {
+                return /[a-z]/.test(this.form[this.fieldName])
+            },
+            hasUpperCase() {
+                return /[A-Z]/.test(this.form[this.fieldName])
+            },
+            hasNumber() {
+                return /[0-9]/.test(this.form[this.fieldName])
+            },
+            hasSpecialChar() {
+                return /[^A-Za-z0-9]/.test(this.form[this.fieldName])
+            },
+            IsLongEnough() {
+                return this.form[this.fieldName].length >= 8
+            },
+        },
+
+        props: {
+            label: {
+                type: String,
+                default: ''
+            },
+
+            fieldName: {
+                type: String,
+                default: '',
+                required: true
+            },
+
+            inputType: {
+                type: String,
+                default: 'password'
+            },
+
+            form: {
+                type: Object,
+                required: true
+            },
+
+            placeholder: {
+                type: String,
+                default: ''
+            },
+
+            help: {
+                type: String,
+                default: ''
+            },
+
+            hasOffset: {
+                type: Boolean,
+                default: false
+            },
+
+            isDisabled: {
+                type: Boolean,
+                default: false
+            },
+
+            showRules: {
+                type: Boolean,
+                default: false
+            },
+        },
+
+        methods: {
+            checkCapsLock(event) {
+                this.hasCapsLockOn = event.getModifierState('CapsLock') ? true : false
+            },
+
+            setFieldType(event) {
+                if (this.currentType != event) {
+                    this.currentType = event
+                }
+            }
+        },
+    }
+</script>

+ 34 - 0
resources/js_vue3/composables/helpers.js

@@ -0,0 +1,34 @@
+// import { ref } from 'vue'
+
+export function useIdGenerator(fieldType, fieldName) {
+	let prefix
+	fieldName = fieldName.toString()
+
+	switch (fieldType) {
+		case 'text':
+			prefix = 'txt'
+			break
+		case 'button':
+			prefix = 'btn'
+			break
+		case 'email':
+			prefix = 'eml'
+			break
+		case 'password':
+			prefix = 'pwd'
+			break
+		case 'radio':
+			prefix = 'rdo'
+			break
+		case 'label':
+			prefix = 'lbl'
+			break
+		default:
+			prefix = 'txt'
+			break
+	}
+
+	return {
+		inputId: prefix + fieldName[0].toUpperCase() + fieldName.toLowerCase().slice(1)
+	}
+}

+ 2 - 2
resources/js_vue3/layouts/FormWrapper.vue

@@ -1,9 +1,9 @@
 <template>
 <template>
-    <responsive-width-wrapper>
+    <ResponsiveWidthWrapper>
         <h1 class="title has-text-grey-dark" v-html="title" v-if="title"></h1>
         <h1 class="title has-text-grey-dark" v-html="title" v-if="title"></h1>
         <div id="punchline" v-if="punchline" class="block" v-html="punchline"></div>
         <div id="punchline" v-if="punchline" class="block" v-html="punchline"></div>
         <slot />
         <slot />
-    </responsive-width-wrapper>   
+    </ResponsiveWidthWrapper>   
 </template>
 </template>
 
 
 <script>
 <script>