Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {UntypedFormControl, ReactiveFormsModule} from '@angular/forms';
import {FormBuilderService} from '../form-builder.service';
import {getMockFormBuilderService} from '../../../mocks/form-builder-service.mock';
import {Injector} from '@angular/core';
import { Subject } from 'rxjs';

describe('DSDynamicTypeBindRelationService test suite', () => {
let service: DsDynamicTypeBindRelationService;
Expand Down Expand Up @@ -81,6 +82,16 @@ describe('DSDynamicTypeBindRelationService test suite', () => {
const relatedModels = service.getRelatedFormModel(testModel);
expect(relatedModels).toHaveSize(1);
});

it('Should not push undefined bind models', () => {
const testModel = mockInputWithTypeBindModel;
testModel.typeBindRelations = getTypeBindRelations(['boundType']);
spyOn((service as any).formBuilderService, 'getTypeBindModel').and.returnValue(undefined);

const relatedModels = service.getRelatedFormModel(testModel);

expect(relatedModels).toHaveSize(0);
});
});

describe('Test matchesCondition method', () => {
Expand Down Expand Up @@ -123,6 +134,163 @@ describe('DSDynamicTypeBindRelationService test suite', () => {
}
});

it('Should return false for MATCH_VISIBLE when bind model is missing', () => {
const testModel = mockInputWithTypeBindModel;
testModel.typeBindRelations = getTypeBindRelations(['boundType']);
const relation = testModel.typeBindRelations[0];
const visibleMatcher: any = {
match: MATCH_VISIBLE,
opposingMatch: HIDDEN_MATCHER.match,
onChange: jasmine.createSpy('onChange')
};
spyOn((service as any).formBuilderService, 'getTypeBindModel').and.returnValue(undefined);

const hasMatch = service.matchesCondition(relation, visibleMatcher);

expect(hasMatch).toBeFalse();
});

it('Should return true for MATCH_HIDDEN matcher when bind model is missing', () => {
const testModel = mockInputWithTypeBindModel;
testModel.typeBindRelations = getTypeBindRelations(['boundType']);
const relation = testModel.typeBindRelations[0];
const hiddenMatcher: any = {
match: HIDDEN_MATCHER.match,
opposingMatch: MATCH_VISIBLE,
onChange: jasmine.createSpy('onChange')
};
spyOn((service as any).formBuilderService, 'getTypeBindModel').and.returnValue(undefined);

const hasMatch = service.matchesCondition(relation, hiddenMatcher);

expect(hasMatch).toBeTrue();
});

it('Should re-evaluate visibility when bind model becomes available after setup', () => {
const testModel = mockInputWithTypeBindModel;
testModel.typeBindRelations = getTypeBindRelations(['boundType']);
const dcTypeControl = new UntypedFormControl();
const bindModelUpdates$ = new Subject<string>();
let bindModelAvailable = false;
const bindModel: any = {
type: 'INPUT',
value: 'boundType',
valueChanges: new Subject<string>(),
valueUpdates: new Subject<string>(),
};

const visibleMatcher: any = {
match: MATCH_VISIBLE,
opposingMatch: HIDDEN_MATCHER.match,
onChange: jasmine.createSpy('onChange')
};
(service as any).dynamicMatchers = [visibleMatcher];
spyOn((service as any).formBuilderService, 'getTypeBindModel').and.callFake(() => bindModelAvailable ? bindModel : undefined);
spyOn((service as any).formBuilderService, 'getTypeBindModelUpdates').and.returnValue(bindModelUpdates$.asObservable());

const subscriptions = service.subscribeRelations(testModel, dcTypeControl);
expect(subscriptions.length).toBe(1);
// Initial evaluation with missing bind model keeps MATCH_VISIBLE hidden.
expect(visibleMatcher.onChange).toHaveBeenCalledWith(false, testModel, dcTypeControl, jasmine.anything());

// Simulate late registration of a type bind model.
bindModelAvailable = true;
bindModelUpdates$.next('dc_type');

expect(visibleMatcher.onChange).toHaveBeenCalledWith(true, testModel, dcTypeControl, jasmine.anything());
subscriptions.forEach((sub) => sub.unsubscribe());
});

it('Should re-evaluate hidden matcher when bind model becomes available after setup', () => {
const testModel = mockInputWithTypeBindModel;
testModel.typeBindRelations = getTypeBindRelations(['boundType']);
const dcTypeControl = new UntypedFormControl();
const bindModelUpdates$ = new Subject<string>();
let bindModelAvailable = false;
const bindModel: any = {
type: 'INPUT',
value: 'boundType',
valueChanges: new Subject<string>(),
valueUpdates: new Subject<string>(),
};

const hiddenMatcher: any = {
match: HIDDEN_MATCHER.match,
opposingMatch: MATCH_VISIBLE,
onChange: jasmine.createSpy('onChange')
};
(service as any).dynamicMatchers = [hiddenMatcher];
spyOn((service as any).formBuilderService, 'getTypeBindModel').and.callFake(() => bindModelAvailable ? bindModel : undefined);
spyOn((service as any).formBuilderService, 'getTypeBindModelUpdates').and.returnValue(bindModelUpdates$.asObservable());

const subscriptions = service.subscribeRelations(testModel, dcTypeControl);
expect(subscriptions.length).toBe(1);
expect(hiddenMatcher.onChange).toHaveBeenCalledWith(true, testModel, dcTypeControl, jasmine.anything());

bindModelAvailable = true;
bindModelUpdates$.next('dc_type');

expect(hiddenMatcher.onChange).toHaveBeenCalledWith(false, testModel, dcTypeControl, jasmine.anything());
subscriptions.forEach((sub) => sub.unsubscribe());
});

it('Should react to late bind model value changes after registration', () => {
const testModel = mockInputWithTypeBindModel;
testModel.typeBindRelations = getTypeBindRelations(['boundType']);
const dcTypeControl = new UntypedFormControl();
const bindModelUpdates$ = new Subject<string>();
let bindModelAvailable = false;
const bindModel: any = {
id: 'dc_type',
type: 'INPUT',
value: 'boundType',
valueChanges: new Subject<string>(),
valueUpdates: new Subject<string>(),
};

const visibleMatcher: any = {
match: MATCH_VISIBLE,
opposingMatch: HIDDEN_MATCHER.match,
onChange: jasmine.createSpy('onChange')
};
(service as any).dynamicMatchers = [visibleMatcher];
spyOn((service as any).formBuilderService, 'getTypeBindModel').and.callFake(() => bindModelAvailable ? bindModel : undefined);
spyOn((service as any).formBuilderService, 'getTypeBindModelUpdates').and.returnValue(bindModelUpdates$.asObservable());

const subscriptions = service.subscribeRelations(testModel, dcTypeControl);
expect(visibleMatcher.onChange).toHaveBeenCalledWith(false, testModel, dcTypeControl, jasmine.anything());

// Register late model and verify the matcher becomes visible.
bindModelAvailable = true;
bindModelUpdates$.next('dc_type');
expect(visibleMatcher.onChange).toHaveBeenCalledWith(true, testModel, dcTypeControl, jasmine.anything());

// Changing controlling model value should re-trigger evaluation via valueChanges subscription.
visibleMatcher.onChange.calls.reset();
bindModel.value = 'anotherType';
bindModel.valueChanges.next('anotherType');
expect(visibleMatcher.onChange).toHaveBeenCalledWith(false, testModel, dcTypeControl, jasmine.anything());

subscriptions.forEach((sub) => sub.unsubscribe());
});

it('Should evaluate only once during setup when related model already exists', () => {
const testModel = mockInputWithTypeBindModel;
testModel.typeBindRelations = getTypeBindRelations(['boundType']);
const dcTypeControl = new UntypedFormControl();
const visibleMatcher: any = {
match: MATCH_VISIBLE,
opposingMatch: HIDDEN_MATCHER.match,
onChange: jasmine.createSpy('onChange')
};
(service as any).dynamicMatchers = [visibleMatcher];

const subscriptions = service.subscribeRelations(testModel, dcTypeControl);

expect(visibleMatcher.onChange.calls.count()).toBe(1);
subscriptions.forEach((sub) => sub.unsubscribe());
});

});

});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Inject, Injectable, Injector, Optional } from '@angular/core';
import { UntypedFormControl } from '@angular/forms';

