[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 <arditdomi@apl-c02g64vpmd6t.home>
This commit is contained in:
arditdomi
2021-11-01 00:30:55 +00:00
committed by GitHub
parent f14d333281
commit 79d54ea4e4
9 changed files with 437 additions and 49 deletions

View File

@@ -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[];
}

View File

@@ -26,6 +26,7 @@ import { FormFieldTypes } from './form-field-types';
import { NumberFieldValidator } from './form-field-validator'; import { NumberFieldValidator } from './form-field-validator';
import { FormWidgetModel } from './form-widget.model'; import { FormWidgetModel } from './form-widget.model';
import { FormModel } from './form.model'; import { FormModel } from './form.model';
import { FormFieldRule } from './form-field-rule';
// Maps to FormFieldRepresentation // Maps to FormFieldRepresentation
export class FormFieldModel extends FormWidgetModel { export class FormFieldModel extends FormWidgetModel {
@@ -72,6 +73,7 @@ export class FormFieldModel extends FormWidgetModel {
currency: string = null; currency: string = null;
dateDisplayFormat: string = this.defaultDateFormat; dateDisplayFormat: string = this.defaultDateFormat;
selectionType: 'single' | 'multiple' = null; selectionType: 'single' | 'multiple' = null;
rule?: FormFieldRule;
// container model members // container model members
numberOfColumns: number = 1; numberOfColumns: number = 1;
@@ -178,6 +180,7 @@ export class FormFieldModel extends FormWidgetModel {
this.validationSummary = new ErrorMessageModel(); this.validationSummary = new ErrorMessageModel();
this.tooltip = json.tooltip; this.tooltip = json.tooltip;
this.selectionType = json.selectionType; this.selectionType = json.selectionType;
this.rule = json.rule;
if (json.placeholder && json.placeholder !== '' && json.placeholder !== 'null') { if (json.placeholder && json.placeholder !== '' && json.placeholder !== 'null') {
this.placeholder = json.placeholder; this.placeholder = json.placeholder;

View File

@@ -40,3 +40,4 @@ export * from './form-variable.model';
export * from './process-variable.model'; export * from './process-variable.model';
export * from './upload-widget-content-link.model'; export * from './upload-widget-content-link.model';
export * from './form-field-file-source'; export * from './form-field-file-source';
export * from './form-field-rule';

View File

@@ -43,6 +43,7 @@
"UPLOAD": "UPLOAD", "UPLOAD": "UPLOAD",
"REQUIRED": "*Required", "REQUIRED": "*Required",
"FILE_NAME": "File Name", "FILE_NAME": "File Name",
"DEPENDS_ON": "Depends on: {{widgetId}}",
"NO_FILE_ATTACHED" : "No file attached", "NO_FILE_ATTACHED" : "No file attached",
"VALIDATOR": { "VALIDATOR": {
"INVALID_NUMBER": "Use a different number format", "INVALID_NUMBER": "Use a different number format",

View File

@@ -1,13 +1,21 @@
<div class="adf-dropdown-widget {{field.className}}" <div class="adf-dropdown-widget {{field.className}}"
[class.adf-invalid]="!field.isValid" [class.adf-readonly]="field.readOnly"> [class.adf-invalid]="!field.isValid" [class.adf-readonly]="field.readOnly">
<label class="adf-label" [attr.for]="field.id">{{field.name | translate }}<span *ngIf="isRequired()">*</span></label> <div class="adf-dropdown-widget-top-labels">
<label class="adf-label" [attr.for]="field.id">{{field.name | translate }}<span
*ngIf="isRequired()">*</span></label>
<label class="adf-label adf-dropdown-widget-linked"
*ngIf="isLinkedWidget()"
[attr.for]="field.id">
{{ 'FORM.FIELD.DEPENDS_ON' | translate: { widgetId: getLinkedWidgetId() } }}
</label>
</div>
<mat-form-field> <mat-form-field>
<mat-select class="adf-select" <mat-select class="adf-select"
[id]="field.id" [id]="field.id"
[(ngModel)]="field.value" [(ngModel)]="field.value"
[disabled]="field.readOnly" [disabled]="field.readOnly"
[compareWith]="compareDropdownValues" [compareWith]="compareDropdownValues"
(ngModelChange)="onFieldChanged(field)" (ngModelChange)="selectionChangedForField(field)"
[matTooltip]="field.tooltip" [matTooltip]="field.tooltip"
matTooltipPosition="above" matTooltipPosition="above"
matTooltipShowDelay="1000" matTooltipShowDelay="1000"

View File

@@ -12,6 +12,17 @@
font-size: 14px; font-size: 14px;
} }
&-top-labels {
display: flex;
flex-direction: row;
justify-content: space-between;
height: 16px;
.adf-dropdown-widget-linked {
display: contents;
}
}
&-select { &-select {
width: 100%; width: 100%;
} }

View File

@@ -30,6 +30,7 @@ import {
import { FormCloudService } from '../../../services/form-cloud.service'; import { FormCloudService } from '../../../services/form-cloud.service';
import { ProcessServiceCloudTestingModule } from '../../../../testing/process-service-cloud.testing.module'; import { ProcessServiceCloudTestingModule } from '../../../../testing/process-service-cloud.testing.module';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { mockConditionalEntries, mockRestDropdownOptions } from '../../../mocks/linked-dropdown.mock';
describe('DropdownCloudWidgetComponent', () => { describe('DropdownCloudWidgetComponent', () => {
@@ -253,44 +254,6 @@ describe('DropdownCloudWidgetComponent', () => {
done(); 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);
});
});
});
}); });

View File

@@ -16,10 +16,19 @@
*/ */
import { Component, OnInit, ViewEncapsulation, OnDestroy } from '@angular/core'; 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 { FormCloudService } from '../../../services/form-cloud.service';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { filter, takeUntil } from 'rxjs/operators';
/* tslint:disable:component-selector */ /* tslint:disable:component-selector */
@@ -41,6 +50,10 @@ import { takeUntil } from 'rxjs/operators';
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class DropdownCloudWidgetComponent extends WidgetComponent implements OnInit, OnDestroy { export class DropdownCloudWidgetComponent extends WidgetComponent implements OnInit, OnDestroy {
static DEFAULT_OPTION = {
id: 'empty',
name: 'Choose one...'
};
typeId = 'DropdownCloudWidgetComponent'; typeId = 'DropdownCloudWidgetComponent';
protected onDestroy$ = new Subject<boolean>(); protected onDestroy$ = new Subject<boolean>();
@@ -52,14 +65,28 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
} }
ngOnInit() { ngOnInit() {
if (this.field && this.field.restUrl) { if (this.hasRestUrl() && !this.isLinkedWidget()) {
this.getValuesFromRestApi(); 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()) { 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$)) .pipe(takeUntil(this.onDestroy$))
.subscribe((result: FormFieldOption[]) => { .subscribe((result: FormFieldOption[]) => {
this.field.options = result; 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 { compareDropdownValues(opt1: FormFieldOption | string, opt2: FormFieldOption | string): boolean {
if (!opt1 || !opt2) { if (!opt1 || !opt2) {
return false; return false;
@@ -93,7 +211,7 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
} }
let optionValue: string = ''; let optionValue: string = '';
if (option.id === 'empty' || option.name !== fieldValue) { if (option.id === DropdownCloudWidgetComponent.DEFAULT_OPTION.id || option.name !== fieldValue) {
optionValue = option.id; optionValue = option.id;
} else { } else {
optionValue = option.name; optionValue = option.name;
@@ -101,11 +219,11 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
return optionValue; return optionValue;
} }
isValidRestType(): boolean { private isValidRestType(): boolean {
return this.field.optionType === 'rest' && !!this.field.restUrl; return this.field.optionType === 'rest' && !!this.field.restUrl;
} }
handleError(error: any) { private handleError(error: any) {
this.logService.error(error); this.logService.error(error);
} }

View File

@@ -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' }
];