Vue3 Element-Plus generates dynamic forms in one stop

Background

Friends who often develop management systems must have encountered form requirements more or less. For a system, there are often more than a dozen or dozens of forms; if each form is written according to the traditional model, it will be almost impossible. The front-end is exhausted, and looking at pieces of code that are similar to each other does not make me feel motivated at all. Even looking at these it understands you, but you don’t want to understand it makes me feel sick.
In the spirit of laziness, I wondered if I could encapsulate a dynamic form. The implementation idea is roughly to dynamically generate the form page through JSON configuration, so I just did it. What we are playing is real, right? Open up, open up…

Project address: github address

Data interface design

Without further ado, here’s the code

At first glance, the code looks a bit messy. Don’t worry, the comments have been arranged.

type TreeItem = {<!-- -->
  value: string
  label: string
  children?: TreeItem[]
}

export type FormListItem = {<!-- -->
  //The number of columns occupied by the grid
  colSpan?: number
  // Properties unique to form elements
  props?: {<!-- -->
    placeholder?: string
    defaultValue?: unknown // Binding default value
    clearable?: boolean
    disabled?: boolean | ((data: {<!-- --> [key: string]: any }) => boolean)
    size?: 'large' | 'default' | 'small'
    group?: unknown // Parent-specific attributes, for nested components Select, Checkbox, Radio
    child?: unknown // Child-specific attributes, for nested components Select, Checkbox, Radio
    [key: string]: unknown
  }
  // Form element-specific slots
  slots?: {<!-- -->
    name: string
    content: unknown
  }[]
  // component type
  typeName?: 'input' | 'select' | 'date-picker' | 'time-picker' | 'switch' | 'checkbox' | 'checkbox-group' | 'checkbox-button' | 'radio-group' | 'radio-button' | 'input-number' | 'tree-select' | 'upload' | 'slider '
  // Styles unique to form elements
  styles?: {<!-- -->
    [key: string]: number | string
  }
  // select options replacement field
  replaceField?: {<!-- --> value: string; label: string }
  // list items
  options?: {<!-- -->
    value?: string | number | boolean | object
    label?: string | number
    disabled?: ((data: {<!-- --> [key: string]: any }) => boolean) | boolean
    [key: string]: unknown
  }[]
  // <el-form-item> unique attributes, same as FormItem Attributes
  formItem: Partial<FormItemProps & amp; {<!-- --> class: string }>
  // Nested <el-form-item>
  children?: FormListItem[]
  //Tree selector data
  treeData?: TreeItem[] // Only for 'tree-select' component
  //Component display conditions
  isShow?: ((data: {<!-- --> [key: string]: any }) => boolean) | boolean
}

export type FConfig = {<!-- -->
  form: Partial<InstanceType<typeof ElForm>> // Form Attributes are consistent with Element attributes
  configs: FormListItem[] // Form body configuration
}

Common form requirements

  • How to control the display and hiding of a component

The implementation idea is to provide a isShow method and bind the method to the corresponding component, so that the component displays hidden conditions

isShow: (data = {<!-- -->}) => {<!-- -->
  return model.value.region == 'shanghai'
}
....
<el-form-item v-if="isShow(model)" v-bind="item.formItem">
  • Whether the target component is disabled needs to be judged based on whether a component has a value.
disabled: (data = {<!-- -->}) => {<!-- -->
    return !model.value.date1
}
....
<component :disabled="disabled(model)"></component>
  • Components assign values to each other. The value of A component is assigned to B component, and the value of B component is assigned to A component

image.png

image.png

  • form validation
formItem: {<!-- -->
  prop: 'name',
  label: 'Activity name',
  rules: [
    {<!-- -->
      required: true,
      message: 'Please enter content',
      trigger: 'blur'
    }
  ]
}

Component packaging

1. Input box component
<template>
  <el-input v-bind="attrs.props"
            ref="elInputRef"
            :style="attrs.styles">
    <template v-for="item in attrs.slots"
              #[item.name]
              :key="item.name">
      <component :is="item.content"></component>
    </template>
  </el-input>
</template>
2. Drop-down selector component
<template>
  <el-select v-bind="attrs.props?.group"
             ref="elSelectRef"
             :style="attrs.styles">
    <el-option v-for="item in attrs.options"
               v-bind="attrs.props?.child"
               :key="item[attrs.replaceField?.value || 'value']"
               :label="item[attrs.replaceField?.label || 'label']"
               :value="item[attrs.replaceField?.value || 'value']"
               :disabled="item.disabled"></el-option>
  </el-select>
</template>
3. Date picker component
<template>
  <el-date-picker v-bind="attrs.props"
                  ref="elDatePickerRef"
                  :style="attrs.styles"></el-date-picker>
