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 toB component
, and the value ofB component
is assigned toA component
- 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
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