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 @@ -14,15 +14,18 @@ import {
HIDDEN_MATCHER_PROVIDER,
REQUIRED_MATCHER_PROVIDER,
} from '@ng-dynamic-forms/core';
import { Subject } from 'rxjs';

import { getMockFormBuilderService } from '../../../mocks/form-builder-service.mock';
import {
dcTypeInputConfig,
mockInputWithTypeBindModel,
MockRelationModel,
} from '../../../mocks/form-models.mock';
import { FormBuilderService } from '../form-builder.service';
import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model';
import { DsDynamicTypeBindRelationService } from './ds-dynamic-type-bind-relation.service';
import { DsDynamicInputModel } from './models/ds-dynamic-input.model';
import { getTypeBindRelations } from './type-bind.utils';

describe('DSDynamicTypeBindRelationService test suite', () => {
Expand Down Expand Up @@ -87,6 +90,12 @@ describe('DSDynamicTypeBindRelationService test suite', () => {
const relatedModels = service.getRelatedFormModel(testModel);
expect(relatedModels).toHaveSize(1);
});
it('Should ask the form builder for the model that controls this field', () => {
const testModel = mockInputWithTypeBindModel;
testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'edm_type');
service.getRelatedFormModel(testModel);
expect((service as any).formBuilderService.getTypeBindModel).toHaveBeenCalledWith('edm_type');
});
});

describe('Test matchesCondition method', () => {
Expand Down Expand Up @@ -129,6 +138,140 @@ describe('DSDynamicTypeBindRelationService test suite', () => {
}
});

it('Expect hasMatch to be true when the controlling model is not registered (field stays hidden)', () => {
const testModel = mockInputWithTypeBindModel;
testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'edm_type');
((service as any).formBuilderService.getTypeBindModel as jasmine.Spy).and.returnValue(undefined);
const relation = dynamicFormRelationService.findRelationByMatcher((testModel as any).typeBindRelations, HIDDEN_MATCHER);
expect(service.matchesCondition(relation, HIDDEN_MATCHER)).toBeTruthy();
});

it('Should attach to the controlling model as soon as it is registered, and stop when the caller unsubscribes', () => {
const bindModelUpdates = new Subject<string>();
const formBuilderServiceSpy: any = (service as any).formBuilderService;
formBuilderServiceSpy.getTypeBindModelUpdates.and.returnValue(bindModelUpdates.asObservable());
formBuilderServiceSpy.getTypeBindModel.and.returnValue(undefined);

const testModel = mockInputWithTypeBindModel;
testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'edm_type');
const dcTypeControl = new UntypedFormControl();
// the caller spreads the result into its own array, so later children must hang off this one
const [subscription] = service.subscribeRelations(testModel, dcTypeControl);

const controllingModel = new DsDynamicInputModel(dcTypeInputConfig);
formBuilderServiceSpy.getTypeBindModel.and.returnValue(controllingModel);
bindModelUpdates.next('edm_type');

controllingModel.value = 'anotherType';
expect(testModel.hidden).toBeTrue();
controllingModel.value = 'boundType';
expect(testModel.hidden).toBeFalse();

subscription.unsubscribe();

controllingModel.value = 'anotherType';
expect(testModel.hidden).toBeFalse();
});

it('Should leave a self-bound field untouched instead of hiding it forever', () => {
const formBuilderServiceSpy: any = (service as any).formBuilderService;
const testModel = mockInputWithTypeBindModel;
// the field's own <type-bind field="..."> resolves back to the field itself
testModel.typeBindRelations = getTypeBindRelations(['boundType'], testModel.id);
formBuilderServiceSpy.resolveTypeBindModelId.and.returnValue(testModel.id);
formBuilderServiceSpy.getTypeBindModel.and.returnValue(testModel);
testModel.hidden = false;

const subscriptions = service.subscribeRelations(testModel, new UntypedFormControl());

expect(service.getRelatedFormModel(testModel)).toHaveSize(0);
// nothing is evaluated, so the misconfigured field stays usable instead of being hidden forever
expect(testModel.hidden).toBeFalse();
subscriptions.forEach((subscription) => subscription.unsubscribe());
});

