From e3ea23da37e08d27f0253019f7fdc09d3dd0d699 Mon Sep 17 00:00:00 2001 From: Tomasz Gnyp <49343696+tomgny@users.noreply.github.com> Date: Thu, 29 Jun 2023 17:21:14 +0200 Subject: [PATCH] =?UTF-8?q?[AAE-15082][AAE-15081]=20Resolve=20the=20option?= =?UTF-8?q?s=20coming=20from=20a=20json=20variabl=E2=80=A6=20(#8673)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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 --- .../core/form-field-variable-options.ts | 23 +++ .../widgets/core/form-field.model.ts | 5 +- lib/core/src/lib/i18n/en.json | 1 + .../dropdown/dropdown-cloud.widget.html | 2 + .../dropdown/dropdown-cloud.widget.spec.ts | 151 +++++++++++++++++- .../widgets/dropdown/dropdown-cloud.widget.ts | 140 ++++++++++++++-- .../src/lib/form/mocks/dropdown.mock.ts | 77 +++++++++ 7 files changed, 379 insertions(+), 20 deletions(-) create mode 100644 lib/core/src/lib/form/components/widgets/core/form-field-variable-options.ts diff --git a/lib/core/src/lib/form/components/widgets/core/form-field-variable-options.ts b/lib/core/src/lib/form/components/widgets/core/form-field-variable-options.ts new file mode 100644 index 0000000000..11f3cd9d72 --- /dev/null +++ b/lib/core/src/lib/form/components/widgets/core/form-field-variable-options.ts @@ -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; +} diff --git a/lib/core/src/lib/form/components/widgets/core/form-field.model.ts b/lib/core/src/lib/form/components/widgets/core/form-field.model.ts index 6105b6204b..8488bd2757 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field.model.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field.model.ts @@ -27,6 +27,7 @@ import { FormWidgetModel } from './form-widget.model'; import { FormFieldRule } from './form-field-rule'; import { ProcessFormModel } from './process-form-model.interface'; import { isNumberValue } from './form-field-utils'; +import { VariableConfig } from './form-field-variable-options'; // Maps to FormFieldRepresentation export class FormFieldModel extends FormWidgetModel { @@ -66,7 +67,7 @@ export class FormFieldModel extends FormWidgetModel { restLabelProperty: string; hasEmptyValue: boolean; className: string; - optionType: 'rest' | 'manual' ; + optionType: 'rest' | 'manual' | 'variable'; params: FormFieldMetadata = {}; hyperlinkUrl: string; displayText: string; @@ -81,6 +82,7 @@ export class FormFieldModel extends FormWidgetModel { selectLoggedUser: boolean; groupsRestriction: string[]; leftLabels: boolean = false; + variableConfig: VariableConfig; // container model members numberOfColumns: number = 1; @@ -194,6 +196,7 @@ export class FormFieldModel extends FormWidgetModel { this.rule = json.rule; this.selectLoggedUser = json.selectLoggedUser; this.groupsRestriction = json.groupsRestriction?.groups; + this.variableConfig = json.variableConfig; if (json.placeholder && json.placeholder !== '' && json.placeholder !== 'null') { this.placeholder = json.placeholder; diff --git a/lib/core/src/lib/i18n/en.json b/lib/core/src/lib/i18n/en.json index 9a6a2f690f..0665c6adf8 100644 --- a/lib/core/src/lib/i18n/en.json +++ b/lib/core/src/lib/i18n/en.json @@ -46,6 +46,7 @@ "UPLOAD": "UPLOAD", "REQUIRED": "This is a required field", "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", "NO_FILE_ATTACHED": "No file attached", "VALIDATOR": { 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 ef2320dc0a..abf377e0c3 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 @@ -37,5 +37,7 @@ required="{{ 'FORM.FIELD.REQUIRED' | translate }}"> + diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.spec.ts index 9565709000..b93287497c 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.spec.ts @@ -19,7 +19,15 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { of, throwError } from 'rxjs'; 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 { ProcessServiceCloudTestingModule } from '../../../../testing/process-service-cloud.testing.module'; import { TranslateModule } from '@ngx-translate/core'; @@ -27,16 +35,22 @@ import { fakeOptionList, filterOptionList, mockConditionalEntries, + mockFormVariableWithJson, + mockPlayersResponse, mockRestDropdownOptions, - mockSecondRestDropdownOptions + mockSecondRestDropdownOptions, + mockVariablesWithDefaultJson, + mockProcessVariablesWithJson } from '../../../mocks/dropdown.mock'; import { OverlayContainer } from '@angular/cdk/overlay'; +import { TaskVariableCloud } from '../../../models/task-variable-cloud.model'; describe('DropdownCloudWidgetComponent', () => { let formService: FormService; let widget: DropdownCloudWidgetComponent; let formCloudService: FormCloudService; + let logService: LogService; let overlayContainer: OverlayContainer; let fixture: ComponentFixture; let element: HTMLElement; @@ -64,6 +78,7 @@ describe('DropdownCloudWidgetComponent', () => { formService = TestBed.inject(FormService); formCloudService = TestBed.inject(FormCloudService); overlayContainer = TestBed.inject(OverlayContainer); + logService = TestBed.inject(LogService); }); 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'); + }); + }); }); 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 5f93c5a80d..1ae7673e66 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 @@ -30,6 +30,7 @@ import { import { FormCloudService } from '../../../services/form-cloud.service'; import { BehaviorSubject, combineLatest, Observable, of, Subject } from 'rxjs'; import { filter, map, takeUntil } from 'rxjs/operators'; +import { TaskVariableCloud } from '../../../models/task-variable-cloud.model'; export const DEFAULT_OPTION = { id: 'empty', @@ -60,10 +61,15 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI typeId = 'DropdownCloudWidgetComponent'; showInputFilter = false; isRestApiFailed = false; + variableOptionsFailed = false; restApiHostName: string; list$: Observable; filter$ = new BehaviorSubject(''); + private readonly defaultVariableOptionId = 'id'; + private readonly defaultVariableOptionLabel = 'name'; + private readonly defaultVariableOptionPath = 'data'; + protected onDestroy$ = new Subject(); constructor(public formService: FormService, @@ -74,25 +80,113 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI } ngOnInit() { - if (this.field.restUrl && !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); - }); - } + this.checkFieldOptionsSource(); 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() { if (this.isValidRestType()) { this.resetRestApiErrorMessage(); @@ -125,6 +219,15 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI private loadFieldOptionsForLinkedWidget() { const parentWidgetValue = this.getParentWidgetValue(); 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 { @@ -317,7 +420,10 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI } 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 { diff --git a/lib/process-services-cloud/src/lib/form/mocks/dropdown.mock.ts b/lib/process-services-cloud/src/lib/form/mocks/dropdown.mock.ts index a13488c8a0..20262f4297 100644 --- a/lib/process-services-cloud/src/lib/form/mocks/dropdown.mock.ts +++ b/lib/process-services-cloud/src/lib/form/mocks/dropdown.mock.ts @@ -16,6 +16,7 @@ */ import { FormFieldOption } from '@alfresco/adf-core'; +import { TaskVariableCloud } from '../models/task-variable-cloud.model'; export const mockConditionalEntries = [ { @@ -103,3 +104,79 @@ export const filterOptionList = [ { id: 'opt_5', name: 'option_5' }, { 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' }) +];