Source

components/shared/Builders/FormBuilder.vue

<template>
  <div class="form-builder">
    <b-form>
      <b-row>
        <b-col
          v-for="(section, sectionIndex) in sections"
          :key="section.name || sectionIndex"
          cols="12"
        >
          <b-row v-if="section.title" class="pl-4 section-title">
            {{ getI18nText(section) }}
          </b-row>
          <b-row
            v-for="(row, rowIndex) in section.rows"
            :key="rowIndex"
            :class="row.class || section.rowsClass || ''"
          >
            <b-col
              v-for="column in row.columns"
              :key="column.label"
              :class="getColumnClass(column, row.columns.length)"
            >
              <label
                v-if="column.type !== 'checkbox' && column.title"
                :class="theme"
                :for="column.label"
                >{{ getI18nText(column) }}</label
              >

              <b-form-input
                v-if="column.type === 'text' || column.type === 'number'"
                v-model="object[column.label]"
                :state="column.validation ? column.validation.state : null"
                :placeholder="getI18nText(column)"
                :type="column.type"
                :class="theme"
                @input="onColumnValueChange($event, column.label)"
                @change="onColumnValueChange($event, column.label)"
              />

              <b-form-checkbox
                v-if="column.type === 'checkbox'"
                v-model="object[column.label]"
                :name="`${column.label}`"
                @input="onColumnValueChange($event, column.label)"
              >
                {{ getI18nText(column) }}
              </b-form-checkbox>

              <b-form-select
                v-if="column.type === 'select'"
                v-model="object[column.label]"
                :value-field="column.valueField || 'value'"
                :state="column.validation ? column.validation.state : null"
                :options="computeSelectOptions(column.options)"
                :class="theme"
                @input="onColumnValueChange($event, column.label)"
              />

              <label
                v-if="
                  column.type !== 'checkbox' &&
                  column.type !== 'select' &&
                  column.title
                "
                :class="theme"
                :for="column.label"
                >{{ getI18nText(column) }}</label
              >

              <slot
                v-if="column.type === 'component'"
                :name="`component_${column.name || column.label}`"
                :form-data="object"
              />

              <p
                v-if="
                  column.validation &&
                  !column.validation.state &&
                  column.type === 'component'
                "
                style="color: red"
              >
                {{ column.validation.message }}
              </p>

              <b-form-invalid-feedback
                v-if="column.validation && !column.validation.state"
                :id="`input-${column.label}-feedback`"
              >
                {{ column.validation.message }}
              </b-form-invalid-feedback>
            </b-col>
          </b-row>
        </b-col>

        <b-col class="">
          <b-row align-h="end" class="mt-3 px-4">
            <PrimaryButton
              v-if="hasCancelBtn"
              variant="primary"
              class="mr-2"
              :disabled="loading"
              @click="goBackForm"
            >
              {{ cancelText || $t('buttons.cancel') }}
            </PrimaryButton>

            <PrimaryButton
              v-if="hasPreviewBtn"
              variant="primary"
              class="mr-2"
              @click="preview"
            >
              <div v-if="loading">
                <b-spinner small type="grow" />
              </div>
              <div v-if="!loading">
                {{ $t('buttons.preview') }}
              </div>
            </PrimaryButton>

            <PrimaryButton
              v-if="hasValidBtn"
              variant="primary"
              :disabled="validBtnDisabled"
              @click="validForm"
            >
              <div v-if="loading">
                <b-spinner small type="grow" />
              </div>

              <div v-if="!loading">
                {{ $t('buttons.valid') }}
              </div>
            </PrimaryButton>

            <PrimaryButton
              v-if="hasSaveBtn"
              variant="primary"
              @click="saveForm"
            >
              <div v-if="loading">
                <b-spinner small type="grow" />
              </div>
              <div v-if="!loading">
                {{ $t('buttons.save') }}
              </div>
            </PrimaryButton>
          </b-row>
        </b-col>
      </b-row>
    </b-form>
  </div>
</template>

<script>
import PrimaryButton from '@/components/shared/Button.vue'
/**
 * @namespace FormBuilder
 * @memberof FormBuilder
 * @function applyValuesFromFormDefinition
 * @todo: Refactor/Remove after fixes
 */
function applyValuesFromFormDefinition(formBuilderSections, formObject = {}) {
  let tmpArray = [...formBuilderSections]
  tmpArray.forEach((section) => {
    section.rows.forEach((row) => {
      if (row.columns && row.columns.length) {
        row.columns.forEach((column) => {
          if (column.value) {
            formObject[column.label] = column.value
          }
        })
      }
    })
  })

  return formObject
}

