[AAE-15082][AAE-15081] Resolve the options coming from a json variabl… (#8673)

* [AAE-15082][AAE-15081] Resolve the options coming from a json variable for a dropdown

* split method to smaller parts and remove duplications in units

* fix unit tests

* get variables from API call

* [AAE-15082] Add handle form variable

* replace variableId by variableName

* improve code
This commit is contained in:
Tomasz Gnyp
2023-06-29 17:21:14 +02:00
committed by GitHub
parent dabe4ca279
commit e3ea23da37
7 changed files with 379 additions and 20 deletions

View File

@@ -0,0 +1,23 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* 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.
*/
export interface VariableConfig {
variableName: string;
optionsPath?: string;
optionsId?: string;
optionsLabel?: string;
}

View File

@@ -27,6 +27,7 @@ import { FormWidgetModel } from './form-widget.model';
import { FormFieldRule } from './form-field-rule'; import { FormFieldRule } from './form-field-rule';
import { ProcessFormModel } from './process-form-model.interface'; import { ProcessFormModel } from './process-form-model.interface';
import { isNumberValue } from './form-field-utils'; import { isNumberValue } from './form-field-utils';
import { VariableConfig } from './form-field-variable-options';
// Maps to FormFieldRepresentation // Maps to FormFieldRepresentation
export class FormFieldModel extends FormWidgetModel { export class FormFieldModel extends FormWidgetModel {
@@ -66,7 +67,7 @@ export class FormFieldModel extends FormWidgetModel {
restLabelProperty: string; restLabelProperty: string;
hasEmptyValue: boolean; hasEmptyValue: boolean;
className: string; className: string;
optionType: 'rest' | 'manual' ; optionType: 'rest' | 'manual' | 'variable';
params: FormFieldMetadata = {}; params: FormFieldMetadata = {};
hyperlinkUrl: string; hyperlinkUrl: string;
displayText: string; displayText: string;
@@ -81,6 +82,7 @@ export class FormFieldModel extends FormWidgetModel {
selectLoggedUser: boolean; selectLoggedUser: boolean;
groupsRestriction: string[]; groupsRestriction: string[];
leftLabels: boolean = false; leftLabels: boolean = false;
variableConfig: VariableConfig;
// container model members // container model members
numberOfColumns: number = 1; numberOfColumns: number = 1;
@@ -194,6 +196,7 @@ export class FormFieldModel extends FormWidgetModel {
this.rule = json.rule; this.rule = json.rule;
this.selectLoggedUser = json.selectLoggedUser; this.selectLoggedUser = json.selectLoggedUser;
this.groupsRestriction = json.groupsRestriction?.groups; this.groupsRestriction = json.groupsRestriction?.groups;
this.variableConfig = json.variableConfig;
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

@@ -46,6 +46,7 @@
"UPLOAD": "UPLOAD", "UPLOAD": "UPLOAD",
"REQUIRED": "This is a required field", "REQUIRED": "This is a required field",
"REST_API_FAILED": "The server `{{ hostname }}` is not reachable", "REST_API_FAILED": "The server `{{ hostname }}` is not reachable",
"VARIABLE_DROPDOWN_OPTIONS_FAILED": "There was a problem loading dropdown elements. Please contact administrator.",
"FILE_NAME": "File Name", "FILE_NAME": "File Name",
"NO_FILE_ATTACHED": "No file attached", "NO_FILE_ATTACHED": "No file attached",
"VALIDATOR": { "VALIDATOR": {

View File

@@ -37,5 +37,7 @@
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"></error-widget> required="{{ 'FORM.FIELD.REQUIRED' | translate }}"></error-widget>
<error-widget class="adf-dropdown-failed-message" *ngIf="isRestApiFailed" <error-widget class="adf-dropdown-failed-message" *ngIf="isRestApiFailed"
required="{{ 'FORM.FIELD.REST_API_FAILED' | translate: { hostname: restApiHostName } }}"></error-widget> required="{{ 'FORM.FIELD.REST_API_FAILED' | translate: { hostname: restApiHostName } }}"></error-widget>
<error-widget class="adf-dropdown-failed-message" *ngIf="variableOptionsFailed"
required="{{ 'FORM.FIELD.VARIABLE_DROPDOWN_OPTIONS_FAILED' | translate }}"></error-widget>
</div> </div>
</div> </div>

View File

@@ -19,7 +19,15 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { of, throwError } from 'rxjs'; import { of, throwError } from 'rxjs';
import { DropdownCloudWidgetComponent } from './dropdown-cloud.widget'; import { DropdownCloudWidgetComponent } from './dropdown-cloud.widget';
import { FormFieldModel, FormModel, FormService, setupTestBed, FormFieldEvent, FormFieldTypes } from '@alfresco/adf-core'; import {
FormFieldModel,
FormModel,
FormService,
setupTestBed,
FormFieldEvent,
FormFieldTypes,
LogService
} from '@alfresco/adf-core';
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';
@@ -27,16 +35,22 @@ import {
fakeOptionList, fakeOptionList,
filterOptionList, filterOptionList,
mockConditionalEntries, mockConditionalEntries,
mockFormVariableWithJson,
mockPlayersResponse,
mockRestDropdownOptions, mockRestDropdownOptions,
mockSecondRestDropdownOptions mockSecondRestDropdownOptions,
mockVariablesWithDefaultJson,
mockProcessVariablesWithJson
} from '../../../mocks/dropdown.mock'; } from '../../../mocks/dropdown.mock';
import { OverlayContainer } from '@angular/cdk/overlay'; import { OverlayContainer } from '@angular/cdk/overlay';
import { TaskVariableCloud } from '../../../models/task-variable-cloud.model';
describe('DropdownCloudWidgetComponent', () => { describe('DropdownCloudWidgetComponent', () => {
let formService: FormService; let formService: FormService;
let widget: DropdownCloudWidgetComponent; let widget: DropdownCloudWidgetComponent;
let formCloudService: FormCloudService; let formCloudService: FormCloudService;
let logService: LogService;
let overlayContainer: OverlayContainer; let overlayContainer: OverlayContainer;
let fixture: ComponentFixture<DropdownCloudWidgetComponent>; let fixture: ComponentFixture<DropdownCloudWidgetComponent>;
let element: HTMLElement; let element: HTMLElement;
@@ -64,6 +78,7 @@ describe('DropdownCloudWidgetComponent', () => {
formService = TestBed.inject(FormService); formService = TestBed.inject(FormService);
formCloudService = TestBed.inject(FormCloudService); formCloudService = TestBed.inject(FormCloudService);
overlayContainer = TestBed.inject(OverlayContainer); overlayContainer = TestBed.inject(OverlayContainer);
logService = TestBed.inject(LogService);
}); });
afterEach(() => fixture.destroy()); afterEach(() => fixture.destroy());
@@ -876,4 +891,136 @@ describe('DropdownCloudWidgetComponent', () => {
}); });
}); });
}); });
describe('variable options', () => {
let logServiceSpy: jasmine.Spy;
const errorIcon: string = 'error_outline';
const getVariableDropdownWidget = (
variableName: string,
optionsPath: string,
optionsId: string,
optionsLabel: string,
processVariables?: TaskVariableCloud[],
variables?: TaskVariableCloud[]
) => new FormFieldModel(
new FormModel({ taskId: 'fake-task-id', processVariables, variables }), {
id: 'variable-dropdown-id',
name: 'variable-options-dropdown',
type: 'dropdown',
readOnly: 'false',
optionType: 'variable',
variableConfig: {
variableName,
optionsPath,
optionsId,
optionsLabel
}
});
const checkDropdownVariableOptionsFailed = () => {
const failedErrorMsgElement = fixture.debugElement.query(By.css('.adf-dropdown-failed-message'));
expect(failedErrorMsgElement.nativeElement.textContent.trim()).toBe(errorIcon.concat('FORM.FIELD.VARIABLE_DROPDOWN_OPTIONS_FAILED'));
expect(widget.field.options.length).toEqual(0);
};
beforeEach(() => {
logServiceSpy = spyOn(logService, 'error');
});
it('should display options persisted from process variable', async () => {
widget.field = getVariableDropdownWidget('variables.json-variable', 'response.people.players', 'playerId', 'playerFullName', mockProcessVariablesWithJson);
fixture.detectChanges();
await openSelect('variable-dropdown-id');
const optOne: any = fixture.debugElement.query(By.css('[id="player-1"]'));
const optTwo: any = fixture.debugElement.query(By.css('[id="player-2"]'));
const optThree: any = fixture.debugElement.query(By.css('[id="player-3"]'));
expect(widget.field.options.length).toEqual(3);
expect(optOne.context.value).toBe('player-1');
expect(optOne.context.viewValue).toBe('Lionel Messi');
expect(optTwo.context.value).toBe('player-2');
expect(optTwo.context.viewValue).toBe('Cristiano Ronaldo');
expect(optThree.context.value).toBe('player-3');
expect(optThree.context.viewValue).toBe('Robert Lewandowski');
});
it('should display options persisted from form variable if there are NO process variables', async () => {
widget.field = getVariableDropdownWidget('json-form-variable', 'countries', 'id', 'name', [], mockFormVariableWithJson);
fixture.detectChanges();
await openSelect('variable-dropdown-id');
const optOne: any = fixture.debugElement.query(By.css('[id="PL"]'));
const optTwo: any = fixture.debugElement.query(By.css('[id="UK"]'));
const optThree: any = fixture.debugElement.query(By.css('[id="GR"]'));
expect(widget.field.options.length).toEqual(3);
expect(optOne.context.value).toBe('PL');
expect(optOne.context.viewValue).toBe('Poland');
expect(optTwo.context.value).toBe('UK');
expect(optTwo.context.viewValue).toBe('United Kingdom');
expect(optThree.context.value).toBe('GR');
expect(optThree.context.viewValue).toBe('Greece');
});
it('should display default options if config options are NOT provided', async () => {
widget.field = getVariableDropdownWidget('variables.json-default-variable', null, null, null, mockVariablesWithDefaultJson);
fixture.detectChanges();
await openSelect('variable-dropdown-id');
const optOne: any = fixture.debugElement.query(By.css('[id="default-pet-1"]'));
const optTwo: any = fixture.debugElement.query(By.css('[id="default-pet-2"]'));
const optThree: any = fixture.debugElement.query(By.css('[id="default-pet-3"]'));
expect(widget.field.options.length).toEqual(3);
expect(optOne.context.value).toBe('default-pet-1');
expect(optOne.context.viewValue).toBe('Dog');
expect(optTwo.context.value).toBe('default-pet-2');
expect(optTwo.context.viewValue).toBe('Cat');
expect(optThree.context.value).toBe('default-pet-3');
expect(optThree.context.viewValue).toBe('Parrot');
});
it('should return empty array and display error when path is incorrect', () => {
widget.field = getVariableDropdownWidget('variables.json-variable', 'response.wrongPath.players', 'playerId', 'playerFullName', mockProcessVariablesWithJson);
fixture.detectChanges();
checkDropdownVariableOptionsFailed();
expect(logServiceSpy).toHaveBeenCalledWith(`wrongPath not found in ${JSON.stringify(mockPlayersResponse.response)}`);
});
it('should return empty array and display error when id is incorrect', () => {
widget.field = getVariableDropdownWidget('variables.json-variable', 'response.people.players', 'wrongId', 'playerFullName', mockProcessVariablesWithJson);
fixture.detectChanges();
checkDropdownVariableOptionsFailed();
expect(logServiceSpy).toHaveBeenCalledWith(`'id' or 'label' is not properly defined`);
});
it('should return empty array and display error when label is incorrect', () => {
widget.field = getVariableDropdownWidget('variables.json-variable', 'response.people.players', 'playerId', 'wrongFullName', mockProcessVariablesWithJson);
fixture.detectChanges();
checkDropdownVariableOptionsFailed();
expect(logServiceSpy).toHaveBeenCalledWith(`'id' or 'label' is not properly defined`);
});
it('should return empty array and display error when variable is NOT found', () => {
widget.field = getVariableDropdownWidget('variables.wrong-variable-id', 'response.people.players', 'playerId', 'playerFullName', mockProcessVariablesWithJson);
fixture.detectChanges();
checkDropdownVariableOptionsFailed();
expect(logServiceSpy).toHaveBeenCalledWith(`variables.wrong-variable-id not found`);
});
it('should return empty array and display error if there are NO process and form variables', () => {
widget.field = getVariableDropdownWidget('variables.json-variable', 'response.people.players', 'playerId', 'playerFullName', [], []);
fixture.detectChanges();
checkDropdownVariableOptionsFailed();
expect(logServiceSpy).toHaveBeenCalledWith('variables.json-variable not found');
});
});
}); });

