In this part I'll define what a dynamic form is, what problems you hit when building one, and give its basic data structures and the generation function. In the next part I'll go into more detail on how field values are stored in such a form, and how to implement these features and components.
In a typical B/S architecture project, we sometimes need to give different users forms containing different fields, based on their permissions and needs.
Take a ticket system with three roles. The people who can accept tickets (A and B), and the person who reviews ticket progress (C). For A, he may need to fill in the start and end times of ticket handling, confirming when he can work on the ticket. If A accepts the ticket but finds he can't finish it within the agreed time, he can share the ticket with B. Then what B sees should not be editable, and B can choose to accept or decline. When A and B have finished the ticket, C should receive a completion review. C can see all the ticket's information and choose whether to close it.
For the business logic above, most of a form's content is reused three times (ticket number, assignee and other fields), while A, B and C each have their own special fields and actions, and these differences in visibility come entirely from data pushed down by the server. So for this kind of scenario, can we compose the final rendered form from reused fields plus each person's special actions? A dynamic form solves exactly this problem.
Of course, given the brief description above, intuition probably pushes us toward this approach:
typescriptconst userInfo = getUserInfo(); const {isResolverSelf, isJudger} = userInfo; const renderForm = () => { if(isResolverSelf){ return <FullEditableForm/>; } if(isJudger){ return <CloseActionForm />; } if(!isResolverSelf){ return <ReciveSharedForm />; } } return renderForm();
But as roles multiply and permission control gets finer, renderForm balloons quickly and maintenance cost goes up.
So when your system has a foreseeable expansion in requirements, consider an implementation like a dynamic form.
To build such a form, we need to handle the following:
onChange, value, label and other props.onChange, onInValid, onSubmit, onCancel.Knowledge you need for what follows:
Such a dynamic form system roughly consists of these 4 modules:
For the UI rendering engine, we generally have two data structures to choose from. One is a flat data structure, such as:
typescriptenum ComponentTypeEnum { text = 0, select = 1, checkbox = 2, container = 3 } interface FormFieldUIProps { value: string | number | boolean; label: string | ReactNode; componentType: ComponentTypeEnum; name: string; // unique parentField: string; // it will save other field name // The componentsProps should from field component. e.x the select component should has options; required?: boolean; componentsProps: Props }; type FormUIStruct = FormFieldUIProps[];
The other is a nested data structure, such as:
typescriptinterface FormFieldUIProps { value: string | number | boolean; label: string | ReactNode; componentType: ComponentTypeEnum; name: string; required?: boolean; components: Props; children: FormFieldUIProps[]; }; type FormUIStruct = FormFieldUIProps[];
Let's compare the characteristics of these two data structures first:
| Flat data structure | Nested data structure | |
| When rendering | Must be converted to a nested structure to render | Can be rendered directly |
| When looking up a form item | Look up directly by name | Must search with DFS |
| Readability | Completely different from DOMTree, the parent-child relation is hard to see | Highly similar to the parent-child relation in DOMTree |
| Reusability | Must consider uniqueness of name | Multiple levels of nesting give natural isolation, name needn't be unique |
PS: To describe the makeup of this system more intuitively, I'll use the nested data structure as the data structure for UI rendering.
PS: To better present the system's design, I'll minimize third-party dependencies, so state management will be implemented with React's Context and hooks. As stated above, state management needs to collect changes to form item values, and notify the components associated with them to update. After the form is filled out, it must support submitting and canceling changes. Based on that, we need the following code:
typescriptinterface FieldValueCollection { [key in string]: FormFieldUIProps['value']; }; interface FieldActionsCollection { submit: (fieldValues: FieldValueCollection) => void; reset: () => void; handleValueChange: (cachePath: string, value: FormFieldUIProps['value']) => void; }
typescripttype validForm = () => { [key in keyof FieldValueCollection]: message; } | undefined type submitForm = () => void; type resetForm = () => void; // we want change run some actions when the value updated in path type getValueByPath = (path: keyof FieldValueCollection) => FormFieldUIProps['value'];
Now that I've defined the data structure describing a form, we pass this data structure as the input of a DSL interpreter function to generate a complete form. That function is implemented as follows:
typescriptconst renderForm = (conf: FormUIStruct) => { const travelConf = (conf?: FormUIStruct, combinePath: string = '', listIndex: number =NaN) => { conf?.forEach((item, index) => { if (!item) { return null; } const { children, componentType, name } = item; if (componentType === ComponentTypeEnum.container) { const newCombinePath = isNaN(listIndex) ? `${combinePath}.${name}` : `${combinePath}.${listIndex}.${name}`; return ( <FieldContainer key= { combinePath } path = { combinePath }> { travelConf(children, newCombinePath)} </FieldContainer> ) }else if (componentType === ComponentTypeEnum.list){ const newCombinePath = `${ combinePath }.${ name } `; return ( <FieldListContainer key= { combinePath } path = { combinePath } > { travelConf(children, newCombinePath, index)} </FieldListContainer> ) } else{ const newCombinePath = `${ combinePath }.${ name } `; return ( <FieldComponent { ...item } key = { combinePath } path = { newCombinePath } /> ) } }) } return travelConf(conf); }