title: vue3 + elememt-plus form generator
date: 2023-10-27 16:12:57
categories: [“Technology”]
tags: [“front-end”, “vue3”]
Reason
Yesterday, I wrote a form with more than ten or twenty fields. It was a bit troublesome to write, and there were changes in the middle. A long string of el-form-item
looked a bit annoying, even if I wrote it in a loop, It almost happened. I just had some free time today, so I wrote a generator to automatically generate forms.
Generator usage
Supported component types
// Temporarily support these types (all are element-plus components, I have omitted the previous el) export type fieldType = | "input" | "number" | "select" | "textarea" | "date" | "time" | "datetime" | "cascader" | "tree-select" | "radio" | "checkbox";
Form rendering
<!-- Each formColumns sub-item supports two slots --> <!-- slot=${prop} completely replaces form-item --> <!-- slot=${prop}Component replaces the component under the form-item, and the label does not change --> <FormGenerator ref="formGeneratorRef" :formColumns="formColumns" :model="form" :rules="rules" :property="property" > <template #nameComponent> This is a name </template> <template #number> quantity component replaces form-item </template> </FormGenerator>
Form binding
//Bind form const form = reactive({<!-- --> name: "", number: 10, number2: 2 });
Form list data
const list = [ {<!-- --> label: "Project 1", value: 1 }, //Item 2 cannot be selected {<!-- --> label: "Project 2", value: 2, disabled: true }, ]; const treeList = [ {<!-- --> label: "Project 1", value: 1 }, {<!-- --> label: "Project 2", value: 2, children: [ {<!-- --> label: "Project 2 Sub-item 1", value: 21 }, //Item 2 cannot be selected {<!-- --> label: "Project 2 Sub-item 2", value: 22, disabled: true }, ], }, ]; // form data const formColumns: FormItemVO[] = [ // label: label attribute of form-item component // fileType: Required for the fieldType type supported above // prop: bound data model attribute required //Other parameters: consistent with the original element-plus component parameters {<!-- --> label: "name", fileType: "input", prop: "name" }, {<!-- --> label: "test parameter", fileType: "number", prop: "filed1" }, {<!-- --> label: "test parameter 2", fileType: "select", options: list, prop: "filed2" }, {<!-- --> label: "test parameter 3", fileType: "radio", options: list, prop: "filed3" }, {<!-- --> label: "test parameter 4", fileType: "checkbox", options: list, prop: "filed4" }, {<!-- --> label: "test parameter 5", fileType: "textarea", prop: "filed5" }, //Test parameter 6 tree selector {<!-- --> label: "Test parameter 6", fileType: "tree-select", //data is the original data data: treeList, prop: "filed6", }, ];
Extra attribute configuration property attribute
interface FormAttributes {<!-- --> // inline mode inline?: boolean; //label position labelPosition?: "left" | "right" | "top"; // label width labelWidth?: string | number; // form size size?: "" | "large" | "default" | "small"; // Invalid when the grid number inline is true col?: number; // label suffix labelSuffix?: string; //The width of the component on the right side of the label componentWidth?: string; } const property = reactive<FormAttributes>({<!-- --> col: 2, labelSuffix: ":", componentWidth: "100%", });
Effect
Submit form verification
// Form verification is consistent with the original el-form rules writing const rules = {<!-- --> name: [{<!-- --> required: true, message: "Please enter a name", trigger: "blur" }], }; const formGeneratorRef = ref<FormGeneratorRef>(null); // Get form instance const formRef = formGeneratorRef.value?.getForm(); const valid = await formRef?.validate(); // Other Form Exposes are written in the same way as the original // const valid = await formRef?.validateField("filed1")
Effect
onEnter
// Because the project requires pressing enter to search // So the onEnter method is thrown [@keyup.enter] const onEnter = (value) => {<!-- --> console.log("onEnter", value); } const formColumns: FormItemVO[] = [ {<!-- --> label: "name", fileType: "input", prop: "name" onEnter }, // ... ]
Form builder code
Directory structure
// Directory structure -FormGenerator -index.vue -types.ts -components -FormItemRenderer.vue
FormGenerator/types.ts
import {<!-- --> DICT_TYPE } from '@/utils/dict' export type fieldType = | 'input' | 'number' | 'select' | 'textarea' | 'date' | 'time' | 'datetime' | 'cascader' | 'tree-select' | 'radio' | 'checkbox' export interface OptionsPropsVO {<!-- --> label?: string value?: string children?: string } export interface FormItemVO {<!-- --> prop: string label: string fileType: fieldType options?: OptionVO[] optionsProps?: OptionsPropsVO // number component min?: number max?: number precision?: number placeholder?: string rows?: number clearable?: boolean multiple?: boolean filterable?: boolean allowCreate?: boolean //Dictionary type dictType?: DICT_TYPE // tree-select component data?: any[] // functon keyboard enter event onEnter?(value: any): void // There are still many properties of element-plus components that have not been written. } export interface OptionVO {<!-- --> label: string value: any children?: OptionVO[] // [key: string]: any } //Default options mapping export const defaultOptionsPropsVO: OptionsPropsVO = {<!-- --> label: 'label', value: 'value', children: 'children', } //The type of element-plus component itself, use fiedType export const unchangedTypes: fieldType[] = ["textarea", "date", "datetime", "time"]; // [placeholder] The prompt word is Please select the component type of ${label} export const selectPlaceholder: fieldType[] = ["select", "cascader", "tree-select"]
FormGenerator/components/FormItemRenderer.vue
<ElFormItem :label="useLabel" :prop="item.prop"> <slot> <component @keyup.enter="useOnEnter" :is="componentType" v-model="useValue" v-bind="bindItem" :style="{ width: property?.componentWidth }" > <component :is="itemComponentType" v-for="(option, index) in useOptions" :key="index" v-bind="option" /> </component> </slot> </ElFormItem>
<script setup name="FormItemRenderer" lang="ts"> // dictionary import {<!-- --> getDictOptions } from "@/utils/dict"; import {<!-- --> ElFormItem, ElInputNumber, ElSelect, ElOption, ElInput, ElDatePicker, ElTimePicker, ElCascader, ElRadioGroup, ElRadio, ElCheckboxGroup, ElCheckbox, ElTreeSelect, } from "element-plus"; import type {<!-- --> FormItemVO, OptionVO } from "../types"; import {<!-- --> unchangedTypes, selectPlaceholder, defaultOptionsPropsVO, } from "../types"; interface PropertyVO {<!-- --> // label suffix labelSuffix?: string; componentWidth?: string; } export interface PropsVO {<!-- --> item: FormItemVO; value: any; property?: PropertyVO; } const props = defineProps<PropsVO>(); const emit = defineEmits(["update:value"]); // label const useLabel = computed(() => {<!-- --> const item = props.item; const property = props.property; if (!property?.labelSuffix) {<!-- --> return item.label; } return item.label + property.labelSuffix; }); const useValue = computed({<!-- --> get() {<!-- --> return props.value; }, set(val) {<!-- --> // Trigger the update:page event, update the limit attribute, and thus update pageNo emit("update:value", val); }, }); // Parameters required by element-plus component const bindItem = computed(() => {<!-- --> const {<!-- --> item } = props; return {<!-- --> ...item, rows: useRows.value, type: useType.value, placeholder: usePlaceholder.value, clearable: useClearable.value, }; }); const useOptionsPops = computed(() => {<!-- --> const {<!-- --> optionsProps } = props.item; return {<!-- --> ...defaultOptionsPropsVO, ...(optionsProps || {<!-- -->}) }; }); /** Component list data */ const useOptions = computed(() => {<!-- --> let list: OptionVO[] = []; const {<!-- --> options, dictType } = props.item; if (options) list = options; if (dictType) list = getIntDictOptions(dictType); const {<!-- --> value, label, children } = useOptionsPops.value; //Field mapping return list.map((item) => {<!-- --> return {<!-- --> ...item, label: item[label as string], value: item[value as string], children: item[children as string], }; }); }); const componentType = computed(() => {<!-- --> switch (props.item.fileType) {<!-- --> case "number": return ElInputNumber; case "select": return ElSelect; case "date": case "datetime": return ElDatePicker; case "time": return ElTimePicker; case "cascader": return ElCascader; case "radio": return ElRadioGroup; case "checkbox": return ElCheckboxGroup; case "tree-select": return ElTreeSelect; default: return ElInput; } }); const itemComponentType = computed(() => {<!-- --> switch (props.item.fileType) {<!-- --> case "select": return ElOption; case "radio": return ElRadio; case "checkbox": return ElCheckbox; default: return; } }); //The type of the component itself const useType = computed(() => {<!-- --> const {<!-- --> fileType } = props.item; if (unchangedTypes.includes(fileType)) return fileType; return ""; }); //The default number of rows is 3 const useRows = computed(() => {<!-- --> if (props.item.rows) return props.item.rows; return 3; }); const isUndefine = (val: any) => {<!-- --> return val === undefined; }; // Default clear is true const useClearable = computed(() => {<!-- --> if (!isUndefine(props.item.clearable)) props.item.clearable; return true; }); //Default prompt Please enter ${label} | Please select ${label} const usePlaceholder = computed(() => {<!-- --> const {<!-- --> placeholder, label, fileType } = props.item; if (!isUndefine(placeholder)) return placeholder; if (selectPlaceholder.includes(fileType)) return `Please select ${<!-- -->label}`; return `Please enter ${<!-- -->label}`; }); const getIntDictOptions = (dictType: string) => {<!-- --> // Get the option list based on dictType and return an object array containing label and value attributes. // Please improve this function according to your application needs return getDictOptions(dictType); }; const useOnEnter = () => {<!-- --> const fn = () => {<!-- -->} if (!props?.item?.onEnter) return fn return props.item.onEnter(useValue.value) } </script>
FormGenerator/index.vue
<el-form @submit.prevent :model="model" :rules="rules" :class="useColClass" :size="property?.size" :inline="property?.inline" :label-width="property?.labelWidth" :label-position="property?.labelPosition" ref="formRef" > <template v-for="item in formColumns" :key="item.prop"> <slot :name="item.prop"> <FormItemRenderer :item="item" :property="property" v-model:value="useModel[item.prop]" > <template #default> <!-- The slot of the rendering component --> <slot :name="item.prop + 'Component'" /> </template> </FormItemRenderer> </slot> </template> </el-form>
<script setup lang="ts" name="FormGenerator"> import FormItemRenderer from "./components/FormItemRenderer.vue"; import {<!-- --> ElForm } from "element-plus"; import type {<!-- --> FormRules } from "element-plus"; import type {<!-- --> FormItemVO } from "./types"; interface FormAttributes {<!-- --> // inline mode inline?: boolean; //label position labelPosition?: "left" | "right" | "top"; // label width labelWidth?: string | number; // form size size?: "" | "large" | "default" | "small"; // Invalid when the grid number inline is true col?: number; // ------ Parameters used by rendering components ------ // label suffix labelSuffix?: string; //The width of the component on the right side of the label componentWidth?: string; } interface PropsVO {<!-- --> /** Form data */ formColumns: FormItemVO[]; model: Record<string, any>; rules?: FormRules; property?: FormAttributes; } const props = defineProps<PropsVO>(); const emit = defineEmits(["update:model"]); const useModel = computed({<!-- --> get() {<!-- --> return props.model; }, set(val) {<!-- --> // Trigger the update:page event, update the limit attribute, and thus update pageNo emit("update:model", val); }, }); // Grid class const useColClass = computed(() => {<!-- --> const {<!-- --> inline, col } = props.property || {<!-- -->}; // Inline mode | The number of grids is empty. Jump out if (inline || !col) return ""; return `grid items-start grid-gap-0-20 grid-cols-${<!-- -->col}`; }); const formRef = ref() // form instance const getForm = () => {<!-- --> return formRef.value } defineExpose({<!-- --> getForm }); </script>
// Raster related scss code .grid { display: -ms-grid; display: grid; } .grid-gap-0-20 { gap: 0 20px; } .items-start { -webkit-box-align: start; -ms-flex-align: start; -webkit-align-items: flex-start; align-items: flex-start; } @for $i from 1 through 4 { .grid-cols-#{$i} { grid-template-columns: repeat($i, minmax(0, 1fr)); } }