AAE-38287 Cannot select dropdown options when selection type is multiple (#11220)

* AAE-38287 Fix multiselect dropdown

* update tests

* update

* update

* update

* update

* update
This commit is contained in:
Bartosz Sekula
2025-09-22 15:54:38 +02:00
committed by GitHub
parent fb72ccffcc
commit 6b9d3d2104
6 changed files with 170 additions and 34 deletions

View File

@@ -36,9 +36,15 @@ import { FormFieldModel } from '../widgets/core/form-field.model';
import { FieldStylePipe } from '../../pipes/field-style.pipe';
import { FormFieldTypes } from '../widgets/core/form-field-types';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormWidget } from '../widgets/reactive-widget.interface';
declare const adf: any;
/**
* Component is a wrapper for widget components.
* It is responsible for instantiating the correct widget component
* based on the field type.
*/
@Component({
selector: 'adf-form-field',
templateUrl: './form-field.component.html',
@@ -98,7 +104,7 @@ export class FormFieldComponent implements OnInit, OnDestroy {
}
});
if (FormFieldTypes.isReactiveType(instance?.field?.type)) {
if (FormFieldTypes.isReactiveWidget(instance)) {
this.updateReactiveFormControlOnFormRulesEvent(instance);
}
}
@@ -106,7 +112,7 @@ export class FormFieldComponent implements OnInit, OnDestroy {
}
}
private updateReactiveFormControlOnFormRulesEvent(instance: any): void {
private updateReactiveFormControlOnFormRulesEvent(instance: ReactiveFormWidget): void {
instance?.formService.formRulesEvent.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
instance?.updateReactiveFormControl();
instance?.field?.form.validateForm(instance?.field);

View File

@@ -15,6 +15,8 @@
* limitations under the License.
*/
import { MaybeReactiveFormWidget, ReactiveFormWidget } from '../reactive-widget.interface';
/* eslint-disable @angular-eslint/component-selector */
export class FormFieldTypes {
@@ -71,6 +73,10 @@ export class FormFieldTypes {
return FormFieldTypes.REACTIVE_TYPES.includes(type);
}
static isReactiveWidget(instance: MaybeReactiveFormWidget): instance is ReactiveFormWidget {
return FormFieldTypes.REACTIVE_TYPES.includes(instance?.field?.type);
}
static isConstantValueType(type: string): boolean {
return FormFieldTypes.CONSTANT_VALUE_TYPES.includes(type);
}

View File

@@ -16,8 +16,13 @@
*/
import { FormService } from '../../services/form.service';
import { FormFieldModel } from './core/form-field.model';
import { WidgetComponent } from './widget.component';
export interface ReactiveFormWidget {
updateReactiveFormControl(): void;
formService: FormService;
field: FormFieldModel;
}
export type MaybeReactiveFormWidget = WidgetComponent | ReactiveFormWidget;

View File

@@ -12,7 +12,8 @@
<div>
<mat-form-field class="adf-form-field-input">
@if ( (field.name || this.field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id">{{ field.name | translate }}</mat-label> }
<mat-label class="adf-label" [attr.for]="field.id">{{ field.name | translate }}</mat-label>
}
<mat-select
class="adf-select"
[formControl]="dropdownControl"
@@ -26,10 +27,23 @@
>
<adf-select-filter-input *ngIf="showInputFilter" (change)="filter$.next($event)" />
<mat-option *ngFor="let opt of list$ | async" [value]="opt" [id]="opt.id">{{opt.name}}</mat-option>
<mat-option id="readonlyOption" *ngIf="isReadOnlyType" [value]="field.value">{{field.value}}</mat-option>
@for(opt of (list$ | async); track opt.id) {
<mat-option [value]="opt" [id]="opt.id">
{{opt.name}}
</mat-option>
}
@if(isReadOnlyType) {
<mat-option
[id]="'readonlyOption-' + field.id"
[value]="field.value"
>
{{field.value}}
</mat-option>
}
</mat-select>
</mat-form-field>
<div
class="adf-error-messages-container"
[ngClass]="!previewState && !field.readOnly ? 'adf-error-messages-container-visible' : 'adf-error-messages-container-hidden'"

View File

@@ -15,11 +15,20 @@
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { of, throwError } from 'rxjs';
import { DEFAULT_OPTION, DropdownCloudWidgetComponent } from './dropdown-cloud.widget';
import { FormFieldModel, FormModel, FormService, FormFieldEvent, FormFieldTypes, UnitTestingUtils } from '@alfresco/adf-core';
import { DEFAULT_OPTION, DROPDOWN_CLOUD_WIDGET_SET_VALUE_DEBOUNCE, DropdownCloudWidgetComponent } from './dropdown-cloud.widget';
import {
FormFieldModel,
FormModel,
FormService,
FormFieldEvent,
FormFieldTypes,
UnitTestingUtils,
FormFieldComponent,
FormRenderingService
} from '@alfresco/adf-core';
import { FormCloudService } from '../../../services/form-cloud.service';
import {
fakeOptionList,
@@ -294,11 +303,12 @@ describe('DropdownCloudWidgetComponent', () => {
expect(requiredErrorElement).toBeFalsy();
});
it('should not display required error when selecting a valid option for a required dropdown', async () => {
it('should not display required error when selecting a valid option for a required dropdown', fakeAsync(async () => {
widget.field.required = true;
widget.field.options = [{ id: 'empty', name: 'Choose empty' }, ...fakeOptionList];
widget.ngOnInit();
tick(DROPDOWN_CLOUD_WIDGET_SET_VALUE_DEBOUNCE);
const dropdown = await loader.getHarness(MatSelectHarness.with({ selector: '.adf-select' }));
await dropdown.open();
@@ -307,7 +317,7 @@ describe('DropdownCloudWidgetComponent', () => {
const requiredErrorElement = fixture.debugElement.query(By.css('.adf-dropdown-required-message .adf-error-text'));
expect(requiredErrorElement).toBeFalsy();
});
}));
it('should not have a value when switching from an available option to the None option', async () => {
widget.field.options = [{ id: 'empty', name: 'This is a mock none option' }, ...fakeOptionList];
@@ -985,48 +995,64 @@ describe('DropdownCloudWidgetComponent', () => {
expect(widget.field.options.length).toEqual(0);
};
it('should set dropdownControl value without emitting events if the mapping is a string', () => {
it('should set dropdownControl value without emitting events if the mapping is a string', fakeAsync(() => {
widget.field = {
value: 'testValue',
options: [],
isVisible: true
isVisible: true,
markAsValid: () => {}
} as any; // Mock field
fixture.detectChanges();
spyOn(widget.dropdownControl, 'setValue').and.callThrough();
widget['setFormControlValue']();
tick(DROPDOWN_CLOUD_WIDGET_SET_VALUE_DEBOUNCE);
expect(widget.dropdownControl.setValue).toHaveBeenCalledWith({ id: 'testValue', name: '' }, { emitEvent: false });
expect(widget.dropdownControl.value).toEqual({ id: 'testValue', name: '' });
});
}));
it('should set dropdownControl value when form field value gets changed', () => {
it('should set dropdownControl value when form field value gets changed', fakeAsync(() => {
widget.field = {
value: { id: 'Id_1', name: 'Label 1' },
options: [],
isVisible: true,
markAsValid: () => {}
} as FormFieldModel;
fixture.detectChanges();
spyOn(widget.dropdownControl, 'setValue').and.callThrough();
widget.updateReactiveFormControl();
tick(DROPDOWN_CLOUD_WIDGET_SET_VALUE_DEBOUNCE);
expect(widget.dropdownControl.setValue).toHaveBeenCalledWith({ id: 'Id_1', name: 'Label 1' }, { emitEvent: false });
expect(widget.dropdownControl.value).toEqual({ id: 'Id_1', name: 'Label 1' });
});
}));
it('should set dropdownControl value without emitting events if is an object', () => {
it('should set dropdownControl value without emitting events if is an object', fakeAsync(() => {
widget.field = {
value: { id: 'testValueObj', name: 'testValueObjName' },
options: [],
isVisible: true
} as any; // Mock field
isVisible: true,
markAsValid: () => {}
} as FormFieldModel;
fixture.detectChanges();
spyOn(widget.dropdownControl, 'setValue').and.callThrough();
widget['setFormControlValue']();
tick(DROPDOWN_CLOUD_WIDGET_SET_VALUE_DEBOUNCE);
expect(widget.dropdownControl.setValue).toHaveBeenCalledWith({ id: 'testValueObj', name: 'testValueObjName' }, { emitEvent: false });
expect(widget.dropdownControl.value).toEqual({ id: 'testValueObj', name: 'testValueObjName' });
});
}));
it('should display options persisted from process variable', async () => {
widget.field = getVariableDropdownWidget(
@@ -1191,3 +1217,68 @@ describe('DropdownCloudWidgetComponent', () => {
});
});
});
describe('DropdownCloudWidgetComponent instantiated by FormFieldComponent wrapper', () => {
let formFieldFixture: ComponentFixture<FormFieldComponent>;
let formFieldComponent: FormFieldComponent;
let loader: HarnessLoader;
let formRenderingService: FormRenderingService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [FormFieldComponent]
});
formFieldFixture = TestBed.createComponent(FormFieldComponent);
formFieldComponent = formFieldFixture.componentInstance;
loader = TestbedHarnessEnvironment.loader(formFieldFixture);
formRenderingService = TestBed.inject(FormRenderingService);
formRenderingService.register({
[FormFieldTypes.DROPDOWN]: () => DropdownCloudWidgetComponent
});
});
/* Checking if events emitted in FormFieldComponent are NOT triggering unnecessary calls to setValue in DropdownCloudWidgetComponent
This may result in setting wrong value in component
e.g. FormFieldComponent.updateReactiveFormControlOnFormRulesEvent
*/
it('should set dropdown controller value only once', async () => {
formFieldComponent.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', readOnly: false, id: 'form-id' }), {
id: 'multiselect-id',
name: 'multiselect',
type: 'dropdown',
selectionType: 'multiple',
options: [
{ id: 'option1', name: 'option1' },
{ id: 'option2', name: 'option2' },
{ id: 'other', name: 'other' }
]
});
const dropdown = await loader.getHarness(MatSelectHarness.with({ selector: '.adf-select' }));
await dropdown.open();
const dropdownCloudWidgetInstanceComponent = formFieldFixture.debugElement.query(
By.directive(DropdownCloudWidgetComponent)
).componentInstance;
const setValueSpy = spyOn(dropdownCloudWidgetInstanceComponent.dropdownControl, 'setValue').and.callThrough();
dropdownCloudWidgetInstanceComponent.event(new Event('focusin'));
// Not using dropdown.clickOptions from harness since it need ot be awaited
// I want to simulate other events at the same time
const option1 = formFieldFixture.debugElement.query(By.css('[ng-reflect-id="option1"]'));
option1.triggerEventHandler('click');
dropdownCloudWidgetInstanceComponent.event(new Event('focusout'));
await dropdown.close();
const selectedOption = await dropdown.getValueText();
expect(selectedOption).toEqual('option1');
expect(setValueSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -35,8 +35,8 @@ import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { TranslatePipe } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { BehaviorSubject, Subject } from 'rxjs';
import { debounceTime, filter, map } from 'rxjs/operators';
import { TaskVariableCloud } from '../../../models/task-variable-cloud.model';
import { FormCloudService } from '../../../services/form-cloud.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -48,6 +48,7 @@ export const DEFAULT_OPTION = {
name: 'Choose one...'
};
export const HIDE_FILTER_LIMIT = 5;
export const DROPDOWN_CLOUD_WIDGET_SET_VALUE_DEBOUNCE = 100;
/* eslint-disable @angular-eslint/component-selector */
@@ -91,6 +92,8 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
private readonly defaultVariableOptionLabel = 'name';
private readonly defaultVariableOptionPath = 'data';
private debounceSetValue = new Subject<void>();
get showRequiredMessage(): boolean {
return this.dropdownControl.touched && this.dropdownControl.errors?.required && !this.isRestApiFailed && !this.variableOptionsFailed;
}
@@ -128,6 +131,26 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
}
ngOnInit() {
/*
We can have a lot of 'control.setValue' caused by form rules events
e.g. every time if we focusin/focusout etc. we are calling a setValue.
*/
this.debounceSetValue.pipe(debounceTime(DROPDOWN_CLOUD_WIDGET_SET_VALUE_DEBOUNCE), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
let value: Array<FormFieldOption> | FormFieldOption | null | undefined;
if (Array.isArray(this.field.value)) {
value = this.field?.value;
} else if (this.field?.value && typeof this.field?.value === 'object') {
value = { id: this.field?.value.id, name: this.field?.value.name };
} else if (this.field.value === null) {
value = this.field.value;
} else {
value = { id: this.field?.value, name: '' };
}
this.dropdownControl.setValue(value, { emitEvent: false });
});
this.setupDropdown();
this.formService.onFormVariableChanged.subscribe(({ field }) => {
@@ -138,9 +161,8 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
}
updateReactiveFormControl(): void {
if (!this.field.hasMultipleValues) {
this.setFormControlValue();
}
this.updateFormControlState();
this.handleErrors();
}
@@ -198,15 +220,7 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
}
private setFormControlValue(): void {
if (Array.isArray(this.field.value)) {
this.dropdownControl.setValue(this.field?.value, { emitEvent: false });
} else if (this.field?.value && typeof this.field?.value === 'object') {
this.dropdownControl.setValue({ id: this.field?.value.id, name: this.field?.value.name }, { emitEvent: false });
} else if (this.field.value === null) {
this.dropdownControl.setValue(this.field?.value, { emitEvent: false });
} else {
this.dropdownControl.setValue({ id: this.field?.value, name: '' }, { emitEvent: false });
}
this.debounceSetValue.next();
}
private updateFormControlState(): void {