diff --git a/src/components/input.tsx b/src/components/input.tsx deleted file mode 100644 index f7d5e6d..0000000 --- a/src/components/input.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { DetailedHTMLProps, InputHTMLAttributes, FocusEvent, FocusEventHandler } from 'react'; -import type { IFormFieldViewModel } from '../form-field-view-model'; -import React, { useEffect, useRef, useCallback, createRef } from 'react'; -import { useEvent } from '../hooks/use-event'; - -interface IHtmlElementProps extends HTMLInputElement { - focus(): void; - - blur(): void; -} - -/** Represents the Input component props, extends the HTML input props. - * @deprecated In future versions this component will be removed. This was only added to handle the {@link IFormFieldViewModel.isFocused}, however this is not something view models should handle. - */ -export interface IInputProps extends DetailedHTMLProps, HTMLInputElement> { - /** The field to bind the focus events to. */ - readonly field: IFormFieldViewModel; -} - -/** A helper component for binding the focus events to the field. - * @deprecated In future versions this component will be removed. This was only added to handle the {@link IFormFieldViewModel.isFocused}, however this is not something view models should handle. - * @template TValue The type of values the field contains. - */ -export function Input({ field, onBlur, onFocus, ...other }: IInputProps): JSX.Element { - const { current: input } = useRef(createRef()); - const isHandlingFocusEvent = useRef(false); - - const onFocusHandler: FocusEventHandler = useCallback( - (event: FocusEvent) => { - onFocus && onFocus(event); - if (!isHandlingFocusEvent.current) { - isHandlingFocusEvent.current = true; - try { - field.isFocused = true; - } - finally { - isHandlingFocusEvent.current = false; - } - } - }, - [input, onFocus] - ); - - const onBlurHandler: FocusEventHandler = useCallback( - (event: FocusEvent): void => { - onBlur && onBlur(event); - if (!isHandlingFocusEvent.current) { - isHandlingFocusEvent.current = true; - try { - field.isFocused = false; - } - finally { - isHandlingFocusEvent.current = false; - } - } - }, - [input, onBlur] - ); - - useEvent, readonly (keyof IFormFieldViewModel)[]>( - field && field.propertiesChanged, - (_, changedProperties) => { - if (!isHandlingFocusEvent.current && changedProperties.indexOf('isFocused') >= 0) - if (field.isFocused) - input.current?.focus(); - else - input.current?.blur(); - }, - [] - ); - - useEffect( - () => { - if (field.isFocused) - input.current?.focus(); - else - input.current?.blur(); - }, - [] - ); - - return ( - - ); -} \ No newline at end of file diff --git a/src/form-field-collection-view-model.ts b/src/form-field-collection-view-model.ts deleted file mode 100644 index 59c9d73..0000000 --- a/src/form-field-collection-view-model.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { IPropertiesChangedEventHandler } from './viewModels'; -import type { IObservableCollection } from './collections/observableCollections/IObservableCollection'; -import type { IReadOnlyObservableCollection } from './collections/observableCollections/IReadOnlyObservableCollection'; -import type { IValidatable } from './validation'; -import { ViewModel } from './viewModels'; -import { type IFormFieldViewModel, FormFieldViewModel } from './form-field-view-model'; -import { ObservableCollection } from './collections/observableCollections/ObservableCollection'; - -/** A set of form fields that can be used in generic parameter constraints. - * @template TFormFieldViewModel the type of fields the form contains. - */ -export type FormFieldSet> = { readonly [key: string]: TFormFieldViewModel; }; - -/** Represents a collection of fields. Typically, this is a enough to represent a form, however for a complex user form multiple such collections can be used as sections that make up the entire form. - * @template TFormFieldViewModel The type of fields the form collection contains, defaults to {@link FormFieldViewModel}. - */ -export abstract class FormFieldCollectionViewModel = FormFieldViewModel> extends ViewModel implements IValidatable { - /** - * Initializes a new form with the given fields. - * @template TFormFieldViewModel The type of field the form contains, defaults to {@link FormFieldViewModel}. - * @template TFormFields The set of fields present on the form. - * @param fields The form fields. - * @returns Returns a new instance of the {@link FormFieldCollectionViewModel} class having the provided fields. - */ - public static create, TFormFields extends FormFieldSet>(fields: TFormFields): FormFieldCollectionViewModel & TFormFields { - return new DynamicFormFieldCollectionViewModel(fields) as FormFieldCollectionViewModel & TFormFields; - } - - private _error: string | undefined; - private readonly _fields: IObservableCollection; - - /** Initializes a new instance of the {@link FormFieldCollectionViewModel} class. */ - public constructor() { - super(); - - const fieldChangedEventHandler: IPropertiesChangedEventHandler = { - handle: this.onFieldChanged.bind(this) - } - this._fields = new ObservableCollection(); - this._fields.collectionChanged.subscribe({ - handle(_, { addedItems: addedFields, removedItems: removedFields }) { - addedFields.forEach(addedField => addedField.propertiesChanged.subscribe(fieldChangedEventHandler)); - removedFields.forEach(removedField => removedField.propertiesChanged.unsubscribe(fieldChangedEventHandler)); - } - }) - } - - /** A collection containing the registered fields. */ - public get fields(): IReadOnlyObservableCollection { - return this._fields; - } - - /** A flag indicating whether the field is valid. Generally, when there is no associated error message and all registered fields are valid. */ - public get isValid(): boolean { - return this._error === undefined && this.fields.every(field => field.isValid); - } - - /** A flag indicating whether the field is invalid. Generally, when there is an associated error message or at least one registered field is invalid. */ - public get isInvalid(): boolean { - return this._error !== undefined || this.fields.some(field => field.isInvalid); - } - - /** An error message (or translation key) providing information as to why the field is invalid. */ - public get error(): string | null | undefined { - return this._error; - } - - /** An error message (or translation key) providing information as to why the field is invalid. */ - public set error(value: string | undefined) { - if (this._error !== value) { - this._error = value; - this.notifyPropertiesChanged('error', 'isValid', 'isInvalid'); - } - } - - /** - * Registers the provided fields. - * @param fields The fields to register. - */ - protected registerFields(...fields: readonly (TFormFieldViewModel | readonly TFormFieldViewModel[])[]): void { - const previousFieldCount = this._fields.length; - - const currentFieldCount = this._fields.push(...fields.reduce( - (reuslt, fieldsOrArrays) => { - if (Array.isArray(fieldsOrArrays)) - reuslt.push(...fieldsOrArrays) - else - reuslt.push(fieldsOrArrays as TFormFieldViewModel); - return reuslt - }, - [] - )); - - if (previousFieldCount !== currentFieldCount) - this.notifyPropertiesChanged('isValid', 'isInvalid'); - } - - /** - * Unregisters the provided fields. - * @param fields The previously registered fields. - */ - protected unregisterFields(...fields: readonly (TFormFieldViewModel | readonly TFormFieldViewModel[])[]): void { - let hasUnregisteredFields = false; - - const removeField = (field: TFormFieldViewModel) => { - const indexToRemove = this._fields.indexOf(field); - if (indexToRemove >= 0) { - this._fields.splice(indexToRemove, 1); - - hasUnregisteredFields = true; - } - } - - fields.forEach(fieldsOrArrays => { - if (Array.isArray(fieldsOrArrays)) - fieldsOrArrays.forEach(removeField) - else - removeField(fieldsOrArrays as TFormFieldViewModel); - }); - - if (hasUnregisteredFields) - this.notifyPropertiesChanged('isValid', 'isInvalid'); - } - - /** - * Called when one of the registered fields notifies about changed properties. - * @param field the field that has changed. - * @param changedProperties the properties that have changed. - */ - protected onFieldChanged(field: TFormFieldViewModel, changedProperties: readonly (keyof TFormFieldViewModel)[]): void { - if (changedProperties.indexOf('isValid') >= 0 || changedProperties.indexOf('isInvalid') >= 0) - this.notifyPropertiesChanged('isValid', 'isInvalid'); - } -} - -/** - * A helper class for creating forms, can be extended or reused to implement a similar feature to {@link FormFieldCollectionViewModel.create}. - * @template TFormFieldViewModel The type of fields the form collection contains, defaults to {@link FormFieldViewModel}. - * @template TFormFields the set of fields to register on the form. - */ -export class DynamicFormFieldCollectionViewModel, TFormFields extends FormFieldSet> extends FormFieldCollectionViewModel { - /** - * Initializes a new instance of the {@link DynamicFormFieldCollectionViewModel} class. - * @param fields The form fields. - */ - public constructor(fields: TFormFields) { - super(); - - const formFields: TFormFieldViewModel[] = []; - Object.getOwnPropertyNames(fields).forEach( - fieldPropertyName => { - const formField = fields[fieldPropertyName]; - formFields.push(formField); - Object.defineProperty( - this, - fieldPropertyName, - { - configurable: false, - enumerable: true, - writable: false, - value: formField - } - ); - } - ); - this.registerFields(...formFields); - } -} \ No newline at end of file diff --git a/src/form-field-view-model.ts b/src/form-field-view-model.ts deleted file mode 100644 index ff05656..0000000 --- a/src/form-field-view-model.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { INotifyPropertiesChanged } from './viewModels'; -import { type IReadOnlyValidatable, type IValidatable, type ValidatorCallback, type IValidationConfig, registerValidators } from './validation'; -import { ViewModel } from './viewModels'; - -/** Represents a form field interface exposing mixture of read-only and read-write properties in order to provide the minimum required set of properties that must be read-write while all other properties can only be read. - * @template TValue The type of values the field contains. - */ -export interface IFormFieldViewModel extends INotifyPropertiesChanged, IReadOnlyValidatable { - /** The name of the field. */ - readonly name: string; - - /** The initial value of the field. Useful in scenarios where the input should be highlighted if the field has changed. */ - readonly initialValue: TValue; - - /** The current value of the field. */ - value: TValue; - - /** A flag indicating whether the field has been touched. Useful for cases when the error message should be displayed only if the field has been touched. - * @deprecated In future versions this flag will be removed. While useful, not all forms require this. Fields can be extended with all the required custom flags as need for each application. - */ - isTouched: boolean; - - /** A flag indicating whether the field is focused. - * @deprecated In future versions this flag will be removed. The focus state of an input element should not be handled by a view model as this is a presentation concern. - */ - isFocused: boolean; -} - -/** Represents the initialization configuration for a {@link FormFieldViewModel}. - * @template TValue The type of values the field contains. - * @template TFormField The type of field validators require. - */ -export interface IFormFieldViewModelConfig = FormFieldViewModel> { - /** The name of the field. */ - readonly name: string; - /** The value of the field, defaults to {@link initialValue}. */ - readonly value?: TValue; - /** The initial value of the field. */ - readonly initialValue: TValue; - /** Optional, a validation config without the target as this is the field that is being initialized. */ - readonly validationConfig?: Omit, 'target'>; - /** Optional, a set of validators for the field. */ - readonly validators?: readonly ValidatorCallback[]; -} - -/** Represents a base form field, in most scenarios this should be enough to cover all necessary form requirements. - * @template TValue The type of values the field contains. - */ -export class FormFieldViewModel extends ViewModel implements IFormFieldViewModel, IValidatable { - private _name: string; - private _value: TValue; - private _initialValue: TValue; - private _isTouched: boolean; - private _isFocused: boolean; - private _error: string | undefined; - - /** Initializes a new instance of the {@link FormFieldViewModel} class. - * @param config The form field configuration. - */ - public constructor(config: IFormFieldViewModelConfig); - /** Initializes a new instance of the {@link FormFieldViewModel} class. - * @deprecated In future versions this constructor will be removed, switch to config approach. - * @param name The name of the field. - * @param initialValue The initial value of the field. - */ - public constructor(name: string, initialValue: TValue); - - public constructor(nameOrConfig: IFormFieldViewModelConfig | string, initialValue?: TValue) { - super(); - - if (typeof nameOrConfig === 'string') { - this._name = nameOrConfig; - this._value = initialValue!; - this._initialValue = initialValue!; - this._isTouched = false; - this._isFocused = false; - this._error = undefined; - } - else { - this._name = nameOrConfig.name; - this._value = 'value' in nameOrConfig ? nameOrConfig.value! : nameOrConfig.initialValue!; - this._initialValue = nameOrConfig.initialValue; - this._isTouched = false; - this._isFocused = false; - this._error = undefined; - - if (nameOrConfig.validators !== undefined && nameOrConfig.validators !== null) - registerValidators({ ...nameOrConfig.validationConfig, target: this }, nameOrConfig.validators); - } - } - - /** The name of the field. */ - public get name(): string { - return this._name; - } - - /** The name of the field. */ - public set name(value: string) { - if (this._name !== value) { - this._name = value; - this.notifyPropertiesChanged('name'); - } - } - - /** The initial value of the field. Useful in scenarios where the input should be highlighted if the field has changed. */ - public get initialValue(): TValue { - return this._initialValue; - } - - /** The initial value of the field. Useful in scenarios where the input should be highlighted if the field has changed. */ - public set initialValue(value: TValue) { - if (this._initialValue !== value) { - this._initialValue = value; - this.notifyPropertiesChanged('initialValue'); - } - } - - /** The current value of the field. */ - public get value(): TValue { - return this._value; - } - - /** The current value of the field. */ - public set value(value: TValue) { - if (this._value !== value) { - this._value = value; - this.notifyPropertiesChanged('value'); - } - } - - /** A flag indicating whether the field has been touched. Useful for cases when the error message should be displayed only if the field has been touched. - * @deprecated In future versions this flag will be removed. While useful, not all forms require this. Fields can be extended with all the required custom flags as need for each application. - */ - public get isTouched(): boolean { - return this._isTouched; - } - - /** A flag indicating whether the field has been touched. Useful for cases when the error message should be displayed only if the field has been touched. - * @deprecated In future versions this flag will be removed. While useful, not all forms require this. Fields can be extended with all the required custom flags as need for each application. - */ - public set isTouched(value: boolean) { - if (this._isTouched !== value) { - this._isTouched = value; - this.notifyPropertiesChanged('isTouched'); - } - } - - /** A flag indicating whether the field is focused. - * @deprecated In future versions this flag will be removed. The focus state of an input element should not be handled by a view model as this is a presentation concern. - */ - public get isFocused(): boolean { - return this._isFocused; - } - - /** A flag indicating whether the field is focused. - * @deprecated In future versions this flag will be removed. The focus state of an input element should not be handled by a view model as this is a presentation concern. - */ - public set isFocused(value: boolean) { - if (this._isFocused !== value) { - this._isFocused = value; - this.notifyPropertiesChanged('isFocused'); - } - } - - /** A flag indicating whether the field is valid. Generally, when there is no associated error message. */ - public get isValid(): boolean { - return this._error === undefined; - } - - /** A flag indicating whether the field is invalid. Generally, when there is an associated error message. */ - public get isInvalid(): boolean { - return this._error !== undefined; - } - - /** An error message (or translation key) providing information as to why the field is invalid. */ - public get error(): string | undefined { - return this._error; - } - - /** An error message (or translation key) providing information as to why the field is invalid. */ - public set error(value: string | undefined) { - if (this._error !== value) { - this._error = value; - this.notifyPropertiesChanged('error', 'isValid', 'isInvalid'); - } - } -} \ No newline at end of file diff --git a/src/forms/FormFieldViewModel.ts b/src/forms/FormFieldViewModel.ts new file mode 100644 index 0000000..2da480f --- /dev/null +++ b/src/forms/FormFieldViewModel.ts @@ -0,0 +1,79 @@ +import { Validatable, type IValidator, type ValidatorCallback, type IObjectValidator, ObjectValidator, type WellKnownValidationTrigger, type ValidationTrigger, resolveAllValidationTriggers } from '../validation'; + +export interface IFormFieldViewModelConfig { + readonly name: string; + readonly value?: TValue; + readonly initialValue: TValue; + + readonly validators?: readonly (IValidator, TValidationError> | ValidatorCallback, TValidationError>)[]; + readonly validationTriggers?: readonly (WellKnownValidationTrigger | ValidationTrigger)[]; +} + +export class FormFieldViewModel extends Validatable { + private _name: string; + private _value: TValue; + private _initialValue: TValue; + + public constructor({ name, initialValue, value = initialValue, validators = [], validationTriggers: validationTriggers = [] }: IFormFieldViewModelConfig) { + super(); + + this._name = name; + this._value = value; + this._initialValue = initialValue; + + this.validation = validators.reduce( + (objectValidator, validator) => objectValidator.add(validator), + new ObjectValidator({ + target: this, + shouldTargetTriggerValidation: (_, changedProperties) => { + return this.onShouldTriggerValidation(changedProperties); + } + }) + ); + + resolveAllValidationTriggers(validationTriggers).forEach(this.validation.triggers.add, this.validation.triggers); + } + + public get name(): string { + return this._name; + } + + public set name(value: string) { + if (this._name !== value) { + this._name = value; + this.notifyPropertiesChanged('name'); + } + } + + public get value(): TValue { + return this._value; + } + + public set value(value: TValue) { + if (this._value !== value) { + this._value = value; + this.notifyPropertiesChanged('value'); + } + } + + public get initialValue(): TValue { + return this._value; + } + + public set initialValue(value: TValue) { + if (this._initialValue !== value) { + this._initialValue = value; + this.notifyPropertiesChanged('initialValue'); + } + } + + public readonly validation: IObjectValidator; + + public reset(): void { + this.validation.reset(); + } + + protected onShouldTriggerValidation(changedProperties: readonly (keyof this)[]): boolean { + return changedProperties.some(changedProperty => changedProperty !== 'error' && changedProperty !== 'isValid' && changedProperty !== 'isInvalid'); + } +} \ No newline at end of file diff --git a/src/forms/FormSectionCollection.ts b/src/forms/FormSectionCollection.ts new file mode 100644 index 0000000..79501a2 --- /dev/null +++ b/src/forms/FormSectionCollection.ts @@ -0,0 +1,59 @@ +import type { IConfigurableFormSectionCollection, FormSectionSetupCallback } from "./IConfigurableFormSectionCollection"; +import type { FormViewModel } from "./FormViewModel"; +import { ObservableCollection } from "../collections"; + +export class FormSectionCollection, TValidationError = string> extends ObservableCollection implements IConfigurableFormSectionCollection { + private readonly _setupCallbacks: FormSectionSetupCallback[]; + + public constructor(sections?: Iterable) { + super(sections); + + this._setupCallbacks = []; + this.collectionChanged.subscribe({ + setupCallbacks: this._setupCallbacks, + handle(_, { addedItems: addedSections }) { + addedSections.forEach(addedSection => { + this.setupCallbacks.forEach(setupCallback => { + setupCallback(addedSection); + }); + }); + } + }); + this._setupSections(); + } + + public withItemSetup(setupCallback: FormSectionSetupCallback): this { + if (typeof setupCallback === 'function') { + this._setupCallbacks.push(setupCallback); + this.forEach(section => { + setupCallback(section); + }); + } + + return this; + } + + public withoutItemSetup(setupCallback: FormSectionSetupCallback): this { + if (typeof setupCallback === 'function') { + const setupCallbackIndex = this._setupCallbacks.indexOf(setupCallback); + if (setupCallbackIndex > 0) { + this._setupCallbacks.splice(setupCallbackIndex, 1); + this.forEach(section => section.reset()); + this._setupSections(); + } + } + + return this; + } + + public clearItemSetups(): void { + this._setupCallbacks.splice(0, this._setupCallbacks.length); + this.forEach(section => section.reset()); + } + + private _setupSections(): void { + this.forEach(section => { + this._setupCallbacks.forEach(setupCallback => setupCallback(section)); + }); + } +} diff --git a/src/forms/FormViewModel.ts b/src/forms/FormViewModel.ts new file mode 100644 index 0000000..9ed7fa3 --- /dev/null +++ b/src/forms/FormViewModel.ts @@ -0,0 +1,177 @@ +import type { IPropertiesChangedEventHandler } from '../viewModels'; +import type { IReadOnlyFormSectionCollection } from './IReadOnlyFormSectionCollection'; +import { type IReadOnlyObservableCollection, type IObservableCollection, type ICollectionChangedEventHandler, type ICollectionReorderedEventHandler, ObservableCollection, ReadOnlyObservableCollection } from '../collections'; +import { type IObjectValidator, Validatable, ObjectValidator } from '../validation'; +import { FormFieldViewModel } from './FormFieldViewModel'; +import { FormSectionCollection } from './FormSectionCollection'; + +export class FormViewModel extends Validatable { + private readonly _fields: AggregateObservableCollection>; + private readonly _sections: AggregateObservableCollection, IReadOnlyFormSectionCollection, TValidationError>>; + + public constructor() { + super(); + + this.validation = new ObjectValidator({ + target: this, + shouldTargetTriggerValidation: (_, changedProperties) => { + return this.onShouldTriggerValidation(changedProperties); + } + }); + this.fields = this._fields = new AggregateObservableCollection>(); + this.sections = this._sections = new AggregateObservableCollection, IReadOnlyFormSectionCollection, TValidationError>>(); + + const fieldChangedEventHandler: IPropertiesChangedEventHandler> = { + handle: this.onFieldChanged.bind(this) + }; + this.fields.collectionChanged.subscribe({ + handle(_, { addedItems: addedFields, removedItems: removedFields }) { + removedFields.forEach(removedField => { + removedField.propertiesChanged.unsubscribe(fieldChangedEventHandler); + removedField.reset(); + }); + addedFields.forEach(addedField => { + addedField.propertiesChanged.subscribe(fieldChangedEventHandler); + }); + } + }); + + const sectionChangedEventHandler: IPropertiesChangedEventHandler> = { + handle: this.onSectionChanged.bind(this) + }; + this.sections.collectionChanged.subscribe({ + handle(_, { addedItems: addedSections, removedItems: removedSections }) { + removedSections.forEach(removedSection => { + removedSection.propertiesChanged.unsubscribe(sectionChangedEventHandler); + removedSection.reset(); + }); + addedSections.forEach(addedSection => { + addedSection.propertiesChanged.subscribe(sectionChangedEventHandler); + }); + } + }); + } + + public readonly validation: IObjectValidator; + + public readonly fields: IReadOnlyObservableCollection>; + + public readonly sections: IReadOnlyObservableCollection>; + + public get isValid(): boolean { + return ( + super.isValid + && this.fields.every(field => field.isValid) + && this.sections.every(section => section.isValid) + ); + } + + public get isInvalid(): boolean { + return ( + super.isInvalid + || this.fields.some(field => field.isInvalid) + || this.sections.some(section => section.isInvalid) + ); + } + + public reset(): void { + this._sections.aggregatedCollections.forEach(sectionsCollection => { + sectionsCollection.clearItemSetups(); + }) + this.fields.forEach(field => field.reset()); + this.validation.reset(); + } + + protected withFields = FormFieldViewModel>(...fields: readonly TField[]): IObservableCollection { + const fieldsCollection = new ObservableCollection(fields); + this._fields.aggregatedCollections.push(fieldsCollection); + + return fieldsCollection; + } + + protected withSections = FormViewModel>(...sections: readonly TSection[]): FormSectionCollection { + return this.withSectionsCollection(new FormSectionCollection(sections)); + } + + protected withSectionsCollection, TSection extends FormViewModel = FormViewModel>(sectionsCollection: TSectionCollection): TSectionCollection { + this._sections.aggregatedCollections.push(sectionsCollection); + + return sectionsCollection; + } + + protected onFieldChanged(field: FormFieldViewModel, changedProperties: readonly (keyof FormFieldViewModel)[]) { + if (changedProperties.some(changedProperty => changedProperty === "isValid" || changedProperty === "isInvalid")) + this.notifyPropertiesChanged("isValid", "isInvalid"); + } + + protected onSectionChanged(field: FormViewModel, changedProperties: readonly (keyof FormViewModel)[]) { + if (changedProperties.some(changedProperty => changedProperty === "isValid" || changedProperty === "isInvalid")) + this.notifyPropertiesChanged("isValid", "isInvalid"); + } + + protected onShouldTriggerValidation(changedProperties: readonly (keyof this)[]): boolean { + return changedProperties.some(changedProperty => changedProperty !== 'error' && changedProperty !== 'isValid' && changedProperty !== 'isInvalid'); + } +} + +class AggregateObservableCollection = IReadOnlyObservableCollection> extends ReadOnlyObservableCollection { + public constructor() { + super(); + + this.aggregatedCollections = new ObservableCollection(); + + const collectionChangedEventHandler: ICollectionChangedEventHandler, TItem> = { + handle: (collection, { startIndex, addedItems, removedItems }) => { + for (let offset = 0, index = 0; index < this.aggregatedCollections.length; index++) + if (collection === this.aggregatedCollections[index]) { + this.splice(startIndex + offset, removedItems.length, ...addedItems); + offset += this.aggregatedCollections[index].length - (addedItems.length - removedItems.length); + } + else + offset += this.aggregatedCollections[index].length; + } + }; + + const collectionReorderedEventHandler: ICollectionReorderedEventHandler, TItem> = { + handle: (collection) => { + for (let offset = 0, index = 0; index < this.aggregatedCollections.length; offset += this.aggregatedCollections[index].length, index++) + if (collection === this.aggregatedCollections[index]) + this.splice(offset, collection.length, ...collection.toArray()); + } + }; + + this.aggregatedCollections.collectionChanged.subscribe({ + handle: (_, { startIndex, addedItems: addedCollections, removedItems: removedCollections }) => { + removedCollections.forEach(removedCollection => { + removedCollection.collectionReordered.unsubscribe(collectionReorderedEventHandler); + removedCollection.collectionChanged.unsubscribe(collectionChangedEventHandler); + }); + + addedCollections.forEach(addedCollection => { + addedCollection.collectionChanged.subscribe(collectionChangedEventHandler); + addedCollection.collectionReordered.subscribe(collectionReorderedEventHandler); + }); + + let offset = 0, index = 0; + while (index < startIndex && index < this.aggregatedCollections.length) { + offset += this.aggregatedCollections[index].length; + index++; + } + + this.splice( + offset, + removedCollections.reduce((removedItemsCount, removedCollection) => removedItemsCount + removedCollection.length, 0), + ...addedCollections.reduce( + (addedItems, addedCollection) => { + addedItems.push(...addedCollection); + return addedItems; + }, + new Array() + ) + ); + } + }); + } + + public readonly aggregatedCollections: IObservableCollection; +} \ No newline at end of file diff --git a/src/forms/IConfigurableFormSectionCollection.ts b/src/forms/IConfigurableFormSectionCollection.ts new file mode 100644 index 0000000..78fb53d --- /dev/null +++ b/src/forms/IConfigurableFormSectionCollection.ts @@ -0,0 +1,10 @@ +import type { FormViewModel } from "./FormViewModel"; + +export type FormSectionSetupCallback, TValidationError = string> = (section: TSection) => void; + +export interface IConfigurableFormSectionCollection, TValidationError = string> { + withItemSetup(setupCallback: FormSectionSetupCallback): this; + withoutItemSetup(setupCallback: FormSectionSetupCallback): this; + + clearItemSetups(): void; +} \ No newline at end of file diff --git a/src/forms/IFormSectionCollection.ts b/src/forms/IFormSectionCollection.ts new file mode 100644 index 0000000..bdccc9b --- /dev/null +++ b/src/forms/IFormSectionCollection.ts @@ -0,0 +1,7 @@ +import type { IObservableCollection } from "../collections"; +import type { FormViewModel } from "./FormViewModel"; +import type { IConfigurableFormSectionCollection } from './IConfigurableFormSectionCollection'; + +export interface IFormSectionCollection, TValidationError = string> + extends IObservableCollection, IConfigurableFormSectionCollection { +} \ No newline at end of file diff --git a/src/forms/IReadOnlyFormSectionCollection.ts b/src/forms/IReadOnlyFormSectionCollection.ts new file mode 100644 index 0000000..4230f32 --- /dev/null +++ b/src/forms/IReadOnlyFormSectionCollection.ts @@ -0,0 +1,7 @@ +import type { IReadOnlyObservableCollection } from "../collections"; +import type { FormViewModel } from "./FormViewModel"; +import type { IConfigurableFormSectionCollection } from './IConfigurableFormSectionCollection'; + +export interface IReadOnlyFormSectionCollection, TValidationError = string> + extends IReadOnlyObservableCollection, IConfigurableFormSectionCollection { +} \ No newline at end of file diff --git a/src/forms/index.ts b/src/forms/index.ts new file mode 100644 index 0000000..068d86f --- /dev/null +++ b/src/forms/index.ts @@ -0,0 +1,7 @@ +export { FormViewModel } from './FormViewModel'; +export { type IFormFieldViewModelConfig, FormFieldViewModel } from './FormFieldViewModel'; + +export type { IConfigurableFormSectionCollection, FormSectionSetupCallback } from './IConfigurableFormSectionCollection' +export type { IReadOnlyFormSectionCollection } from './IReadOnlyFormSectionCollection'; +export type { IFormSectionCollection } from './IFormSectionCollection' +export { FormSectionCollection } from './FormSectionCollection'; \ No newline at end of file diff --git a/src/forms/tests/FormFieldViewModel.tests.ts b/src/forms/tests/FormFieldViewModel.tests.ts new file mode 100644 index 0000000..9db8091 --- /dev/null +++ b/src/forms/tests/FormFieldViewModel.tests.ts @@ -0,0 +1,127 @@ +import { ObservableCollection } from '../../collections'; +import { CollectionChangedValidationTrigger, CollectionReorderedValidationTrigger } from '../../validation/triggers'; +import { FormFieldViewModel } from '../FormFieldViewModel'; + +describe('FormFieldViewModel', (): void => { + it('creating a field initializes it with provided values and fallbacks', (): void => { + const initialValue = {}; + const field = new FormFieldViewModel({ + name: 'name', + initialValue + }); + + expect(field.name).toBe('name'); + expect(field.error).toBeNull(); + expect(field.isValid).toBe(true); + expect(field.isInvalid).toBe(false); + expect(field.value).toStrictEqual(initialValue); + expect(field.initialValue).toStrictEqual(initialValue); + }); + + it('creating a field with value initializes it', (): void => { + const value = {}; + const initialValue = {}; + const field = new FormFieldViewModel({ + name: 'name', + value, + initialValue + }); + + expect(field.name).toBe('name'); + expect(field.error).toBeNull(); + expect(field.isValid).toBe(true); + expect(field.isInvalid).toBe(false); + expect(field.value).toStrictEqual(value); + expect(field.initialValue).toStrictEqual(initialValue); + }); + + it('creating a field with validators initializes it', (): void => { + const field = new FormFieldViewModel({ + name: 'name', + initialValue: null, + validators: [() => 'error'] + }); + + expect(field.name).toBe('name'); + expect(field.error).toBe('error'); + expect(field.isValid).toBe(false); + expect(field.isInvalid).toBe(true); + expect(field.value).toBeNull(); + expect(field.initialValue).toBeNull(); + expect(field.validation.validators.length).toBe(1); + expect(field.validation.triggers.size).toBe(0); + }); + + it('creating a field with validation triggers initializes it', (): void => { + const validationTrigger = new ObservableCollection(); + const field = new FormFieldViewModel({ + name: 'name', + initialValue: null, + validators: [() => 'error'], + validationTriggers: [validationTrigger] + }); + + expect(field.name).toBe('name'); + expect(field.error).toBe('error'); + expect(field.isValid).toBe(false); + expect(field.isInvalid).toBe(true); + expect(field.value).toBeNull(); + expect(field.initialValue).toBeNull(); + expect(field.validation.validators.length).toBe(1); + expect(field.validation.triggers.size).toBe(2); + expect(Array.from(field.validation.triggers).some(trigger => trigger instanceof CollectionChangedValidationTrigger)).toBeTruthy(); + expect(Array.from(field.validation.triggers).some(trigger => trigger instanceof CollectionReorderedValidationTrigger)).toBeTruthy(); + }); + + it('changing a trigger revalidates the field', (): void => { + let error = 'error 1'; + const validationTrigger = new ObservableCollection(); + const field = new FormFieldViewModel({ + name: 'name', + initialValue: null, + validators: [() => error], + validationTriggers: [validationTrigger] + }); + + error = 'error 2'; + validationTrigger.push({}); + + expect(field.error).toBe('error 2'); + expect(field.isValid).toBe(false); + expect(field.isInvalid).toBe(true); + }); + + it('resetting validation on a field resets the error message', (): void => { + const field = new FormFieldViewModel({ + name: 'name', + initialValue: null, + validators: [() => 'error'], + validationTriggers: [new ObservableCollection()] + }); + + field.validation.reset(); + + expect(field.error).toBeNull(); + expect(field.isValid).toBe(true); + expect(field.isInvalid).toBe(false); + expect(field.validation.validators.length).toBe(0); + expect(field.validation.triggers.size).toBe(0); + }); + + it('resetting a field resets validation', (): void => { + const field = new FormFieldViewModel({ + name: 'name', + initialValue: null, + validators: [() => 'error'], + validationTriggers: [new ObservableCollection()] + }); + + field.reset(); + + expect(field.error).toBeNull(); + expect(field.isValid).toBe(true); + expect(field.isInvalid).toBe(false); + expect(field.validation.validators.length).toBe(0); + expect(field.validation.triggers.size).toBe(0); + }); +}); \ No newline at end of file diff --git a/src/forms/tests/FormViewModel.tests.ts b/src/forms/tests/FormViewModel.tests.ts new file mode 100644 index 0000000..d845608 --- /dev/null +++ b/src/forms/tests/FormViewModel.tests.ts @@ -0,0 +1,375 @@ +import type { IObservableCollection, IReadOnlyObservableCollection } from '../../collections'; +import { FormFieldViewModel } from '../FormFieldViewModel'; +import { FormSectionCollection } from '../FormSectionCollection'; +import { FormViewModel } from '../FormViewModel'; + +describe('FormViewModel', (): void => { + it('adding fields when initializing collections adds them to the form', (): void => { + const form = new TestFormViewModel(); + const field1 = new FormFieldViewModel({ name: 'field 1', initialValue: {} }); + const field2 = new FormFieldViewModel({ name: 'field 2', initialValue: {} }); + + form.withFields(field1, field2); + + expect(form.fields.length).toBe(2); + expect(form.fields.toArray()).toEqual([field1, field2]); + }); + + it('adding fields to field collections adds them to the form', (): void => { + const form = new TestFormViewModel(); + const field1 = new FormFieldViewModel({ name: 'field 1', initialValue: {} }); + const field2 = new FormFieldViewModel({ name: 'field 2', initialValue: {} }); + + const fields = form.withFields(); + fields.push(field1, field2); + + expect(form.fields.length).toBe(2); + expect(form.fields.toArray()).toEqual([field1, field2]); + }); + + it('initializing two field collections adds all to the form', (): void => { + const form = new TestFormViewModel(); + const field1 = new FormFieldViewModel({ name: 'field 1', initialValue: {} }); + const field2 = new FormFieldViewModel({ name: 'field 2', initialValue: {} }); + + form.withFields(field1); + form.withFields(field2); + + expect(form.fields.length).toBe(2); + expect(form.fields.toArray()).toEqual([field1, field2]); + }); + + it('changing field collections keeps the entire collection in sync', (): void => { + const form = new TestFormViewModel(); + function expectFields(fields: readonly FormFieldViewModel[]) { + expect(form.fields.length).toBe(fields.length); + expect(form.fields.toArray().map(({ name }) => ({ name }))).toEqual(fields.map(({ name }) => ({ name }))); + } + + const field1 = new FormFieldViewModel({ name: 'field 1', initialValue: {} }); + const field2 = new FormFieldViewModel({ name: 'field 2', initialValue: {} }); + const field3 = new FormFieldViewModel({ name: 'field 3', initialValue: {} }); + const field4 = new FormFieldViewModel({ name: 'field 4', initialValue: {} }); + const field5 = new FormFieldViewModel({ name: 'field 5', initialValue: {} }); + const field6 = new FormFieldViewModel({ name: 'field 6', initialValue: {} }); + const field7 = new FormFieldViewModel({ name: 'field 7', initialValue: {} }); + const field8 = new FormFieldViewModel({ name: 'field 8', initialValue: {} }); + const field9 = new FormFieldViewModel({ name: 'field 9', initialValue: {} }); + const field10 = new FormFieldViewModel({ name: 'field 10', initialValue: {} }); + + const fieldCollection1 = form.withFields(field1, field2, field3); + const fieldCollection2 = form.withFields(field4, field5, field6, field7); + const fieldCollection3 = form.withFields(field8, field9, field10); + expectFields([field1, field2, field3, field4, field5, field6, field7, field8, field9, field10]); + + fieldCollection2.reverse(); + expectFields([field1, field2, field3, field7, field6, field5, field4, field8, field9, field10]); + + fieldCollection1.sort((left, right) => -left.name.localeCompare(right.name)); + expectFields([field3, field2, field1, field7, field6, field5, field4, field8, field9, field10]); + + const [removedField] = fieldCollection3.splice(1, 1); + expect(removedField).toEqual(field9); + expectFields([field3, field2, field1, field7, field6, field5, field4, field8, field10]); + + fieldCollection1.push(removedField); + expectFields([field3, field2, field1, field9, field7, field6, field5, field4, field8, field10]); + + fieldCollection3.push(...fieldCollection1); + expectFields([field3, field2, field1, field9, field7, field6, field5, field4, field8, field10, field3, field2, field1, field9]); + + fieldCollection2.splice(0); + expectFields([field3, field2, field1, field9, field8, field10, field3, field2, field1, field9]); + + fieldCollection3.reverse(); + expectFields([field3, field2, field1, field9, field9, field1, field2, field3, field10, field8]); + }); + + it('removing a form field resets it', () => { + let resetInvocationCount = 0; + const form = new TestFormViewModel(); + const field = new FormFieldViewModel({ name: 'field', initialValue: null }); + field.reset = () => { + resetInvocationCount++; + }; + + const sectionCollection = form.withFields(field); + expect(resetInvocationCount).toBe(0); + + sectionCollection.splice(0); + expect(resetInvocationCount).toBe(1); + }); + + it('adding sections when initializing collections adds them to the form', (): void => { + const form = new TestFormViewModel(); + const section1 = new FormViewModel(); + const section2 = new FormViewModel(); + + form.withSections(section1, section2); + + expect(form.sections.length).toBe(2); + expect(form.sections.toArray()).toEqual([section1, section2]); + }); + + it('adding sections to section collections adds them to the form', (): void => { + const form = new TestFormViewModel(); + const section1 = new FormViewModel(); + const section2 = new FormViewModel(); + + const sections = form.withSections(); + sections.push(section1, section2); + + expect(form.sections.length).toBe(2); + expect(form.sections.toArray()).toEqual([section1, section2]); + }); + + it('initializing two section collections adds all to the form', (): void => { + const form = new TestFormViewModel(); + const section1 = new FormViewModel(); + const section2 = new FormViewModel(); + + form.withSections(section1); + form.withSections(section2); + + expect(form.sections.length).toBe(2); + expect(form.sections.toArray()).toEqual([section1, section2]); + }); + + it('changing section collections keeps the entire collection in sync', (): void => { + const form = new TestFormViewModel(); + function expectSections(sections: readonly FormViewModel[]) { + expect(form.sections.length).toBe(sections.length); + expect(form.sections.toArray()).toEqual(sections); + } + + const section1 = new FormViewModel(); + const section2 = new FormViewModel(); + const section3 = new FormViewModel(); + const section4 = new FormViewModel(); + const section5 = new FormViewModel(); + const section6 = new FormViewModel(); + const section7 = new FormViewModel(); + const section8 = new FormViewModel(); + const section9 = new FormViewModel(); + const section10 = new FormViewModel(); + + const sectionCollection1 = form.withSections(section1, section2, section3); + const sectionCollection2 = form.withSections(section4, section5, section6, section7); + const sectionCollection3 = form.withSections(section8, section9, section10); + expectSections([section1, section2, section3, section4, section5, section6, section7, section8, section9, section10]); + + sectionCollection2.reverse(); + expectSections([section1, section2, section3, section7, section6, section5, section4, section8, section9, section10]); + + const [removedSection] = sectionCollection3.splice(1, 1); + expect(removedSection).toEqual(section9); + expectSections([section1, section2, section3, section7, section6, section5, section4, section8, section10]); + + sectionCollection1.push(removedSection); + expectSections([section1, section2, section3, section9, section7, section6, section5, section4, section8, section10]); + + sectionCollection3.push(...sectionCollection1); + expectSections([section1, section2, section3, section9, section7, section6, section5, section4, section8, section10, section1, section2, section3, section9]); + + sectionCollection2.splice(0); + expectSections([section1, section2, section3, section9, section8, section10, section1, section2, section3, section9]); + + sectionCollection3.reverse(); + expectSections([section1, section2, section3, section9, section9, section3, section2, section1, section10, section8]); + }); + + it('removing a form section resets it', () => { + let resetInvocationCount = 0; + const form = new TestFormViewModel(); + const section = new FormViewModel(); + section.reset = () => { + resetInvocationCount++; + }; + + const sectionCollection = form.withSections(section); + expect(resetInvocationCount).toBe(0); + + sectionCollection.splice(0); + expect(resetInvocationCount).toBe(1); + }); + + it('invalidating a field makes the entire form invalid', (): void => { + const form = new TestFormViewModel(); + const [field] = form.withFields(new FormFieldViewModel({ + name: 'field', + initialValue: null + })); + + field.error = 'invalid'; + + expect(form.isValid).toBeFalsy(); + expect(form.isInvalid).toBeTruthy(); + }); + + it('invalidating a field propagates property change notifications', (): void => { + let invocationCount = 0; + const form = new TestFormViewModel(); + form.propertiesChanged.subscribe({ + handle(_, changedProperties) { + invocationCount++; + expect(changedProperties.length).toBe(2); + expect(changedProperties).toContain('isValid'); + expect(changedProperties).toContain('isInvalid'); + } + }) + const field = new FormFieldViewModel({ + name: 'field', + initialValue: null + }); + form.withFields(field); + + field.error = 'invalid'; + + expect(invocationCount).toBe(1); + }); + + it('invalidating a section makes the entire form invalid', (): void => { + const form = new TestFormViewModel(); + const [section] = form.withSections( + new FormViewModel() + ); + + section.error = 'invalid'; + + expect(form.isValid).toBeFalsy(); + expect(form.isInvalid).toBeTruthy(); + }); + + it('invalidating a section propagates property change notifications', (): void => { + let invocationCount = 0; + const form = new TestFormViewModel(); + form.propertiesChanged.subscribe({ + handle(_, changedProperties) { + invocationCount++; + expect(changedProperties.length).toBe(2); + expect(changedProperties).toContain('isValid'); + expect(changedProperties).toContain('isInvalid'); + } + }) + const [section] = form.withSections( + new FormViewModel() + ); + + section.error = 'invalid'; + + expect(invocationCount).toBe(1); + }); + + it('configuring a form section collection initializes each added section', () => { + let invocationCount = 0; + const form = new TestFormViewModel(); + const sectionCollection = form.withSections(); + const formSection = new FormViewModel(); + sectionCollection.withItemSetup( + section => { + invocationCount++; + expect(section).toStrictEqual(formSection); + } + ); + + sectionCollection.push(formSection); + + expect(invocationCount).toBe(1); + }); + + it('removing a configuration callback reconfigures the section', () => { + let setup1InvocationCount = 0; + let setup2InvocationCount = 0; + let resetInvocationCount = 0; + const form = new TestFormViewModel(); + const sectionCollection = form.withSections(); + const formSection = new FormViewModel(); + formSection.reset = () => { + resetInvocationCount++; + } + + const setup1 = (section: FormViewModel) => { + setup1InvocationCount++; + expect(section).toStrictEqual(formSection); + }; + const setup2 = (section: FormViewModel) => { + setup2InvocationCount++; + expect(section).toStrictEqual(formSection); + }; + sectionCollection.withItemSetup(setup1); + sectionCollection.withItemSetup(setup2); + + sectionCollection.push(formSection); + + sectionCollection.withoutItemSetup(setup2); + + expect(setup1InvocationCount).toBe(2); + expect(resetInvocationCount).toBe(1); + expect(setup2InvocationCount).toBe(1); + }); + + it('clearing configuration callbacks resets the section', () => { + let resetInvocationCount = 0; + const form = new TestFormViewModel(); + const sectionCollection = form.withSections(); + const formSection = new FormViewModel(); + formSection.reset = () => { + resetInvocationCount++; + } + sectionCollection.withItemSetup(() => { }); + + sectionCollection.push(formSection); + sectionCollection.clearItemSetups(); + + expect(resetInvocationCount).toBe(1); + }); + + it('resetting a form section resets fields, sections and sections collection configurations', () => { + let fieldResetInvocationCount = 0; + let sectionResetInvocationCount = 0; + let sectionSetupInvocationCount = 0; + + const form = new TestFormViewModel(); + form.validation.add(() => 'error', [form]); + const field = new FormFieldViewModel({ name: 'field', initialValue: null }); + field.reset = () => { fieldResetInvocationCount++; }; + form.withFields(field); + const formSection = new FormViewModel(); + formSection.reset = () => { sectionResetInvocationCount++; } + const formSectionsCollection = form.withSections(formSection); + formSectionsCollection.withItemSetup( + () => { sectionSetupInvocationCount++; } + ); + + expect(fieldResetInvocationCount).toBe(0); + expect(sectionResetInvocationCount).toBe(0); + expect(sectionSetupInvocationCount).toBe(1); + expect(form.error).toBe('error'); + expect(form.isValid).toBeFalsy(); + expect(form.isInvalid).toBeTruthy(); + expect(form.validation.validators.length).toBe(1); + expect(form.validation.triggers.size).toBe(1); + + form.reset(); + formSectionsCollection.push(new FormViewModel()); + + expect(fieldResetInvocationCount).toBe(1); + expect(sectionResetInvocationCount).toBe(1); + expect(sectionSetupInvocationCount).toBe(1); + expect(form.error).toBeNull(); + expect(form.isValid).toBeTruthy(); + expect(form.isInvalid).toBeFalsy(); + expect(form.validation.validators.length).toBe(0); + expect(form.validation.triggers.size).toBe(0); + }); +}); + +class TestFormViewModel extends FormViewModel { + public withFields = FormFieldViewModel>(...fields: readonly TField[]): IObservableCollection { + return super.withFields.apply(this, arguments); + } + + public withSections = FormViewModel>(...sections: readonly TSection[]): FormSectionCollection { + return super.withSections.apply(this, arguments); + } +} \ No newline at end of file diff --git a/src/hooks/use-collection-item-validators.ts b/src/hooks/use-collection-item-validators.ts deleted file mode 100644 index cdc091f..0000000 --- a/src/hooks/use-collection-item-validators.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { INotifyPropertiesChanged } from '../viewModels'; -import type { IReadOnlyObservableCollection } from '../collections/observableCollections/IReadOnlyObservableCollection'; -import type { IValidatable, ValidatableSelectorCallback, ValidationConfigSelectorCallback, CollectionItemValidatorCallback } from '../validation'; -import { registerCollectionItemValidators } from '../validation'; -import { useEffect } from 'react'; - -type Destructor = () => void; -type EffectResult = void | Destructor; - -/** Registers and applies the provided validators to each item. The collection and validators are part of the dependencies. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed just on that item and not the entire collection. This is useful when items have individual validation rules (e.g.: required value). - * @template TItem The type of object the collection contains. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param collection The collection to watch, validators are registered for each item. When the collection changes all subscriptions and unsubscriptions are done accordingly. - * @param selector A callback that selects a validatable from each item. The returned validatable or target and triggers must be the same for each item in particular in order to properly unsubscribe the event handlers. - * @param validators The callback validators that handle validation for each item. - * @returns Returns a callback that unsubscribes all event handlers, a cleanup callback. -*/ -export function useCollectionItemValidators(collection: IReadOnlyObservableCollection, selector: ValidatableSelectorCallback, validators: readonly (CollectionItemValidatorCallback | undefined)[]): void; - -/** Registers and applies the provided validators to each item. The collection and validators are part of the dependencies. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed just on that item and not the entire collection. This is useful when items have individual validation rules (e.g.: required value). - * @template TItem The type of object the collection contains. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param collection The collection to watch, validators are registered for each item. When the collection changes all subscriptions and unsubscriptions are done accordingly. - * @param selector A callback that selects a validation config from each item. The returned validatable or target and triggers must be the same for each item in particular in order to properly unsubscribe the event handlers. - * @param validators The callback validators that handle validation for each item. - * @returns Returns a callback that unsubscribes all event handlers, a cleanup callback. -*/ -export function useCollectionItemValidators(collection: IReadOnlyObservableCollection, selector: ValidationConfigSelectorCallback, validators: readonly (CollectionItemValidatorCallback | undefined)[]): void; - -/** Registers and applies the provided validators to each item. The collection and validators are part of the dependencies. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed just on that item and not the entire collection. This is useful when items have individual validation rules (e.g.: required value). - * @template TItem The type of object the collection contains. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param collection The collection to watch, validators are registered for each item. When the collection changes all subscriptions and unsubscriptions are done accordingly. - * @param selector A callback that selects a validatable or validation config from each item. The returned validatable or target and triggers must be the same for each item in particular in order to properly unsubscribe the event handlers. - * @param validators The callback validators that handle validation for each item. - * @returns Returns a callback that unsubscribes all event handlers, a cleanup callback. -*/ -export function useCollectionItemValidators(collection: IReadOnlyObservableCollection, selector: ValidatableSelectorCallback | ValidationConfigSelectorCallback, validators: readonly CollectionItemValidatorCallback[]): void { - useEffect( - (): EffectResult => registerCollectionItemValidators(collection, selector as any, validators), - [collection, ...validators] - ); -} \ No newline at end of file diff --git a/src/hooks/use-collection-validators.ts b/src/hooks/use-collection-validators.ts deleted file mode 100644 index 3b5a858..0000000 --- a/src/hooks/use-collection-validators.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { INotifyPropertiesChanged } from '../viewModels'; -import type { IReadOnlyObservableCollection } from '../collections/observableCollections/IReadOnlyObservableCollection'; -import type { IValidatable, ValidatableSelectorCallback, ValidationConfigSelectorCallback, CollectionItemValidatorCallback } from '../validation'; -import { registerCollectionValidators } from '../validation'; -import { useEffect } from 'react'; - -type Destructor = () => void; -type EffectResult = void | Destructor; - -/** Registers and applies the provided validators to each item. The collection and validators are part of the dependencies. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed on the entire collection. Each item is revalidated, this is useful when items must have a unique value. - * @template TItem The type of object the collection contains. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param collection The collection to watch, validators are registered for each item. When the collection changes all subscriptions and unsubscriptions are done accordingly. - * @param selector A callback that selects a validatable from each item. The returned validatable or target and triggers must be the same for each item in particular in order to properly unsubscribe the event handlers. - * @param validators The callback validators that handle validation for each item. -*/ -export function useCollectionValidators(collection: IReadOnlyObservableCollection, selector: ValidatableSelectorCallback, validators: readonly (CollectionItemValidatorCallback | undefined)[]): void; - -/** Registers and applies the provided validators to each item. The collection and validators are part of the dependencies. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed on the entire collection. Each item is revalidated, this is useful when items must have a unique value. - * @template TItem The type of object the collection contains. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param collection The collection to watch, validators are registered for each item. When the collection changes all subscriptions and unsubscriptions are done accordingly. - * @param selector A callback that selects a validation config from each item. The returned validatable or target and triggers must be the same for each item in particular in order to properly unsubscribe the event handlers. - * @param validators The callback validators that handle validation for each item. -*/ -export function useCollectionValidators(collection: IReadOnlyObservableCollection, selector: ValidationConfigSelectorCallback, validators: readonly (CollectionItemValidatorCallback | undefined)[]): void; - -/** Registers and applies the provided validators to each item. The collection and validators are part of the dependencies. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed on the entire collection. Each item is revalidated, this is useful when items must have a unique value. - * @template TItem The type of object the collection contains. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param collection The collection to watch, validators are registered for each item. When the collection changes all subscriptions and unsubscriptions are done accordingly. - * @param selector A callback that selects a validatable or validation config from each item. The returned validatable or target and triggers must be the same for each item in particular in order to properly unsubscribe the event handlers. - * @param validators The callback validators that handle validation for each item. -*/ -export function useCollectionValidators(collection: IReadOnlyObservableCollection, selector: ValidatableSelectorCallback | ValidationConfigSelectorCallback, validators: readonly CollectionItemValidatorCallback[]): void { - useEffect( - (): EffectResult => registerCollectionValidators(collection, selector as any, validators), - [collection, ...validators] - ); -} \ No newline at end of file diff --git a/src/hooks/use-event.ts b/src/hooks/use-event.ts index 6214cb1..84518ae 100644 --- a/src/hooks/use-event.ts +++ b/src/hooks/use-event.ts @@ -34,15 +34,4 @@ export function useEvent(event: IEvent(event: IEvent, handler: EventHandler, deps?: DependencyList): void { - useEvent(event, handler, deps || []); } \ No newline at end of file diff --git a/src/hooks/use-observable-collection.ts b/src/hooks/use-observable-collection.ts index 418a0c8..93eb081 100644 --- a/src/hooks/use-observable-collection.ts +++ b/src/hooks/use-observable-collection.ts @@ -39,13 +39,4 @@ export function useObservableCollection(observableCollection: IReadOnlyOb function hasChanges(previous: readonly TItem[], next: IReadOnlyObservableCollection): boolean { return previous.length !== next.length || previous.some((item, index) => item !== next.at(index)); -} - -/** Watches the collection for changes, requesting a render when it does. The collection is the only hook dependency. - * @deprecated In future versions this hook will be removed, switch to {@link useObservableCollection}. - * @template TItem The type of items the collection contains. - * @param observableCollection The collection to watch. - */ -export function watchCollection(observableCollection: IReadOnlyObservableCollection): void { - useObservableCollection(observableCollection); } \ No newline at end of file diff --git a/src/hooks/use-validators.ts b/src/hooks/use-validators.ts deleted file mode 100644 index 13e548d..0000000 --- a/src/hooks/use-validators.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { INotifyPropertiesChanged } from '../viewModels'; -import type { IValidatable, IValidationConfig, ValidatorCallback } from '../validation'; -import { registerValidators } from '../validation'; -import { useEffect } from 'react'; - -type Destructor = () => void; -type EffectResult = void | Destructor; - -/** Registers and applies the provided validators. The validatable and validators are part of the dependencies. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param validatable The object that will be validated by the provided validators. - * @param validators The callback validators that handle validation. -*/ -export function useValidators(validatable: TValidatableViewModel, validators: readonly ValidatorCallback[]): void; - -/** Registers and applies the provided validators. The validation config and validators are part of the dependencies. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param validationConfig The config for setting up validation. - * @param validators The callback validators that handle validation. -*/ -export function useValidators(validationConfig: IValidationConfig, validators: readonly ValidatorCallback[]): void; - -/** Registers and applies the provided validators. The validatable (or validation config) and validators are part of the dependencies. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param validatableOrConfig The object that will be validated by the provided validators or the config for setting up validation. - * @param validators The callback validators that handle validation. -*/ -export function useValidators(validatableOrConfig: TValidatableViewModel | IValidationConfig, validators: readonly ValidatorCallback[]): void { - useEffect( - (): EffectResult => registerValidators(validatableOrConfig as any, validators), - [validatableOrConfig, ...validators] - ); -} \ No newline at end of file diff --git a/src/hooks/use-view-model-memo.ts b/src/hooks/use-view-model-memo.ts index c98b3b0..73f1412 100644 --- a/src/hooks/use-view-model-memo.ts +++ b/src/hooks/use-view-model-memo.ts @@ -18,14 +18,4 @@ export function useViewModelMemo(vi useViewModel(viewModel, watchedProperties); return viewModel; -} - -/** Ensures a unique instance per component that is generated by the factory is created and watches the view model for changes. Returns the view model instance. - * @deprecated In future versions this hook will be removed, switch to {@link useViewModelMemo}. - * @template TViewModel The type of view model to create. - * @param viewModelFactory The view model factory callback that initializes the instance. - * @param watchedProperties Optional, a render will be requested when only one of these properties has changed. - */ -export function useViewModelFactory(viewModelFactory: ViewModelFactory, watchedProperties?: readonly (keyof TViewModel)[]): TViewModel { - return useViewModelMemo(viewModelFactory, [], watchedProperties); } \ No newline at end of file diff --git a/src/hooks/use-view-model-type.ts b/src/hooks/use-view-model-type.ts deleted file mode 100644 index facf74e..0000000 --- a/src/hooks/use-view-model-type.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { INotifyPropertiesChanged } from '../viewModels'; -import { type ViewModelType, useViewModel } from './use-view-model'; - -/** Ensures a unique instance per component of the given type is created and watches the view model for changes. Returns the view model instance. - * @deprecated In future versions this hook will be removed, switch to {@link useViewModel}. - * @template TViewModel The type of view model to create. - * @param viewModelType The view model type to initialize. - * @param watchedProperties Optional, a render will be requested when only one of these properties has changed. - */ -export function useViewModelType(viewModelType: ViewModelType, watchedProperties?: readonly (keyof TViewModel)[]): TViewModel { - return useViewModel(viewModelType, [], watchedProperties); -} \ No newline at end of file diff --git a/src/hooks/use-view-model.ts b/src/hooks/use-view-model.ts index 53e1deb..8823e28 100644 --- a/src/hooks/use-view-model.ts +++ b/src/hooks/use-view-model.ts @@ -98,14 +98,4 @@ function selectProps(object: any, properties: readonly PropertyKey[]): void { }, {} ); -} - -/** Watches the view model for changes, requesting a render when it does. The view model and watched properties are part of the hook dependencies. - * @deprecated In future versions this hook will be removed, switch to {@link useViewModel}. - * @template TViewModel The type of view model to watch. - * @param viewModel The view model to change, a view model is any object that implements INotifyPropertiesChanged. - * @param watchedProperties Optional, when provided, a render will be requested when only one of these properties has changed. - */ -export function watchViewModel(viewModel: TViewModel, watchedProperties?: readonly (keyof TViewModel)[]): void { - useViewModel(viewModel, watchedProperties); } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 0bf3da6..9d1d039 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,33 +60,27 @@ export { } from './collections'; export { - type IReadOnlyValidatable, - type IValidatable, + type IReadOnlyValidatable, type IValidatable, Validatable, - type IValidationConfig, - type ValidatorCallback, + type IValidator, type ValidatorCallback, + type IReadOnlyObjectValidator, type IObjectValidator, ObjectValidator, - type CollectionItemValidatorCallback, - type ValidatableSelectorCallback, - type ValidationConfigSelectorCallback, - type UnsubscribeCallback, + type WellKnownValidationTrigger, ValidationTrigger, - registerValidators, - registerCollectionValidators, - registerCollectionItemValidators -} from './validation'; - -export { type IFormFieldViewModel, type IFormFieldViewModelConfig, FormFieldViewModel } from './form-field-view-model'; -export { type FormFieldSet, FormFieldCollectionViewModel, DynamicFormFieldCollectionViewModel } from './form-field-collection-view-model'; + type IViewModelChangedValidationTriggerConfig, ViewModelChangedValidationTrigger, + type ICollectionChangedValidationTriggerConfig, CollectionChangedValidationTrigger, + type ICollectionReorderedValidationTriggerConfig, CollectionReorderedValidationTrigger, + type ISetChangedValidationTriggerConfig, SetChangedValidationTrigger, + type IMapChangedValidationTriggerConfig, MapChangedValidationTrigger, -export { type EventHandler, useEvent, watchEvent } from './hooks/use-event'; -export { type ViewModelType, useViewModel, watchViewModel } from './hooks/use-view-model'; -export { type ViewModelFactory, useViewModelMemo, useViewModelFactory } from './hooks/use-view-model-memo'; -export { useObservableCollection, watchCollection } from './hooks/use-observable-collection'; -export { useViewModelType } from './hooks/use-view-model-type'; + type ICollectionItemValidationTriggerConfig, CollectionItemValidationTrigger, + type ISetItemValidationTriggerConfig, SetItemValidationTrigger, + type IMapItemValidationTriggerConfig, MapItemValidationTrigger, -export { useValidators } from './hooks/use-validators'; -export { useCollectionValidators } from './hooks/use-collection-validators'; -export { useCollectionItemValidators } from './hooks/use-collection-item-validators'; + resolveValidationTriggers, resolveAllValidationTriggers +} from './validation'; -export { type IInputProps, Input } from './components/input'; \ No newline at end of file +export { type EventHandler, useEvent } from './hooks/use-event'; +export { type ViewModelType, useViewModel } from './hooks/use-view-model'; +export { type ViewModelFactory, useViewModelMemo } from './hooks/use-view-model-memo'; +export { useObservableCollection } from './hooks/use-observable-collection'; \ No newline at end of file diff --git a/src/validation.ts b/src/validation.ts deleted file mode 100644 index 83ab738..0000000 --- a/src/validation.ts +++ /dev/null @@ -1,428 +0,0 @@ -import type { INotifyPropertiesChanged, IPropertiesChangedEventHandler } from './viewModels'; -import type { ICollectionChangedEventHandler } from './collections/observableCollections/ICollectionChangedEventHandler'; -import type { IReadOnlyObservableCollection } from './collections/observableCollections/IReadOnlyObservableCollection'; - -/** Represents a read-only interface for objects that can be validated. */ -export interface IReadOnlyValidatable { - /** A flag indicating whether the field is valid. Generally, when there is no associated error message. */ - readonly isValid: boolean; - - /** A flag indicating whether the field is invalid. Generally, when there is an associated error message. */ - readonly isInvalid: boolean; - - /** An error message (or translation key) providing information as to why the field is invalid. */ - readonly error: string | null | undefined; -} - -/** Represents an interface for objects that can be validated. */ -export interface IValidatable extends IReadOnlyValidatable { - /** An error message (or translation key) providing information as to why the field is invalid. */ - get error(): string | null | undefined; - - /** An error message (or translation key) providing information as to why the field is invalid. */ - set error(value: string | undefined); -} - -/** Represents a validation config covering scenarios where one object may depend on other objects to determine their valid state. - * @template TValidatableViewModel The type of validatable objects that is being configured. - */ -export interface IValidationConfig { - /** The object that is being validated. */ - readonly target: TValidatableViewModel; - /** Additional validation triggers, if the target or any of the triggers notify of properties changing then a validation is done on the target. */ - readonly triggers?: readonly INotifyPropertiesChanged[]; - /** A collection of property names to watch for. The validation is carried out only if the specified properties have changed. - * These come in addition to the filters that is placed on the target. The target is not validated when its error, isValid and isInvalid properties change. - */ - readonly watchedProperties?: readonly PropertyKey[]; -} - -/** Represents a validator callback. - * @template T The type of validatable objects to validate. - * @param validatable The object being validated. - */ -export type ValidatorCallback = (validatable: T) => string | undefined; - -/** Represents a collection bound validator callback. - * @template TValidatable The type of validatable objects to validate. - * @template TItem The type of items the collection contains. - * @param validatable The object being validated. - * @param item The item from which the validatable has been selected. - * @param collection The collection to which the item belongs. - */ -export type CollectionItemValidatorCallback = (validatable: TValidatable, item: TItem, collection: Iterable) => string | undefined; - -/** Represents a validatable selector callback. - * @template TItem The type of items from which to select validatable objects. - * @template TValidatableViewModel The type of validatable objects that are selected from an item. - * @param source The item from which a validatable is selected. - */ -export type ValidatableSelectorCallback = (source: TItem) => TValidatableViewModel; - -/** Represents a validation config selector callback. - * @template TItem The type of items from which to select validatable objects. - * @template TValidatableViewModel The type of validatable objects that are selected from an item as part of the validation config. - * @param source The item from which a validation config is selected. - */ -export type ValidationConfigSelectorCallback = (source: TItem) => IValidationConfig; - -/** Represents a clean-up callback that unsubscribes event handlers that perform validation. */ -export type UnsubscribeCallback = () => void; - -/** Registers and applies the provided validators returning a clean-up callback. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param validatable The object that will be validated by the provided validators. - * @param validators The callback validators that handle validation. - * @returns Returns a clean-up callback that unsubscribes all event registrations. - */ -export function registerValidators(validatable: TValidatableViewModel, validators: readonly ValidatorCallback[]): UnsubscribeCallback; - -/** Registers and applies the provided validators returning a clean-up callback. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param validationConfig The config for setting up validation. - * @param validators The callback validators that handle validation. - * @returns Returns a clean-up callback that unsubscribes all event registrations. - */ -export function registerValidators(validationConfig: IValidationConfig, validators: readonly ValidatorCallback[]): UnsubscribeCallback; - -/** Registers and applies the provided validators returning a clean-up callback. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param validatableOrConfig The object that will be validated by the provided validators or the config for setting up validation. - * @param validators The callback validators that handle validation. - * @returns Returns a clean-up callback that unsubscribes all event registrations. - */ -export function registerValidators(validatableOrConfig: TValidatableViewModel | IValidationConfig, validators: readonly ValidatorCallback[]): UnsubscribeCallback { - let target: TValidatableViewModel; - let triggers: readonly INotifyPropertiesChanged[] | undefined = undefined; - let watchedProperties: readonly PropertyKey[] | undefined = undefined; - if (isValidationConfig(validatableOrConfig)) { - target = validatableOrConfig.target; - triggers = validatableOrConfig.triggers; - watchedProperties = validatableOrConfig.watchedProperties; - } - else - target = validatableOrConfig; - - const validatableChangedEventHandler: IPropertiesChangedEventHandler = { - handle(_, changedProperties): void { - if (changedProperties.some(changedProperty => changedProperty !== 'error' && changedProperty !== 'isValid' && changedProperty !== 'isInvalid') - && (watchedProperties === undefined || containsAny(changedProperties, watchedProperties))) - applyValidators(target, validators); - } - }; - const triggerChangedEventHandler: IPropertiesChangedEventHandler = { - handle(_, changedProperties): void { - if (watchedProperties === undefined || containsAny(changedProperties, watchedProperties)) - applyValidators(target, validators); - } - }; - - applyValidators(target, validators); - target.propertiesChanged.subscribe(validatableChangedEventHandler); - triggers !== undefined && triggers.forEach(trigger => trigger.propertiesChanged.subscribe(triggerChangedEventHandler)); - return () => { - triggers !== undefined && triggers.forEach(trigger => trigger.propertiesChanged.unsubscribe(triggerChangedEventHandler)); - target.propertiesChanged.unsubscribe(validatableChangedEventHandler); - } -} - -/** Registers and applies the provided validators to each item and returns a clean-up callback. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed on the entire collection. Each item is revalidated, this is useful when items must have a unique value. - * @template TItem The type of object the collection contains. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param collection The collection to watch, validators are registered for each item. When the collection changes all subscriptions and unsubscriptions are done accordingly. - * @param selector A callback that selects a validatable from each item. The returned value must be the same for each item in particular in order to properly unsubscribe the event handlers. - * @param validators The callback validators that handle validation for each item. - * @returns Returns a clean-up callback that unsubscribes all event registrations. - */ -export function registerCollectionValidators(collection: IReadOnlyObservableCollection, selector: ValidatableSelectorCallback, validators: readonly CollectionItemValidatorCallback[]): UnsubscribeCallback; - -/** Registers and applies the provided validators to each item and returns a clean-up callback. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed on the entire collection. Each item is revalidated, this is useful when items must have a unique value. - * @template TItem The type of object the collection contains. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param collection The collection to watch, validators are registered for each item. When the collection changes all subscriptions and unsubscriptions are done accordingly. - * @param selector A callback that selects a validation config from each item. The returned target and triggers must be the same for each item in particular in order to properly unsubscribe the event handlers. - * @param validators The callback validators that handle validation for each item. - * @returns Returns a clean-up callback that unsubscribes all event registrations. -*/ -export function registerCollectionValidators(collection: IReadOnlyObservableCollection, selector: ValidationConfigSelectorCallback, validators: readonly CollectionItemValidatorCallback[]): UnsubscribeCallback; - -/** Registers and applies the provided validators to each item and returns a clean-up callback. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed on the entire collection. Each item is revalidated, this is useful when items must have a unique value. - * @template TItem The type of object the collection contains. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param collection The collection to watch, validators are registered for each item. When the collection changes all subscriptions and unsubscriptions are done accordingly. - * @param selector A callback that selects a validatable or validation config from each item. The returned validatable or target and triggers must be the same for each item in particular in order to properly unsubscribe the event handlers. - * @param validators The callback validators that handle validation for each item. - * @returns Returns a clean-up callback that unsubscribes all event registrations. - */ -export function registerCollectionValidators(collection: IReadOnlyObservableCollection, selector: ValidatableSelectorCallback | ValidationConfigSelectorCallback, validators: readonly CollectionItemValidatorCallback[]): UnsubscribeCallback { - const validatableChangedEventHandler: IPropertiesChangedEventHandler = { - handle(_, changedProperties): void { - if (changedProperties.some(changedProperty => changedProperty !== 'error' && changedProperty !== 'isValid' && changedProperty !== 'isInvalid')) - validateItems(changedProperties); - } - }; - const triggerChangedEventHandler: IPropertiesChangedEventHandler = { - handle(_, changedProperties): void { - validateItems(changedProperties); - } - }; - const collectionChangedEventHandler: ICollectionChangedEventHandler, TItem> = { - handle(_, collectionChange): void { - collectionChange.removedItems && collectionChange.removedItems.forEach(unwatchItem); - collectionChange.addedItems && collectionChange.addedItems.forEach(watchItem); - validateItems(); - } - }; - - collection.forEach(watchItem); - validateItems(); - collection.collectionChanged.subscribe(collectionChangedEventHandler); - - return () => { - collection.collectionChanged.unsubscribe(collectionChangedEventHandler); - collection.forEach(unwatchItem); - }; - - function watchItem(item: TItem): void { - if (item !== undefined && item !== null) { - const validatableOrConfig = selector(item); - if (isValidationConfig(validatableOrConfig)) { - const { target, triggers } = validatableOrConfig; - target.propertiesChanged.subscribe(validatableChangedEventHandler); - triggers && triggers.forEach(trigger => trigger.propertiesChanged.subscribe(triggerChangedEventHandler)); - } - else { - const validatable: TValidatableViewModel = validatableOrConfig; - validatable.propertiesChanged.subscribe(validatableChangedEventHandler); - } - } - } - - function unwatchItem(item: TItem): void { - if (item !== undefined && item !== null) { - const validatableOrConfig = selector(item); - if (isValidationConfig(validatableOrConfig)) { - const { target, triggers } = validatableOrConfig; - triggers && triggers.forEach(trigger => trigger.propertiesChanged.unsubscribe(triggerChangedEventHandler)); - target.propertiesChanged.unsubscribe(validatableChangedEventHandler); - } - else { - const validatable: TValidatableViewModel = validatableOrConfig; - validatable.propertiesChanged.unsubscribe(validatableChangedEventHandler); - } - } - } - - function validateItems(changedProperties?: readonly (keyof TValidatableViewModel)[]): void { - collection.forEach((item, _) => { - const validatableOrConfig = selector(item); - if (isValidationConfig(validatableOrConfig)) { - const { target, watchedProperties } = validatableOrConfig; - if (!changedProperties || !watchedProperties || containsAny(changedProperties, watchedProperties)) - applyCollectionItemValidators(target, item, collection, validators); - } - else { - const validatable: TValidatableViewModel = validatableOrConfig; - applyCollectionItemValidators(validatable, item, collection, validators); - } - }); - } -} - -/** Registers and applies the provided validators to each item and returns a clean-up callback. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed just on that item and not the entire collection. This is useful when items have individual validation rules (e.g.: required value). - * @template TItem The type of object the collection contains. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param collection The collection to watch, validators are registered for each item. When the collection changes all subscriptions and unsubscriptions are done accordingly. - * @param selector A callback that selects a validatable from each item. The returned value must be the same for each item in particular in order to properly unsubscribe the event handlers. - * @param validators The callback validators that handle validation for each item. - * @returns Returns a clean-up callback that unsubscribes all event registrations. - */ -export function registerCollectionItemValidators(collection: IReadOnlyObservableCollection, selector: ValidatableSelectorCallback, validators: readonly CollectionItemValidatorCallback[]): UnsubscribeCallback; - -/** Registers and applies the provided validators to each item and returns a clean-up callback. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed just on that item and not the entire collection. This is useful when items have individual validation rules (e.g.: required value). - * @template TItem The type of object the collection contains. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param collection The collection to watch, validators are registered for each item. When the collection changes all subscriptions and unsubscriptions are done accordingly. - * @param selector A callback that selects a validation config from each item. The returned target and triggers must be the same for each item in particular in order to properly unsubscribe the event handlers. - * @param validators The callback validators that handle validation for each item. - * @returns Returns a clean-up callback that unsubscribes all event registrations. - */ -export function registerCollectionItemValidators(collection: IReadOnlyObservableCollection, selector: ValidationConfigSelectorCallback, validators: readonly CollectionItemValidatorCallback[]): UnsubscribeCallback; - -/** Registers and applies the provided validators to each item and returns a clean-up callback. - * - * The validators are applied one after the other until the first one returns an error message (a value different from undefined). - * - * Whenever a property has changed (except for error, isValid and isInvalid) on the validatable, a new validation is performed just on that item and not the entire collection. This is useful when items have individual validation rules (e.g.: required value). - * @template TItem The type of object the collection contains. - * @template TValidatableViewModel The type of validatable objects that are registered for validation. - * @param collection The collection to watch, validators are registered for each item. When the collection changes all subscriptions and unsubscriptions are done accordingly. - * @param selector A callback that selects a validatable or validation config from each item. The returned validatable or target and triggers must be the same for each item in particular in order to properly unsubscribe the event handlers. - * @param validators The callback validators that handle validation for each item. - * @returns Returns a clean-up callback that unsubscribes all event registrations. - */ -export function registerCollectionItemValidators(collection: IReadOnlyObservableCollection, selector: ValidatableSelectorCallback | ValidationConfigSelectorCallback, validators: readonly CollectionItemValidatorCallback[]): UnsubscribeCallback { - const validatableChangedEventHandler: IPropertiesChangedEventHandler = { - handle(validatable: TValidatableViewModel, changedProperties): void { - if (changedProperties.some(changedProperty => changedProperty !== 'error' && changedProperty !== 'isValid' && changedProperty !== 'isInvalid')) { - collection.forEach(item => { - if (item !== undefined && item !== null) { - const currentValidatableOrConfig = selector(item); - if (isValidationConfig(currentValidatableOrConfig)) { - const { target, watchedProperties } = currentValidatableOrConfig; - if (target === validatable && (!watchedProperties || containsAny(changedProperties, watchedProperties))) - applyCollectionItemValidators(target, item, collection, validators); - } - else { - const currentValidatable: TValidatableViewModel = currentValidatableOrConfig; - if (currentValidatable === validatable) - applyCollectionItemValidators(currentValidatable, item, collection, validators); - } - } - }); - } - } - }; - const triggerChangedEventHandler: IPropertiesChangedEventHandler = { - handle(trigger, changedProperties): void { - collection.forEach(item => { - if (item !== undefined && item !== null) { - const { target, triggers, watchedProperties } = selector(item) as IValidationConfig; - if (triggers && triggers.indexOf(trigger) >= 0 && (!watchedProperties || containsAny(watchedProperties, changedProperties))) - applyCollectionItemValidators(target, item, collection, validators); - } - }); - } - }; - const collectionChangedEventHandler: ICollectionChangedEventHandler, TItem> = { - handle(_, collectionChange): void { - collectionChange.removedItems && collectionChange.removedItems.forEach(unwatchItem); - collectionChange.addedItems && collectionChange.addedItems.forEach(watchItem); - } - }; - - collection.forEach(watchItem); - collection.collectionChanged.subscribe(collectionChangedEventHandler); - - return () => { - collection.collectionChanged.unsubscribe(collectionChangedEventHandler); - collection.forEach(unwatchItem); - }; - - function watchItem(item: TItem): void { - if (item !== undefined && item !== null) { - const validatableOrConfig = selector(item); - if (isValidationConfig(validatableOrConfig)) { - const { target, triggers } = validatableOrConfig; - target.propertiesChanged.subscribe(validatableChangedEventHandler); - triggers && triggers.forEach(trigger => trigger.propertiesChanged.subscribe(triggerChangedEventHandler)); - - applyCollectionItemValidators(target, item, collection, validators); - } - else { - const validatable: TValidatableViewModel = validatableOrConfig; - validatable.propertiesChanged.subscribe(validatableChangedEventHandler); - applyCollectionItemValidators(validatable, item, collection, validators); - } - } - } - - function unwatchItem(item: TItem): void { - if (item !== undefined && item !== null) { - const validatableOrConfig = selector(item); - if (isValidationConfig(validatableOrConfig)) { - const { target, triggers } = validatableOrConfig; - - triggers && triggers.forEach(trigger => trigger.propertiesChanged.unsubscribe(triggerChangedEventHandler)); - target.propertiesChanged.unsubscribe(validatableChangedEventHandler); - } - else { - const validatable: TValidatableViewModel = validatableOrConfig; - validatable.propertiesChanged.unsubscribe(validatableChangedEventHandler); - } - } - } -} - -function containsAny(items: readonly T[], values: readonly T[]): boolean { - let index = 0; - while (index < items.length && values.every(value => value !== items[index])) - index++; - return index < items.length; -} - -function applyValidators(validatable: TValidatable, validators: readonly (ValidatorCallback | any)[]): void { - let error = undefined; - let index = 0; - while (index < validators.length && error === undefined) { - const validator = validators[index]; - if (validator && validator.call && validator.apply) - error = validator(validatable); - index++; - } - validatable.error = error; -} - -function applyCollectionItemValidators(validatable: TValidatable, item: TItem, collection: Iterable, validators: readonly (CollectionItemValidatorCallback | undefined)[]): void { - let index = 0; - let error = undefined; - while (index < validators.length && error === undefined) { - const validator = validators[index]; - if (!!validator) - error = validator(validatable, item, collection); - index++; - } - validatable.error = error; -} - -function isValidatable(potentialValidatable: any): potentialValidatable is IValidatable { - return potentialValidatable && isBoolean(potentialValidatable.isValid) && isBoolean(potentialValidatable.isInvalid); -} - -function isBoolean(potentialBoolean: any): potentialBoolean is boolean { - return potentialBoolean === true || potentialBoolean === false; -} - -function isValidationConfig(potentialConfig: TValidatableViewModel | IValidationConfig): potentialConfig is IValidationConfig { - return potentialConfig && !isValidatable(potentialConfig) && isValidatable(potentialConfig.target) && isPropertiesChangedNotifier(potentialConfig.target); -} - -function isPropertiesChangedNotifier(potentialPropertiesChangedNotifier: any): potentialPropertiesChangedNotifier is INotifyPropertiesChanged { - return potentialPropertiesChangedNotifier && potentialPropertiesChangedNotifier.propertiesChanged && isFunction(potentialPropertiesChangedNotifier.propertiesChanged.subscribe) && isFunction(potentialPropertiesChangedNotifier.propertiesChanged.unsubscribe); -} - -function isFunction(potentialFunction: any): potentialFunction is Function { - return potentialFunction && potentialFunction.call && potentialFunction.apply; -} \ No newline at end of file diff --git a/src/validation/index.ts b/src/validation/index.ts index 1cf5c25..ed30ba7 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -17,5 +17,11 @@ export { type ICollectionChangedValidationTriggerConfig, CollectionChangedValidationTrigger, type ICollectionReorderedValidationTriggerConfig, CollectionReorderedValidationTrigger, type ISetChangedValidationTriggerConfig, SetChangedValidationTrigger, - type IMapChangedValidationTriggerConfig, MapChangedValidationTrigger + type IMapChangedValidationTriggerConfig, MapChangedValidationTrigger, + + type ICollectionItemValidationTriggerConfig, CollectionItemValidationTrigger, + type ISetItemValidationTriggerConfig, SetItemValidationTrigger, + type IMapItemValidationTriggerConfig, MapItemValidationTrigger, + + resolveValidationTriggers, resolveAllValidationTriggers } from './triggers' \ No newline at end of file