Skip to content

Commit

Permalink
Work in progress, refactoring observable collections
Browse files Browse the repository at this point in the history
  • Loading branch information
Andrei15193 committed Apr 21, 2024
1 parent d4faa31 commit 28778f9
Show file tree
Hide file tree
Showing 27 changed files with 667 additions and 406 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-model-view-viewmodel",
"version": "3.0.0-events.1",
"version": "3.0.0-collections",
"description": "A library for developing React applications using Model-View-ViewModel inspired by .NET",
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
Expand Down Expand Up @@ -55,8 +55,9 @@
"preset": "ts-jest",
"testEnvironment": "jsdom",
"testMatch": [
"**/tests/**/*.ts",
"**/tests/**/*.tsx"
"**/*.tests.ts",
"**/tests/**/*.tests.ts",
"**/tests/**/*.tests.tsx"
]
}
}
62 changes: 12 additions & 50 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,6 @@ export class EventDispatcher<TSubject, TEventArgs = void> implements IEvent<TSub
}
}

/**
* A base implementation of an event. To avoid misuse, declare a private event of this type and expose it as an IEvent.
* @deprecated In future versions this class will be removed, switch to {@link EventDispatcher}, this is only a rename.
* @template TEventArgs Optional, can be used to provide context when notifying subscribers.
*/
export class DispatchEvent<TEventArgs = void> extends EventDispatcher<TEventArgs> {
}

