Model form

Here we'll talk about the content of the @banquette/model-form package.

Its content is at the crossroad between the @banquette/model and @banquette/form packages. The goal of this package is to avoid making them inter-dependent.

You'll find two things in here:

  • A form transformer and its decorators you can use to convert a model into a form and vice-versa,
  • A service to keep a model and form synchronized: FormModelBinder.

NOTE

To read this section you must be familiar with the concepts of model, transformer and form, if you're not, please read the corresponding chapters first.

Form transformer

The form transformer converts a model into a FormObject and vice-versa. First you have to define a model class:

class Article {
    public id: number;
    public title: string;
    public content: string;
}

On which you add a @Form() decorator on the properties you want in your form:

import { Form } from '@banquette/model-form';

class Article {
    public id: number;
    
    @Form()
    public title: string;
    
    @Form()
    public content: string;
}

Then you call the TransformService with an instance of your model, that will return a FormObject:

import { Injector } from '@banquette/dependency-injection';
import { TransformService } from '@banquette/model';
import { FormTransformerSymbol } from '@banquette/model-form';

const form = Injector.Get(TransformService).transform(new Article(), FormTransformerSymbol).result;

NOTE

Please note the .result at the end. Remember that transform() returns a TransformResult and not the result itself directly.


Handling relations

Let's say our Article model contains a property category which corresponds to another model:

import { Form } from '@banquette/model-form';

class Category {
    public name: string;
}

class Article {
    public id: number;

    @Form()
    public title: string;

    @Form()
    public content: string;
    
    public category: Category;
}

How to integrate it into the form?

From what we've just seen, you may want to do:

@Form()
public category: Category;

While this is not wrong at all, you have to understand what it does.

@Form() will create a FormControl, so when you do this you will create a single form component called category at the root of the form.

This FormControl will contain whatever value you put in the category attribute of your model (in this case a Category instance).

WARNING

By doing this you will not by able to edit the name of the category, because the Category instance is just a value for the form, not a FormObject.

You could totally do this if you have for example a <select> in your form to select a category.


FormObject

If you want to edit the content of the Category, you need to generate a FormObject.

To do this you have to add two things:

  • A FormObject transformer as argument to @Form(),
  • A @Relation decorator to define the type of the related model. This is necessary because the TypeScript type is not available at runtime.
import { Relation } from '@banquette/model';
import { Form, FormObject } from '@banquette/model-form';

class Article {
    public id: number;

    @Form()
    public title: string;

    @Form()
    public content: string;
    
    @Relation(Category)
    @Form(FormObject())
    public category: Category;
}

WARNING

Please beware of the import statement. We import FormObject from @banquette/model-form and not @banquette/form.

FormObject here is a transformer, not a form component.


FormArray

If you have an array of values, that's exactly the same principle:

import { Form, FormArray } from '@banquette/model-form'; // Again, note the import

class Article {
    @Form()
    public title: string;
    
    @Form(FormArray())
    public tags: string[];
}

Here we give a FormArray transformer to @Form() so a FormArray component will be generated.

FormArray() takes a transformer as input (by default set to FormControl()) and will apply it to each item in the array.

This means if the value of tags is: ['a', 'b', 'c'], three FormControl will be created and added as child to the FormArray, having the value 'a', 'b' and 'c' respectively.

If your tags are a separate entity, the exact same logic as previously seen with FormObject applies. You only need the @Relation decorator if you want to edit the content of the tags.

Let's see some examples:

class Tag {
    @Form()
    public name: string;
    
    @Form()
    public color: string;
}

/**
 * First example:
 * 
 * We only define a FormArray() so the Tag instances 
 * will be stored as is in the FormArray.
 */
class Article {
    @Form(FormArray())
    public tags: Tag[];
}

/**
 * Second example:
 * 
 * With a FormObject inside the FormArray,
 * each tag will be transformed into a FormObject
 * containing 2 controls: "name" and "color".
 */
class Article {
    @Relation(Tag)
    @Form(FormArray(FormObject()))
    public tags: Tag[];
}

Both examples are perfectly valid, it depends on what you want to do.


FormControl

