Complex Reactive Forms

sshot.png

There are a lot of very complex forms in our applications. Complex forms does not mean just complex validation, but we fill form fields automagically or change editable state based on other form fields’ values. This needs a powerful form model. If you try to implement it with event handlers then it will certainly cause you some serious headaches.

Consider the following simplified invoice model:

Field Type
partnerid ID
paymet ID
invdate Date
due_days Integer
duedate Date
productid ID
qty Number
price Number
vat Number
grossprice Number

In this simple example the user should fill in three fields, all the others can be determined, or defaulted from these values. Every partner has a default payment method and if it’s payment method is credit, then usually it has a payment deadline in days (due_days) determined by a contract. Invdate is usually the actual day, duedate is calculated from invdate and due_days.

The selected product and the customer usually determines the price, VAT and grossprice. So if the user specifies the partner, product and quantity the application can fill all the other fields. However, we want the user to be able to change any other values.

Do not bother if you couldn’t follow everything, just read forward. Or you can jump to the form demo below.

Challenges

This problem is very complex on different levels.

First of all, the auto-fill of the fields can be very confusing to users. If the price field depends on the product selected, then the onChange handler of the product field have to set the price field. But what happens if the user modifies the price value and then she selects an other product? What should the program do? Should it set the price value to the product’s price, overwriting the previously set value?

The next problem is the implementation. This is a very difficult and error-prone process, it involves a lot of onChange handlers with a lot of dependencies, recursive field auto-fills, etc.

I have searched for a good form model we could use, but I couldn’t find anything beyond validation libraries. I thought a lot about the problem and I have come up with a possible solution.

Suggested solution

We can design a reactive form model with defining small functional rules between the fields. Complement our model with expressions that calculate a default value for the fields.

Field Type Value
partnerid ID -
paymet ID = partners[partnerid].paymet
invdate Date = ‘today’
due_days Integer = partners[partnerid].due_day
duedate Date = invdate + due_days
productid ID -
qty Number = 1
price Number = priceCalc(productid, partnerid)
vat Number = products[productid].vat
grossprice Number = price * ( 1 + vat / 100 )

This model can be easily implemented in a reactive environment (for example in a spreadsheet :) ). We want the user to be able to change the values, so we implement some field states. A field can be in four states:

State Sub-state Appearance
AUTO Gray
CHANGED OK Green
CHANGED WARN Yellow
INVALID Red

The AUTO state means the user haven’t changed the value of the field, it shows a calculated default value (or it can be empty). If the user changes the value of a field, it gets into the CHANGED state. The WARN sub-state can alert the user that the fields calculated value and the user value differ. The INVALID state of course indicates a validation error.

If we give the user a way to control the CHANGED state, then we get a consistent GUI. See this demo below, it is not perfect, but you can get a feel of the concept. You should at least set the customer and the product. Then every other field gets a default value and you can play with it.

Demo

Implementation

I am considering to create an open-source JavaScript library to implement this. The library would get a model consisting of:

  • Field descriptions with type definitions, type constraints (considering the use of tcomb)
  • Value links with reactive rules (field auto-fill)
  • State links with reactive rules (visibility, editability, optionality)
  • Validation rules (for complex inter-field validation constraints)

and it should handle any data interaction with the form.

Here are my expectations about the library:

  • It should work in the browser and on the server side.
  • It should work well with React.
  • It should be able to handle all form interaction on the client side:
    • Form auto fill
    • All sorts of validation
    • Field-level user permissions
    • Toggle field properties (visibility, editability, optionality) based on form state
  • It should be able to handle server side API validation
  • It should have slight dependencies (for example it should not depend on React, but it will have some React helpers and components in a separate library)

I would appreciate any thoughts, comments.

