Skip to content

Commit

Permalink
Improved validator selector & added documentaton
Browse files Browse the repository at this point in the history
  • Loading branch information
Andrei15193 committed Oct 24, 2024
1 parent a099cbb commit 420af66
Show file tree
Hide file tree
Showing 13 changed files with 262 additions and 21 deletions.
198 changes: 196 additions & 2 deletions src/forms/Form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { IPropertiesChangedEventHandler } from '../viewModels';
import type { IReadOnlyFormCollection } from './IReadOnlyFormCollection';
import type { ReadOnlyFormCollection } from './ReadOnlyFormCollection';
import { type IReadOnlyObservableCollection, type IObservableCollection, type ICollectionChangedEventHandler, type ICollectionReorderedEventHandler, ObservableCollection, ReadOnlyObservableCollection } from '../collections';
import { type IValidatable, type IObjectValidator, Validatable, ObjectValidator } from '../validation';
import { FormField } from './FormField';
import { type IValidatable, type IObjectValidator, type WellKnownValidationTrigger, type ValidationTrigger, Validatable, ObjectValidator } from '../validation';
import { type IFormFieldConfig, FormField } from './FormField';
import { FormCollection } from './FormCollection';

/**
Expand Down Expand Up @@ -415,6 +415,200 @@ export class Form<TValidationError = string> extends Validatable<TValidationErro

/**
* 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.
*/
public readonly validation: IObjectValidator<this, TValidationError>;

Expand Down
5 changes: 4 additions & 1 deletion src/forms/FormField.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Validatable, type IValidator, type ValidatorCallback, type IObjectValidator, ObjectValidator, type WellKnownValidationTrigger, type ValidationTrigger, resolveAllValidationTriggers } from '../validation';
import type { Form } from './Form';
import { type IValidator, type ValidatorCallback, type IObjectValidator, type WellKnownValidationTrigger, type ValidationTrigger, Validatable, ObjectValidator, resolveAllValidationTriggers } from '../validation';

/**
* Represents the configuration of a field, this can be extended for custom fields to easily add more features.
Expand Down Expand Up @@ -192,6 +193,8 @@ export class FormField<TValue, TValidationError = string> extends Validatable<TV

/**
* Gets the validation configuration for the current field.
*
* @see {@linkcode Form.validation}
*/
public readonly validation: IObjectValidator<this, TValidationError>;

Expand Down
2 changes: 2 additions & 0 deletions src/forms/ReadOnlyFormCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export class ReadOnlyFormCollection<TForm extends Form<TValidationError>, TValid

