Skip to content

Commit 4f07be2

Browse files
Clarin9/Port per-field type binding (submit.type-bind.field "A=>B") (#876) (#1431)
* Clarin9/Port per-field type binding (submit.type-bind.field "A=>B") (#876) Upstream type-bind supports exactly one global controlling field. LINDAT needs per-field control, expressed as `submit.type-bind.field = dc.type, dc.language.iso=>edm.type`, and none of that machinery was ported to the v9 branch: `FormFieldModel` had no `typeBindField` (so cerialize dropped the REST value), the parser always stamped the relation with the global type field, and `FormBuilderService` kept a single `typeField` string and a single type bind model. Selecting `edm.type = TEXT` therefore evaluated the relation against the empty `dc.type` model and the dependent language field kept its `d-none` class. - `FormFieldModel.typeBindField` is deserialized again. - `FormBuilderService` keeps a map of controlling fields (default + one entry per `A=>B` override, order-independent, duplicate-safe, trimmed) and a map of registered controlling models, plus a subject that emits every registration. `getTypeBindModel(ref?)` takes an optional ref so all existing call sites and mocks keep working. - `FieldParser.getTypeBindFieldRef()` stamps the relation with the controlling model id when `<type-bind field="...">` is declared, and otherwise with the field's own metadata name, which is resolved against the map later - the property arrives over REST asynchronously. - `DsDynamicTypeBindRelationService` passes the relation id through, no longer dereferences a missing bind model, and attaches to a controlling model that is only registered by a later `modelFromConfiguration()` call. Deliberate deviations from the 7.x implementation (documented in the PR): `findById` and `row-parser` are left untouched, `typeBindField` is not carried on control models, and the inverted second clause of the 7.x `getTypeBindModel` guard is dropped - it is a no-op for the LINDAT config and would otherwise fall back to `dc_type`, reproducing this very bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Review feedback: fix the subscription hand-off, scope the registry, harden parsing Behaviour: - subscribeRelations now returns a single owning Subscription. The caller spreads the returned array into its own, so a child created after that snapshot - which is exactly what the late-registration listener does - could never be torn down and kept mutating hidden/disabled on a destroyed model. - Always listen for type bind model registrations, not only when nothing was attached: until the real controlling model exists the field is temporarily attached to the default one, and that case was never re-attached (Copilot). - The type bind registry is now dropped when the submission changes. Sections of one submission still share it (a controlling field may live in another section), but a model from the previously opened collection's form can no longer answer lookups and defeat the fall-back-to-default behaviour. - Controlling models are registered again once submit.type-bind.field arrives, so an override that exists only in the property - with no <type-bind field="..."> in the XML - still resolves if the config lands after the form was parsed. - A self-referencing type bind is now a console.warn + skip instead of a throw: it can be raised from the registration callback, and one misconfigured field should not take down the whole submission section. - Tolerate blank/whitespace/malformed values in the property (a null entry used to throw inside the subscribe) and trim typeBindField before building the model id (Copilot). getTypeBindModel is typed `| undefined` (Copilot). Tests: late registration now asserts the relation is really re-evaluated and stops on unsubscribe; added the attached-to-default case, cross-submission isolation, a delayed configuration response, malformed values, and a cerialize round-trip proving typeBindField survives deserialization. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Copilot follow-up: bound the parsed-rows cache and fix a misleading comment - typeBindParsedRows is only filled until submit.type-bind.field has been processed, and is dropped once it has. A section form re-parses on every data update, so the cache would otherwise keep growing and retain the model graph of every re-parse for the lifetime of the submission. Without a config service the map can never change, so nothing is cached at all. - Reword the matchesCondition guard comment: getTypeBindModel falls back to the default model, so no model at all means neither the field's controlling model nor the default one has been registered yet - not that the controlling field is permanently absent from the form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Copilot follow-up: trim typeBindField in getTypeBindModelIds too isNotEmpty(' ') is true in this codebase, so a padded `<type-bind field=" edm.type ">` would have registered the controlling model as ' edm_type ' while FieldParser.getTypeBindFieldRef - which does trim - stamps the relations with 'edm_type'. The lookup would then miss, fall back to dc_type and leave the dependent field permanently hidden, i.e. reproduce the very bug this PR fixes. The existing end-to-end spec now uses a padded value, so it fails without the trim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Follow the controlling model when its section is re-parsed Attachment was deduped by model id, but setTypeBindModel emits on identity: when the section holding the controlling field is re-parsed (section forms re-parse on every data update) a NEW instance is registered under the same id, and a bound field in another section kept listening to the dead one - so the dependent field stopped reacting, which is the A3 symptom again in a narrower form. Attachment is now keyed by id but compared by identity, and the stale child subscription is removed from the owning Subscription and unsubscribed, so nothing accumulates across re-parses either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Copilot follow-up: do not evaluate a self-bound relation at all Skipping the self-reference in getRelatedFormModel only stopped it from being subscribed to; evaluateRelations still ran matchesCondition against the field's own (empty) value on the initial pass, hid the field, and - with nothing attached - never re-evaluated it, so a misconfigured <type-bind field="..."> pointing at its own metadata field made that field permanently unreachable. subscribeRelations now detects the self-reference up front, warns once and returns without evaluating or attaching anything, leaving the field exactly as rendered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Distinguish a configured self-reference from a not-yet-parsed controlling model The self-reference guard used the runtime lookup, which falls back to the default model. For a field whose own id IS the default model id, a relation whose real target simply had not been parsed yet therefore looked like a misconfiguration: subscribeRelations bailed out before wiring the registration listener, so the field never picked up its controlling model. Resolve the reference from configuration only (new FormBuilderService.resolveTypeBindModelId, order-independent) to decide whether the field really is bound to itself; the transient case now just skips the initial evaluation - so the field is not hidden for no reason - and still attaches when the real model is registered. Also add the spec that actually pins the getTypeBindModelIds trim. The one added in 79f94f1 did not: the suite's config already contributes 'edm_type' through its A=>B entry, so the padded id was merely an extra miss. The new case configures only 'dc.type', making the padded <type-bind field=" edm.type "> the sole source of the controlling id - verified to fail with the trim removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Copilot follow-up: make the default type bind model id explicit resolveTypeBindModelId declared `string` but ended in a bare Map.get, and getTypeBindModel fed the same possibly-undefined value into its fallback lookup. Both now go through getDefaultTypeBindModelId(), which falls back to the TYPE_BIND_DEFAULT_MODEL_ID constant that also replaces the four scattered 'dc_type' literals. Also fix a comment still naming the pre-rename dependsOnItself(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Copilot follow-up: route getTypeField through the guaranteed default too It was the one remaining place that returned a bare Map.get for a method declared to return string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Copilot follow-up: honest mock return value and an optional typeBindField - The shared FormBuilderService mock returned undefined from resolveTypeBindModelId while the real one always returns a model id. It now mirrors the real implementation for a reference the type field map does not remap, so a caller that starts using the value does not silently get undefined. - typeBindField is genuinely optional in the REST payload and the code already treats it that way (`?.trim()` in FieldParser and in getTypeBindModelIds, and a spec asserting it stays undefined when the attribute is absent). I argued for consistency with the other non-optional @autoserialize members earlier; the usage asymmetry is the stronger argument, so it is now declared optional. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Copilot follow-up: align the mock signature with the optional parameter resolveTypeBindModelId takes an optional ref, which is what the `??` fallback in the fake is there for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Shorten the type bind comments Keep the non-obvious reasoning, drop the prose around it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Shorten the type bind spec comments Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d513553 commit 4f07be2

9 files changed

Lines changed: 764 additions & 87 deletions

src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,18 @@ import {
1414
HIDDEN_MATCHER_PROVIDER,
1515
REQUIRED_MATCHER_PROVIDER,
1616
} from '@ng-dynamic-forms/core';
17+
import { Subject } from 'rxjs';
1718

1819
import { getMockFormBuilderService } from '../../../mocks/form-builder-service.mock';
1920
import {
21+
dcTypeInputConfig,
2022
mockInputWithTypeBindModel,
2123
MockRelationModel,
2224
} from '../../../mocks/form-models.mock';
2325
import { FormBuilderService } from '../form-builder.service';
2426
import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model';
2527
import { DsDynamicTypeBindRelationService } from './ds-dynamic-type-bind-relation.service';
28+
import { DsDynamicInputModel } from './models/ds-dynamic-input.model';
2629
import { getTypeBindRelations } from './type-bind.utils';
2730

2831
describe('DSDynamicTypeBindRelationService test suite', () => {
@@ -87,6 +90,12 @@ describe('DSDynamicTypeBindRelationService test suite', () => {
8790
const relatedModels = service.getRelatedFormModel(testModel);
8891
expect(relatedModels).toHaveSize(1);
8992
});
93+
it('Should ask the form builder for the model that controls this field', () => {
94+
const testModel = mockInputWithTypeBindModel;
95+
testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'edm_type');
96+
service.getRelatedFormModel(testModel);
97+
expect((service as any).formBuilderService.getTypeBindModel).toHaveBeenCalledWith('edm_type');
98+
});
9099
});
91100

92101
describe('Test matchesCondition method', () => {
@@ -129,6 +138,140 @@ describe('DSDynamicTypeBindRelationService test suite', () => {
129138
}
130139
});
131140

141+
it('Expect hasMatch to be true when the controlling model is not registered (field stays hidden)', () => {
142+
const testModel = mockInputWithTypeBindModel;
143+
testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'edm_type');
144+
((service as any).formBuilderService.getTypeBindModel as jasmine.Spy).and.returnValue(undefined);
145+
const relation = dynamicFormRelationService.findRelationByMatcher((testModel as any).typeBindRelations, HIDDEN_MATCHER);
146+
expect(service.matchesCondition(relation, HIDDEN_MATCHER)).toBeTruthy();
147+
});
148+
149+
it('Should attach to the controlling model as soon as it is registered, and stop when the caller unsubscribes', () => {
150+
const bindModelUpdates = new Subject<string>();
151+
const formBuilderServiceSpy: any = (service as any).formBuilderService;
152+
formBuilderServiceSpy.getTypeBindModelUpdates.and.returnValue(bindModelUpdates.asObservable());
153+
formBuilderServiceSpy.getTypeBindModel.and.returnValue(undefined);
154+
155+
const testModel = mockInputWithTypeBindModel;
156+
testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'edm_type');
157+
const dcTypeControl = new UntypedFormControl();
158+
// the caller spreads the result into its own array, so later children must hang off this one
159+
const [subscription] = service.subscribeRelations(testModel, dcTypeControl);
160+
161+
const controllingModel = new DsDynamicInputModel(dcTypeInputConfig);
162+
formBuilderServiceSpy.getTypeBindModel.and.returnValue(controllingModel);
163+
bindModelUpdates.next('edm_type');
164+
165+
controllingModel.value = 'anotherType';
166+
expect(testModel.hidden).toBeTrue();
167+
controllingModel.value = 'boundType';
168+
expect(testModel.hidden).toBeFalse();
169+
170+
subscription.unsubscribe();
171+
172+
controllingModel.value = 'anotherType';
173+
expect(testModel.hidden).toBeFalse();
174+
});
175+
176+
it('Should leave a self-bound field untouched instead of hiding it forever', () => {
177+
const formBuilderServiceSpy: any = (service as any).formBuilderService;
178+
const testModel = mockInputWithTypeBindModel;
179+
// the field's own <type-bind field="..."> resolves back to the field itself
180+
testModel.typeBindRelations = getTypeBindRelations(['boundType'], testModel.id);
181+
formBuilderServiceSpy.resolveTypeBindModelId.and.returnValue(testModel.id);
182+
formBuilderServiceSpy.getTypeBindModel.and.returnValue(testModel);
183+
testModel.hidden = false;
184+
185+
const subscriptions = service.subscribeRelations(testModel, new UntypedFormControl());
186+
187+
expect(service.getRelatedFormModel(testModel)).toHaveSize(0);
188+
// nothing is evaluated, so the misconfigured field stays usable instead of being hidden forever
189+
expect(testModel.hidden).toBeFalse();
190+
subscriptions.forEach((subscription) => subscription.unsubscribe());
191+
});
192+
193+
it('Should not hide a field whose controlling model has not been parsed yet, and attach when it is', () => {
194+
// edm_type is not registered yet, so the fallback default model happens to be this field itself
195+
const bindModelUpdates = new Subject<string>();
196+
const formBuilderServiceSpy: any = (service as any).formBuilderService;
197+
formBuilderServiceSpy.getTypeBindModelUpdates.and.returnValue(bindModelUpdates.asObservable());
198+
formBuilderServiceSpy.resolveTypeBindModelId.and.returnValue('edm_type');
199+
200+
const testModel = mockInputWithTypeBindModel;
201+
testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'edm_type');
202+
formBuilderServiceSpy.getTypeBindModel.and.returnValue(testModel);
203+
testModel.hidden = false;
204+
205+
const [subscription] = service.subscribeRelations(testModel, new UntypedFormControl());
206+
expect(testModel.hidden).toBeFalse();
207+
208+
const controllingModel = new DsDynamicInputModel({ ...dcTypeInputConfig, id: 'edm_type', name: 'edm.type' });
209+
formBuilderServiceSpy.getTypeBindModel.and.returnValue(controllingModel);
210+
bindModelUpdates.next('edm_type');
211+
212+
controllingModel.value = 'anotherType';
213+
expect(testModel.hidden).toBeTrue();
214+
215+
subscription.unsubscribe();
216+
});
217+
218+
it('Should attach the real controlling model even when it was first bound to the default one', () => {
219+
// dc_type is attached as the fallback, so the late edm_type registration must still be picked up
220+
const bindModelUpdates = new Subject<string>();
221+
const formBuilderServiceSpy: any = (service as any).formBuilderService;
222+
formBuilderServiceSpy.getTypeBindModelUpdates.and.returnValue(bindModelUpdates.asObservable());
223+
formBuilderServiceSpy.getTypeBindModel.and.returnValue(new DsDynamicInputModel(dcTypeInputConfig));
224+
225+
const testModel = mockInputWithTypeBindModel;
226+
testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'edm_type');
227+
const [subscription] = service.subscribeRelations(testModel, new UntypedFormControl());
228+
229+
const controllingModel = new DsDynamicInputModel({
230+
...dcTypeInputConfig,
231+
id: 'edm_type',
232+
name: 'edm.type',
233+
});
234+
formBuilderServiceSpy.getTypeBindModel.and.returnValue(controllingModel);
235+
bindModelUpdates.next('edm_type');
236+
237+
controllingModel.value = 'anotherType';
238+
expect(testModel.hidden).toBeTrue();
239+
controllingModel.value = 'boundType';
240+
expect(testModel.hidden).toBeFalse();
241+
242+
subscription.unsubscribe();
243+
});
244+
245+
it('Should follow a re-registered controlling model and drop the stale one', () => {
246+
// re-parsing the section that holds the controlling field yields a new instance under the same id
247+
const bindModelUpdates = new Subject<string>();
248+
const formBuilderServiceSpy: any = (service as any).formBuilderService;
249+
formBuilderServiceSpy.getTypeBindModelUpdates.and.returnValue(bindModelUpdates.asObservable());
250+
251+
const firstInstance = new DsDynamicInputModel({ ...dcTypeInputConfig, id: 'edm_type', name: 'edm.type' });
252+
formBuilderServiceSpy.getTypeBindModel.and.returnValue(firstInstance);
253+
254+
const testModel = mockInputWithTypeBindModel;
255+
testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'edm_type');
256+
const [subscription] = service.subscribeRelations(testModel, new UntypedFormControl());
257+
258+
const secondInstance = new DsDynamicInputModel({ ...dcTypeInputConfig, id: 'edm_type', name: 'edm.type' });
259+
formBuilderServiceSpy.getTypeBindModel.and.returnValue(secondInstance);
260+
bindModelUpdates.next('edm_type');
261+
262+
secondInstance.value = 'boundType';
263+
expect(testModel.hidden).toBeFalse();
264+
265+
// the replaced instance must no longer drive the field
266+
firstInstance.value = 'anotherType';
267+
expect(testModel.hidden).toBeFalse();
268+
269+
secondInstance.value = 'anotherType';
270+
expect(testModel.hidden).toBeTrue();
271+
272+
subscription.unsubscribe();
273+
});
274+
132275
});
133276