/**
 * @namespace FormBuilder
 * @module FormBuilder
 * @description Reusable component to build basic forms. Accept text, checkboxes, select and custom components. Layout can be single/two columns per row. It uses bootstrap.
 * @example
 * 
 * 
 * FormBuilder(v-model="formData":sections="sections")
 * 
 * sections: [
    {
      name: "routing_start_section",
      header: false,
      collapsable: false,
      rows: [
        {
          columns: [
            {
              label: "transportType",
              //i18n
              title: "geocoding.routing.transportType",
              type: "select",
              value: "car",
              options: [{text:"Car", value:"car"}],
            },
          ],
        }
    }
  ]
* @namespace components
 * @category components
 * @subcategory shared
 * @module FormBuilder
 */
export default {
  name: 'FormBuilder',
  components: {
    PrimaryButton,
  },
  props: {
    value: {
      type: Object,
      default: () => ({}),
    },
    sections: {
      type: Array,
      default: () => [],
    },
    hasCancelBtn: {
      type: Boolean,
      default: false,
    },
    cancelText: {
      type: String,
      default: '',
    },
    hasValidBtn: {
      type: Boolean,
      default: false,
    },
    validBtnDisabled: {
      type: Boolean,
      default: false,
    },
    hasPreviewBtn: {
      type: Boolean,
      default: false,
    },
    hasSaveBtn: {
      type: Boolean,
      default: false,
    },
    loading: {
      type: Boolean,
      default: false,
    },
    theme: {
      type: String,
      default: 'theme-default',
    },
  },
  data() {
    return {
      /***
       * @todo: Find a better self descriptive name like formData. Avoid generic names.
       */
      object: {},
    }
  },
  /**
   * v-model support
   * write: (value -> object)
   * read: input event with object copy
   */
  watch: {
    object: {
      handler() {
        this.updateColumnsValidationState()
        this.$emit('input', { ...this.object })
      },
      deep: true,
    },
    value: {
      handler() {
        Object.keys(this.value).forEach((key) => {
          this.$set(this.object, key, this.value[key])

          //@todo: Remove when fixed: Do not store values in form definition (this.sections)
          this.sections.forEach((s) =>
            s.rows.forEach((sr) =>
              sr.columns.forEach((src) => {
                if (src.label === key) {
                  this.$set(src, 'value', this.value[key])
                }
              })
            )
          )
        })
      },
      deep: true,
      immediate: true,
    },
  },
  methods: {
    computeSelectOptions(options) {
      return options.map((o) => {
        return {
          ...o,
          text: this.$t(o.text),
        }
      })
    },
    updateColumnsValidationState() {
      this.sections.forEach((section) => {
        if (section.rows) {
          section.rows.forEach((row) => {
            if (row.columns) {
              row.columns.forEach((item) => {
                this.updateColumnValidationState(item)
              })
            }
          })
        }
      })
    },
    updateColumnValidationState(columnItem) {
      if (
        columnItem.validation &&
        columnItem.validation.required &&
        ['component', 'checkbox'].includes(columnItem.type)
      ) {
        if (!columnItem.value && columnItem.validation.required) {
          columnItem.validation.state = false
          columnItem.validation.message = columnItem.validation.hint
        } else {
          columnItem.validation.state = true
        }
      }
    },
    /**
     * @description
     * Fallback 1: Tries to find a locale by title: popup.form_builder.sections.foo
     * Fallback 2: Tries to find a locale by title: foo
     * Fallback 3: Tries to find a locale by label: popup.form_builder.sections.foo
     * Fallback 4: Tries to find a locale by label: foo
     * Fallback 5: Tries to return title if availalble
     * Fallback 6: Tries to return label (label is mandatory)
     */
    getI18nText(item, path = '') {
      if (!path) {
        return (
          this.getI18nText(item, item.title || 'ABCD') ||
          this.getI18nText(item, item.label || 'ABCD') ||
          item.title ||
          item.label
        )
      } else {
        if (this.$te(`popup.form_builder.sections.${path}`)) {
          return this.$t(`popup.form_builder.sections.${path}`)
        }
        if (this.$te(`popup.form_builder.${path}`)) {
          return this.$t(`popup.form_builder.${path}`)
        }
        if (this.$te(path)) {
          return this.$t(path)
        }
        return ''
      }
    },
    /**
     * @todo this.object already contains values. Do not store values in form definition (this.sections)
     */
    validForm() {
      this.object = applyValuesFromFormDefinition(this.sections, this.object)
      const isFormValid = this.isFormValid()
      if (isFormValid) {
        this.$emit('valid', { ...(this.object || {}) })
      }
    },
    /**
     * Will reset the form data (this.object) and validation state
     */
    goBackForm() {
      const rows = this.sections.flatMap((section) =>
        section.rows.filter((row) => row.columns.length > 0)
      )
      rows.forEach((row) => {
        row.columns.forEach((column) => {
          column.value = ''
          this.$set(this.object, column.label, '')
          if (column.validation) {
            column.validation.state = null
            column.validation.message = ''
          }
        })
      })

      this.$emit('go-back', this.object)
    },
    saveForm() {
      this.$emit('save', { ...this.object })
    },
    preview() {
      this.$emit('preview', { ...this.object })
    },
    /**
     * @todo: binding: Use v-model instead
     * @todo: validation state: The validation should be applied internally (Do not delegate validation state change to parent)
     */
    onColumnValueChange(event, label) {
      this.$emit('update-column-value', { event: event, label: label })
    },
    /**
     * @todo: validation state: The validation should be applied internally (Do not delegate validation state change to parent)
     * @todo: Do not store validation state in the definition object (Use a separate variable) (i.g accessible via v-model: object.$state.city.isDirty)
     */
    isFormValid() {
      let canSend = true
      this.sections.forEach((section) => {
        if (section.rows) {
          section.rows.forEach((row) => {
            if (row.columns) {
              row.columns.forEach((column) => {
                if (
                  column.validation &&
                  column.validation.required &&
                  !column.value
                ) {
                  this.$emit('invalid', { label: column.label })
                  canSend = false
                }
              })
            }
          })
        }
      })
      return canSend
    },
    getColumnClass(column, columnsLen) {
      let classes
      switch (columnsLen) {
        case 1:
          classes = 'col-12'
          break
        case 2:
          classes = 'col-6'
          break
        case 3:
          classes = 'col-4'
          break
        case 4:
          classes = 'col-3'
          break
        default:
          classes = 'col-12'
          break
      }

      if (
        !['checkbox', 'select'].includes(column.type) &&
        column.disableFlyingLabel !== true
      ) {
        classes += ` fieldOuter ${this.theme}`
      }
      return classes
    },
  },
}
</script>

