Skip to content

ToDo List App Tutorial Part 7: Adding Form Validation

Andrei Fangli edited this page Jul 4, 2021 · 1 revision

With ToDo List App Tutorial Part 6: Deleting ToDo Items completed, we can now fully manage our list of todo items. We can view them, add items, edit items, progress and delete items.

One issue we have is that we can create todo items that have a blank description. We are going to resolve this by adding form validation by using registerValidators. We can do this in one go when we assign the field as the result of an assignment is the value that is being assigned, in our case, the field.

import type { IFormFieldViewModel } from "react-model-view-viewmodel";
import { FormFieldViewModel, registerValidators } from "react-model-view-viewmodel";
import { FormFieldCollectionViewModel } from "react-model-view-viewmodel";
import { ToDoItemState } from "../models/to-do-item-state";

export class ToDoItemFormViewModel extends FormFieldCollectionViewModel {
    public constructor() {
        super();
        registerValidators(this.description = this.addField("Description", ""), [required]);
        registerValidators(this.state = this.addField("State", ToDoItemState.ToDo), [required]);
    }

    public readonly description: FormFieldViewModel<string>;

    public readonly state: FormFieldViewModel<ToDoItemState>;
}

function required(field: IFormFieldViewModel<any>): string | undefined {
    if (field === null || field === undefined || (typeof field.value === "string" && field.value.length === 0))
        return `${field.name} field is required.`;
    else
        return undefined;
}

Next, we need to watch the form for changes. Each form field collection exposes isValid and isInvalid properties that update when registered fields get validated (or invalidated). Same as before, we will use watchViewModel so that our component re-renders when the form changes. We'll just add the following line on both forms.

watchViewModel(viewModel.form);

On the form itself we will display errors if the field became invalid. The pattern for inputs becomes even more clear.

export function ToDoItemForm({ form }: IToDoItemFormProps): JSX.Element {
    watchViewModel(form.description);
    watchViewModel(form.state);

    const descriptionChangedCallback: ChangeEventHandler<HTMLInputElement> = useCallback(
        (event) => { form.description.value = event.target.value; },
        [form.description]
    );

    const stateChangedCallback: ChangeEventHandler<HTMLSelectElement> = useCallback(
        (event) => { form.state.value = Number(event.target.value); },
        [form.state]
    );

    return (
        <div className="form">
            <div className="form-group">
                <label htmlFor={form.description.name}>{form.description.name}</label>
                <input id={form.description.name} name={form.description.name} value={form.description.value} onChange={descriptionChangedCallback} />
                {form.description.isInvalid && <div>{form.description.error}</div>}
            </div>
            <div className="form-group">
                <label htmlFor={form.state.name}>{form.state.name}</label>
                <select id={form.state.name} name={form.state.name} value={form.state.value} onChange={stateChangedCallback}>
                    <option value={ToDoItemState.ToDo}>To do</option>
                    <option value={ToDoItemState.InProgress}>Doing</option>
                    <option value={ToDoItemState.Done}>Done</option>
                </select>
                {form.state.isInvalid && <div>{form.state.error}</div>}
            </div>
        </div>
    );
}

Obviously, there's a lot more that we can do with validation, this is just a basic example that can go a very long way. Be sure the check the API for more information, you can configure validators for IReadOnlyObservableCollections as well.

Validators are registered per fields, for more complex scenarios where the validity of one field is dependent on the value of another (e.g.: the value of field A has to be greater than the value of field B) we can register multiple validator callbacks, one for checking whether we have a value and one for checking the values.

class extends FormFieldCollectionViewModel {
    public constructor() {
        super();
        this.fieldA = this.addField<number | undefined>("FieldA", undefined);
        this.fieldB = this.addField<number | undefined>("FieldB", undefined);

        registerValidators(this.fieldA, [required]);
        registerValidators(this.fieldB, [required, () => (this.fieldB.value || 0) < (this.fieldA.value || 0) ? "FieldB must have a value greater or equal to FieldA." : undefined]);
    }

    public readonly fieldA: FormFieldViewModel<number | undefined>;

    public readonly fieldB: FormFieldViewModel<number | undefined>;
}

function required(field: IFormFieldViewModel<any>): string | undefined {
    if (field === null || field === undefined || (typeof field.value === "string" && field.value.length === 0))
        return `${field.name} field is required.`;
    else
        return undefined;
}

This almost works, FieldB will validate whenever its value changes, but the validity of FieldB is dependent on FieldA. Whenever we change the latter we need to trigger a validation on the former. This can be done though IValidationConfig that allows us to configure additional validation triggers. Only the target is being validated, but whenever the target or any of the triggers change the field is revalidated.

registerValidators(
    {
        target: this.fieldB,
        triggers: [this.fieldA]
    },
    [required, () => (this.fieldB.value || 0) < (this.fieldA.value || 0) ? "FieldB must have a value greater or equal to FieldA." : undefined]
);

We will conclude the tutorial with ToDo List App Tutorial Part 8: Adding a Search Bar for ToDo Items.

Clone this wiki locally