In a recent personal web project, I tried to implement a deeply nested form field. Previously, when using web frameworks such as Ruby-On-Rails, this is built into the framework through helpers such as nested_form_fields
, which hides most of the implementation details.
Since I’m building my entire application manually from scratch, I tried to implement it from scratch using pure javascript. The main rationale for my choice was that I didn’t want to introduce another framework such as ReactJs when the current UI and javascript is already established.
Above is a screenshot of the form in question. It allows you to dynamically create a genai data schema which is passed downstream when doing Function Calling in Gemini or Structured Output in Gemini.
Each schema consists of a name, description and a list of parameters. Each parameter has a name, type. It has attributes which marks it as either an array or required. The parameter type can be a scalar value such as a string or number but it can also be a complex type such as an object or an enumeration ( enum ) which means it needs to be able to store multiple values or in the case of an object, it can also contain other scalar attributes or even nested objects.
The example screenshot shows an example of creating a schema for a function call controlLight
which takes 2 parameters: a number called brightness and a string called colour. The data schema created has the following structure:
1
2
3
4
5
6
7
8
{
"name":"controlLight",
"description":"Set the brightness and color temperature of a room light.","params":[
{"name":"brightness","type":"2"},
{"name":"colour","type":"1"}
],
"required":["brightness","colour"]
}
The Add Params
button has an event handler which calls out to a function addParams
which creates a nested form field dynamically:
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
document.getElementById("add-params").addEventListener("click", addParams);
function addParams(event) {
event.preventDefault();
inline = `
<div class="col-md-4">
<label class="form-label">Param Name</label>
<input type="text" class="form-control" id="params" name="params[${x}][name]">
</div>
<div class="col-md-4">
<label class="form-label">Param Type</label>
<select class="form-select" id="params-type" name="params[${x}][type]" data-path="params[${x}]">
<option value="1">string</option>
<option value="2">number</option>
<option value="3">integer</option>
<option value="4">boolean</option>
<option value="6">object</option>
<option value="enum">enum</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Options</label>
<div class="form-control">
<a href="#" id="params-array">Array</a>
<a href="#" id="params-required-link">Required</a>
<a href="#" class="icon-link" id="delete-params"><i class="bi bi-trash"></i> Delete</a>
<input type="hidden" id="params-items" name="params[${x}][items]" value="" />
<input type="hidden" id="params-required" name="required[${x}]" value=""/>
</div>
</div>
`
var item = document.createElement("div");
item.className = "row mb-3";
item.id = `param-${x}`;
item.setAttribute("data-id", x)
item.innerHTML = inline;
item.querySelector("a#params-array").addEventListener("click", setArray);
item.querySelector("a#params-required-link").addEventListener("click", setRequired);
item.querySelector("a#delete-params").addEventListener("click", deleteParams);
item.querySelector("select#params-type").addEventListener("change", setType);
document.getElementById("params").appendChild(item);
x++;
}
Note that we use a variable x
to keep track of each parameter created. I used an external plugin form-to-object to parse the form inputs. We need to subscript each parameter field with an index such as params[${x}]
in order to create a object. For instance, params[0][name]
refers to the first parameter name attribute. We use two hidden form fields to keep track of any array or required items.
Depending on the parameter type, we need to render a different form structure. For example, when creating a enumeration type, we provide the option of allowing the user to specify individual values via Add Enum Value
as show below:
This is handled via another event handler:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function addEnum(event) {
event.preventDefault();
var id = event.target.parentNode.dataset.id;
let path = event.target.getAttribute("data-path");
var inline = `
<div class="col-md-6 mb-3">
<input type="text" class="form-control" id="params" name="${path}[enum][]" />
</div>
<div class="col-md-2 mt-2" id="delete-enum">
<a href="#" id="delete-enum">Delete Enum</a>
</div>
`
var item = document.createElement("div");
item.className = "row px-4";
item.id = "param";
item.innerHTML = inline;
item.querySelector("a#delete-enum").addEventListener("click", deleteEnum);
event.target.parentNode.querySelector("#params-enum").append(item);
}
To keep track of which parameter this enumeration belongs to, we add a data attribute to the Add Enum Value
link of the form:
<a class="icon-link px-5" id="add-enum" href="#" data-path="params[0]">Add Enum Value</a>
The data-path
indicates that this enum is to be created for params[0]
. This is interpolated into the enum input field:
<input type="text" class="form-control" id="params" name="params[0][enum][]">
Note that the enumeration is an array and so we need to further subscript it by adding square brackets to the end. The following schema is an example of a parameter with an enumeration:
1
2
3
4
5
6
7
8
9
{
"params":[
{
"name":"color",
"type":"enum",
"enum":["dim","warm","daylight"]
}
]
}
Suppose we need to create a schema where one of the attributes is an object. The following screenshot uses an example to create a schema for recipes where each recipe is an object with several attributes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"name":"recipes",
"params":[
{
"name":"recipe",
"type":"6",
"objects":[
{"name":"recipeName","type":"1"},
{"name":"desc","type":"1"},
{"name":"ingredients","type":"1"},
{"name":"instructions","type":"1"}
]
}
]
}
Given the example above, the input field name has the following format:
<input type="text" class="form-control" id="params-name" name="params[0][objects][0][name]">
Note that the name has two further subscripts: objects[0][name]
. This means that the input belongs to the first object with a value of name
.
Objects can also be other objects. The screenshot below shows a nested object which is 3 levels deep:
The schema produced is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"name":"nestedObject",
"params":[{
"name":"OBJ1",
"type":"6",
"objects":[{
"name":"OBJ2",
"type":"6",
"objects":[{
"name":"OBJ3",
"type":"6",
"objects":[{
"name":"NAME",
"type":"1"
}]
}]
}]
}]
}
The input field for the name attribute becomes:
<input type="text" class="form-control" id="params-name" name="params[0][objects][0][objects][0][objects][0][name]">
This is made possible by adding a data-path
attribute as shown earlier in the enumeration exaample:
<a class="icon-link px-5" id="add-objs" href="#" data-path="params[0][objects][0][objects][0]">Add Obj Property</a>
The complexity comes when parsing the form inputs to render it into JSON for submission to the backend. How does one parse the nested object structure? The form-to-object plugin was able to parse the nested form but it returns each nested object or enum value as a nested object:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"name":"nestedObject",
"params":{
"0":{
"name":"OBJ1",
"type":"6",
"objects":{
"0":{
"name":"OBJ2",
"type":"6",
"objects":{
"0":{
"name":"OBJ3",
"type":"6",
"objects":{
"0":{"name":"NAME","type":"1"}
}
}
}
}
}
}
}
}
Rather than further conversions on the backend, I decided to create my own custom parsing function after the plugin has parsed the form:
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
function convertObjects(params) {
for (let[key, value] of Object.entries(params)) {
if (value.type === "6") {
console.log("FOUND NESTED OBJ!!!!")
// value of form {name: "XXX", type: "6", objects: {}}
if (value.objects) {
tmpArray = []
for (let[key2, obj2] of Object.entries(value.objects)) {
// If enum present we need to flatten it
if (obj2.enum) {
let tmpEnum = []
for (let[id, enumVal] of Object.entries(obj2.enum)) {
tmpEnum.push(enumVal)
}
obj2.enum = tmpEnum
}
tmpArray[key2] = obj2
}
value.objects = tmpArray
}
// Check for nested objects
convertObjects(value.objects)
}
}
}
The function loops through each of the params attributes returned by the form plugin. If its of type object, it loops through each of its attributes and converts it into an array. To further check if the object contains another nested object, it performs recursion by calling itself again and further converting any nested object into an array, thereby creating the required data schema.
The example below shows a deeply nested form with nested objects each with its own attributes:
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
{
"params":[{
"name":"OBJ1",
"type":"6",
"objects":[{
"name":"OBJ2",
"type":"6",
"objects":[{
"name":"inner","type":"1"
}]
},
{
"name":"OBJ3",
"type":"6",
"objects":[{
"name":"INNER","type":"1"
},
{
"name":"INNER2",
"type":"1"
}]
}
]
}]
}
Given the above approach, I was able to create a form with arbitrary nested objects. The UI could do with some improvements on the alignment of the nested form items.
In summary, it’s possible to create a nested form with pure javascript without the need for using a framework. It requires some thought on the UI design and how to create the data structure efficiently.
In further posts, I will demonstrate how the form component fits into the application which uses Gemini API to create Function Calling in Gemini or Structured Output in Gemini.
H4PPY H4CK1NG !