<style lang="scss">
.form-builder .section-title {
  color: var(--color-denim);
  font-weight: bold;
}

.form-builder input.theme-simpliciti,
.form-builder select.theme-simpliciti {
  border: 0px;
  border-bottom: 1px solid var(--color-slate-gray);
  border-radius: 0px;
}
.form-builder input.theme-simpliciti:focus,
.form-builder input.theme-simpliciti:active,
.form-builder select.theme-simpliciti:focus,
.form-builder select.theme-simpliciti:active {
  outline: 0px;
  box-shadow: none;
  border-bottom: 1px solid var(--color-main);
}
.form-builder label.theme-simpliciti {
  color: var(--color-main);
}

.form-builder .fieldOuter input::placeholder {
  /* Chrome, Firefox, Opera, Safari 10.1+ */
  color: var(--color-slate-gray);
  opacity: 1; /* Firefox */
  position: relative;
  top: -5px;
}
.form-builder .fieldOuter input:focus::placeholder,
.form-builder .fieldOuter input:active::placeholder {
  color: transparent;
}

.form-builder label {
  margin-bottom: 0px;
}

.form-builder .fieldOuter {
  position: relative;
  margin: 0;
  font: normal normal normal 0.9rem Open Sans;
  letter-spacing: 0px;
  &.big-form {
    margin-top: 10px;
  }
  &.big-form {
    margin-bottom: 5px;
  }
}
.form-builder .fieldOuter input {
  transition: opacity 1s;
  padding: 0px;
  font-size: 0.9rem;
  height: 30px;
  padding-top: 8px;
  &.big-form {
    height: 40px;
  }
}
.form-builder .fieldOuter label {
  position: absolute;
  left: 15px;
  font-size: 0.8rem;
  top: 0;
  transition: opacity 0.5s;
  overflow: hidden;
  color: var(--color-slate-gray);
  white-space: nowrap;
  z-index: 1;
  opacity: 0;
}
.form-builder .fieldOuter input:focus + label,
.form-builder .fieldOuter input:not(:placeholder-shown) + label {
  opacity: 1;
  top: -7px;
}
.form-builder .fieldOuter input:focus::placeholder + label {
  color: transparent !important;
}
.form-builder .fieldOuter input:focus {
  outline: none;
}

.form-builder button {
  max-width: 87px;
  font: normal normal bold 14px/21px Open Sans;
  letter-spacing: 0px;
  color: #484848;
}
.form-builder button.btn-primary {
  font: normal normal bold 14px/21px Open Sans;
  letter-spacing: 0px;
  color: #ffffff;
}
</style>