134277
});

src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts

Lines changed: 112 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -69,20 +69,44 @@ export class DsDynamicTypeBindRelationService {
6969

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

72-
if (model.id === rel.id) {
73-
throw new Error(`FormControl ${model.id} cannot depend on itself`);
72+
const bindModel: DynamicFormControlModel | undefined = this.formBuilderService.getTypeBindModel(rel?.id);
73+
74+
if (hasNoValue(bindModel)) {
75+
return;
7476
}
7577

76-
const bindModel: DynamicFormControlModel = this.formBuilderService.getTypeBindModel();
78+
if (bindModel.id === model.id) {
79+
// self-bound field, see isConfiguredToDependOnItself()
80+
return;
81+
}
7782

78-
if (model && !models.some((modelElement) => modelElement === bindModel)) {
83+
if (!models.some((modelElement) => modelElement === bindModel)) {
7984
models.push(bindModel);
8085
}
8186
}));
8287

8388
return models;
8489
}
8590

91+
/**
92+
* Whether the configuration binds the model to its own metadata field, i.e. a `<type-bind field>`
93+
* pointing at the field it is declared on. Config-only, so it can't be confused with a relation
94+
* that merely falls back to the default model until its real target is parsed.
95+
*/
96+
private isConfiguredToDependOnItself(model: DynamicFormControlModel): boolean {
97+
return ((model as any).typeBindRelations || []).some((relGroup) =>
98+
(relGroup.when || []).some((rel) => this.formBuilderService.resolveTypeBindModelId(rel?.id) === model.id));
99+
}
100+
101+
/**
102+
* Whether a relation resolves to the model itself *right now* - the misconfiguration above, or
103+
* transiently the default model standing in for a controlling model that isn't parsed yet.
104+
*/
105+
private currentlyResolvesToItself(model: DynamicFormControlModel): boolean {
106+
return ((model as any).typeBindRelations || []).some((relGroup) =>
107+
(relGroup.when || []).some((rel) => this.formBuilderService.getTypeBindModel(rel?.id)?.id === model.id));
108+
}
109+
86110
/**
87111
* Return false if the type bind relation (eg. {MATCH_VISIBLE, OR, ['book', 'book part']}) matches the value in
88112
* matcher.match or true if the opposite match. Since this is called with regard to actively *hiding* a form
@@ -102,7 +126,13 @@ export class DsDynamicTypeBindRelationService {
102126
// like relation group component and submission section form component).
103127
// This model (DynamicRelationGroupModel) contains eg. mandatory field, formConfiguration, relationFields,
104128
// submission scope, form/section type and other high level properties
105-
const bindModel: any = this.formBuilderService.getTypeBindModel();
129+
const bindModel: any = this.formBuilderService.getTypeBindModel(condition?.id);
130+
131+
// Nothing registered yet, not even the default fallback - the section holding the controlling
132+
// field hasn't been parsed. Keep MATCH_VISIBLE fields hidden until one shows up.
133+
if (hasNoValue(bindModel)) {
134+
return relation.match === matcher.opposingMatch;
135+
}
106136

107137
let values: string[];
108138
let bindModelValue = bindModel.value;
@@ -174,45 +204,90 @@ export class DsDynamicTypeBindRelationService {
174204
}
175205

176206
/**
177-
* Return an array of subscriptions to a calling component
207+
* Return an array of subscriptions to a calling component.
208+
*
209+
* One owning {@link Subscription} rather than the individual children: callers snapshot the
210+
* returned array, and children are still added afterwards when a controlling model shows up late.
211+
*
178212
* @param model
179213
* @param control
180214
*/
181215
subscribeRelations(model: DynamicFormControlModel, control: UntypedFormControl): Subscription[] {
182216

183-
const relatedModels = this.getRelatedFormModel(model);
184-
const subscriptions: Subscription[] = [];
185-
186-
Object.values(relatedModels).forEach((relatedModel: any) => {
187-
188-
if (hasValue(relatedModel)) {
189-
const initValue = (hasNoValue(relatedModel.value) || typeof relatedModel.value === 'string') ? relatedModel.value :
190-
(Array.isArray(relatedModel.value) ? relatedModel.value : relatedModel.value.value);
191-
192-
const updateSubject = (relatedModel.type === 'CHECKBOX_GROUP' ? relatedModel.valueUpdates : relatedModel.valueChanges);
193-
const valueChanges = updateSubject.pipe(
194-
startWith(initValue),
195-
);
196-
197-
// Build up the subscriptions to watch for changes;
198-
subscriptions.push(valueChanges.subscribe(() => {
199-
// Iterate each matcher
200-
if (hasValue(this.dynamicMatchers)) {
201-
this.dynamicMatchers.forEach((matcher) => {
202-
// Find the relation
203-
const relation = this.dynamicFormRelationService.findRelationByMatcher((model as any).typeBindRelations, matcher);
204-
// If the relation is defined, get matchesCondition result and pass it to the onChange event listener
205-
if (relation !== undefined) {
206-
const hasMatch = this.matchesCondition(relation, matcher);
207-
matcher.onChange(hasMatch, model, control, this.injector);
208-
}
209-
});
217+
const subscriptions = new Subscription();
218+
219+
if (this.isConfiguredToDependOnItself(model)) {
220+
// Upstream throws here, taking down the whole section over one bad field. Evaluating the
221+
// relation instead would hide the field forever, so leave it as rendered and warn.
222+
console.warn(`FormControl ${model.id} cannot depend on itself, ignoring its type bind relation`);
223+
return [subscriptions];
224+
}
225+
226+
// keyed by id, compared by identity: re-parsing a section yields a new instance under the same id
227+
const attachedModels = new Map<string, DynamicFormControlModel>();
228+
const attachedSubscriptions = new Map<string, Subscription>();
229+
230+
const attachRelatedModels = (relatedModels: DynamicFormControlModel[]) => {
231+
relatedModels.forEach((relatedModel: any) => {
232+
233+
if (hasValue(relatedModel) && attachedModels.get(relatedModel.id) !== relatedModel) {
234+
const staleSubscription = attachedSubscriptions.get(relatedModel.id);
235+
if (hasValue(staleSubscription)) {
236+
subscriptions.remove(staleSubscription);
237+
staleSubscription.unsubscribe();
210238
}
211-
}));
212-
}
213-
});
239+
attachedModels.set(relatedModel.id, relatedModel);
240+
241+
const initValue = (hasNoValue(relatedModel.value) || typeof relatedModel.value === 'string') ? relatedModel.value :
242+
(Array.isArray(relatedModel.value) ? relatedModel.value : relatedModel.value.value);
243+
244+
const updateSubject = (relatedModel.type === 'CHECKBOX_GROUP' ? relatedModel.valueUpdates : relatedModel.valueChanges);
245+
const valueChanges = updateSubject.pipe(
246+
startWith(initValue),
247+
);
248+
249+
// Build up the subscriptions to watch for changes;
250+
const valueChangesSubscription = valueChanges.subscribe(() => this.evaluateRelations(model, control));
251+
attachedSubscriptions.set(relatedModel.id, valueChangesSubscription);
252+
subscriptions.add(valueChangesSubscription);
253+
}
254+
});
255+
};
256+
257+
attachRelatedModels(this.getRelatedFormModel(model));
258+
259+
if (attachedModels.size === 0 && !this.currentlyResolvesToItself(model)) {
260+
// Nothing to listen to yet, so apply the "controlling model missing" fallback once. Skipped
261+
// when the relation resolves to this field itself - matching against our own value would hide
262+
// it while the real controlling model is still unparsed.
263+
this.evaluateRelations(model, control);
264+
}
214265

215-
return subscriptions;
266+
// The controlling model (e.g. `edm_type`) may only be registered by a later
267+
// modelFromConfiguration() call, so attach to it as soon as it shows up.
268+
subscriptions.add(this.formBuilderService.getTypeBindModelUpdates().subscribe(() => {
269+
attachRelatedModels(this.getRelatedFormModel(model));
270+
}));
271+
272+
return [subscriptions];
273+
}
274+
275+
/**
276+
* Re-evaluate every type bind relation of the given model and notify the matchers of the outcome
277+
*/
278+
private evaluateRelations(model: DynamicFormControlModel, control: UntypedFormControl): void {
279+
if (hasValue(this.dynamicMatchers)) {
280+
// Iterate each matcher
281+
this.dynamicMatchers.forEach((matcher) => {
282+
// Find the relation
283+
const relation = this.dynamicFormRelationService.findRelationByMatcher((model as any).typeBindRelations, matcher);
284+
// If the relation is defined, get matchesCondition result and pass it to the onChange event listener
285+
if (relation !== undefined) {
286+
const hasMatch = this.matchesCondition(relation, matcher);
287+
matcher.onChange(hasMatch, model, control, this.injector);
288+
}
289+
});
290+
}
216291
}
217292

218293
}

0 commit comments

Comments
 (0)