</template>

The packaging methods are the same, and there are many components. I won’t list them one by one here. You can check the source code for details.

Project path src/components/Form

Component integration

<template>
  <el-form v-bind="props.form"
           ref="formRef"
           :model="model">
    <el-row :gutter="20">
      <el-col v-for="item in props.configs"
              :key="item.formItem.prop"
              :span="item.colSpan">
        <el-form-item v-if="ifShow(item, model)"
                      v-bind="item.formItem">
          <template v-if="item.typeName == 'upload'">
            <el-upload v-bind="item.props">
              <template v-for="it in item.slots"
                        #[it.name]
                        :key="it.name">
                <component :is="it.content"></component>
              </template>
            </el-upload>
          </template>

          <template v-if="!item.children?.length">
            <component :is="components[`m-${item.typeName}`]"
                       v-bind="item"
                       v-model="model[item.formItem.prop as string]"
                       :form-data="model"
                       :disabled="ifDisabled(item, model)"></component>
          </template>

          <template v-else>
            <el-col v-for="(child, index) in item.children"
                    :key="index"
                    :span="child.colSpan">
              <el-form-item v-bind="child.formItem">
                <component :is="components[`m-${child.typeName}`]"
                           v-bind="child"
                           v-model="model[child.formItem.prop as string]"
                           :form-data="model"
                           :disabled="ifDisabled(child, model)"></component>
              </el-form-item>
            </el-col>
          </template>
        </el-form-item>
      </el-col>
    </el-row>
  </el-form>
</template>

<script setup lang="ts">
import cloneDeep from 'lodash/cloneDeep'
import {<!-- --> ref, onMounted, watch, computed } from 'vue'
import {<!-- --> getType } from '@/utils/util'
import type {<!-- --> ElForm, FormInstance } from 'element-plus'
import {<!-- --> FormListItem, FConfig } from './form'

import mInput from './components/m-input.vue'
import mSelect from './components/m-select.vue'
import mDatePicker from './components/m-date-picker.vue'
import mTimePicker from './components/m-time-picker.vue'
import mSwitch from './components/m-switch.vue'
import mCheckbox from './components/m-checkbox.vue'
import mCheckboxGroup from './components/m-checkbox-group.vue'
import mCheckboxButton from './components/m-checkbox-button.vue'
import mRadioGroup from './components/m-radio-group.vue'
import mRadioButton from './components/m-radio-button.vue'
import mInputNumber from './components/m-input-number.vue'
import mTreeSelect from './components/m-tree-select.vue'
import mSlider from './components/m-slider.vue'

type Props = FConfig & amp; {<!-- -->
  data: {<!-- --> [key: string]: any }
}
const emits = defineEmits(['update:data'])
const props = withDefaults(defineProps<Props>(), {<!-- -->})
const model = ref<{<!-- --> [key: string]: any }>({<!-- -->})
const formRef = ref<FormInstance | null>()
const components: {<!-- --> [key: string]: any } = {<!-- -->
  'm-input': mInput,
  'm-select': mSelect,
  'm-date-picker': mDatePicker,
  'm-time-picker': mTimePicker,
  'm-switch': mSwitch,
  'm-checkbox': mCheckbox,
  'm-checkbox-group': mCheckboxGroup,
  'm-checkbox-button': mCheckboxButton,
  'm-radio-group': mRadioGroup,
  'm-radio-button': mRadioButton,
  'm-input-number': mInputNumber,
  'm-tree-select': mTreeSelect,
  'm-slider': mSlider
}

//Initialize form method
const initForm = () => {<!-- -->
  if (props.configs?.length) {<!-- -->
    let m: {<!-- --> [key: string]: any } = {<!-- -->}
    props.configs.map((item) => {<!-- -->
      if (!item.children?.length) {<!-- -->
        m[item.formItem.prop as string] = item.props?.defaultValue
      } else {<!-- -->
        item.children.map((child) => {<!-- -->
          m[child.formItem.prop as string] = child.props?.defaultValue
        })
      }
    })
    model.value = cloneDeep({<!-- --> ...props.data, ...m })
  }
}

const ifDisabled = computed(() => {<!-- -->
  return (column: FormListItem, model: {<!-- --> [key: string]: any }) => {<!-- -->
    let disabled = column.props?.disabled
    switch (getType(disabled)) {<!-- -->
      case 'function':
        disabled = (disabled as any)(model)
        break
      case 'undefined':
        disabled=false
    }
    return disabled
  }
})

const ifShow = (column: FormListItem, model: {<!-- --> [key: string]: any }) => {<!-- -->
  let flag = column.isShow
  switch (getType(flag)) {<!-- -->
    case 'function':
      flag = (flag as any)(model)
      break
    case 'undefined':
      flag = true
      break
  }
  return flag
}

