vue3 + elememt-plus form generator

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));
  }
}