From 2e27e5c533c0c88738258037beb742c6b51498ed Mon Sep 17 00:00:00 2001 From: Alexandre Vryghem Date: Mon, 23 Jun 2025 17:20:49 +0200 Subject: [PATCH] 131984: Fix hierarchical vocabulary error messages being hidden after user interaction with the field This was because the blur event was never triggered, so the field still thinks that the field is being focused --- .../onebox/dynamic-onebox.component.html | 3 +- .../onebox/dynamic-onebox.component.spec.ts | 63 +++++++++++++++++-- .../models/onebox/dynamic-onebox.component.ts | 43 ++++++++----- .../vocabulary-treeview.component.html | 6 +- 4 files changed, 93 insertions(+), 22 deletions(-) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html index 3c19ecda13f..c99a52debca 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html @@ -43,7 +43,7 @@ [resultTemplate]="rt" [type]="model.inputType" [(ngModel)]="currentValue" - (blur)="onBlur($event)" + (blur)="onBlur($event, false)" (focus)="onFocus($event)" (change)="onChange($event)" (input)="onInput($event)" @@ -67,6 +67,7 @@ [disabled]="model.readOnly" [type]="model.inputType" [value]="currentValue?.display" + (blur)="onBlur($event, true)" (focus)="onFocus($event)" (change)="onChange($event)" (click)="openTree($event)" diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts index 69520aba633..2f3dc67a793 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts @@ -69,7 +69,7 @@ function init() { }; } -describe('DsDynamicOneboxComponent test suite', () => { +describe('DsDynamicOneboxComponent', () => { let scheduler: TestScheduler; let testComp: TestComponent; @@ -243,7 +243,9 @@ describe('DsDynamicOneboxComponent test suite', () => { it('should emit blur Event onBlur when popup is closed', () => { spyOn(oneboxComponent.blur, 'emit'); spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); - oneboxComponent.onBlur(new Event('blur')); + + oneboxCompFixture.debugElement.query(By.css('input')).triggerEventHandler('blur', new Event('blur')); + expect(oneboxComponent.blur.emit).toHaveBeenCalled(); }); @@ -262,7 +264,9 @@ describe('DsDynamicOneboxComponent test suite', () => { spyOn(oneboxComponent.blur, 'emit'); spyOn(oneboxComponent.change, 'emit'); spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); - oneboxComponent.onBlur(new Event('blur',)); + + oneboxCompFixture.debugElement.query(By.css('input')).triggerEventHandler('blur', new Event('blur')); + expect(oneboxComponent.change.emit).toHaveBeenCalled(); expect(oneboxComponent.blur.emit).toHaveBeenCalled(); }); @@ -275,7 +279,9 @@ describe('DsDynamicOneboxComponent test suite', () => { spyOn(oneboxComponent.blur, 'emit'); spyOn(oneboxComponent.change, 'emit'); spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); - oneboxComponent.onBlur(new Event('blur',)); + + oneboxCompFixture.debugElement.query(By.css('input')).triggerEventHandler('blur', new Event('blur')); + expect(oneboxComponent.change.emit).not.toHaveBeenCalled(); expect(oneboxComponent.blur.emit).toHaveBeenCalled(); }); @@ -288,7 +294,9 @@ describe('DsDynamicOneboxComponent test suite', () => { spyOn(oneboxComponent.blur, 'emit'); spyOn(oneboxComponent.change, 'emit'); spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); - oneboxComponent.onBlur(new Event('blur',)); + + oneboxCompFixture.debugElement.query(By.css('input')).triggerEventHandler('blur', new Event('blur')); + expect(oneboxComponent.change.emit).not.toHaveBeenCalled(); expect(oneboxComponent.blur.emit).toHaveBeenCalled(); }); @@ -410,6 +418,29 @@ describe('DsDynamicOneboxComponent test suite', () => { expect((oneboxComponent as any).modalService.open).toHaveBeenCalled(); done(); }); + + it('should emit the blur event when the popup is closed', () => { + spyOn(oneboxComponent.blur, 'emit'); + + oneboxComponent.vocabularyTreeOpen = false; + oneboxCompFixture.debugElement.query(By.css('input')).triggerEventHandler('blur', new Event('blur')); + + expect(oneboxComponent.blur.emit).toHaveBeenCalled(); + }); + + it('should not emit the blur event when the popup is open', fakeAsync(() => { + spyOn(oneboxComponent.blur, 'emit'); + spyOn(oneboxComponent, 'onBlur').and.callThrough(); + + scheduler.schedule(() => oneboxComponent.openTree(new Event('click'))); + scheduler.flush(); + expect(oneboxComponent.vocabularyTreeOpen).toBeTrue(); + + oneboxCompFixture.debugElement.query(By.css('input')).triggerEventHandler('blur', new Event('blur')); + + expect(oneboxComponent.onBlur).toHaveBeenCalled(); + expect(oneboxComponent.blur.emit).not.toHaveBeenCalled(); + })); }); describe('when init model value is not empty', () => { @@ -445,6 +476,28 @@ describe('DsDynamicOneboxComponent test suite', () => { expect((oneboxComponent as any).modalService.open).toHaveBeenCalled(); done(); }); + + it('should emit the blur event when the popup is closed', () => { + spyOn(oneboxComponent.blur, 'emit'); + + oneboxCompFixture.debugElement.query(By.css('input')).triggerEventHandler('blur', new Event('blur')); + + expect(oneboxComponent.blur.emit).toHaveBeenCalled(); + }); + + it('should not emit the blur event when the popup is open', fakeAsync(() => { + spyOn(oneboxComponent.blur, 'emit'); + spyOn(oneboxComponent, 'onBlur').and.callThrough(); + + scheduler.schedule(() => oneboxComponent.openTree(new Event('click'))); + scheduler.flush(); + expect(oneboxComponent.vocabularyTreeOpen).toBeTrue(); + + oneboxCompFixture.debugElement.query(By.css('input')).triggerEventHandler('blur', new Event('blur')); + + expect(oneboxComponent.onBlur).toHaveBeenCalled(); + expect(oneboxComponent.blur.emit).not.toHaveBeenCalled(); + })); }); }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts index 2ff4256404d..a41697d7183 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -62,6 +62,12 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple inputValue: any; preloadLevel: number; + /** + * Whether the controlled vocabulary tree popup modal is open. We use this to know whether the loss of focus was + * caused by opening the modal or if it was caused by the user. + */ + vocabularyTreeOpen = false; + private vocabulary$: Observable; private isHierarchicalVocabulary$: Observable; private subs: Subscription[] = []; @@ -171,22 +177,29 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple /** * Emits a blur event containing a given value. * @param event The value to emit. + * @param hierarchical Whether this event was emitted from a hierarchical vocabulary field */ - onBlur(event: Event) { - if (!this.instance.isPopupOpen()) { - if (!this.model.vocabularyOptions.closed && isNotEmpty(this.inputValue)) { - if (isNotNull(this.inputValue) && this.model.value !== this.inputValue) { - this.dispatchUpdate(this.inputValue); - } - this.inputValue = null; + onBlur(event: Event, hierarchical: boolean = false) { + if (hierarchical) { + if (!this.vocabularyTreeOpen) { + super.onBlur(event); } - this.blur.emit(event); } else { - // prevent on blur propagation if typeahed suggestions are showed - event.preventDefault(); - event.stopImmediatePropagation(); - // set focus on input again, this is to avoid to lose changes when no suggestion is selected - (event.target as HTMLInputElement).focus(); + if (!this.instance.isPopupOpen()) { + if (!this.model.vocabularyOptions.closed && isNotEmpty(this.inputValue)) { + if (isNotNull(this.inputValue) && this.model.value !== this.inputValue) { + this.dispatchUpdate(this.inputValue); + } + this.inputValue = null; + } + super.onBlur(event); + } else { + // prevent on blur propagation if typeahed suggestions are showed + event.preventDefault(); + event.stopImmediatePropagation(); + // set focus on input again, this is to avoid to lose changes when no suggestion is selected + (event.target as HTMLInputElement).focus(); + } } } @@ -226,6 +239,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple take(1) ).subscribe((preloadLevel) => { const modalRef: NgbModalRef = this.modalService.open(VocabularyTreeviewModalComponent, { size: 'lg', windowClass: 'treeview' }); + this.vocabularyTreeOpen = true; modalRef.componentInstance.vocabularyOptions = this.model.vocabularyOptions; modalRef.componentInstance.preloadLevel = preloadLevel; modalRef.componentInstance.selectedItems = this.currentValue ? [this.currentValue.value] : []; @@ -234,8 +248,9 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple this.currentValue = result; this.dispatchUpdate(result); } + this.vocabularyTreeOpen = false; }, () => { - return; + this.vocabularyTreeOpen = false; }); })); } diff --git a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html index fb7d1620082..336406c7b0e 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html @@ -45,6 +45,7 @@

{{node.item.display}} @@ -78,19 +79,20 @@

{{node.item.display}} - -