/** A core interface for objects that notify subscribers when their properties have changed. Components can react to this and display the new value as a consequence. */
export interface INotifyPropertiesChanged {
/** An event that is raised when one or more properties may have changed. */
Expand Down Expand Up @@ -113,66 +105,36 @@ export interface IPropertiesChangedEventHandler<T> extends IEventHandler<T, read

/**
* A core interface for observable collections. Components can react to this and display the new value as a consequence.
*
* Any collection change can be reduced to [Array.splice](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/splice),
* thus the colleciton changed notification contains all the necessary information to reproduce the operation on subsequent collections.
* @template TItem The type of items the collection contains.
*/
export interface INotifyCollectionChanged<TItem> {
/** An event that is raised when an item is added to the collection. */
readonly itemAdded: IEvent<this, IItemAddedEventArgs<TItem>>;

/** An event that is raised when an item is removed from the collection. */
readonly itemRemoved: IEvent<this, IItemRemovedEventArgs<TItem>>;

/** An event that is raised when the collection changed. */
readonly collectionChanged: ICollectionChangedEvent<this, TItem>;
}

/**
* Contains information about the item that was added to a collection, including the option to subscribe a clean-up callback.
* @template TItem The type of items the collection contains.
*/
export interface IItemAddedEventArgs<TItem> {
/** The item that was added. */
readonly item: TItem;
/** The index where the item was added. */
readonly index: number;

/**
* Subscribes the provided `callback` that will be executed when the item is removed.
* @param callback A callback that will be invoked when the item is removed.
*/
addItemRemovalCallback(callback: ItemRemovedCallback<TItem>): void;
}

/**
* Represents an item removal callback.
* @template TItem The type of items the collection contains.
* @param item The item that was removed from the collection.
* @param index The index from which the item was removed.
*/
export type ItemRemovedCallback<TItem> = (item: TItem, index: number) => void;

/**
* Contains information about the item that was removed from a collection.
* @template TItem The type of items the collection contains.
*/
export interface IItemRemovedEventArgs<TItem> {
/** The item that was removed. */
readonly item: TItem;
/** The index from which the item was removed. */
readonly index: number;
}

/**
* Contains information about the changes in the collection.
* @template TItem The type of items the collection contains.
*/
export interface ICollectionChange<TItem> {
/** The start index where the change has happenend. */
readonly startIndex: number;
/** An array of added items, if any. */
readonly addedItems: readonly TItem[];
/** An array of removed items, if any. */
readonly removedItems: readonly TItem[];
/** The operation that was performed */
readonly operation: CollectionOperation;
}

/**
* Describes all the possible operations that can be performed on a collection.
*/
export type CollectionOperation = 'push' | 'pop' | 'unshift' | 'shift' | 'sort' | 'reverse' | 'copyWithin' | 'set' | 'delete' | 'fill' | 'clear' | 'reset';

/**
* A specialized event for subscribing and unsubscribing from collection changed events.
* @template TSubject Provides the object that raised the event.
Expand Down
111 changes: 61 additions & 50 deletions src/form-field-collection-view-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,25 @@ export abstract class FormFieldCollectionViewModel<TFormFieldViewModel extends I
}

private _error: string | undefined;
private readonly _fields: IObservableCollection<TFormFieldViewModel> = new ObservableCollection<TFormFieldViewModel>();
private readonly _fieldChangedEventHandler: IPropertiesChangedEventHandler<TFormFieldViewModel>;
private readonly _fields: IObservableCollection<TFormFieldViewModel>;

/** Initializes a new instance of the {@link FormFieldCollectionViewModel} class. */
public constructor() {
super();
this._fieldChangedEventHandler = {
handle: (_, changedProperties) => {
if (changedProperties.indexOf('isValid') >= 0 || changedProperties.indexOf('isInvalid') >= 0)
this.notifyPropertiesChanged('isValid', 'isInvalid');
}

const fieldChangedEventHandler: IPropertiesChangedEventHandler<TFormFieldViewModel> = {
handle: this.onFieldChanged.bind(this)
}
this._fields = new ObservableCollection<TFormFieldViewModel>();
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 of registered fields. */
/** A collection containing the registered fields. */
public get fields(): IReadOnlyObservableCollection<TFormFieldViewModel> {
return this._fields;
}
Expand All @@ -56,7 +60,7 @@ export abstract class FormFieldCollectionViewModel<TFormFieldViewModel extends I
}

/** An error message (or translation key) providing information as to why the field is invalid. */
public get error(): string | undefined {
public get error(): string | null | undefined {
return this._error;
}

Expand All @@ -68,89 +72,96 @@ export abstract class FormFieldCollectionViewModel<TFormFieldViewModel extends I
}
}

/** Registers a new FormFieldViewModel having the provided initial value and returns it.
* @deprecated In future versions this method will be removed. It was introduced mostly for utility purposes, however there are beter ways of adding fields, see {@link create}.
* @param name The name of the field.
* @param initialValue The initial value of the field.
*/
protected addField<TValue>(name: string, initialValue: TValue): TFormFieldViewModel {
return this.registerField(new FormFieldViewModel<TValue>(name, initialValue) as any as TFormFieldViewModel);
}

/** Registers the provided field and returns it.
* @param field The field to register.
* @returns Returns the provided field that has been registered.
*/
protected registerField(field: TFormFieldViewModel): TFormFieldViewModel {
this.registerFields(field);
return field;
}

/** Registers the provided fields.
/**
* Registers the provided fields.
* @param fields The fields to register.
*/
protected registerFields(...fields: readonly TFormFieldViewModel[]): void {
fields.forEach(field => {
field.propertiesChanged.subscribe(this._fieldChangedEventHandler);
this._fields.push(field);
});

if (fields.length > 0)
protected registerFields(...fields: readonly (TFormFieldViewModel | readonly TFormFieldViewModel[])[]): void {
const previousFieldCount = this._fields.length;

const currentFieldCount = this._fields.push(...fields.reduce<TFormFieldViewModel[]>(
(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 field.
* @param field The previously registered field.
*/
protected unregisterField(field: TFormFieldViewModel): void {
this.unregisterFields(field);
}

/** Unregisters the provided fields.
/**
* Unregisters the provided fields.
* @param fields The previously registered fields.
*/
protected unregisterFields(...fields: readonly TFormFieldViewModel[]): void {
protected unregisterFields(...fields: readonly (TFormFieldViewModel | readonly TFormFieldViewModel[])[]): void {
let hasUnregisteredFields = false;
fields.forEach(field => {

const removeField = (field: TFormFieldViewModel) => {
const indexToRemove = this._fields.indexOf(field);
if (indexToRemove >= 0) {
const removedField = this._fields[indexToRemove];
removedField.propertiesChanged.unsubscribe(this._fieldChangedEventHandler);
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}.
/**
* 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<TFormFieldViewModel extends IFormFieldViewModel<any>, TFormFields extends FormFieldSet<TFormFieldViewModel>> extends FormFieldCollectionViewModel<TFormFieldViewModel> {
/** Initializes a new instance of the {@link DynamicFormFieldCollectionViewModel} class.
/**
* 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: this.registerField(fields[fieldPropertyName])
value: formField
}
);
}
);
this.registerFields(...formFields);
}
}
4 changes: 2 additions & 2 deletions src/hooks/use-observable-collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export function useObservableCollection<TItem>(observableCollection: IReadOnlyOb
);
}

function hasChanges<TItem>(previous: readonly TItem[], next: readonly TItem[]): boolean {
return previous.length !== next.length || previous.some((item, index) => item !== next[index]);
function hasChanges<TItem>(previous: readonly TItem[], next: IReadOnlyObservableCollection<TItem>): 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.
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { type IEvent, type IEventHandler, type INotifyPropertiesChanged, type IPropertiesChangedEvent, type IPropertiesChangedEventHandler, type INotifyCollectionChanged, type ICollectionChangedEvent, type ICollectionChangedEventHandler, type IItemAddedEventArgs, type IItemRemovedEventArgs, type ItemRemovedCallback, type ICollectionChange, EventDispatcher, DispatchEvent } from './events';
export { type IEvent, type IEventHandler, type INotifyPropertiesChanged, type IPropertiesChangedEvent, type IPropertiesChangedEventHandler, type INotifyCollectionChanged, type ICollectionChangedEvent, type ICollectionChangedEventHandler, type ICollectionChange, EventDispatcher } from './events';

export { ViewModel, isViewModel } from './view-model';

Expand Down
Loading

0 comments on commit 28778f9

Please sign in to comment.