From 79d54ea4e4f0a4ce087caf664818c45f60c8a5b6 Mon Sep 17 00:00:00 2001 From: arditdomi <32884230+arditdomi@users.noreply.github.com> Date: Mon, 1 Nov 2021 00:30:55 +0000 Subject: [PATCH] [AAE-6025] - Resolve linked dropdowns during runtime (#7289) * Resolve linked dropdown during runtime, Draft commit * Remove app from appconfig * Make the link work for Saved and Completed tasks * Call the new API to fetch dropdown options in case of rest type * When widgetId is missing from restUrl * Update unit tests * Declare default option * Rebase, remove appName example * Maurizify the PR * Fix lint error Co-authored-by: Ardit Domi --- .../widgets/core/form-field-rule.ts | 28 +++ .../widgets/core/form-field.model.ts | 3 + .../form/components/widgets/core/index.ts | 1 + lib/core/i18n/en.json | 1 + .../dropdown/dropdown-cloud.widget.html | 12 +- .../dropdown/dropdown-cloud.widget.scss | 11 + .../dropdown/dropdown-cloud.widget.spec.ts | 209 ++++++++++++++---- .../widgets/dropdown/dropdown-cloud.widget.ts | 136 +++++++++++- .../lib/form/mocks/linked-dropdown.mock.ts | 85 +++++++ 9 files changed, 437 insertions(+), 49 deletions(-) create mode 100644 lib/core/form/components/widgets/core/form-field-rule.ts create mode 100644 lib/process-services-cloud/src/lib/form/mocks/linked-dropdown.mock.ts diff --git a/lib/core/form/components/widgets/core/form-field-rule.ts b/lib/core/form/components/widgets/core/form-field-rule.ts new file mode 100644 index 0000000000..6e8169522a --- /dev/null +++ b/lib/core/form/components/widgets/core/form-field-rule.ts @@ -0,0 +1,28 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FormFieldOption } from './form-field-option'; + +export interface FormFieldRule { + ruleOn: string; + entries: RuleEntry[]; +} + +export interface RuleEntry { + key: string; + options: FormFieldOption[]; +} diff --git a/lib/core/form/components/widgets/core/form-field.model.ts b/lib/core/form/components/widgets/core/form-field.model.ts index 2a5dea5cae..d29905b140 100644 --- a/lib/core/form/components/widgets/core/form-field.model.ts +++ b/lib/core/form/components/widgets/core/form-field.model.ts @@ -26,6 +26,7 @@ import { FormFieldTypes } from './form-field-types'; import { NumberFieldValidator } from './form-field-validator'; import { FormWidgetModel } from './form-widget.model'; import { FormModel } from './form.model'; +import { FormFieldRule } from './form-field-rule'; // Maps to FormFieldRepresentation export class FormFieldModel extends FormWidgetModel { @@ -72,6 +73,7 @@ export class FormFieldModel extends FormWidgetModel { currency: string = null; dateDisplayFormat: string = this.defaultDateFormat; selectionType: 'single' | 'multiple' = null; + rule?: FormFieldRule; // container model members numberOfColumns: number = 1; @@ -178,6 +180,7 @@ export class FormFieldModel extends FormWidgetModel { this.validationSummary = new ErrorMessageModel(); this.tooltip = json.tooltip; this.selectionType = json.selectionType; + this.rule = json.rule; if (json.placeholder && json.placeholder !== '' && json.placeholder !== 'null') { this.placeholder = json.placeholder; diff --git a/lib/core/form/components/widgets/core/index.ts b/lib/core/form/components/widgets/core/index.ts index dd2d3d0bc7..5075159f16 100644 --- a/lib/core/form/components/widgets/core/index.ts +++ b/lib/core/form/components/widgets/core/index.ts @@ -40,3 +40,4 @@ export * from './form-variable.model'; export * from './process-variable.model'; export * from './upload-widget-content-link.model'; export * from './form-field-file-source'; +export * from './form-field-rule'; diff --git a/lib/core/i18n/en.json b/lib/core/i18n/en.json index 72679d0d1e..3452a1161f 100644 --- a/lib/core/i18n/en.json +++ b/lib/core/i18n/en.json @@ -43,6 +43,7 @@ "UPLOAD": "UPLOAD", "REQUIRED": "*Required", "FILE_NAME": "File Name", + "DEPENDS_ON": "Depends on: {{widgetId}}", "NO_FILE_ATTACHED" : "No file attached", "VALIDATOR": { "INVALID_NUMBER": "Use a different number format", 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 ec010e469b..76678712c4 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 @@ -1,13 +1,21 @@
- +
+ + +
{ @@ -253,44 +254,6 @@ describe('DropdownCloudWidgetComponent', () => { done(); }); }); - - it('should map properties if restResponsePath is set', (done) => { - widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id' }), { - id: 'dropdown-id', - name: 'date-name', - type: 'dropdown-cloud', - readOnly: 'false', - restUrl: 'fake-rest-url', - optionType: 'rest', - restResponsePath: 'path' - }); - - const dropdownSpy = spyOn(formCloudService, 'getRestWidgetData').and.returnValue(of( [ - { id: 'opt_1', name: 'option_1' }, - { id: 'opt_2', name: 'option_2' }, - { id: 'opt_3', name: 'option_3' }] - )); - - widget.ngOnInit(); - fixture.detectChanges(); - - openSelect('#dropdown-id'); - - fixture.whenStable().then(() => { - expect(dropdownSpy).toHaveBeenCalled(); - - const optOne: any = fixture.debugElement.queryAll(By.css('[id="opt_1"]')); - expect(optOne[0].context.value).toBe('opt_1'); - expect(optOne[0].context.viewValue).toBe('option_1'); - const optTwo: any = fixture.debugElement.queryAll(By.css('[id="opt_2"]')); - expect(optTwo[0].context.value).toBe('opt_2'); - expect(optTwo[0].context.viewValue).toBe('option_2'); - const optThree: any = fixture.debugElement.queryAll(By.css('[id="opt_3"]')); - expect(optThree[0].context.value).toBe('opt_3'); - expect(optThree[0].context.viewValue).toBe('option_3'); - done(); - }); - }); }); }); @@ -349,4 +312,174 @@ describe('DropdownCloudWidgetComponent', () => { ]); }); }); + + describe('Linked Dropdown', () => { + + describe('Rest URL options', () => { + + const parentDropdown = new FormFieldModel(new FormModel(), { id: 'parentDropdown', type: 'dropdown' }); + beforeEach(() => { + widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id' }), { + id: 'child-dropdown-id', + name: 'child-dropdown', + type: 'dropdown-cloud', + readOnly: 'false', + optionType: 'rest', + restUrl: 'myFakeDomain.com/cities?country=${parentDropdown}', + rule: { + ruleOn: 'parentDropdown', + entries: null + } + }); + widget.field.form.id = 'fake-form-id'; + fixture.detectChanges(); + }); + + it('should fetch the options from a rest url for a linked dropdown', async () => { + const jsonDataSpy = spyOn(formCloudService, 'getRestWidgetData').and.returnValue(of(mockRestDropdownOptions)); + const mockParentDropdown = { id: 'parentDropdown', value: 'mock-value' }; + spyOn(widget.field.form, 'getFormFields').and.returnValue([mockParentDropdown]); + parentDropdown.value = 'UK'; + widget.selectionChangedForField(parentDropdown); + + fixture.detectChanges(); + openSelect('child-dropdown-id'); + fixture.detectChanges(); + await fixture.whenStable(); + + const optOne: any = fixture.debugElement.query(By.css('[id="LO"]')); + const optTwo: any = fixture.debugElement.query(By.css('[id="MA"]')); + + expect(jsonDataSpy).toHaveBeenCalledWith('fake-form-id', 'child-dropdown-id', { parentDropdown: 'mock-value' }); + expect(optOne.context.value).toBe('LO'); + expect(optOne.context.viewValue).toBe('LONDON'); + expect(optTwo.context.value).toBe('MA'); + expect(optTwo.context.viewValue).toBe('MANCHESTER'); + }); + + it('should reset the options for a linked dropdown with restUrl when the parent dropdown selection changes to empty', async () => { + widget.field.options = mockConditionalEntries[1].options; + parentDropdown.value = 'empty'; + widget.selectionChangedForField(parentDropdown); + + fixture.detectChanges(); + openSelect('child-dropdown-id'); + fixture.detectChanges(); + await fixture.whenStable(); + + const defaultOption: any = fixture.debugElement.query(By.css('[id="empty"]')); + + expect(widget.field.options).toEqual([{ 'id': 'empty', 'name': 'Choose one...' }]); + expect(defaultOption.context.value).toBe('empty'); + expect(defaultOption.context.viewValue).toBe('Choose one...'); + }); + }); + + describe('Manual options', () => { + const parentDropdown = new FormFieldModel(new FormModel(), { id: 'parentDropdown', type: 'dropdown' }); + + beforeEach(() => { + widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id' }), { + id: 'child-dropdown-id', + name: 'child-dropdown', + type: 'dropdown-cloud', + readOnly: 'false', + optionType: 'manual', + rule: { + ruleOn: 'parentDropdown', + entries: mockConditionalEntries + } + }); + fixture.detectChanges(); + }); + + it('Should display the options for a linked dropdown based on the parent dropdown selection', async () => { + parentDropdown.value = 'GR'; + widget.selectionChangedForField(parentDropdown); + fixture.detectChanges(); + openSelect('child-dropdown-id'); + + fixture.detectChanges(); + await fixture.whenStable(); + + const optOne: any = fixture.debugElement.query(By.css('[id="empty"]')); + const optTwo: any = fixture.debugElement.query(By.css('[id="ATH"]')); + const optThree: any = fixture.debugElement.query(By.css('[id="SKG"]')); + + expect(widget.field.options).toEqual(mockConditionalEntries[0].options); + expect(optOne.context.value).toBe('empty'); + expect(optOne.context.viewValue).toBe('Choose one...'); + expect(optTwo.context.value).toBe('ATH'); + expect(optTwo.context.viewValue).toBe('Athens'); + expect(optThree.context.value).toBe('SKG'); + expect(optThree.context.viewValue).toBe('Thessaloniki'); + }); + + it('should reset the options for a linked dropdown when the parent dropdown selection changes to empty', async () => { + widget.field.options = mockConditionalEntries[1].options; + parentDropdown.value = 'empty'; + widget.selectionChangedForField(parentDropdown); + + fixture.detectChanges(); + openSelect('child-dropdown-id'); + fixture.detectChanges(); + await fixture.whenStable(); + + const defaultOption: any = fixture.debugElement.query(By.css('[id="empty"]')); + + expect(widget.field.options).toEqual([{ 'id': 'empty', 'name': 'Choose one...' }]); + expect(defaultOption.context.value).toBe('empty'); + expect(defaultOption.context.viewValue).toBe('Choose one...'); + }); + }); + + describe('Load selection for linked dropdown (i.e. saved, completed forms)', () => { + + it('should load the selection of a manual type linked dropdown', () => { + widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id' }), { + id: 'child-dropdown-id', + name: 'child-dropdown', + type: 'dropdown-cloud', + readOnly: 'false', + optionType: 'manual', + rule: { + ruleOn: 'parentDropdown', + entries: mockConditionalEntries + } + }); + const updateFormSpy = spyOn(widget.field, 'updateForm'); + const mockParentDropdown = { id: 'parentDropdown', value: 'IT' }; + spyOn(widget.field.form, 'getFormFields').and.returnValue([mockParentDropdown]); + fixture.detectChanges(); + + expect(updateFormSpy).toHaveBeenCalled(); + expect(widget.field.options).toEqual(mockConditionalEntries[1].options); + }); + + it('should load the selection of a rest type linked dropdown', () => { + const jsonDataSpy = spyOn(formCloudService, 'getRestWidgetData').and.returnValue(of(mockRestDropdownOptions)); + widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id' }), { + id: 'child-dropdown-id', + name: 'child-dropdown', + type: 'dropdown-cloud', + readOnly: 'false', + restUrl: 'mock-url.com/country=${country}', + optionType: 'rest', + rule: { + ruleOn: 'country', + entries: null + } + }); + widget.field.form.id = 'fake-form-id'; + const updateFormSpy = spyOn(widget.field, 'updateForm'); + const mockParentDropdown = { id: 'country', value: 'UK' }; + spyOn(widget.field.form, 'getFormFields').and.returnValue([mockParentDropdown]); + fixture.detectChanges(); + + expect(updateFormSpy).toHaveBeenCalled(); + expect(jsonDataSpy).toHaveBeenCalledWith('fake-form-id', 'child-dropdown-id', { country: 'UK' }); + expect(widget.field.options).toEqual(mockRestDropdownOptions); + }); + }); + }); }); 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 853e2c02b0..1bde1709d6 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 @@ -16,10 +16,19 @@ */ import { Component, OnInit, ViewEncapsulation, OnDestroy } from '@angular/core'; -import { WidgetComponent, FormService, LogService, FormFieldOption } from '@alfresco/adf-core'; +import { + WidgetComponent, + FormService, + LogService, + FormFieldOption, + FormFieldEvent, + FormFieldModel, + FormFieldTypes, + RuleEntry +} from '@alfresco/adf-core'; import { FormCloudService } from '../../../services/form-cloud.service'; import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { filter, takeUntil } from 'rxjs/operators'; /* tslint:disable:component-selector */ @@ -41,6 +50,10 @@ import { takeUntil } from 'rxjs/operators'; encapsulation: ViewEncapsulation.None }) export class DropdownCloudWidgetComponent extends WidgetComponent implements OnInit, OnDestroy { + static DEFAULT_OPTION = { + id: 'empty', + name: 'Choose one...' + }; typeId = 'DropdownCloudWidgetComponent'; protected onDestroy$ = new Subject(); @@ -52,14 +65,28 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI } ngOnInit() { - if (this.field && this.field.restUrl) { - this.getValuesFromRestApi(); + if (this.hasRestUrl() && !this.isLinkedWidget()) { + this.persistFieldOptionsFromRestApi(); + } + + if (this.isLinkedWidget()) { + this.loadFieldOptionsForLinkedWidget(); + + this.formService.formFieldValueChanged + .pipe( + filter((event: FormFieldEvent) => this.isFormFieldEventOfTypeDropdown(event) && this.isParentFormFieldEvent(event)), + takeUntil(this.onDestroy$)) + .subscribe((event: FormFieldEvent) => { + const valueOfParentWidget = event.field.value; + this.parentValueChanged(valueOfParentWidget); + }); } } - getValuesFromRestApi() { + private persistFieldOptionsFromRestApi() { if (this.isValidRestType()) { - this.formCloudService.getRestWidgetData(this.field.form.id, this.field.id) + const bodyParam = this.buildBodyParam(); + this.formCloudService.getRestWidgetData(this.field.form.id, this.field.id, bodyParam) .pipe(takeUntil(this.onDestroy$)) .subscribe((result: FormFieldOption[]) => { this.field.options = result; @@ -67,6 +94,97 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI } } + private buildBodyParam(): any { + const bodyParam = Object.assign({}); + if (this.isLinkedWidget()) { + const parentWidgetValue = this.getParentWidgetValue(); + const parentWidgetId = this.getLinkedWidgetId(); + bodyParam[parentWidgetId] = parentWidgetValue; + } + return bodyParam; + } + + private loadFieldOptionsForLinkedWidget() { + const parentWidgetValue = this.getParentWidgetValue(); + this.parentValueChanged(parentWidgetValue); + this.field.updateForm(); + } + + private getParentWidgetValue(): string { + const parentWidgetId = this.getLinkedWidgetId(); + const parentWidget = this.getFormFieldById(parentWidgetId); + return parentWidget?.value; + } + + private parentValueChanged(value: string) { + if (this.isValidValue(value)) { + this.isValidRestType() ? this.persistFieldOptionsFromRestApi() : this.persistFieldOptionsFromManualList(value); + } else if (this.isDefaultValue(value)) { + this.addDefaultOption(); + } + } + + private isValidValue(value: string): boolean { + return !!value && value !== DropdownCloudWidgetComponent.DEFAULT_OPTION.id; + } + + private isDefaultValue(value: string): boolean { + return value === DropdownCloudWidgetComponent.DEFAULT_OPTION.id; + } + + private getFormFieldById(fieldId): FormFieldModel { + return this.field.form.getFormFields().filter((field: FormFieldModel) => field.id === fieldId)[0]; + } + + private persistFieldOptionsFromManualList(value: string) { + if (this.hasRuleEntries()) { + const rulesEntries = this.getRuleEntries(); + rulesEntries.forEach((ruleEntry: RuleEntry) => { + if (ruleEntry.key === value) { + this.field.options = ruleEntry.options; + } + }); + } + } + + private getRuleEntries(): RuleEntry[] { + return this.field.rule.entries; + } + + private hasRuleEntries(): boolean { + return !!this.getRuleEntries().length; + } + + private addDefaultOption() { + this.field.options = [DropdownCloudWidgetComponent.DEFAULT_OPTION]; + } + + selectionChangedForField(field: FormFieldModel) { + const formFieldValueChangedEvent = new FormFieldEvent(field.form, field); + this.formService.formFieldValueChanged.next(formFieldValueChangedEvent); + this.onFieldChanged(field); + } + + private isParentFormFieldEvent(event: FormFieldEvent): boolean { + return event.field.id === this.getLinkedWidgetId(); + } + + private isFormFieldEventOfTypeDropdown(event: FormFieldEvent): boolean { + return event.field.type === FormFieldTypes.DROPDOWN; + } + + private hasRestUrl(): boolean { + return !!this.field?.restUrl; + } + + isLinkedWidget(): boolean { + return !!this.getLinkedWidgetId(); + } + + getLinkedWidgetId(): string { + return this.field?.rule?.ruleOn; + } + compareDropdownValues(opt1: FormFieldOption | string, opt2: FormFieldOption | string): boolean { if (!opt1 || !opt2) { return false; @@ -93,7 +211,7 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI } let optionValue: string = ''; - if (option.id === 'empty' || option.name !== fieldValue) { + if (option.id === DropdownCloudWidgetComponent.DEFAULT_OPTION.id || option.name !== fieldValue) { optionValue = option.id; } else { optionValue = option.name; @@ -101,11 +219,11 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI return optionValue; } - isValidRestType(): boolean { + private isValidRestType(): boolean { return this.field.optionType === 'rest' && !!this.field.restUrl; } - handleError(error: any) { + private handleError(error: any) { this.logService.error(error); } diff --git a/lib/process-services-cloud/src/lib/form/mocks/linked-dropdown.mock.ts b/lib/process-services-cloud/src/lib/form/mocks/linked-dropdown.mock.ts new file mode 100644 index 0000000000..bed92b5ccd --- /dev/null +++ b/lib/process-services-cloud/src/lib/form/mocks/linked-dropdown.mock.ts @@ -0,0 +1,85 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FormFieldOption } from '@alfresco/adf-core'; + +export const mockConditionalEntries = [ + { + key: 'GR', + options: [ + { + id: 'empty', + name: 'Choose one...' + }, + { + id: 'ATH', + name: 'Athens' + }, + { + id: 'SKG', + name: 'Thessaloniki' + } + ] + }, + { + key: 'IT', + options: [ + { + id: 'empty', + name: 'Choose one...' + }, + { + id: 'MI', + name: 'MILAN' + }, + { + id: 'RM', + name: 'ROME' + } + ] + }, + { + key: 'UK', + options: [ + { + id: 'empty', + name: 'Choose one...' + }, + { + id: 'LDN', + name: 'London' + }, + { + id: 'MAN', + name: 'Manchester' + }, + { + id: 'SHE', + name: 'Sheffield' + }, + { + id: 'LEE', + name: 'Leeds' + } + ] + } +]; + +export const mockRestDropdownOptions: FormFieldOption[] = [ + { id: 'LO', name: 'LONDON' }, + { id: 'MA', name: 'MANCHESTER' } +];