UNPKG

react-model-view-viewmodel

Version:

A library for developing ReactJS applications using Model-View-ViewModel, inspired by .NET.

768 lines (767 loc) 31.2 kB
import type { FormField } from './FormField'; import type { IReadOnlyFormCollection } from './IReadOnlyFormCollection'; import { type IReadOnlyObservableCollection, type IObservableCollection } from '../collections'; import { type IObjectValidator, Validatable } from '../validation'; import { FormCollection } from './FormCollection'; /** * 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. * * @template TValidationError The concrete type for representing validation errors (strings, enums, numbers etc.). * * ---- * * @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. * * * {@linkcode Form} - root or parent node * * {@linkcode FormField} - leaf nodes, any changes to a field are propagated to the parent node, a {@linkcode Form}. * * {@linkcode FormCollection} - a collection of {@linkcode Form} instances, any change to a form collection is propagated to the parent node, a {@linkcode Form}. * * Any changes to a {@linkcode Form} is propagated to the {@linkcode FormCollection} * 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 {@linkcode Form} and * adding fields using {@linkcode withFields}. * * In case of large forms it can be beneficial to group fields into sections, * which are just different {@linkcode Form} composing a larger one. This can be * done using {@linkcode 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 * {@linkcode FormCollection} must be used to allow for items to be added and * removed. To conrol the interface for mutating the collection consider * extending {@link ReadOnlyFormCollection} instead. * * To add your own form collections to a form use {@linkcode withSectionsCollection} * as this will perform the same operation as {@linkcode 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 {@linkcode fields} and {@linkcode 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 * {@linkcode IValidatable} 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 {@linkcode validation} for more information. * * ---- * * @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. * * ```ts * 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 {@linkcode useViewModel} hook. * * ```tsx * 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 {@linkcode FormField}. * * ---- * * @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 {@linkcode FormField}, 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 {@linkcode FormField} to define this flag. * * ```ts * 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. * * ```ts * 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 {@linkcode 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 * {@linkcode IValidatable} object the general way to represent an error * is through a `string`. * * Validation errors can be represented in several ways such as `number`s * or error codes, `enum`s or even [literal types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html) * 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 `string`s, however this * can be changed through the {@linkcode TValidationError} generic parameter. * The snippet below illustrates using a [literal type](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html) * for validation results. * * ```ts * 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. * * ```ts * 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. * * ```ts * const form = new SpecificAppForm(); * * form.description * .validation * .add(field => !field.value ? 'Required' : null); * * form.count * .validation * .add(field => field.value <= 0 ? 'GreaterThanZero' : null); * ``` * * @see {@linkcode FormField} * @see {@linkcode ReadOnlyFormCollection} * @see {@linkcode FormCollection} */ export declare class Form<TValidationError = string> extends Validatable<TValidationError> { private readonly _fields; private readonly _sections; /** * Initializes a new instance of the {@linkcode Form} class. */ constructor(); /** * Gets the validation configuration for the form. Fields have their own individual validation config as well. * * @guidance Inline Configuration * * In most cases, validation rules do not change across the life-cycle of an entity thus it can be done in the * constructor to ensure it always configured and always the same. The library does make a distinction between * form structure and configuration, see the remarks on {@linkcode Form} for more information about this. * * The following sample uses the classic start/end date pair example as it includes both individual field * validation as well as a dependency between two fields. * * ```ts * class DatePairForm extends Form { * public constructor() { * super(); * * this.withFields( * this.startDate = new FormField<Date | null>({ * name: 'startDate', * initialValue: null, * validators: [required] * }), * this.endDate = new FormField<Date | null>({ * name: 'endDate', * initialValue: null, * validators: [ * required, * () => ( * // This validator only gets called if required passes * this.startDate.value && this.startDate.value < this.endDate.value! * ? 'End date must be after the start date' * : undefined * ) * ], * // If the start date changes, the end date may become invalid * validationTriggers: [ * this.startDate * ] * }) * ) * } * * public readonly startDate: FormField<Date | null>; * public readonly endDate: FormField<Date | null>; * } * * function required(formField: FormField<any>): string | undefined { * if ( * formField.value === null * || formField.value === undefined * || formField.value === '' * ) * return 'Required'; * else * return; * } * ``` * * This covers most cases, however there are scenarios where fields have interdependencies. For this, * validation can only be configured after both have been initialized. For instance, if start date * should show a validation error when it is past the end date, this can only be done by configuring * validation after both fields have been initialized. * * @guidance Configuring Validation * * All for components have expose a `validation` property allowing for validation to be configured at that level, * for more info check {@linkcode ReadOnlyFormCollection.validation} and {@linkcode FormField.validation}. * * Consider a form having three amount fields, two representing the price of two individual items and the third * representing the total as a way to check the inputs. * * Validation can be configured in two ways, one is by providing the validators and validation triggers to the * field when being initialized. The other is to configure the validation after form initialization. * * The end result is the same, both approaches configure the {@link IObjectValidator} for the form component * which can later be changed, more validators can be added or even removed. * * ```ts * class PriceForm extends Form { * public constructor() { * super(); * * this.withFields( * this.item1Price = new FormField<number | null>({ * name: 'item1Price', * initialValue: null * }), * this.item2Price = new FormField<number | null>({ * name: 'item2Price', * initialValue: null * }), * this.total = new FormField<number | null>({ * name: 'total', * initialValue: null * }) * ); * } * * public readonly item1Price: FormField<number | null>; * public readonly item2Price: FormField<number | null>; * public readonly total: FormField<number | null>; * } * * const form = new PriceForm(); * * form.total * .validation * .add(total => ( * total.value !== (form.item1Price.value || 0) + (form.item2Price.value || 0) * ? 'It does not add up' * : null * ) * .triggers * .add(form.item1Price) * .add(form.item2Price); * ``` * * The validity of the `total` field is based on the individual prices of each item, whenever one of them * changes we need to recheck the validity of the `total` thus they act as triggers. * * A rule of thumb is to treat validation triggers the same as a ReactJS hook dependency, if they are part * of the validator then they should also be triggers. * * @guidance Collection Item Triggers * * While the example above showcases how to configure validation, the scenario does not cover for having any * number of items whose total must add up. For cases such as these a collection would be needed. * * Any changes to the collection where items are added or removed, or when part of the individual items * change a validation should be triggered. Other examples for this use case are checking uniqueness of fields, * such as a code, in a list of items. * * The following snippet shows the form using a collection of items that have individual amounts that need * to add up to the specified total. For simplicity, {@linkcode FormCollection} is used directly for the items * instead of defining a custom collection, for more information see {@linkcode withSectionsCollection}. * * ```ts * class OrderCheckingForm extends Form { * public constructor() { * super(); * * this.withFields( * this.total = new FormField<number | null>({ * name: 'total', * initialValue: null * }) * ); * this.withSectionsCollection( * this.items = new FormCollection<OrderItem>() * ); * } * * public readonly total: FormField<number | null>; * * public readonly items: FormCollection<OrderItem>; * } * * class OrderItem extends Form { * public constructor() { * super(); * * this.withFields( * this.amount = new FormField<number | null>({ * name: 'amount', * initialValue: null * }) * ); * } * * public readonly amount: FormField<number | null, string>; * } * * const form = new OrderCheckingForm(); * * form.total * .validation * .add(total => { * const calcualted = form.items.reduce( * (total, item) => total + (item.amount.value || 0), * 0 * ); * * if (total.value !== calcualted) * return 'It does not add up'; * else * return; * }) * .triggers * .add([form.items, item => item.amount]); * ``` * * The {@linkcode WellKnownValidationTrigger} covers most, if not all, validation trigger scenarios, * each gets mapped to a concrete {@linkcode ValidationTrigger} and it should be a rare case where * a custom one should be implemented. Check the source code for samples on how to write your own * custom validation trigger. */ readonly validation: IObjectValidator<this, TValidationError>; /** * Gets the fields defined within the form instance. */ readonly fields: IReadOnlyObservableCollection<FormField<unknown, TValidationError>>; /** * Gets the sections defined within the form instance. */ readonly sections: IReadOnlyObservableCollection<Form<TValidationError>>; /** * Gets the sections collections defined within the form instance. */ readonly sectionsCollections: IReadOnlyObservableCollection<IReadOnlyFormCollection<Form<TValidationError>, TValidationError>>; /** * Indicates whether the form is valid. * * A form is only valid when all contained fields and sections are valid. */ get isValid(): boolean; /** * Indicates whether the form is invalid. * * A form is invalid when at least one contained field or section is invalid. */ get isInvalid(): boolean; /** * Resets the form, contained fields and sections to their initial configuration. * * Validation and other flags are reset, fields retain their current values. */ reset(): void; /** * Adds the provided fields to the form, returns an observable collection containing them. * * Any changes made to the returned collection is reflected in the form as well, added fields are * added, removed fields are removed, sorting or moving fields around are moved in the form as well. * @param fields The fields to add to the form. * @returns Returns a collection containing the provided fields. The form reacts to changes made in * the returned collection always keeping in sync. * * @guidance Defining a Form * * This method adds the provided fields to the form, a necessary step to watch the form fields for * changes as well as their validity. The {@linkcode isValid} and {@linkcode isInvalid} properties * are dependent on the fields. * * ```ts * class MyForm extends Form { * public constructor() { * super(); * * this.withFields( * this.name = new FormField({ * name: 'name', * initialValue: '' * }) * ); * } * * public readonly name: FormField<string>; * } * ``` * * @see {@linkcode withSections} * @see {@linkcode withSectionsCollection} */ protected withFields(...fields: readonly FormField<any, TValidationError>[]): IObservableCollection<FormField<any, TValidationError>>; /** * Adds the provided sections to the form, returns an observable collection containing them. * * Any changes made to the returned collection is reflected in the form as well, added sections are * added, removed sections are removed, sorting or moving sections around are moved in the form as well. * @param sections The sections to add to the form. * @returns Returns a collection containing the provided sections. The form reacts to changes made in * the returned collection always keeping in sync. * * @guidance Splitting a Form into Sections * * In some cases the form that is being edited is rather large and having all fields put together would * make the code hard to follow. It is natural to want to group fields together in such cases as it * provides a way to clearly group related fields together and even reuse parts of a form. * * Adding sections to a form is done similar to how fields are done, any form section is itself a form. * * ```ts * class MyForm extends Form { * public constructor() { * super(); * * this.withSections( * this.first = new MyFirstSection(), * this.second = new MySecondSection() * ); * } * * public readonly first: MyFirstSection; * * public readonly second: MySecondSection; * } * * class MyFirstSection extends Form { * // ... * } * * class MySecondSection extends Form { * // ... * } * ``` * * This will propagate any changes from the individual sections to the form itself making it easy * to check the validity of the entire form. * * @see {@linkcode withFields} * @see {@linkcode withSectionsCollection} */ protected withSections(...sections: readonly Form<TValidationError>[]): FormCollection<Form<TValidationError>, TValidationError>; /** * Adds the provided sections collection to the form, this is similar to the {@linkcode withSections} method, * the difference being that a custom collection can be added for better handling the addition and removal * of sections as well as keeping a clean interface. * * Any changes made to the returned collection is reflected in the form as well, added sections are * added, removed sections are removed, sorting or moving sections around are moved in the form as well. * * @param sectionsCollection The sections collection to add. * @returns Returns the provided sections collection. * * @guidance Collections of Editable Items * * One usecase for sections is to split the form up and group fields together to make it easier to understand * and maintain. * * The other usecase is for more complex forms where there are lists of items that themselves are editable, but * also the list itself, items can be added or removed. Things like a todo list, or tables with a number of fields. * * For full control on how items are added to the collection, extend from {@linkcode ReadOnlyFormCollection}, this * requires that methods for adding and removing items need to be defined, all standard mutating methods are available * as protected from the base class. * * ```ts * class ToDoList extends Form { * public constructor() { * super(); * * this.withFields( * this.name = new FormField({ * name: 'name', * initialValue: '' * }) * ) * * this.withSectionsCollection( * this.items = new ToDoItemsCollection() * ); * } * * public readonly name: FormField<string>; * * public readonly items: ToDoItemsCollection * } * * class ToDoItemsCollection extends ReadOnlyFormCollection<ToDoItem> { * public add(): ToDoItem { * const item = new ToDoItem(); * this.push(item); * * return item; * } * * public remove(item: ToDoItem): void { * const itemIndex = this.indexOf(item); * if (itemIndex >= 0) * this.splice(itemIndex, 1); * } * } * * class ToDoItem extends Form { * public constructor() { * super(); * * this.withFields( * this.description = new FormField({ * name: 'name', * initialValue: '' * }) * ); * } * * public readonly description: FormField<string>; * } * ``` * * @see {@linkcode withFields} * @see {@linkcode withSections} */ protected withSectionsCollection(sectionsCollection: IReadOnlyFormCollection<Form<TValidationError>, TValidationError>): IReadOnlyFormCollection<Form<TValidationError>, TValidationError>; /** * Invoked when a field's properies change, this is a plugin method through which notification propagation can be made with ease. */ protected onFieldChanged(field: FormField<unknown, TValidationError>, changedProperties: readonly (keyof FormField<unknown, TValidationError>)[]): void; /** * Invoked when a section's properies change, this is a plugin method through which notification propagation can be made with ease. */ protected onSectionChanged(section: Form<TValidationError>, changedProperties: readonly (keyof Form<TValidationError>)[]): void; /** * Invoked when a section's properies change, this is a plugin method through which notification propagation can be made with ease. */ protected onSectionsCollectionChanged(sectionsCollection: IReadOnlyFormCollection<Form<TValidationError>, TValidationError>, changedProperties: readonly (keyof IReadOnlyFormCollection<Form<TValidationError>, TValidationError>)[]): void; /** * Invoked when the current instance's properties change, this is a plugin method to help reduce validations when changes do not * have an effect on validation. * * @returns Returns `true` if a validation should be triggered for the given changed properties; otherwise `false`. */ protected onShouldTriggerValidation(changedProperties: readonly (keyof this)[]): boolean; }