diff --git a/lib/core/src/lib/form/components/form-field/form-field.component.ts b/lib/core/src/lib/form/components/form-field/form-field.component.ts index b6590ecb7a..5d7233e0e8 100644 --- a/lib/core/src/lib/form/components/form-field/form-field.component.ts +++ b/lib/core/src/lib/form/components/form-field/form-field.component.ts @@ -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); diff --git a/lib/core/src/lib/form/components/widgets/core/form-field-types.ts b/lib/core/src/lib/form/components/widgets/core/form-field-types.ts index 6b906a9228..f6a814530b 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field-types.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field-types.ts @@ -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); } diff --git a/lib/core/src/lib/form/components/widgets/reactive-widget.interface.ts b/lib/core/src/lib/form/components/widgets/reactive-widget.interface.ts index 5965511c65..67ef97f9d8 100644 --- a/lib/core/src/lib/form/components/widgets/reactive-widget.interface.ts +++ b/lib/core/src/lib/form/components/widgets/reactive-widget.interface.ts @@ -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; diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.html b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.html index 39c48994f0..a23f75a89e 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.html +++ b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.html @@ -12,7 +12,8 @@
@if ( (field.name || this.field?.required) && !field.leftLabels) { - {{ field.name | translate }} } + {{ field.name | translate }} + } - {{opt.name}} - {{field.value}} + @for(opt of (list$ | async); track opt.id) { + + {{opt.name}} + + } + + @if(isReadOnlyType) { + + {{field.value}} + + } +
{ 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; + 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); + }); +}); 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 4fa9742fad..81eaf37204 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 @@ -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(); + 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 | 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.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 {