There is also a FormControl() transformer which is implicit most of the time. But you can use it if you need to transform the value from the model to the form.

Below are some commented examples:

import { Form, FormControl } from '@banquette/model-form';

class Article {
    // @Form() takes a transformer as parameter.
    // Its default value is FormControl().
    // So doing @Form() is the same as doing @Form(FormControl()).
    @Form()
    public title: string;

    // This does exactly the same thing as above.
    @Form(FormControl())
    public content: string;
    
    // FormControl() also takes a transformer as parameter.
    // Here we transform the Date object into a string when transforming into a FormControl.
    @Form(FormControl(DateToString()))
    public creationDate: Date;
}

TIP

By default, FormControl() has a Raw() transformer assigned, so it does nothing to the value.

Form model binder

The FormModelBinder is a service that keeps in sync a model and a form. Let's take the following models as example:

class Category {
    @Form()
    public name: string;
}

class User {
    @Form()
    public firstName: string;

    @Form()
    public lastName: string;

    @Form()
    public email: string;

    @Relation(Category)
    @Form(FormObject())
    public category: Category;
}

As we've just seen, you could create a form from this model by calling the TransformService:

const form = transformService.transform(new User, FormTransformerSymbol).result;

This works, but is a one shot. What if you change the model and want to form to replicate the change?

React to changes

The role of the FormModelBinder is to keep the model and the form in sync. If one change the other changes as well. To use it, inject it from the container:

// Get a binder. It's a module so you'll get a new instance each time you inject it.
const binder = Injector.Get(FormModelBinder);

Then simply call the bind function to bind a form and a model together:

// Create our model
let user = new User();  

// Create the corresponding form
const form = transformService.transform(user, FormTransformerSymbol).result;

// Bind them together
// Please note the return value.
user = binder.bind(user, form);

TIP

The bind method returns a "proxified" model you must use so your changes are detected.

Now every change you make on the model will be reflected in the form, including creating sub models, at any depth.

WARNING

Each binder can only handle 1 form instance, so inject it for each time you want to synchronize with a form.

If you call bind multiple times on the same binder, only the last form will be synchronized.

In practice

Let see it in practice, using the User class we defined above:

let user = new User();
const form = transformService.transform(user, FormTransformerSymbol);

user = binder.bind(user, form);

We can update the model:

// Important to use the proxy here.
user.firstName = 'Paul';

console.log(form.value);
// {firstName: 'Paul'}

Or the form:

form.get('lastName').setValue('Toy'); 

console.log(user);
// User {firstName: 'Paul', lastName: 'Toy'}

At this point the form doesn't contain a child category. To add it, we just have to modify the model:

user.category = new Category();
user.category = 'Category 1';

console.log(form.value); 
// {firstName: 'Paul', lastName: 'Toy', category: {name: 'Category 1'}}

It works with removal too:

delete user.category;

// or

user.category = null;

console.log(form.value);
// {firstName: 'Paul', lastName: 'Toy'}

The form doesn't contain a category child anymore.


Limitations

There is no limitation in the direction model => form, you can have a model as complex as you want, with as many levels as you want.

But there is a limitation in the direction form => model: only values can be updated.

This means you can't mutate the form structure and expect the model to create new properties. This is by design, because that's the role of the model to define the shape of the data, not the form's.

You can see the form as a way to allow the end-user to edit the values of the model.

You may wonder about repeatable fields, but that's the same. For example, for a dynamic array of tags, you could do:

Models definition

export class Tag {
    @Form() public name: string;
    @Form() public color: string;
    
    public constructor(name: string, color: string) {
        this.name = name;
        this.color = color;
    }
}

export class User {
    @Relation(Tag)
    @Form(FormArray(FormObject()))
    public tags: Tag[] = [];
}

Form creation

let user = new User();
const form = transformService.transform(user, FormTransformerSymbol);

user = binder.bind(user, form);

Now to create a new tag, just edit the model:

user.tags.push(new Tag('new tag', '#00ff00'));

If you can edit the form, you can edit the model, because you have to get the two values together to call bind(). Mutating the model through the form really looks like an anti-pattern.

TIP

In fact, the form will actually throw a UsageException if you try to mutate it manually while bound to a model.