import { Subscription } from 'rxjs';
import { startWith } from 'rxjs/operators';
import { filter } from 'rxjs/operators';

import {
AND_OPERATOR,
Expand Down Expand Up @@ -69,7 +69,7 @@ export class DsDynamicTypeBindRelationService {

const bindModel: DynamicFormControlModel = this.formBuilderService.getTypeBindModel(rel?.id);

if (model && !models.some((modelElement) => modelElement === bindModel)) {
if (bindModel && !models.some((modelElement) => modelElement === bindModel)) {
models.push(bindModel);
}
}));
Expand Down Expand Up @@ -98,6 +98,13 @@ export class DsDynamicTypeBindRelationService {
// submission scope, form/section type and other high level properties
const bindModel: any = this.formBuilderService.getTypeBindModel(condition?.id);

// If the bind model is not available yet, keep fields with MATCH_VISIBLE hidden.
// For opposite matchers (e.g. HIDDEN matcher evaluating a VISIBLE relation), return true to preserve
// the previous hiding behavior until the bind model is available.
if (hasNoValue(bindModel)) {
return relation.match === matcher.opposingMatch;
}

let values: string[];
let bindModelValue = bindModel.value;

Expand Down Expand Up @@ -176,39 +183,58 @@ export class DsDynamicTypeBindRelationService {

const relatedModels = this.getRelatedFormModel(model);
const subscriptions: Subscription[] = [];
const attachedModelIds = new Set<string>();

Object.values(relatedModels).forEach((relatedModel: any) => {

if (hasValue(relatedModel)) {
const initValue = (hasNoValue(relatedModel.value) || typeof relatedModel.value === 'string') ? relatedModel.value :
(Array.isArray(relatedModel.value) ? relatedModel.value : relatedModel.value.value);

const updateSubject = (relatedModel.type === 'CHECKBOX_GROUP' ? relatedModel.valueUpdates : relatedModel.valueChanges);
const valueChanges = updateSubject.pipe(
startWith(initValue)
);

// Build up the subscriptions to watch for changes;
subscriptions.push(valueChanges.subscribe(() => {
// Iterate each matcher
if (hasValue(this.dynamicMatchers)) {
this.dynamicMatchers.forEach((matcher) => {
// Find the relation
const relation = this.dynamicFormRelationService.findRelationByMatcher((model as any).typeBindRelations, matcher);
// If the relation is defined, get matchesCondition result and pass it to the onChange event listener
if (relation !== undefined) {
const hasMatch = this.matchesCondition(relation, matcher);
matcher.onChange(hasMatch, model, control, this.injector);
}
});
}
}));
}
});
// If no related model is available at setup time, evaluate once so MATCH_VISIBLE fallback logic is applied.
if (relatedModels.length === 0) {
this.evaluateRelations(model, control);
}

const attachRelatedModels = (models: DynamicFormControlModel[]) => {
Object.values(models).forEach((relatedModel: any) => {

if (hasValue(relatedModel) && !attachedModelIds.has(relatedModel.id)) {
attachedModelIds.add(relatedModel.id);

const updateSubject = (relatedModel.type === 'CHECKBOX_GROUP' ? relatedModel.valueUpdates : relatedModel.valueChanges);

// Build up the subscriptions to watch for changes;
subscriptions.push(updateSubject.subscribe(() => this.evaluateRelations(model, control)));
}
});
};

attachRelatedModels(relatedModels);

// If no related model was found at this point, listen for type-bind model registration and attach
// value change listeners as soon as related models become available.
if (relatedModels.length === 0) {
subscriptions.push(this.formBuilderService.getTypeBindModelUpdates().pipe(
filter((bindModelId: string) => hasValue(bindModelId))
).subscribe(() => {
const lateRelatedModels = this.getRelatedFormModel(model);
attachRelatedModels(lateRelatedModels);
this.evaluateRelations(model, control);
}));
}

return subscriptions;
}

private evaluateRelations(model: DynamicFormControlModel, control: UntypedFormControl): void {
if (hasValue(this.dynamicMatchers)) {
this.dynamicMatchers.forEach((matcher) => {
// Find the relation
const relation = this.dynamicFormRelationService.findRelationByMatcher((model as any).typeBindRelations, matcher);
// If the relation is defined, get matchesCondition result and pass it to the onChange event listener
if (relation !== undefined) {
const hasMatch = this.matchesCondition(relation, matcher);
matcher.onChange(hasMatch, model, control, this.injector);
}
});
}
}

/**
* Helper function to construct a typeBindRelations array
* @param configuredTypeBindValues
Expand Down
12 changes: 12 additions & 0 deletions src/app/shared/form/builder/form-builder.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
DynamicPathable,
parseReviver,
} from '@ng-dynamic-forms/core';
import { Observable, Subject } from 'rxjs';
import isObject from 'lodash/isObject';
import isString from 'lodash/isString';
import mergeWith from 'lodash/mergeWith';
Expand Down Expand Up @@ -64,6 +65,11 @@ export class FormBuilderService extends DynamicFormService {
*/
private typeBindModel: Map<string,DynamicFormControlModel>;

/**
* Emits when a type bind model is registered.
*/
private typeBindModelUpdates: Subject<string>;

/**
* This map contains the active forms model
*/
Expand All @@ -90,6 +96,7 @@ export class FormBuilderService extends DynamicFormService {
this.formGroups = new Map();
this.typeFields = new Map();
this.typeBindModel = new Map();
this.typeBindModelUpdates = new Subject<string>();

this.typeFields.set(TYPE_BIND_DEFAULT_KEY, 'dc_type');
// If optional config service was passed, perform an initial set of type field (default dc_type) for type binds
Expand Down Expand Up @@ -134,6 +141,11 @@ export class FormBuilderService extends DynamicFormService {

setTypeBindModel(model: DynamicFormControlModel) {
this.typeBindModel.set(model.id, model);
this.typeBindModelUpdates.next(model.id);
}

getTypeBindModelUpdates(): Observable<string> {
return this.typeBindModelUpdates.asObservable();
}

findById(id: string | string[], groupModel: DynamicFormControlModel[], arrayIndex = null): DynamicFormControlModel | null {
Expand Down
3 changes: 3 additions & 0 deletions src/app/shared/mocks/form-builder-service.mock.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { FormBuilderService } from '../form/builder/form-builder.service';
import { UntypedFormControl, UntypedFormGroup } from '@angular/forms';
import {DsDynamicInputModel} from '../form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model';
import { Subject } from 'rxjs';

export function getMockFormBuilderService(): FormBuilderService {
const typeBindModelUpdates = new Subject<string>();

return jasmine.createSpyObj('FormBuilderService', {
modelFromConfiguration: [],
Expand Down Expand Up @@ -40,6 +42,7 @@ export function getMockFormBuilderService(): FormBuilderService {
]
}
),
getTypeBindModelUpdates: typeBindModelUpdates.asObservable(),
setTypeBindFieldFromConfig: {},
});

Expand Down
Loading