it('Should not hide a field whose controlling model has not been parsed yet, and attach when it is', () => {
// edm_type is not registered yet, so the fallback default model happens to be this field itself
const bindModelUpdates = new Subject<string>();
const formBuilderServiceSpy: any = (service as any).formBuilderService;
formBuilderServiceSpy.getTypeBindModelUpdates.and.returnValue(bindModelUpdates.asObservable());
formBuilderServiceSpy.resolveTypeBindModelId.and.returnValue('edm_type');

const testModel = mockInputWithTypeBindModel;
testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'edm_type');
formBuilderServiceSpy.getTypeBindModel.and.returnValue(testModel);
testModel.hidden = false;

const [subscription] = service.subscribeRelations(testModel, new UntypedFormControl());
expect(testModel.hidden).toBeFalse();

const controllingModel = new DsDynamicInputModel({ ...dcTypeInputConfig, id: 'edm_type', name: 'edm.type' });
formBuilderServiceSpy.getTypeBindModel.and.returnValue(controllingModel);
bindModelUpdates.next('edm_type');

controllingModel.value = 'anotherType';
expect(testModel.hidden).toBeTrue();

subscription.unsubscribe();
});

it('Should attach the real controlling model even when it was first bound to the default one', () => {
// dc_type is attached as the fallback, so the late edm_type registration must still be picked up
const bindModelUpdates = new Subject<string>();
const formBuilderServiceSpy: any = (service as any).formBuilderService;
formBuilderServiceSpy.getTypeBindModelUpdates.and.returnValue(bindModelUpdates.asObservable());
formBuilderServiceSpy.getTypeBindModel.and.returnValue(new DsDynamicInputModel(dcTypeInputConfig));

const testModel = mockInputWithTypeBindModel;
testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'edm_type');
const [subscription] = service.subscribeRelations(testModel, new UntypedFormControl());

const controllingModel = new DsDynamicInputModel({
...dcTypeInputConfig,
id: 'edm_type',
name: 'edm.type',
});
formBuilderServiceSpy.getTypeBindModel.and.returnValue(controllingModel);
bindModelUpdates.next('edm_type');

controllingModel.value = 'anotherType';
expect(testModel.hidden).toBeTrue();
controllingModel.value = 'boundType';
expect(testModel.hidden).toBeFalse();

subscription.unsubscribe();
});

it('Should follow a re-registered controlling model and drop the stale one', () => {
// re-parsing the section that holds the controlling field yields a new instance under the same id
const bindModelUpdates = new Subject<string>();
const formBuilderServiceSpy: any = (service as any).formBuilderService;
formBuilderServiceSpy.getTypeBindModelUpdates.and.returnValue(bindModelUpdates.asObservable());

const firstInstance = new DsDynamicInputModel({ ...dcTypeInputConfig, id: 'edm_type', name: 'edm.type' });
formBuilderServiceSpy.getTypeBindModel.and.returnValue(firstInstance);

const testModel = mockInputWithTypeBindModel;
testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'edm_type');
const [subscription] = service.subscribeRelations(testModel, new UntypedFormControl());

const secondInstance = new DsDynamicInputModel({ ...dcTypeInputConfig, id: 'edm_type', name: 'edm.type' });
formBuilderServiceSpy.getTypeBindModel.and.returnValue(secondInstance);
bindModelUpdates.next('edm_type');

secondInstance.value = 'boundType';
expect(testModel.hidden).toBeFalse();

// the replaced instance must no longer drive the field
firstInstance.value = 'anotherType';
expect(testModel.hidden).toBeFalse();

secondInstance.value = 'anotherType';
expect(testModel.hidden).toBeTrue();

subscription.unsubscribe();
});

});

});
Original file line number Diff line number Diff line change
Expand Up @@ -69,20 +69,44 @@ export class DsDynamicTypeBindRelationService {

(model as any).typeBindRelations.forEach((relGroup) => relGroup.when.forEach((rel) => {

if (model.id === rel.id) {
throw new Error(`FormControl ${model.id} cannot depend on itself`);
const bindModel: DynamicFormControlModel | undefined = this.formBuilderService.getTypeBindModel(rel?.id);

if (hasNoValue(bindModel)) {
return;
}

const bindModel: DynamicFormControlModel = this.formBuilderService.getTypeBindModel();
if (bindModel.id === model.id) {
// self-bound field, see isConfiguredToDependOnItself()
return;
}

if (model && !models.some((modelElement) => modelElement === bindModel)) {
if (!models.some((modelElement) => modelElement === bindModel)) {
models.push(bindModel);
}
}));

return models;
}

/**
* Whether the configuration binds the model to its own metadata field, i.e. a `<type-bind field>`
* pointing at the field it is declared on. Config-only, so it can't be confused with a relation
* that merely falls back to the default model until its real target is parsed.
*/
private isConfiguredToDependOnItself(model: DynamicFormControlModel): boolean {
return ((model as any).typeBindRelations || []).some((relGroup) =>
(relGroup.when || []).some((rel) => this.formBuilderService.resolveTypeBindModelId(rel?.id) === model.id));
}

/**
* Whether a relation resolves to the model itself *right now* - the misconfiguration above, or
* transiently the default model standing in for a controlling model that isn't parsed yet.
*/
private currentlyResolvesToItself(model: DynamicFormControlModel): boolean {
return ((model as any).typeBindRelations || []).some((relGroup) =>
(relGroup.when || []).some((rel) => this.formBuilderService.getTypeBindModel(rel?.id)?.id === model.id));
}

/**
* Return false if the type bind relation (eg. {MATCH_VISIBLE, OR, ['book', 'book part']}) matches the value in
* matcher.match or true if the opposite match. Since this is called with regard to actively *hiding* a form
Expand All @@ -102,7 +126,13 @@ export class DsDynamicTypeBindRelationService {
// like relation group component and submission section form component).
// This model (DynamicRelationGroupModel) contains eg. mandatory field, formConfiguration, relationFields,
// submission scope, form/section type and other high level properties
const bindModel: any = this.formBuilderService.getTypeBindModel();
const bindModel: any = this.formBuilderService.getTypeBindModel(condition?.id);

// Nothing registered yet, not even the default fallback - the section holding the controlling
// field hasn't been parsed. Keep MATCH_VISIBLE fields hidden until one shows up.
if (hasNoValue(bindModel)) {
return relation.match === matcher.opposingMatch;
}

let values: string[];
let bindModelValue = bindModel.value;
Expand Down Expand Up @@ -174,45 +204,90 @@ export class DsDynamicTypeBindRelationService {
}

/**
* Return an array of subscriptions to a calling component
* Return an array of subscriptions to a calling component.
*
* One owning {@link Subscription} rather than the individual children: callers snapshot the
* returned array, and children are still added afterwards when a controlling model shows up late.
*
* @param model
* @param control
*/
subscribeRelations(model: DynamicFormControlModel, control: UntypedFormControl): Subscription[] {

const relatedModels = this.getRelatedFormModel(model);
const subscriptions: Subscription[] = [];

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);
}
});
const subscriptions = new Subscription();