You can look at the source of the above example. This is a work in progress, so I don’t bother myself with documenting it in detail, but a lot of things are self explanatory:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
///////////
// Model //
///////////
let invoiceModel = {
fields: {
partnerid: new PartnerID().vRequired(),
paymet: new PaymentMethod().vRequired(),
invdate: new ISDK.Field().vRequired(),
due_days: new ISDK.Integer().vPositive(),
duedate: new ISDK.Field(),
n_item: {
ocsid: new ISDK.Field().vRequired(),
qty: new ISDK.Numeric().vPositive().vRequired(),
price: new ISDK.Numeric().vPositive(),
vat: new ISDK.Field(),
grossprice: new ISDK.Numeric().vPositive()
}
},
data: [
// Head
{ dst: 'invdate',
func: () => ISDK.dateNow()
},
{ dst: 'paymet',
src: 'partnerid',
func: (partnerid) => partners[partnerid].paymet
},
{ dst: 'due_days',
src: 'partnerid',
func: (partnerid) => partners[partnerid].due_days
},
{ dst: 'duedate',
src: ['due_days', 'invdate'],
func: (due_days, invdate) => ISDK.dateAdd(invdate, due_days)
}, // Items
// Items
{ dst: 'n_item.qty',
src: ['n_item.ocsid', 'partnerid'],
func: (ocsid) => ocsid ? 1 : ''
},
{ dst: 'n_item.price',
src: ['n_item.ocsid', 'partnerid'],
func: priceCalc
},
{ dst: 'n_item.vat',
src: ['n_item.ocsid'],
func: (ocsid) => products[ocsid].vat
},
{ dst: 'n_item.grossprice',
src: ['n_item.price', 'n_item.vat'],
func: (price, vat) => price && vat ? (price*(1+vat/100)).toFixed(2) : ''
},
{ dst: 'n_item.price',
src: ['n_item.grossprice', 'n_item.vat'],
cond: (grossprice, vat) => grossprice.s == ISDK.S_CHANGED,
func: (grossprice, vat) => grossprice && vat ? (grossprice/(1+vat/100)).toFixed(2) : ''
},
],
visible: [
{ src: 'paymet',
dst: ['due_days', 'duedate'],
func: (paymet) => (paymet=='CS' ? {due_days: false, duedate: false} : {due_days: true, duedate: true})
}
],
validate: [
{ dst: ['n_item.price', 'n_item.grossprice'],
src: 'n_item.vat',
func: (price, grossprice, vat) => (price && vat && grossprice ? (
Math.abs(price*(1+vat/100) - grossprice)>0.01 ? 'netprice/grossprice/VAT inconsistency!' : undefined
) : undefined)
}
]
};
//////////
// Form //
//////////
class InvoiceForm extends ISDK.Form {
render() {
let fs = this.state.formState;
return <div>
<nav className='navbar navbar-default navbar-inverse'>
<div className='container-fluid'>
<div className='navbar-header'>
<a className='navbar-brand' href='#'>Reactive form test</a>
</div>
<div className='navbar-collapse collapse'>
<ul className='nav navbar-nav'>
<li>
{this.state.undoStack.length>0
? <a onClick={this.onUndo.bind(this)}><span className='glyphicon glyphicon-chevron-left'/></a>
: <span/>
}
</li>
</ul>
</div>
</div>
</nav>
<form className='form-horizontal container-fluid' role='form' onSubmit={this.onSubmit.bind(this)}>
<fieldset>
<div className='row'>
<ISDK.Select className='col-xs-12 col-sm-6' id='partnerid' label='Customer: ' stat={fs.partnerid} form={this}
items={[['SYM', 'Symbion Products Ltd.'], ['RE', 'Reactive Elements Co.'], ['GOV', 'Government']]}/>
<ISDK.Select className='col-xs-6 col-sm-3' id='paymet' label='Payment method: ' stat={fs.paymet} form={this}
items={[['CR', 'Credit'], ['CS', 'Cash']]}/>
<ISDK.Entry className='col-xs-6 col-sm-3' id='invdate' type='date' label='Inv date: ' stat={fs.invdate} form={this}/>
<ISDK.Entry className='col-xs-6 col-sm-3' id='due_days' type='number' label='Due days: ' stat={fs.due_days} form={this}/>
<ISDK.Entry className='col-xs-6 col-sm-3' id='duedate' type='date' label='Due date: ' stat={fs.duedate} form={this}/>
</div>
</fieldset>
<fieldset>
<div className='row'>
<ISDK.Select className='col-xs-8 col-sm-4' id='n_item.ocsid' label='Product' stat={fs.n_item.ocsid} form={this}
items={[['1', 'Apple'], ['2', 'Banana'], ['3', 'Orange 3']]}/>
{/*
<Entry className='col-xs-4 col-sm-2' id='n_item.tgqty' type='number' stat={fs.n_item.tgqty} form={this} placeholder='menny.'/>
*/}
<ISDK.Entry className='col-xs-4 col-sm-2' id='n_item.qty' type='number' label='Quantity' stat={fs.n_item.qty} form={this}/>
<ISDK.Entry className='col-xs-4 col-sm-2' id='n_item.price' type='number' label='Net price' stat={fs.n_item.price} form={this}/>
<ISDK.Select className='col-xs-4 col-sm-2' id='n_item.vat' label='VAT' stat={fs.n_item.vat} form={this}
items={[['0', '0 %'], ['5', '5 %'], ['27', '27 %']]}/>
<ISDK.Entry className='col-xs-4 col-sm-2' id='n_item.grossprice' type='number' label='Gross price' stat={fs.n_item.grossprice} form={this}/>
</div>
</fieldset>
</form>
</div>
}
}