View File

@@ -30,6 +30,7 @@ import {
import { FormCloudService } from '../../../services/form-cloud.service'; import { FormCloudService } from '../../../services/form-cloud.service';
import { BehaviorSubject, combineLatest, Observable, of, Subject } from 'rxjs'; import { BehaviorSubject, combineLatest, Observable, of, Subject } from 'rxjs';
import { filter, map, takeUntil } from 'rxjs/operators'; import { filter, map, takeUntil } from 'rxjs/operators';
import { TaskVariableCloud } from '../../../models/task-variable-cloud.model';
export const DEFAULT_OPTION = { export const DEFAULT_OPTION = {
id: 'empty', id: 'empty',
@@ -60,10 +61,15 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
typeId = 'DropdownCloudWidgetComponent'; typeId = 'DropdownCloudWidgetComponent';
showInputFilter = false; showInputFilter = false;
isRestApiFailed = false; isRestApiFailed = false;
variableOptionsFailed = false;
restApiHostName: string; restApiHostName: string;
list$: Observable<FormFieldOption[]>; list$: Observable<FormFieldOption[]>;
filter$ = new BehaviorSubject<string>(''); filter$ = new BehaviorSubject<string>('');
private readonly defaultVariableOptionId = 'id';
private readonly defaultVariableOptionLabel = 'name';
private readonly defaultVariableOptionPath = 'data';
protected onDestroy$ = new Subject<boolean>(); protected onDestroy$ = new Subject<boolean>();
constructor(public formService: FormService, constructor(public formService: FormService,
@@ -74,25 +80,113 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
} }
ngOnInit() { ngOnInit() {
if (this.field.restUrl && !this.isLinkedWidget()) { this.checkFieldOptionsSource();
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);
});
}
this.updateOptions(); this.updateOptions();
} }
private checkFieldOptionsSource(): void {
switch (true) {
case this.field.restUrl && !this.isLinkedWidget():
this.persistFieldOptionsFromRestApi();
break;
case this.isLinkedWidget():
this.loadFieldOptionsForLinkedWidget();
break;
case this.isVariableOptionType():
this.persistFieldOptionsFromVariable();
break;
default:
break;
}
}
private persistFieldOptionsFromVariable(): void {
const optionsPath = this.field?.variableConfig?.optionsPath ?? this.defaultVariableOptionPath;
const variableName = this.field?.variableConfig?.variableName;
const processVariables = this.field?.form?.processVariables;
const formVariables = this.field?.form?.variables;
const dropdownOptions = this.getOptionsFromVariable(processVariables, formVariables, variableName);
if (dropdownOptions) {
const formVariableOptions: FormFieldOption[] = this.getOptionsFromPath(dropdownOptions, optionsPath);
this.field.options = formVariableOptions;
this.resetInvalidValue();
this.field.updateForm();
} else {
this.handleError(`${variableName} not found`);
this.resetOptions();
this.variableOptionsFailed = true;
}
}
private getOptionsFromPath(data: any, path: string): FormFieldOption[] {
const optionsId = this.field?.variableConfig?.optionsId ?? this.defaultVariableOptionId;
const optionsLabel = this.field?.variableConfig?.optionsLabel ?? this.defaultVariableOptionLabel;
const properties = path.split('.');
const currentProperty = properties.shift();
if (!data.hasOwnProperty(currentProperty)) {
this.handleError(`${currentProperty} not found in ${JSON.stringify(data)}`);
this.variableOptionsFailed = true;
return [];
}
const nestedData = data[currentProperty];
if (Array.isArray(nestedData)) {
return this.getOptionsFromArray(nestedData, optionsId, optionsLabel);
}
return this.getOptionsFromPath(nestedData, properties.join('.'));
}
private getOptionsFromArray(nestedData: any[], id: string, label: string): FormFieldOption[] {
const options = nestedData.map(item => this.createOption(item, id, label));
const hasInvalidOption = options.some(option => !option);
if (hasInvalidOption) {
this.variableOptionsFailed = true;
return [];
}
this.variableOptionsFailed = false;
return options;
}
private createOption(item: any, id: string, label: string): FormFieldOption {
const option: FormFieldOption = {
id: item[id],
name: item[label]
};
if (!option.id || !option.name) {
this.handleError(`'id' or 'label' is not properly defined`);
return undefined;
}
return option;
}
private getOptionsFromVariable(processVariables: TaskVariableCloud[], formVariables: TaskVariableCloud[], variableName: string): TaskVariableCloud {
const processVariableDropdownOptions: TaskVariableCloud = this.getVariableValueByName(processVariables, variableName);
const formVariableDropdownOptions: TaskVariableCloud = this.getVariableValueByName(formVariables, variableName);
return processVariableDropdownOptions ?? formVariableDropdownOptions;
}
private getVariableValueByName(variables: TaskVariableCloud[], variableName: string): any {
return variables?.find((variable: TaskVariableCloud) => variable?.name === `variables.${variableName}` || variable?.name === variableName)?.value;
}
private isVariableOptionType(): boolean {
return this.field?.optionType === 'variable';
}
private persistFieldOptionsFromRestApi() { private persistFieldOptionsFromRestApi() {
if (this.isValidRestType()) { if (this.isValidRestType()) {
this.resetRestApiErrorMessage(); this.resetRestApiErrorMessage();
@@ -125,6 +219,15 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
private loadFieldOptionsForLinkedWidget() { private loadFieldOptionsForLinkedWidget() {
const parentWidgetValue = this.getParentWidgetValue(); const parentWidgetValue = this.getParentWidgetValue();
this.parentValueChanged(parentWidgetValue); this.parentValueChanged(parentWidgetValue);
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);
});
} }
private getParentWidgetValue(): string { private getParentWidgetValue(): string {
@@ -317,7 +420,10 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI
} }
showRequiredMessage(): boolean { showRequiredMessage(): boolean {
return (this.isInvalidFieldRequired() || (this.isNoneValueSelected(this.field.value) && this.isRequired())) && this.isTouched() && !this.isRestApiFailed; return (this.isInvalidFieldRequired() || (this.isNoneValueSelected(this.field.value) && this.isRequired())) &&
this.isTouched() &&
!this.isRestApiFailed &&
!this.variableOptionsFailed;
} }
getDefaultOption(options: FormFieldOption[]): FormFieldOption { getDefaultOption(options: FormFieldOption[]): FormFieldOption {

View File

@@ -16,6 +16,7 @@
*/ */
import { FormFieldOption } from '@alfresco/adf-core'; import { FormFieldOption } from '@alfresco/adf-core';
import { TaskVariableCloud } from '../models/task-variable-cloud.model';
export const mockConditionalEntries = [ export const mockConditionalEntries = [
{ {
@@ -103,3 +104,79 @@ export const filterOptionList = [
{ id: 'opt_5', name: 'option_5' }, { id: 'opt_5', name: 'option_5' },
{ id: 'opt_6', name: 'option_6' } { id: 'opt_6', name: 'option_6' }
]; ];
export const mockPlayersResponse = {
response: {
people: {
players:
[
{
playerId: 'player-1',
playerFullName: 'Lionel Messi',
totalGoals: 999,
shirtNumber: 10
},
{
playerId: 'player-2',
playerFullName: 'Cristiano Ronaldo',
totalGoals: 15,
shirtNumber: 7
},
{
playerId: 'player-3',
playerFullName: 'Robert Lewandowski',
totalGoals: 500,
shirtNumber: 9
}
]
}
}
};
export const mockDefaultResponse = {
data:
[
{
id: 'default-pet-1',
name: 'Dog'
},
{
id: 'default-pet-2',
name: 'Cat'
},
{
id: 'default-pet-3',
name: 'Parrot'
}
]
};
export const mockCountriesResponse = {
countries: [
{
id: 'PL',
name: 'Poland'
},
{
id: 'UK',
name: 'United Kingdom'
},
{
id: 'GR',
name: 'Greece'
}
]
};
export const mockFormVariableWithJson = [
new TaskVariableCloud({ name: 'json-form-variable', value: mockCountriesResponse, type: 'json', id: 'fake-id-1' })
];
export const mockProcessVariablesWithJson = [
new TaskVariableCloud({ name: 'variables.json-variable', value: mockPlayersResponse, type: 'json', id: 'fake-id-1' }),
new TaskVariableCloud({ name: 'variables.different-variable', value: 'fake-value', type: 'json', id: 'fake-id-2' })
];
export const mockVariablesWithDefaultJson = [
new TaskVariableCloud({ name: 'variables.json-default-variable', value: mockDefaultResponse, type: 'json', id: 'fake-id-1' })
];