if (this.isConfiguredToDependOnItself(model)) {
// Upstream throws here, taking down the whole section over one bad field. Evaluating the
// relation instead would hide the field forever, so leave it as rendered and warn.
console.warn(`FormControl ${model.id} cannot depend on itself, ignoring its type bind relation`);
return [subscriptions];
}

// keyed by id, compared by identity: re-parsing a section yields a new instance under the same id
const attachedModels = new Map<string, DynamicFormControlModel>();
const attachedSubscriptions = new Map<string, Subscription>();

const attachRelatedModels = (relatedModels: DynamicFormControlModel[]) => {
relatedModels.forEach((relatedModel: any) => {

if (hasValue(relatedModel) && attachedModels.get(relatedModel.id) !== relatedModel) {
const staleSubscription = attachedSubscriptions.get(relatedModel.id);
if (hasValue(staleSubscription)) {
subscriptions.remove(staleSubscription);
staleSubscription.unsubscribe();
}
}));
}
});
attachedModels.set(relatedModel.id, 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;
const valueChangesSubscription = valueChanges.subscribe(() => this.evaluateRelations(model, control));
attachedSubscriptions.set(relatedModel.id, valueChangesSubscription);
subscriptions.add(valueChangesSubscription);
}
});
};

attachRelatedModels(this.getRelatedFormModel(model));

if (attachedModels.size === 0 && !this.currentlyResolvesToItself(model)) {
// Nothing to listen to yet, so apply the "controlling model missing" fallback once. Skipped
// when the relation resolves to this field itself - matching against our own value would hide
// it while the real controlling model is still unparsed.
this.evaluateRelations(model, control);
}

return subscriptions;
// The controlling model (e.g. `edm_type`) may only be registered by a later
// modelFromConfiguration() call, so attach to it as soon as it shows up.
subscriptions.add(this.formBuilderService.getTypeBindModelUpdates().subscribe(() => {
attachRelatedModels(this.getRelatedFormModel(model));
}));

return [subscriptions];
}

/**
* Re-evaluate every type bind relation of the given model and notify the matchers of the outcome
*/
private evaluateRelations(model: DynamicFormControlModel, control: UntypedFormControl): void {
if (hasValue(this.dynamicMatchers)) {
// Iterate each matcher
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);
}
});
}
}

}
Loading
Loading