<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>
Source