Skip to content
github-actions[bot] edited this page Dec 3, 2024 · 3 revisions
API / Form<TValidationError> class

Represents a form for which both fields and sections can be configured. Form sections are forms themselves making this a tree structure where fields represent leaves and sections are parent nodes.

Extends Validatable<TValidationError>.

class Form<TValidationError = string>
    extends Validatable<TValidationError>

Source reference: src/forms/Form.ts:351.

Generic Parameters

  • TValidationError - The concrete type for representing validation errors (strings, enums, numbers etc.).

    Default value: string.

Description

Any change within the tree can be propagated to the root as, for instance, the validity of a node is determined by its own valid state plus the valid states of its descendants. If a field is invalid, the entire form is invalid, if a section is invalid, the entire form is invalid.

More flags and properties can be added to forms through inheritance, the model provides the bare minimum that would be required for any application. For instance, an isTouched flag can be added to fields, however this does not need to be propagated to the root. On the other hand, a hasChanged feature can be added on fields, but this should propagate to the root in order to know if the form itself has changes.

The design is mean to be extensible with the idea that each application would make its own customizations through inheritance (e.g.: declaring a MyAppForm and a MyAppFormModel). While it can be a bit redundant if no extra features are added, but it prepares the code in case any flag will be added in the future, the effort is small and covers for any scenario in the future.

The validation error defaults to string, however this can be anything, enums, numbers, specific strings and so on. This is to enable type-safety at the validation level and not force a particular approach. In most cases, a string will do, but if there is need for something else, the option is available as well.

Remarks

The library makes a distinction between form definition and form configuration.

Form definition relates to the structure of the form, what fields and what type of value they handle along side any sections or collections of editable items the form can handle.

Large forms can be broken down into sections to group relevant fields together, in essence, it is still one form, however the object model allows for an easier navigation and understanding of the form structure.

Form configuration relates to form validation and field locking. In more complex scenarios, an edit form may have different validation rules depending on the underlying entity state.

For instnace, when placing orders in an online shop, the respective order goes through a number of states and some fields are editable dependent on that.

The configuration does not change the state, the form still looks more or less the same, but the way fields behave is different. Some fields become required or have different validation rules while other can become locked and are no longer editable.

Form Structure and Change Propagation

Forms have a hierarchical structure comprising of fields and sections which are forms themselves. This allows for both simple and complex form definitions through the same model.

Any form contrains a collection of fields and a collection of sections, however propagation has an additional level of sections collections. Any form is a parent node while fields are leaves in the tree structure with propagation generally going bottom-up.

Any changes to a Form<TValidationError> is propagated to the FormCollection<TForm, TValidationError> to which it was added. With this, extensions and validation can be added at any level, from fields, to forms and form collections themselves.

For simple cases, defining a form is done by extending a Form<TValidationError> and adding fields using Form.withFields.

In case of large forms it can be beneficial to group fields into sections, which are just different Form<TValidationError> composing a larger one. This can be done using Form.withSections.

For more complex cases where there are collections of forms where items can be added and removed, and each item has its own set of editable fields, a FormCollection<TForm, TValidationError> must be used to allow for items to be added and removed. To conrol the interface for mutating the collection consider extending ReadOnlyFormCollection<TForm, TValidationError> instead.

To add your own form collections to a form use Form.withSectionsCollection as this will perform the same operation as Form.withSections only that you have control over the underlying form collection. Any changes to the collection are reflected on the form as well.

All fields and sections that are added with any of the mentioned methods are available through the Form.fields and Form.sections properties.

Validation

Validation is one of the best examples for change propagation and is offered out of the box. Whenever a field becomes invalid, the entire form becomes invalid.

This applies to form sections as well, whenever a section collection is invalid, the form (parent node) becomes invalid, and finally, when a form becomes invalid, the form collection it was added to also becomes invalid.

With this, the propagation can be seen clearly as validity is determined completely by the status of each component of the entire form, from all levels. Any change in one of the nodes goes all the way up to the root node making it very easy to check if the entire form is valid or not, and later on checking which sections or fields are invalid.

Multiple validators can be added and upon any change that is notified by the target invokes them until the first validator returns an error message. E.g.: if a field is required and has 2nd validator for checking the length of the content, the 2nd validator will only be invoked when the 1st one passes, when the field has an actual value.

This allows for granular validation messages as well as reusing them across IValidatable<TValidationError> objects.

For more complex cases when the validity of one field is dependent on the value of another field, such as the start date/end date pair, then validation triggers can be configured so that when either field changes the validators are invoked. This is similar in a way to how dependencies work on a ReactJS hook.

All form components have a validation property where configuraiton can be made, check Form.validation for more information.

Constructors

Properties

  • readonly fields - Gets the fields defined within the form instance.
  • override isInvalid - A flag indicating whether the object is invalid.
  • override isValid - A flag indicating whether the object is valid.
  • readonly sections - Gets the sections defined within the form instance.
  • readonly sectionsCollections - Gets the sections collections defined within the form instance.
  • readonly validation - Gets the validation configuration for the form. Fields have their own individual validation config as well.
  • inherited error - Gets or sets the error message when the object is invalid.
  • inherited propertiesChanged - An event that is raised when one or more properties may have changed.