/**
* Gets the validation configuration for the form. Fields have their own individual validation config as well.
*
* @see {@linkcode Form.validation}
*/
readonly validation: IObjectValidator<this, TValidationError>;

Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ export {
type IValidator, type ValidatorCallback,
type IReadOnlyObjectValidator, type IObjectValidator, type IValidationTriggersSet, IObjectValidatorConfig, ObjectValidator,

type WellKnownValidationTrigger, type ValidationTriggerSelector, ValidationTrigger,
type WellKnownValidationTrigger, type ValidationTriggerSelector, type ValidationTriggerSet,
ValidationTrigger,

type IViewModelChangedValidationTriggerConfig, ViewModelChangedValidationTrigger,
type ICollectionChangedValidationTriggerConfig, CollectionChangedValidationTrigger,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe('CollectionItemValidationTrigger', (): void => {
const validationTrigger = new CollectionItemValidationTrigger({
collection,
validationTriggerSelector({ viewModel }) {
return [viewModel];
return viewModel;
}
});
validationTrigger.validationTriggered.subscribe({
Expand Down
2 changes: 1 addition & 1 deletion src/validation/__tests__/MapItemValidationTrigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe('MapItemValidationTrigger', (): void => {
const validationTrigger = new MapItemValidationTrigger({
map,
validationTriggerSelector({ viewModel }) {
return [viewModel];
return viewModel;
}
});
validationTrigger.validationTriggered.subscribe({
Expand Down
2 changes: 1 addition & 1 deletion src/validation/__tests__/SetItemValidationTrigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe('SetItemValidationTrigger', (): void => {
const validationTrigger = new SetItemValidationTrigger({
set,
validationTriggerSelector({ viewModel }) {
return [viewModel];
return viewModel;
}
});
validationTrigger.validationTriggered.subscribe({
Expand Down
3 changes: 2 additions & 1 deletion src/validation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export {
} from './objectValidator';

export {
type WellKnownValidationTrigger, ValidationTriggerSelector, ValidationTrigger,
type WellKnownValidationTrigger, type ValidationTriggerSelector, type ValidationTriggerSet,
ValidationTrigger,

type IViewModelChangedValidationTriggerConfig, ViewModelChangedValidationTrigger,
type ICollectionChangedValidationTriggerConfig, CollectionChangedValidationTrigger,
Expand Down
11 changes: 9 additions & 2 deletions src/validation/triggers/ValidationTrigger.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { INotifyPropertiesChanged } from '../../viewModels';
import type { INotifyCollectionChanged, INotifyCollectionReordered, INotifySetChanged, INotifyMapChanged } from '../../collections';
import type { ValidationTriggerSelector } from './ValidationTriggerSelector';
import type { IObjectValidator } from '../objectValidator';
import { Form } from '../../forms';
import { type IEvent, EventDispatcher } from '../../events';

/**
Expand All @@ -9,6 +12,10 @@ import { type IEvent, EventDispatcher } from '../../events';
*
* @template TKey The type of keys the map contains.
* @template TItem The type of items the collection, set, or map contains.
*
* @see {@link Form.validation}
* @see {@link IObjectValidator}
* @see {@link ValidationTrigger}
*/
export type WellKnownValidationTrigger<TKey = unknown, TItem = unknown>
= INotifyPropertiesChanged
Expand All @@ -18,11 +25,11 @@ export type WellKnownValidationTrigger<TKey = unknown, TItem = unknown>
| INotifyMapChanged<unknown, unknown>
| [
INotifyMapChanged<TKey, TItem> & Iterable<[TKey, TItem]>,
(item: TItem) => readonly (WellKnownValidationTrigger | ValidationTrigger)[]
ValidationTriggerSelector<TItem>
]
| [
(INotifyCollectionChanged<TItem> | INotifySetChanged<TItem>) & Iterable<TItem>,
(item: TItem) => readonly (WellKnownValidationTrigger | ValidationTrigger)[]
ValidationTriggerSelector<TItem>
]

/**
Expand Down
19 changes: 18 additions & 1 deletion src/validation/triggers/ValidationTriggerSelector.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import type { IObjectValidator } from '../objectValidator';
import type { WellKnownValidationTrigger, ValidationTrigger } from './ValidationTrigger';
import { Form } from '../../forms';

/**
* Represents a callback selector for individual items when configuring collection-based triggers.
* @param object The item in the collection for whom to select the triggers.
* @returns Returns the validation triggers for the given object.
*
* @see {@linkcode WellKnownValidationTrigger}
*/
export type ValidationTriggerSelector<T> = (object: T) => readonly (WellKnownValidationTrigger | ValidationTrigger)[];
export type ValidationTriggerSelector<T> = (object: T) => ValidationTriggerSet;

/**
* Represents a single validation trigger or a range of validation triggers that should be
* configured for an individual target.
*
* @see {@linkcode Form.validation}
* @see {@linkcode IObjectValidator}
* @see {@linkcode WellKnownValidationTrigger}
*/
export type ValidationTriggerSet
= WellKnownValidationTrigger
| ValidationTrigger
| readonly (WellKnownValidationTrigger | ValidationTrigger)[];
2 changes: 1 addition & 1 deletion src/validation/triggers/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { type WellKnownValidationTrigger, ValidationTrigger } from './ValidationTrigger';
export type { ValidationTriggerSelector } from './ValidationTriggerSelector';
export type { ValidationTriggerSelector, ValidationTriggerSet } from './ValidationTriggerSelector';

export { type IViewModelChangedValidationTriggerConfig, ViewModelChangedValidationTrigger } from './ViewModelChangedValidationTrigger';
export { type ICollectionChangedValidationTriggerConfig, CollectionChangedValidationTrigger } from './CollectionChangedValidationTrigger';
Expand Down
28 changes: 19 additions & 9 deletions src/validation/triggers/resolveAllValidationTriggers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ValidationTriggerSet } from './ValidationTriggerSelector';
import { type WellKnownValidationTrigger, ValidationTrigger } from './ValidationTrigger';
import { resolveValidationTriggers } from './resolveValidationTriggers';

Expand All @@ -6,15 +7,24 @@ import { resolveValidationTriggers } from './resolveValidationTriggers';
* @param validationTriggers The well-known validation triggers to interpret.
* @returns Returns a set of concrete validation triggers that correspond to the given well-known ones.
*/
export function resolveAllValidationTriggers(validationTriggers: readonly (WellKnownValidationTrigger | ValidationTrigger)[]): readonly ValidationTrigger[] {
if (validationTriggers === null || validationTriggers === undefined || validationTriggers.length === 0)
export function resolveAllValidationTriggers(validationTriggers: ValidationTriggerSet): readonly ValidationTrigger[] {
if (validationTriggers === null || validationTriggers === undefined)
return [];
else if (isArray<WellKnownValidationTrigger | ValidationTrigger>(validationTriggers))
if (validationTriggers.length === 0)
return []
else
return validationTriggers.reduce(
(resolvedValidationTriggers, validationTrigger) => {
resolvedValidationTriggers.push(...resolveValidationTriggers(validationTrigger));
return resolvedValidationTriggers;
},
new Array<ValidationTrigger>()
);
else
return validationTriggers.reduce(
(resolvedValidationTriggers, validationTrigger) => {
resolvedValidationTriggers.push(...resolveValidationTriggers(validationTrigger));
return resolvedValidationTriggers;
},
new Array<ValidationTrigger>()
);
return resolveValidationTriggers(validationTriggers);
}

function isArray<T>(maybeArray: any): maybeArray is readonly T[] {
return Array.isArray(maybeArray);
}
Loading

0 comments on commit 420af66

Please sign in to comment.