diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.spec.ts index b3c2984a4c..6d44acfcba 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.spec.ts @@ -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', () => { it('should have left labels classes on leftLabels true', async () => { widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', readOnly: false, leftLabels: true }), { diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.ts b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.ts index 02795e73e6..8187402f1f 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.ts @@ -99,6 +99,8 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI private readonly defaultVariableOptionLabel = 'name'; private readonly defaultVariableOptionPath = 'data'; + private parentValueWarningLogged = false; + private readonly debounceSetValue = new Subject(); get showRequiredMessage(): boolean { @@ -455,18 +457,49 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI takeUntilDestroyed(this.destroyRef) ) .subscribe((event: FormFieldEvent) => { - const valueOfParentWidget = event.field.value; + const valueOfParentWidget = this.normalizeParentValueToId(event.field.value); this.parentValueChanged(valueOfParentWidget); }); } - private getParentWidgetValue(): string { - const parentWidgetId = this.linkedWidgetId; - const parentWidget = this.getFormFieldById(parentWidgetId); - return parentWidget?.value; + private normalizeParentValueToId(value: any): string | null { + if (value === null || value === undefined || value === '') { + return null; + } + 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)) { this.isValidRestConfig ? this.persistFieldOptionsFromRestApi() : this.persistFieldOptionsFromManualList(value); } else if (this.isNoneValueSelected(value)) { @@ -479,15 +512,15 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI } } - private isNoneValueSelected(value: string): boolean { - return value === undefined; + private isNoneValueSelected(value: string | null): boolean { + return value === undefined || value === null; } private getFormFieldById(fieldId): FormFieldModel { return this.field.form.getFormFields().filter((field: FormFieldModel) => field.id === fieldId)[0]; } - private persistFieldOptionsFromManualList(value: string) { + private persistFieldOptionsFromManualList(value: string | null) { if (this.hasRuleEntries()) { const rulesEntries = this.field.rule.entries; rulesEntries.forEach((ruleEntry: RuleEntry) => {