Skip to content

Commit

Permalink
Added map item validation trigger
Browse files Browse the repository at this point in the history
  • Loading branch information
Andrei Fangli committed Sep 1, 2024
1 parent ff0f15e commit df468c1
Show file tree
Hide file tree
Showing 11 changed files with 416 additions and 21 deletions.
24 changes: 13 additions & 11 deletions src/collections/observableMap/ReadOnlyObservableMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,19 +132,21 @@ export class ReadOnlyObservableMap<TKey, TItem> extends ViewModel implements IRe
* @see [Map.set](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Map/set)
*/
protected set(key: TKey, item: TItem): this {
this._changeToken = (this._changeToken + 1) % Number.MAX_VALUE;
if (!this._map.has(key) || item !== this._map.get(key)) {
this._changeToken = (this._changeToken + 1) % Number.MAX_VALUE;

const previousSize = this.size;
this._map.set(key, item);
const previousSize = this.size;
this._map.set(key, item);

this._mapChangedEvent.dispatch(this, {
operation: 'set',
addedEntries: [[key, item]],
removedEntries: []
});
this._mapChangedEvent.dispatch(this, {
operation: 'set',
addedEntries: [[key, item]],
removedEntries: []
});

if (previousSize !== this.size)
this.notifyPropertiesChanged('size');
if (previousSize !== this.size)
this.notifyPropertiesChanged('size');
}

return this;
}
Expand Down Expand Up @@ -180,7 +182,7 @@ export class ReadOnlyObservableMap<TKey, TItem> extends ViewModel implements IRe
protected clear(): void {
if (this.size > 0) {
this._changeToken = (this._changeToken + 1) % Number.MAX_VALUE;

const removedEntries = Array.from(this._map.entries());
this._map.clear();

Expand Down
31 changes: 30 additions & 1 deletion src/collections/observableMap/tests/ObservableMap.set.tests.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ObservableMap } from '../ObservableMap';
import { selfResult, testMutatingOperation } from './common';
import { selfResult, testBlankMutatingOperation, testMutatingOperation } from './common';

describe('ObservableMap.set', (): void => {
it('setting an item in an empty map adds it', (): void => {
Expand Down Expand Up @@ -58,6 +58,35 @@ describe('ObservableMap.set', (): void => {
});
});

it('setting the same item on the same key has no effect', (): void => {
testBlankMutatingOperation<number, string>({
initialState: [
[1, 'a'],
[2, 'b'],
[3, 'c']
],

applyOperation: map => map.set(2, 'b'),
expectedResult: selfResult
});
});

it('setting the same item while iterating does not break iterators', (): void => {
expect(
() => {
const observableMap = new ObservableMap<number, string>([
[1, 'a'],
[2, 'b'],
[3, 'c']
]);

for (const _ of observableMap)
observableMap.set(2, 'b');
})
.not
.toThrow();
});

it('setting items while iterating breaks iterators', (): void => {
expect(
() => {
Expand Down
9 changes: 6 additions & 3 deletions src/validation/objectValidator/IObjectValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import type { IValidator, ValidatorCallback } from '../IValidator';
import type { IReadOnlyObjectValidator } from './IReadOnlyObjectValidator';
import type { WellKnownValidationTrigger, ValidationTrigger } from '../triggers';


export interface IObjectValidator<TValidatable extends IValidatable<TValidationError> & INotifyPropertiesChanged, TValidationError = string> extends IReadOnlyObjectValidator<TValidatable, TValidationError> {
readonly validators: IObservableCollection<IValidator<TValidatable, TValidationError>>;
readonly triggers: IObservableSet<WellKnownValidationTrigger | ValidationTrigger>;
readonly triggers: IValidationTriggersSet;

add<TItem = unknown>(validator: IValidator<TValidatable, TValidationError> | ValidatorCallback<TValidatable, TValidationError>, triggers?: readonly (WellKnownValidationTrigger<TItem> | ValidationTrigger)[]): this;
add<TKey = unknown, TItem = unknown>(validator: IValidator<TValidatable, TValidationError> | ValidatorCallback<TValidatable, TValidationError>, triggers?: readonly (WellKnownValidationTrigger<TKey, TItem> | ValidationTrigger)[]): this;

validate(): TValidationError | null;

reset(): this;
}

export interface IValidationTriggersSet extends IObservableSet<WellKnownValidationTrigger | ValidationTrigger> {
add<TKey = unknown, TItem = unknown>(trigger: WellKnownValidationTrigger<TKey, TItem> | ValidationTrigger): this;
}
6 changes: 3 additions & 3 deletions src/validation/objectValidator/ObjectValidator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { IEventHandler } from '../../events';
import type { IValidatable } from '../IValidatable';
import type { IValidator, ValidatorCallback } from '../IValidator';
import type { IObjectValidator } from './IObjectValidator';
import type { IObjectValidator, IValidationTriggersSet } from './IObjectValidator';
import type { WellKnownValidationTrigger, ValidationTrigger } from '../triggers';
import type { INotifyPropertiesChanged } from '../../viewModels';
import { type IObservableCollection, type IObservableSet, ObservableCollection, ObservableSet } from '../../collections';
Expand Down Expand Up @@ -79,9 +79,9 @@ export class ObjectValidator<TValidatable extends IValidatable<TValidationError>
public readonly target: TValidatable;

public readonly validators: IObservableCollection<IValidator<TValidatable, TValidationError>>;
public readonly triggers: IObservableSet<WellKnownValidationTrigger | ValidationTrigger>;
public readonly triggers: IValidationTriggersSet;

public add<TItem = unknown>(validator: IValidator<TValidatable, TValidationError> | ValidatorCallback<TValidatable, TValidationError>, triggers?: readonly (WellKnownValidationTrigger<TItem> | ValidationTrigger)[]): this {
public add<TKey = unknown, TItem = unknown>(validator: IValidator<TValidatable, TValidationError> | ValidatorCallback<TValidatable, TValidationError>, triggers?: readonly (WellKnownValidationTrigger<TKey, TItem> | ValidationTrigger)[]): this {
if (triggers !== null && triggers !== undefined)
triggers.forEach(this.triggers.add, this.triggers);

Expand Down
2 changes: 1 addition & 1 deletion src/validation/objectValidator/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export type { IReadOnlyObjectValidator } from './IReadOnlyObjectValidator';
export type { IObjectValidator } from './IObjectValidator';
export type { IObjectValidator, IValidationTriggersSet } from './IObjectValidator';
export { ObjectValidator } from './ObjectValidator';
229 changes: 229 additions & 0 deletions src/validation/tests/MapItemValidationTrigger.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { ObservableMap } from "../../collections";
import { ViewModel } from "../../viewModels";
import { MapItemValidationTrigger } from "../triggers/MapItemValidationTrigger";

describe('MapItemValidationTrigger', (): void => {
it('validation is triggered when map item changes', (): void => {
let invocationCount = 0;
const item = new TestItem();
const map = new ObservableMap<number, TestItem>([[1, item]]);
const validationTrigger = new MapItemValidationTrigger({
map,
validationTriggerSelector({ viewModel }) {
return [viewModel];
}
});
validationTrigger.validationTriggered.subscribe({
handle(subject) {
invocationCount++;
expect(subject).toStrictEqual(validationTrigger);
}
});

item.viewModel.notifyPropertiesChanged();

expect(invocationCount).toBe(1);
});

it('validation is triggered when an item is added to the map', (): void => {
let invocationCount = 0;
const map = new ObservableMap<number, TestItem>();
const validationTrigger = new MapItemValidationTrigger({
map,
validationTriggerSelector({ viewModel }) {
return [viewModel];
}
});
validationTrigger.validationTriggered.subscribe({
handle(subject) {
invocationCount++;
expect(subject).toStrictEqual(validationTrigger);
}
});

const item = new TestItem();
map.set(1, item);

expect(invocationCount).toBe(1);
});

it('validation is triggered once when the map contains the same item multiple times and it changes', (): void => {
let invocationCount = 0;
const item = new TestItem();
const map = new ObservableMap<number, TestItem>([[1, item], [2, item], [3, item]]);
const validationTrigger = new MapItemValidationTrigger({
map,
validationTriggerSelector({ viewModel }) {
return [viewModel];
}
});
validationTrigger.validationTriggered.subscribe({
handle(subject) {
invocationCount++;
expect(subject).toStrictEqual(validationTrigger);
}
});

item.viewModel.notifyPropertiesChanged();

expect(invocationCount).toBe(1);
});

it('validation is triggered each time the same item is added to the map and triggered once when it changes ', (): void => {
let invocationCount = 0;
const map = new ObservableMap<number, TestItem>();
const validationTrigger = new MapItemValidationTrigger({
map,
validationTriggerSelector({ viewModel }) {
return [viewModel];
}
});
validationTrigger.validationTriggered.subscribe({
handle(subject) {
invocationCount++;
expect(subject).toStrictEqual(validationTrigger);
}
});

const item = new TestItem();
map.set(1, item);
expect(invocationCount).toBe(1);

map.set(2, item);
expect(invocationCount).toBe(2);

map.set(3, item);
expect(invocationCount).toBe(3);

item.viewModel.notifyPropertiesChanged();
expect(invocationCount).toBe(4);
});

it('validation is no longer triggered when a removed item changes', () => {
let invocationCount = 0;
const item1 = new TestItem();
const item2 = new TestItem();
const map = new ObservableMap<number, TestItem>([[1, item1], [2, item2]]);
const validationTrigger = new MapItemValidationTrigger({
map,
validationTriggerSelector({ viewModel }) {
return [viewModel];
}
});
validationTrigger.validationTriggered.subscribe({
handle(subject) {
invocationCount++;
expect(subject).toStrictEqual(validationTrigger);
}
});

map.delete(1);
expect(invocationCount).toBe(1);

item1.viewModel.notifyPropertiesChanged();
expect(invocationCount).toBe(1);

item2.viewModel.notifyPropertiesChanged();
expect(invocationCount).toBe(2);
});

it('validation is still triggered when the same item was added multiple times, removed, but at least one instance is still contained by the map', () => {
let invocationCount = 0;
const item1 = new TestItem();
const item2 = new TestItem();
const map = new ObservableMap<number, TestItem>([[1, item1], [2, item2]]);
const validationTrigger = new MapItemValidationTrigger({
map,
validationTriggerSelector({ viewModel }) {
return [viewModel];
}
});
validationTrigger.validationTriggered.subscribe({
handle(subject) {
invocationCount++;
expect(subject).toStrictEqual(validationTrigger);
}
});

map.set(3, item1);
expect(invocationCount).toBe(1);

map.set(4, item1);
expect(invocationCount).toBe(2);

map.delete(1);
expect(invocationCount).toBe(3);

item1.viewModel.notifyPropertiesChanged();
expect(invocationCount).toBe(4);

item2.viewModel.notifyPropertiesChanged();
expect(invocationCount).toBe(5);

map.delete(3);
expect(invocationCount).toBe(6);

item1.viewModel.notifyPropertiesChanged();
expect(invocationCount).toBe(7);

item2.viewModel.notifyPropertiesChanged();
expect(invocationCount).toBe(8);

map.delete(4);
expect(invocationCount).toBe(9);

item1.viewModel.notifyPropertiesChanged();
expect(invocationCount).toBe(9);

item2.viewModel.notifyPropertiesChanged();
expect(invocationCount).toBe(10);

map.delete(2);
expect(invocationCount).toBe(11);

item1.viewModel.notifyPropertiesChanged();
expect(invocationCount).toBe(11);

item2.viewModel.notifyPropertiesChanged();
expect(invocationCount).toBe(11);
});

it('validation is not triggered when check returns false', () => {
let invocationCount = 0;
let checkInvocationCount = 0;
const item = new TestItem();
const map = new ObservableMap<number, TestItem>([[1, item]]);
const validationTrigger = new MapItemValidationTrigger({
map,
validationTriggerSelector({ viewModel }) {
return [viewModel];
},
shouldTriggerValidation(actualItem) {
checkInvocationCount++;
expect(actualItem).toStrictEqual(item);
return false;
}
});
validationTrigger.validationTriggered.subscribe({
handle(subject) {
invocationCount++;
expect(subject).toStrictEqual(validationTrigger);
}
});

item.viewModel.notifyPropertiesChanged();

expect(invocationCount).toBe(0);
expect(checkInvocationCount).toBe(1);
});
});

class TestItem {
public viewModel = new FakeViewModel();
}

class FakeViewModel extends ViewModel {
public notifyPropertiesChanged(): void {
super.notifyPropertiesChanged('propertiesChanged');
}
}
21 changes: 21 additions & 0 deletions src/validation/tests/ObjectValidator.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,27 @@ describe('ObjectValidator', () => {
expect(validatable.error).toBe('test error');
});

it('adding a map item trigger validates target when an item changes', () => {
let invocationCount = 0;
const item = new FakeValidatable();
const validatable = new FakeValidatable();
const observableMap = new ObservableMap<number, FakeValidatable>([[1, item]]);

const objectValidator = new ObjectValidator({ target: validatable });
objectValidator.add(
() => {
invocationCount++;
return 'test error';
},
[[observableMap, item => [item]]]
);

item.notifyPropertiesChanged();

expect(invocationCount).toBe(2);
expect(validatable.error).toBe('test error');
});

it('adding validation triggers multiple times only registers them once', () => {
const validatable = new FakeValidatable();
const viewModelValidationTrigger = new FakeValidatable();
Expand Down
Loading

0 comments on commit df468c1

Please sign in to comment.