From 6bb7ec3b7d3d79a749fa0a5e7ee21125a5943312 Mon Sep 17 00:00:00 2001 From: Darren Thornton <6361057+dthornton-hyl@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:34:41 -0500 Subject: [PATCH] AAE-50868 Fix some visibility conditions scenarios with form variables (#12206) * AAE-50868 Fix some visibility conditions senarios with form variables * form 0 check updates * copilot review suggestions --- .../widgets/core/form-variable.model.ts | 1 + .../widgets/core/form.model.spec.ts | 27 +++ .../components/widgets/core/form.model.ts | 20 ++- .../models/task-process-variable.model.ts | 2 +- .../widget-visibility.service.spec.ts | 165 ++++++++++++++++++ .../services/widget-visibility.service.ts | 27 +-- .../components/form-cloud.component.spec.ts | 123 +++++++++++++ .../form/components/form-cloud.component.ts | 27 ++- 8 files changed, 370 insertions(+), 22 deletions(-) diff --git a/lib/core/src/lib/form/components/widgets/core/form-variable.model.ts b/lib/core/src/lib/form/components/widgets/core/form-variable.model.ts index 0bac1bb08a..3efe973bbe 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-variable.model.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-variable.model.ts @@ -20,4 +20,5 @@ export interface FormVariableModel { name: string; type: string; value?: any; + runtimeSet?: boolean; } diff --git a/lib/core/src/lib/form/components/widgets/core/form.model.spec.ts b/lib/core/src/lib/form/components/widgets/core/form.model.spec.ts index f2a834bb54..90d8fcd047 100644 --- a/lib/core/src/lib/form/components/widgets/core/form.model.spec.ts +++ b/lib/core/src/lib/form/components/widgets/core/form.model.spec.ts @@ -600,6 +600,33 @@ describe('FormModel', () => { const missing = form.getProcessVariableValue('missing'); expect(missing).toBeUndefined(); }); + + it('should return zero process variable value instead of the form default', () => { + const formWithZero = new FormModel({ + variables: [{ id: 'amount-var', name: 'amount', type: 'integer', value: 99 }], + processVariables: [{ name: 'variables.amount', value: 0, type: 'integer' }] + }); + + expect(formWithZero.getProcessVariableValue('amount')).toBe(0); + }); + + it('should return false process variable value instead of the form default', () => { + const formWithFalse = new FormModel({ + variables: [{ id: 'flag-var', name: 'flag', type: 'boolean', value: true }], + processVariables: [{ name: 'variables.flag', value: false, type: 'boolean' }] + }); + + expect(formWithFalse.getProcessVariableValue('flag')).toBe(false); + }); + + it('should return empty string process variable value instead of the form default', () => { + const formWithEmpty = new FormModel({ + variables: [{ id: 'text-var', name: 'text', type: 'string', value: 'default' }], + processVariables: [{ name: 'variables.text', value: '', type: 'string' }] + }); + + expect(formWithEmpty.getProcessVariableValue('text')).toBe(''); + }); }); describe('add values not present', () => { diff --git a/lib/core/src/lib/form/components/widgets/core/form.model.ts b/lib/core/src/lib/form/components/widgets/core/form.model.ts index 2fea8ce8dd..1d53f2f2d1 100644 --- a/lib/core/src/lib/form/components/widgets/core/form.model.ts +++ b/lib/core/src/lib/form/components/widgets/core/form.model.ts @@ -307,22 +307,17 @@ export class FormModel implements ProcessFormModel { * @returns process variable value */ getProcessVariableValue(name: string): any { - let value; if (this.processVariables?.length) { const names = [`variables.${name}`, name]; const processVariable = this.processVariables.find((entry) => names.includes(entry.name)); if (processVariable) { - value = this.parseValue(processVariable.type, processVariable.value); + return this.parseValue(processVariable.type, processVariable.value); } } - if (!value) { - value = this.getDefaultFormVariableValue(name); - } - - return value; + return this.getDefaultFormVariableValue(name); } protected parseValue(type: string, value: any): any { @@ -535,9 +530,20 @@ export class FormModel implements ProcessFormModel { const variable = this.getFormVariable(variableId); if (variable) { variable.value = value; + variable.runtimeSet = true; } } + /** + * Checks whether a form variable has been given a value at runtime, for example by a form rule. + * + * @param identifier The `name` or `id` value + * @returns `true` when the value was set at runtime rather than coming from the form definition + */ + isVariableSetAtRuntime(identifier: string): boolean { + return !!this.getFormVariable(identifier)?.runtimeSet; + } + private loadInjectedFieldValidators(injectedFieldValidators: FormFieldValidator[]): void { this.fieldValidators = injectedFieldValidators ? [...FORM_FIELD_VALIDATORS, ...injectedFieldValidators] : [...FORM_FIELD_VALIDATORS]; } diff --git a/lib/core/src/lib/form/models/task-process-variable.model.ts b/lib/core/src/lib/form/models/task-process-variable.model.ts index 1bfc188caa..f2f764f366 100644 --- a/lib/core/src/lib/form/models/task-process-variable.model.ts +++ b/lib/core/src/lib/form/models/task-process-variable.model.ts @@ -18,5 +18,5 @@ export class TaskProcessVariableModel { id?: string; type?: string; - value: string; + value: any; } diff --git a/lib/core/src/lib/form/services/widget-visibility.service.spec.ts b/lib/core/src/lib/form/services/widget-visibility.service.spec.ts index a75f9fa768..ce761cace6 100644 --- a/lib/core/src/lib/form/services/widget-visibility.service.spec.ts +++ b/lib/core/src/lib/form/services/widget-visibility.service.spec.ts @@ -1002,4 +1002,169 @@ describe('WidgetVisibilityService', () => { expect(textField.isVisible).toBe(true); }); }); + + describe('Visibility calculation from form variables', () => { + const hiddenWhileRequestor = new WidgetVisibilityModel({ + leftType: 'variable', + leftValue: 'person_type', + operator: '!=', + rightType: 'value', + rightValue: 'Requestor', + nextConditionOperator: '', + nextCondition: null + }); + + const requestorProcessVariables = [{ id: 'variables.person_type', value: 'Requestor' }]; + + let formJson: any; + let form: FormModel; + + beforeEach(() => { + formJson = { + id: 'person-type-form', + variables: [{ id: 'person-type-var', name: 'person_type', value: null }], + processVariables: [{ name: 'variables.person_type', value: 'Requestor' }] + }; + form = new FormModel(formJson); + service.cleanProcessVariable(); + }); + + it('should resolve a variable from the form when the cached process variables no longer hold it', () => { + service.refreshVisibility(form, requestorProcessVariables); + + service.refreshVisibility(new FormModel({ id: 'another-form' }), [{ id: 'variables.other', value: 'other' }]); + + expect(service.evaluateVisibility(form, hiddenWhileRequestor)).toBe(false); + }); + + it('should resolve a variable from the form when the refreshed data omits it', () => { + service.refreshVisibility(form, requestorProcessVariables); + service.refreshVisibility(form, [{ id: 'processOutput', value: 'result' }]); + + expect(service.evaluateVisibility(form, hiddenWhileRequestor)).toBe(false); + }); + + it('should prefer a process variable over the value defined in the form definition', () => { + const formWithDefault = new FormModel({ + id: 'person-type-form-with-default', + variables: [{ id: 'person-type-var', name: 'person_type', value: 'Approver' }], + processVariables: [{ name: 'variables.person_type', value: 'Requestor' }] + }); + + expect(service.getVariableValue(formWithDefault, 'person_type', requestorProcessVariables)).toBe('Requestor'); + }); + + it('should prefer a variable changed at runtime over a process variable of the same name', () => { + service.refreshVisibility(form, requestorProcessVariables); + + form.changeVariableValue('person-type-var', 'Approver'); + service.refreshVisibility(form, requestorProcessVariables); + + expect(service.evaluateVisibility(form, hiddenWhileRequestor)).toBe(true); + }); + + it('should prefer a variable cleared at runtime over a process variable of the same name', () => { + service.refreshVisibility(form, requestorProcessVariables); + + form.changeVariableValue('person-type-var', ''); + service.refreshVisibility(form, requestorProcessVariables); + + expect(service.evaluateVisibility(form, hiddenWhileRequestor)).toBe(true); + }); + + it('should keep a variable changed at runtime when the form is rebuilt from the same definition', () => { + service.refreshVisibility(form, requestorProcessVariables); + form.changeVariableValue('person-type-var', 'Approver'); + + const rebuiltForm = new FormModel(formJson); + service.refreshVisibility(rebuiltForm, [{ id: 'processOutput', value: 'result' }]); + + expect(service.evaluateVisibility(rebuiltForm, hiddenWhileRequestor)).toBe(true); + }); + + it('should not convert the type of a variable changed at runtime', () => { + const numericForm = new FormModel({ id: 'numeric-form', variables: [{ id: 'amount-var', name: 'amount', type: 'integer', value: 0 }] }); + const booleanForm = new FormModel({ id: 'boolean-form', variables: [{ id: 'flag-var', name: 'flag', type: 'boolean', value: true }] }); + + service.refreshVisibility(numericForm); + service.refreshVisibility(booleanForm); + + numericForm.changeVariableValue('amount-var', 5); + booleanForm.changeVariableValue('flag-var', false); + + service.refreshVisibility(numericForm); + service.refreshVisibility(booleanForm); + + expect(service.getVariableValue(numericForm, 'amount')).toBe(5); + expect(service.getVariableValue(booleanForm, 'flag', [])).toBe(false); + }); + + it('should evaluate a numeric variable changed at runtime without string comparison', () => { + const numericForm = new FormModel({ + id: 'numeric-visibility-form', + variables: [{ id: 'amount-var', name: 'amount', type: 'integer', value: 0 }] + }); + const amountOverTen = new WidgetVisibilityModel({ + leftType: 'variable', + leftValue: 'amount', + operator: '>', + rightType: 'value', + rightValue: '10', + nextConditionOperator: '', + nextCondition: null + }); + + service.refreshVisibility(numericForm); + numericForm.changeVariableValue('amount-var', 5); + service.refreshVisibility(numericForm); + + expect(service.evaluateVisibility(numericForm, amountOverTen)).toBe(false); + }); + + it('should resolve a zero process variable from the form when the refreshed data omits it', () => { + const zeroForm = new FormModel({ + id: 'zero-form', + variables: [{ id: 'amount-var', name: 'amount', type: 'integer', value: 99 }], + processVariables: [{ name: 'variables.amount', value: 0, type: 'integer' }] + }); + const amountIsZero = new WidgetVisibilityModel({ + leftType: 'variable', + leftValue: 'amount', + operator: '==', + rightType: 'value', + rightValue: '0', + nextConditionOperator: '', + nextCondition: null + }); + + service.refreshVisibility(zeroForm, [{ id: 'variables.amount', value: 0 }]); + service.refreshVisibility(zeroForm, [{ id: 'processOutput', value: 'result' }]); + + expect(service.getVariableValue(zeroForm, 'amount', [])).toBe(0); + expect(service.evaluateVisibility(zeroForm, amountIsZero)).toBe(true); + }); + + it('should resolve a false process variable from the form when the refreshed data omits it', () => { + const falseForm = new FormModel({ + id: 'false-form', + variables: [{ id: 'flag-var', name: 'flag', type: 'boolean', value: true }], + processVariables: [{ name: 'variables.flag', value: false, type: 'boolean' }] + }); + const flagIsFalse = new WidgetVisibilityModel({ + leftType: 'variable', + leftValue: 'flag', + operator: '==', + rightType: 'value', + rightValue: 'false', + nextConditionOperator: '', + nextCondition: null + }); + + service.refreshVisibility(falseForm, [{ id: 'variables.flag', value: false }]); + service.refreshVisibility(falseForm, [{ id: 'processOutput', value: 'result' }]); + + expect(service.getVariableValue(falseForm, 'flag', [])).toBe(false); + expect(service.evaluateVisibility(falseForm, flagIsFalse)).toBe(true); + }); + }); }); diff --git a/lib/core/src/lib/form/services/widget-visibility.service.ts b/lib/core/src/lib/form/services/widget-visibility.service.ts index a7d1ea039d..618a08d2e1 100644 --- a/lib/core/src/lib/form/services/widget-visibility.service.ts +++ b/lib/core/src/lib/form/services/widget-visibility.service.ts @@ -29,7 +29,7 @@ import { FormService } from './form.service'; export class WidgetVisibilityService { private readonly formService = inject(FormService); - private processVarList: TaskProcessVariableModel[]; + private processVarList: TaskProcessVariableModel[] = []; private form: FormModel; public refreshVisibility(form: FormModel, processVarList?: TaskProcessVariableModel[]) { @@ -110,7 +110,7 @@ export class WidgetVisibilityService { } } - getLeftValue(form: FormModel, visibilityObj: WidgetVisibilityModel): string { + getLeftValue(form: FormModel, visibilityObj: WidgetVisibilityModel): any { let leftValue = ''; if (visibilityObj.leftType === WidgetTypeEnum.variable) { leftValue = this.getVariableValue(form, visibilityObj.leftValue, this.processVarList); @@ -124,7 +124,7 @@ export class WidgetVisibilityService { return leftValue; } - getRightValue(form: FormModel, visibilityObj: WidgetVisibilityModel): string { + getRightValue(form: FormModel, visibilityObj: WidgetVisibilityModel): any { let valueFound = ''; if (visibilityObj.rightType === WidgetTypeEnum.variable) { @@ -270,20 +270,21 @@ export class WidgetVisibilityService { return field.id && fieldToFind ? field.id.toUpperCase() === fieldToFind.toUpperCase() : false; } - public getVariableValue(form: FormModel, name: string, processVarList: TaskProcessVariableModel[]): string { - const processVariableValue = this.getProcessVariableValue(name, processVarList); - const variableDefaultValue = form.getDefaultFormVariableValue(name); + public getVariableValue(form: FormModel, name: string, processVarList: TaskProcessVariableModel[] = []): any { + if (form.isVariableSetAtRuntime(name)) { + return form.getDefaultFormVariableValue(name); + } - return processVariableValue === undefined ? variableDefaultValue : processVariableValue; + const processVariableValue = this.getProcessVariableValue(name, processVarList); + + return processVariableValue === undefined ? form.getProcessVariableValue(name) : processVariableValue; } - private getProcessVariableValue(name: string, processVarList: TaskProcessVariableModel[]): string { - if (processVarList) { - const processVariable = processVarList.find((variable) => variable.id === name || variable.id === `variables.${name}`); + private getProcessVariableValue(name: string, processVarList: TaskProcessVariableModel[]): any { + const processVariable = processVarList.find((variable) => variable.id === name || variable.id === `variables.${name}`); - if (processVariable) { - return processVariable.value; - } + if (processVariable) { + return processVariable.value; } return undefined; } diff --git a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts index 221f4dedb5..8467382568 100644 --- a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts @@ -2575,3 +2575,126 @@ describe('FormCloudComponent — runtime state preservation on data refresh', () }); }); }); + +describe('FormCloudComponent — form variable visibility on data refresh', () => { + let fixture: ComponentFixture; + let formComponent: FormCloudComponent; + let visibilityService: WidgetVisibilityService; + + /** field is hidden while person_type is Requestor */ + const personTypeFormJson = { + id: 'person-type-form', + name: 'Person Type Form', + variables: [{ id: 'person-type-var', name: 'person_type', value: null }], + fields: [ + { + fieldType: 'ContainerRepresentation', + id: 'container1', + name: 'Container', + type: 'container', + tab: null, + numberOfColumns: 1, + fields: { + 1: [ + { + fieldType: 'FormFieldRepresentation', + id: 'conditionalField', + name: 'Conditional Field', + type: 'multiline-text', + value: null, + required: false, + readOnly: false, + visibilityCondition: { + leftType: 'variable', + leftValue: 'person_type', + operator: '!=', + rightType: 'value', + rightValue: 'Requestor', + nextConditionOperator: '', + nextCondition: null + }, + params: { existingColspan: 1, maxColspan: 1 } + } + ] + } + } + ] + }; + + const requestorVariables = () => [new TaskVariableCloud({ name: 'variables.person_type', value: 'Requestor' })]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NoopTranslateModule, NoopAuthModule, FormCloudComponent], + providers: [ + { provide: VersionCompatibilityService, useValue: {} }, + { provide: FormRenderingService, useClass: CloudFormRenderingService } + ] + }); + + const apiService = TestBed.inject(AlfrescoApiService); + spyOn(apiService, 'getInstance').and.returnValue(mockOauth2Auth); + + visibilityService = TestBed.inject(WidgetVisibilityService); + visibilityService.cleanProcessVariable(); + + fixture = TestBed.createComponent(FormCloudComponent); + formComponent = fixture.componentInstance; + + formComponent.formCloudRepresentationJSON = new FormCloudRepresentation(JSON.parse(JSON.stringify(personTypeFormJson))); + formComponent.formCloudRepresentationJSON.processVariables = requestorVariables(); + formComponent.data = requestorVariables(); + formComponent.form = formComponent.parseForm(formComponent.formCloudRepresentationJSON); + visibilityService.refreshVisibility(formComponent.form, formComponent.data); + + fixture.detectChanges(); + }); + + it('should keep the field hidden when the refreshed data omits the variable', () => { + expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeFalse(); + + const partialData = [new TaskVariableCloud({ name: 'processOutput', value: 'result' })]; + const change = new SimpleChange(formComponent.data, partialData, false); + formComponent.data = partialData; + + formComponent.ngOnChanges({ data: change }); + + expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeFalse(); + }); + + it('should show the field when the refreshed data changes the variable', () => { + const approverData = [new TaskVariableCloud({ name: 'variables.person_type', value: 'Approver' })]; + const change = new SimpleChange(formComponent.data, approverData, false); + formComponent.data = approverData; + + formComponent.ngOnChanges({ data: change }); + + expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeTrue(); + }); + + it('should keep a variable changed by a form rule when the refreshed data omits it', () => { + formComponent.form.changeVariableValue('person-type-var', 'Approver'); + + const partialData = [new TaskVariableCloud({ name: 'processOutput', value: 'result' })]; + const change = new SimpleChange(formComponent.data, partialData, false); + formComponent.data = partialData; + + formComponent.ngOnChanges({ data: change }); + + expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeTrue(); + }); + + it('should keep the latest received variable value across a following partial refresh', () => { + const approverData = [new TaskVariableCloud({ name: 'variables.person_type', value: 'Approver' })]; + formComponent.data = approverData; + formComponent.ngOnChanges({ data: new SimpleChange(requestorVariables(), approverData, false) }); + + expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeTrue(); + + const partialData = [new TaskVariableCloud({ name: 'processOutput', value: 'result' })]; + formComponent.data = partialData; + formComponent.ngOnChanges({ data: new SimpleChange(approverData, partialData, false) }); + + expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeTrue(); + }); +}); diff --git a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts index c2ab42da40..66f91cc602 100644 --- a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts +++ b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts @@ -601,6 +601,8 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges, private refreshFormData(previousData: TaskVariableCloud[] = []) { const snapshot = this.snapshotRuntimeState(); + this.mergeProcessVariables(this.data ?? []); + this.form = this.parseForm(this.formCloudRepresentationJSON); if (!this.form) { return; @@ -610,13 +612,36 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges, this.restoreRuntimeState(this.form, snapshot, changedFieldIds); this.setCheckParentVisibilityForValidationOnFields(); - this.visibilityService.refreshVisibility(this.form); + this.visibilityService.refreshVisibility(this.form, this.data); this.form.validateForm(); this.onFormLoaded(this.form); this.formService.formRulesEvent.next(new FormRulesEvent('dataRefreshed', new FormEvent(this.form))); this.onFormDataRefreshed(this.form); } + /** + * Keeps the process variables on the stored representation up to date with the latest data, so that a + * variable omitted by a later partial refresh still resolves to the most recent value received. + * + * @param updates Variables received on the latest data refresh + */ + private mergeProcessVariables(updates: TaskVariableCloud[]): void { + if (!this.formCloudRepresentationJSON) { + return; + } + + const existing: TaskVariableCloud[] = this.formCloudRepresentationJSON.processVariables ?? []; + const byName = new Map(); + + for (const variable of [...existing, ...updates]) { + if (variable?.name) { + byName.set(variable.name, variable); + } + } + + this.formCloudRepresentationJSON.processVariables = Array.from(byName.values()); + } + private snapshotRuntimeState(): Map { const snapshot = new Map(); if (!this.form) {