// Component overrides the method to reset the form
const resetFields = () => {<!-- -->
  //Reset the form of element-plus
  formRef.value?.resetFields()
}

// form validation
const validate = () => {<!-- -->
  return new Promise((resolve, reject) => {<!-- -->
    formRef.value?.validate((valid) => {<!-- -->
      if (valid) {<!-- -->
        resolve(true)
      } else {<!-- -->
        reject(false)
      }
    })
  })
}

const getFormData = () => {<!-- -->
  return model.value
}

onMounted(() => {<!-- -->
  initForm()
})

watch(
  () => model.value,
  (val) => {<!-- -->
    emits('update:data', val)
  }
)

watch(
  () => props.data,
  (val) => {<!-- -->
    model.value = val
  }
)

watch(
  () => props.configs,
  () => {<!-- -->
    initForm()
  },
  {<!-- --> deep: true }
)

defineExpose({<!-- -->
  resetFields,
  getFormData,
  validate
})
</script>

<style scoped></style>

Attach complete configuration

const config = ref<FConfig>({<!-- -->
    form: {<!-- -->
      labelWidth: '140px'
    },
    configs: [
      // Input box
      {<!-- -->
        colSpan: 12,
        typeName: 'input',
        props: {<!-- -->
          defaultValue: '',
          clearable: true,
          placeholder: 'Please enter content'
        },
        slots: [
          {<!-- -->
            name: 'suffix',
            content: () => (
              <ElIcon class="el-input__icon">
                <Search />
              </ElIcon>
            )
          }
        ],
        formItem: {<!-- -->
          prop: 'name',
          label: 'Activity name',
          rules: [
            {<!-- -->
              required: true,
              message: 'Please enter content',
              trigger: 'blur'
            }
          ]
        }
      },
      // Selector
      {<!-- -->
        colSpan: 12,
        typeName: 'select',
        props: {<!-- -->
          placeholder: 'Please select content',
          defaultValue: undefined,
          group: {<!-- -->
            clearable: true,
            onChange: events.changeSelect
          },
          child: {<!-- -->}
        },
        replaceField: {<!-- --> value: 'key', label: 'title' },
        options: [
          {<!-- --> key: 'shanghai', title: 'Zone one' },
          {<!-- --> key: 'beijing', title: 'Zone two' }
        ],
        styles: {<!-- -->
          width: '100%'
        },
        formItem: {<!-- -->
          prop: 'region',
          label: 'Activity zone',
          rules: [
            {<!-- -->
              required: true,
              message: 'Please select Activity zone',
              trigger: 'change'
            }
          ]
        }
      },
      {<!-- -->
        colSpan: 24,
        formItem: {<!-- -->
          required: true,
          label: 'Activity time'
        },
        children: [
          // date picker
          {<!-- -->
            colSpan: 12,
            typeName: 'date-picker',
            props: {<!-- -->
              type: 'datetime',
              clearable: true,
              valueFormat: 'YYYY-MM-DD HH:mm:ss',
              placeholder: 'Pick a day'
            },
            styles: {<!-- --> width: '100%' },
            formItem: {<!-- -->
              prop: 'date1',
              rules: [
                {<!-- -->
                  type: 'date',
                  required: true,
                  message: 'Please pick a date',
                  trigger: 'change'
                }
              ]
            }
          },
          // time picker
          {<!-- -->
            colSpan: 12,
            typeName: 'time-picker',

            props: {<!-- -->
              disabled: (data = {<!-- -->}) => {<!-- -->
                return !model.value.date1
              },
              clearable: true,
              placeholder: 'Pick a time'
            },
            styles: {<!-- --> width: '100%' },
            formItem: {<!-- -->
              prop: 'date2',
              rules: [
                {<!-- -->
                  type: 'date',
                  required: true,
                  message: 'Please pick a time',
                  trigger: 'change'
                }
              ]
            }
          }
        ]
      },
      // switch
      {<!-- -->
        colSpan: 24,
        typeName: 'switch',
        props: {<!-- -->
          defaultValue: false
        },
        formItem: {<!-- -->
          prop: 'delivery',
          label: 'Instant delivery'
        }
      },
      // Checkbox
      {<!-- -->
        colSpan: 12,
        typeName: 'checkbox-group',
        props: {<!-- -->
          group: {<!-- -->},
          child: {<!-- -->}
        },
        formItem: {<!-- -->
          prop: 'type',
          label: 'Activity type',
          rules: [
            {<!-- -->
              type: 'array',
              required: true,
              message: 'Please select at least one activity type',
              trigger: 'change'
            }
          ]
        },
        // replaceField: { value: 'value', label: 'label' },
        options: [
          {<!-- --> value: 'shanghai', label: 'Zone one' },
          {<!-- --> value: 'beijing', label: 'Zone two' }
        ]
      },
      //Multiple selection button box
      {<!-- -->
        colSpan: 12,
        typeName: 'checkbox-button',
        props: {<!-- -->
          group: {<!-- -->},
          child: {<!-- -->}
        },
        formItem: {<!-- -->
          prop: 'button',
          label: 'Activity button',
          rules: [
            {<!-- -->
              type: 'array',
              required: true,
              message: 'Please select at least one activity type',
              trigger: 'change'
            }
          ]
        },
        // replaceField: { value: 'value', label: 'label' },
        options: [
          {<!-- --> value: 'shanghai', label: 'Zone one' },
          {<!-- --> value: 'beijing', label: 'Zone two' }
        ]
      },
      // Single box
      {<!-- -->
        colSpan: 12,
        typeName: 'radio-group',
        props: {<!-- -->},
        formItem: {<!-- -->
          prop: 'resource',
          label: 'Resources',
          rules: [
            {<!-- -->
              required: true,
              message: 'Please select activity resource',
              trigger: 'change'
            }
          ]
        },
        options: [
          {<!-- --> value: 'shanghai', label: 'Sponsorship' },
          {<!-- --> value: 'beijing', label: 'Venue' }
        ]
      },
      // radio button box
      {<!-- -->
        colSpan: 12,
        typeName: 'radio-button',
        props: {<!-- -->},
        formItem: {<!-- -->
          prop: 'resourceButton',
          label: 'Resources button',
          rules: [
            {<!-- -->
              required: true,
              message: 'Please select activity resource',
              trigger: 'change'
            }
          ]
        },
        options: [
          {<!-- --> value: 'shanghai', label: 'Sponsorship' },
          {<!-- --> value: 'beijing', label: 'Venue' }
        ]
      },
      // text field
      {<!-- -->
        colSpan: 24,
        typeName: 'input',
        formItem: {<!-- -->
          prop: 'desc',
          label: 'Activity form'
        },
        props: {<!-- -->
          rows: 5,
          type: 'textarea',
          clearable: true,
          placeholder: 'Please enter content'
        },
        isShow: (data = {<!-- -->}) => {<!-- -->
          return model.value.region == 'shanghai'
        }
      },
      // File Upload
      {<!-- -->
        colSpan: 24,
        typeName: 'upload',
        formItem: {<!-- -->
          prop: 'fileName',
          label: 'Upload File',
          rules: [
            {<!-- -->
              required: true,
              message: 'Please select at least one activity type',
              trigger: 'change'
            }
          ]
        },
        props: {<!-- -->
          httpRequest: events.httpRequest
        },
        slots: [
          {<!-- -->
            name: 'default',
            content: () => <ElButton type="primary">Upload</ElButton>
          },
          {<!-- -->
            name: 'tip',
            content: () => <span style="margin-left:10px">jpg/png files with a size less than 500KB</span>
          }
        ]
      },
      // slider
      {<!-- -->
        colSpan: 16,
        typeName: 'slider',
        props: {<!-- -->
          onChange: (val: number) => {<!-- -->
            model.value.number = val
          }
        },
        formItem: {<!-- -->
          label: 'Activity slider',
          prop: 'slider',
          rules: [
            {<!-- -->
              required: true,
              message: 'Please enter content',
              trigger: 'change'
            }
          ]
        }
      },
      //Number input box
      {<!-- -->
        colSpan: 8,
        typeName: 'input-number',
        formItem: {<!-- -->
          prop: 'number',
          label: 'Activity number'
        },
        props: {<!-- -->
          min: 1,
          max: 100,
          onChange: (val: number) => {<!-- -->
            model.value.slider = val
          }
        }
      },
      //Tree selector
      {<!-- -->
        colSpan: 24,
        typeName: 'tree-select',
        formItem: {<!-- -->
          prop: 'tree',
          label: 'Activity tree'
        },
        styles: {<!-- --> width: '100%' },
        props: {<!-- -->
          multiple: true,
          showCheckbox: true,
          placeholder: 'Please select content'
        },
        treeData: []
      }
    ]
  })

Achieve results

image.png

image.png

image.png
For the detailed implementation logic, I would like to ask everyone to check it out in the project.

Finally

That’s it for now. If this article is helpful to you, don’t forget to give it a thumbs up.
If there are any errors or shortcomings in this article, you are welcome to point them out in the comment area and give us your valuable opinions!

Finally share the project address: github address

syntaxbug.com © 2021 All Rights Reserved.