AAE-46249 Normalize cascade dropdown parent value before child option lookup

This commit is contained in:
Alex Molodyh
2026-07-10 14:47:53 -07:00
parent 7f57b3936b
commit 13096f76e1
2 changed files with 185 additions and 9 deletions
@@ -1009,6 +1009,149 @@ describe('DropdownCloudWidgetComponent', () => {
}); });
}); });
describe('cascade parent value normalization', () => {
const parentDropdown = new FormFieldModel(new FormModel(), { id: 'parentDropdown', type: 'dropdown' });
beforeEach(() => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', readOnly: 'false' }), {
id: 'child-dropdown-id',
name: 'child-dropdown',
type: 'dropdown',
optionType: 'manual',
rule: {
ruleOn: 'parentDropdown',
entries: mockConditionalEntries
}
});
fixture.detectChanges();
});
it('should resolve child options when parent value is an object { id, name }', () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', readOnly: 'false' }), {
id: 'child-dropdown-id',
name: 'child-dropdown',
type: 'dropdown',
optionType: 'manual',
rule: { ruleOn: 'parentDropdown', entries: mockConditionalEntries }
});
const mockParentDropdown = { id: 'parentDropdown', value: { id: 'GR', name: 'Greece' }, validate: (): boolean => true };
spyOn(widget.field.form, 'getFormFields').and.returnValue([mockParentDropdown]);
widget.ngOnInit();
expect(widget.field.options).toEqual(mockConditionalEntries[0].options);
});
it('should resolve child options when parent value is a plain string (no regression)', () => {
parentDropdown.value = 'GR';
widget.selectionChangedForField(parentDropdown);
fixture.detectChanges();
expect(widget.field.options).toEqual(mockConditionalEntries[0].options);
});
it('should clear child options and not warn when parent value is null', () => {
const warnSpy = spyOn(console, 'warn');
parentDropdown.value = null;
widget.selectionChangedForField(parentDropdown);
fixture.detectChanges();
expect(widget.field.options).toEqual([]);
expect(warnSpy).not.toHaveBeenCalled();
});
it('should clear child options and not warn when parent value is undefined', () => {
const warnSpy = spyOn(console, 'warn');
parentDropdown.value = undefined;
widget.selectionChangedForField(parentDropdown);
fixture.detectChanges();
expect(widget.field.options).toEqual([]);
expect(warnSpy).not.toHaveBeenCalled();
});
it('should resolve child options from first element when parent value is an array of objects', () => {
parentDropdown.value = [{ id: 'GR', name: 'Greece' }];
widget.selectionChangedForField(parentDropdown);
fixture.detectChanges();
expect(widget.field.options).toEqual(mockConditionalEntries[0].options);
});
it('should resolve child options from first element when parent value is an array of strings', () => {
parentDropdown.value = ['GR', 'IT'];
widget.selectionChangedForField(parentDropdown);
fixture.detectChanges();
expect(widget.field.options).toEqual(mockConditionalEntries[0].options);
});
it('should clear child options and not warn when parent value is an empty string', () => {
const warnSpy = spyOn(console, 'warn');
widget.field.options = mockConditionalEntries[1].options;
parentDropdown.value = '';
widget.selectionChangedForField(parentDropdown);
fixture.detectChanges();
expect(widget.field.options).toEqual([]);
expect(warnSpy).not.toHaveBeenCalled();
});
it('should clear child options and warn exactly once for unexpected value shapes', () => {
const warnSpy = spyOn(console, 'warn');
parentDropdown.value = 42;
widget.selectionChangedForField(parentDropdown);
fixture.detectChanges();
parentDropdown.value = { name: 'no id' };
widget.selectionChangedForField(parentDropdown);
fixture.detectChanges();
expect(widget.field.options).toEqual([]);
expect(warnSpy.calls.count()).toBe(1);
});
it('should return null for null input to normalizeParentValueToId', () => {
expect((widget as any).normalizeParentValueToId(null)).toBeNull();
});
it('should return null for undefined input to normalizeParentValueToId', () => {
expect((widget as any).normalizeParentValueToId(undefined)).toBeNull();
});
it('should return null for empty string input to normalizeParentValueToId', () => {
expect((widget as any).normalizeParentValueToId('')).toBeNull();
});
it('should return the same string for string input to normalizeParentValueToId', () => {
expect((widget as any).normalizeParentValueToId('GR')).toBe('GR');
});
it('should return id for { id, name } object input to normalizeParentValueToId', () => {
expect((widget as any).normalizeParentValueToId({ id: 'GR', name: 'Greece' })).toBe('GR');
});
it('should return first element for array of strings input to normalizeParentValueToId', () => {
expect((widget as any).normalizeParentValueToId(['GR', 'IT'])).toBe('GR');
});
it('should return id of first element for array of objects input to normalizeParentValueToId', () => {
expect((widget as any).normalizeParentValueToId([{ id: 'GR', name: 'Greece' }])).toBe('GR');
});
it('should return null and warn for unexpected shape input to normalizeParentValueToId', () => {
const warnSpy = spyOn(console, 'warn');
expect((widget as any).normalizeParentValueToId(42)).toBeNull();
expect(warnSpy).toHaveBeenCalled();
});
it('should return null and warn once for an empty array input to normalizeParentValueToId', () => {
const warnSpy = spyOn(console, 'warn');
expect((widget as any).normalizeParentValueToId([])).toBeNull();
expect(warnSpy.calls.count()).toBe(1);
});
});
describe('when form model has left labels', () => { describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => { it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', readOnly: false, leftLabels: true }), { widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', readOnly: false, leftLabels: true }), {
@@ -99,6 +99,8 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
private readonly defaultVariableOptionLabel = 'name'; private readonly defaultVariableOptionLabel = 'name';
private readonly defaultVariableOptionPath = 'data'; private readonly defaultVariableOptionPath = 'data';
private parentValueWarningLogged = false;
private readonly debounceSetValue = new Subject<void>(); private readonly debounceSetValue = new Subject<void>();
get showRequiredMessage(): boolean { get showRequiredMessage(): boolean {
@@ -455,18 +457,49 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
) )
.subscribe((event: FormFieldEvent) => { .subscribe((event: FormFieldEvent) => {
const valueOfParentWidget = event.field.value; const valueOfParentWidget = this.normalizeParentValueToId(event.field.value);
this.parentValueChanged(valueOfParentWidget); this.parentValueChanged(valueOfParentWidget);
}); });
} }
private getParentWidgetValue(): string { private normalizeParentValueToId(value: any): string | null {
const parentWidgetId = this.linkedWidgetId; if (value === null || value === undefined || value === '') {
const parentWidget = this.getFormFieldById(parentWidgetId); return null;
return parentWidget?.value; }
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
const first = value[0];
if (typeof first === 'string') {
return first;
}
if (first && typeof first === 'object' && 'id' in first) {
return first.id;
}
return this.warnUnexpectedParentValue(value);
}
if (typeof value === 'object' && 'id' in value) {
return value.id;
}
return this.warnUnexpectedParentValue(value);
} }
private parentValueChanged(value: string) { private warnUnexpectedParentValue(value: any): null {
if (!this.parentValueWarningLogged) {
this.parentValueWarningLogged = true;
console.warn('DropdownCloudWidgetComponent: unexpected parent widget value shape for cascade lookup', value);
}
return null;
}
private getParentWidgetValue(): string | null {
const parentWidgetId = this.linkedWidgetId;
const parentWidget = this.getFormFieldById(parentWidgetId);
return this.normalizeParentValueToId(parentWidget?.value);
}
private parentValueChanged(value: string | null) {
if (value && !this.isNoneValueSelected(value)) { if (value && !this.isNoneValueSelected(value)) {
this.isValidRestConfig ? this.persistFieldOptionsFromRestApi() : this.persistFieldOptionsFromManualList(value); this.isValidRestConfig ? this.persistFieldOptionsFromRestApi() : this.persistFieldOptionsFromManualList(value);
} else if (this.isNoneValueSelected(value)) { } else if (this.isNoneValueSelected(value)) {
@@ -479,15 +512,15 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
} }
} }
private isNoneValueSelected(value: string): boolean { private isNoneValueSelected(value: string | null): boolean {
return value === undefined; return value === undefined || value === null;
} }
private getFormFieldById(fieldId): FormFieldModel { private getFormFieldById(fieldId): FormFieldModel {
return this.field.form.getFormFields().filter((field: FormFieldModel) => field.id === fieldId)[0]; return this.field.form.getFormFields().filter((field: FormFieldModel) => field.id === fieldId)[0];
} }
private persistFieldOptionsFromManualList(value: string) { private persistFieldOptionsFromManualList(value: string | null) {
if (this.hasRuleEntries()) { if (this.hasRuleEntries()) {
const rulesEntries = this.field.rule.entries; const rulesEntries = this.field.rule.entries;
rulesEntries.forEach((ruleEntry: RuleEntry) => { rulesEntries.forEach((ruleEntry: RuleEntry) => {