Methods

  • reset - Resets the form, contained fields and sections to their initial configuration.
  • protected onFieldChanged - Invoked when a field's properies change, this is a plugin method through which notification propagation can be made with ease.
  • protected onSectionChanged - Invoked when a section's properies change, this is a plugin method through which notification propagation can be made with ease.
  • protected onSectionsCollectionChanged - Invoked when a section's properies change, this is a plugin method through which notification propagation can be made with ease.
  • protected onShouldTriggerValidation - Invoked when the current instance's properties change, this is a plugin method to help reduce validations when changes do not
  • protected withFields - Adds the provided fields to the form, returns an observable collection containing them.
  • protected withSections - Adds the provided sections to the form, returns an observable collection containing them.
  • protected withSectionsCollection - Adds the provided sections collection to the form, this is similar to the Form.withSections method,

Inheritance Hierarchy

Guidance: Define a Form

To define a form with all related fields we need to extend from the base class and then declare the fields it contains and add them to the form.

We can do this in one expression, we assign each individual field to properties to easily access them later on.

export class MyForm extends Form {
  public constructor() {
    super();

    this.withFields(
      this.name = new FormField<string>({
        name: 'name',
        initialValue: '',
        validators: [field => field.value === '' ? 'Required' : null]
      }),
      this.description = new FormField<string>({
        name: 'description',
        initialValue: ''
      })
    );
  }

  public readonly name: FormField<string>;

  public readonly description: FormField<string>;
}

All forms are view models as well, we can create an instance and watch it for changes using the useViewModel hook.

function MyFormComponent(): JSX.Element {
  const myForm = useViewModel(MyForm);

  return (
    <>
      <input
        value={myForm.name.value}
        onChange={event => myForm.name.value = event.target.value} />
      <input
        value={myForm.description.value}
        onChange={event => myForm.description.value = event.target.value} />
    </>
  )
}

Ideally, input binding is done through a specific component that handles a specific type of input (text, numbers dates etc.) with appropriate memoization.

For more information about input binding see FormField<TValue, TValidationError>.

Guidance: Field Changes Proparagation

One of the big features of this library is extensibility, especially when it comes to forms.

It is very easy to extend a FormField<TValue, TValidationError>, however that is not always enough. Some extensions apply to the form as a whole, changes at the field level propagate to the form that contains them.

One such example is adding a has changes feature which can be used as a navigation guard on edit pages. If there are no changes, then there is no need to prompt the user.

Whether a form has changed is determined at the field level, if there is at least one field that changed then we can say that the form indeed has changes.

First, we will define a custom FormField<TValue, TValidationError> to define this flag.

class MyCustomFormField<TValue> extends FormField<TValue> {
  public get value(): TValue {
    return super.value;
  }

  public set value(value: TValue) {
    super.value = value;
    this.notifyPropertiesChanged('hasChanged');
  }

  public get hasChanged(): boolean {
    return this.initialValue !== this.value;
  }
}

We can further improve on the code above, even extend the field config to ask for an equality comparer in case we are dealing with complex types, but for this example it will do.

We need to define a custom form as well which will take into account the newly defined flag on our custom field. Whenever we are notified that hasChanged may have changed (no pun intended), we will propagate this on the form itself.

class MyCustomForm extends Form {
  public readonly fields: IReadOnlyObservableCollection<MyCustomFormField<unknown>>;

  protected withFields(
    ...fields: readonly MyCustomFormField<any>[]
  ): IObservableCollection<MyCustomFormField<any>> {
    return super.withFields.apply(this, arguments);
  }

  public get hasChanges(): boolean {
    return this.fields.some(field => field.hasChanged);
  }

  protected onFieldChanged(
    field: MyCustomFormField<unknown>,
    changedProperties: readonly (keyof MyCustomFormField<unknown>)[]
  ): void {
    if (changedProperties.includes('hasChanged'))
      this.notifyPropertiesChanged('hasChanges');
  }
}

This example only covers field extension propagation, forms can contain sub-forms, or sections, which they too can propagate changes. For more information about this check Form.sections.

Sections are part of complex forms and more than enough applications will not even need to cover for this, however keep in mind that the library provides easy extension throughout the form definition including complex scenarios like lists or tables of editable items.

Guidance: Specific Validation Errors

Generally, when validating a field or perform validation on any type of IValidatable<TValidationError> object the general way to represent an error is through a string.

Validation errors can be represented in several ways such as numbers or error codes, enums or even literal types which are more or less predefined strings.

Some offer more freedom as to how errors are represented, while others are more restrictive on one hand, but on the other provide type checking and intellisense support.

By default, validation errors are represented using strings, however this can be changed through the TValidationError generic parameter. The snippet below illustrates using a literal type for validation results.

type ValidationError = 'Required' | 'GreaterThanZero';

class AppForm extends Form<ValidationError> {
}

This is all, additionally an AppFormField can be defined as well to bind the generic parameter at the field level as well.

Defining a specific form is similar as in the first example, inherit from the newly defined AppForm class and define the fields.

class SpecificAppForm extends AppForm {
  constructor() {
    super();

    this.withFields(
      this.description = new FormField<string, ValidationError>({
        name: 'description',
        initialValue: ''
      }),
      this.count = new FormField<number, ValidationError>({
        name: 'count',
        initialValue: 0
      })
    )
  }

  public readonly description: FormField<string, ValidationError>;

  public readonly count: FormField<number, ValidationError>;
}

Finally, we get to configuring the form and adding validators. We can do this when we instantiate the fields as well. Each validator is bound to the specific ValidationResult type. Attempting to return something else would result in a compilation error.

const form = new SpecificAppForm();

form.description
  .validation
  .add(field => !field.value ? 'Required' : null);

form.count
  .validation
  .add(field => field.value <= 0 ? 'GreaterThanZero' : null);

See also

Clone this wiki locally