diff --git a/lib/core/src/lib/form/components/widgets/base-display-text/base-display-text.widget.ts b/lib/core/src/lib/form/components/widgets/base-display-text/base-display-text.widget.ts new file mode 100644 index 0000000000..b6b8287c83 --- /dev/null +++ b/lib/core/src/lib/form/components/widgets/base-display-text/base-display-text.widget.ts @@ -0,0 +1,114 @@ +/*! + * @license + * Copyright © 2005-2025 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. + */ + +import { ChangeDetectorRef, Component, inject, AfterViewInit, DestroyRef, InjectionToken, Optional, Inject } from '@angular/core'; +import { debounceTime, filter, isObservable, Observable } from 'rxjs'; +import { FormRulesEvent } from '../../../events'; +import { FormExpressionService } from '../../../services/form-expression.service'; +import { WidgetComponent } from '../widget.component'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +export interface DisplayTextWidgetSettings { + enableExpressionEvaluation: boolean; + // a setting for a /juel API can be added here for full expression support +} + +export const ADF_DISPLAY_TEXT_SETTINGS = new InjectionToken('adf-display-text-settings'); + +@Component({ + template: '', + standalone: true +}) +export abstract class BaseDisplayTextWidgetComponent extends WidgetComponent implements AfterViewInit { + private readonly formExpressionService = inject(FormExpressionService); + private readonly cdr = inject(ChangeDetectorRef); + private enableExpressionEvaluation: boolean = false; + protected originalFieldValue?: string; + + constructor(@Optional() @Inject(ADF_DISPLAY_TEXT_SETTINGS) settings: Observable | DisplayTextWidgetSettings) { + super(); + if (isObservable(settings)) { + settings.pipe(takeUntilDestroyed()).subscribe((data: DisplayTextWidgetSettings) => { + this.updateSettingsBasedProperties(data); + }); + } else { + this.updateSettingsBasedProperties(settings); + } + } + + override ngAfterViewInit() { + if (this.enableExpressionEvaluation) { + this.storeOriginalValue(); + this.setupFieldDependencies(); + this.applyExpressions(); + } + super.ngAfterViewInit(); + } + + protected abstract storeOriginalValue(): void; + protected abstract evaluateExpressions(): void; + protected abstract reevaluateExpressions(): void; + private readonly destroyRef = inject(DestroyRef); + + protected resolveExpressions(text: string): string { + return this.formExpressionService.resolveExpressions(this.field.form, text); + } + + private applyExpressions() { + if (!this.field) { + return; + } + + this.evaluateExpressions(); + this.cdr.detectChanges(); + } + + private setupFieldDependencies() { + if (!this.field?.form || !this.originalFieldValue) { + return; + } + + const dependencies = this.formExpressionService.getFieldDependencies(this.originalFieldValue); + if (dependencies.length === 0) { + return; + } + + this.formService.formRulesEvent + .pipe( + filter((event: FormRulesEvent) => event.type === 'fieldValueChanged' && event.field && dependencies.includes(event.field.id)), + debounceTime(300), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.reapplyExpressions(); + }); + } + + private reapplyExpressions() { + if (!this.field || !this.originalFieldValue) { + return; + } + + this.reevaluateExpressions(); + this.fieldChanged.emit(this.field); + this.cdr.detectChanges(); + } + + private updateSettingsBasedProperties(data: DisplayTextWidgetSettings): void { + this.enableExpressionEvaluation = data?.enableExpressionEvaluation ?? false; + } +} diff --git a/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.spec.ts b/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.spec.ts index 981bb4258d..30fc30a7a8 100644 --- a/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.spec.ts +++ b/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.spec.ts @@ -18,18 +18,24 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormFieldModel, FormModel } from '../core'; import { DisplayTextWidgetComponent } from './display-text.widget'; +import { ADF_DISPLAY_TEXT_SETTINGS } from '../base-display-text/base-display-text.widget'; +import { FormService } from '../../../services/form.service'; +import { of } from 'rxjs'; describe('DisplayTextWidgetComponent', () => { let fixture: ComponentFixture; let widget: DisplayTextWidgetComponent; + let formService: FormService; beforeEach(() => { TestBed.configureTestingModule({ - imports: [DisplayTextWidgetComponent] + imports: [DisplayTextWidgetComponent], + providers: [FormService] }); fixture = TestBed.createComponent(DisplayTextWidgetComponent); widget = fixture.componentInstance; + formService = TestBed.inject(FormService); }); describe('event tracking', () => { @@ -49,4 +55,189 @@ describe('DisplayTextWidgetComponent', () => { expect(eventSpy).toHaveBeenCalledWith(clickEvent); }); }); + + describe('expression evaluation', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [DisplayTextWidgetComponent], + providers: [ + FormService, + { + provide: ADF_DISPLAY_TEXT_SETTINGS, + useValue: { enableExpressionEvaluation: true } + } + ] + }); + + fixture = TestBed.createComponent(DisplayTextWidgetComponent); + widget = fixture.componentInstance; + formService = TestBed.inject(FormService); + }); + + it('should resolve field expressions in text value', () => { + const form = new FormModel({ + fields: [ + { id: 'displayText1', type: 'display-text', value: 'Hello ${field.name}' }, + { id: 'name', type: 'text', value: 'John' } + ] + }); + + widget.field = form.getFieldById('displayText1'); + fixture.detectChanges(); + + expect(widget.field.value).toBe('Hello John'); + }); + + it('should resolve variable expressions in text value', () => { + const form = new FormModel({ + fields: [{ id: 'displayText1', type: 'display-text', value: 'Status: ${variable.status}' }], + variables: [{ id: 'status', name: 'status', type: 'string', value: 'Active' }] + }); + + widget.field = form.getFieldById('displayText1'); + fixture.detectChanges(); + + expect(widget.field.value).toBe('Status: Active'); + }); + + it('should resolve multiple expressions in text value', () => { + const form = new FormModel({ + fields: [ + { id: 'displayText1', type: 'display-text', value: '${field.firstName} ${field.lastName}' }, + { id: 'firstName', type: 'text', value: 'John' }, + { id: 'lastName', type: 'text', value: 'Doe' } + ] + }); + + widget.field = form.getFieldById('displayText1'); + fixture.detectChanges(); + + expect(widget.field.value).toBe('John Doe'); + }); + + it('should update display text when dependent field value changes', (done) => { + const form = new FormModel({ + fields: [ + { id: 'displayText1', type: 'display-text', value: 'Hello ${field.name}' }, + { id: 'name', type: 'text', value: 'John' } + ] + }); + + widget.field = form.getFieldById('displayText1'); + const nameField = form.getFieldById('name'); + fixture.detectChanges(); + + expect(widget.field.value).toBe('Hello John'); + + nameField.value = 'Jane'; + formService.formRulesEvent.next({ type: 'fieldValueChanged', field: nameField } as any); + + setTimeout(() => { + expect(widget.field.value).toBe('Hello Jane'); + done(); + }, 350); + }); + + it('should handle non-string field values by stringifying them', () => { + const form = new FormModel({ + fields: [ + { id: 'displayText1', type: 'display-text', value: 'Count: ${field.count}' }, + { id: 'count', type: 'number', value: 42 } + ] + }); + + widget.field = form.getFieldById('displayText1'); + fixture.detectChanges(); + + expect(widget.field.value).toBe('Count: 42'); + }); + + it('should handle missing field references with empty string', () => { + const form = new FormModel({ + fields: [{ id: 'displayText1', type: 'display-text', value: 'Hello ${field.nonExistent}' }] + }); + + widget.field = form.getFieldById('displayText1'); + fixture.detectChanges(); + + expect(widget.field.value).toBe('Hello '); + }); + + it('should not resolve expressions when enableExpressionEvaluation is false', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [DisplayTextWidgetComponent], + providers: [ + FormService, + { + provide: ADF_DISPLAY_TEXT_SETTINGS, + useValue: { enableExpressionEvaluation: false } + } + ] + }); + + fixture = TestBed.createComponent(DisplayTextWidgetComponent); + widget = fixture.componentInstance; + + const form = new FormModel({ + fields: [ + { id: 'displayText1', type: 'display-text', value: 'Hello ${field.name}' }, + { id: 'name', type: 'text', value: 'John' } + ] + }); + + widget.field = form.getFieldById('displayText1'); + fixture.detectChanges(); + + expect(widget.field.value).toBe('Hello ${field.name}'); + }); + + it('should preserve original value for re-evaluation', () => { + const form = new FormModel({ + fields: [ + { id: 'displayText1', type: 'display-text', value: 'Hello ${field.name}' }, + { id: 'name', type: 'text', value: 'John' } + ] + }); + + widget.field = form.getFieldById('displayText1'); + fixture.detectChanges(); + + expect(widget.field.value).toBe('Hello John'); + expect(widget['originalFieldValue']).toBe('Hello ${field.name}'); + }); + + it('should support observable settings', (done) => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [DisplayTextWidgetComponent], + providers: [ + FormService, + { + provide: ADF_DISPLAY_TEXT_SETTINGS, + useValue: of({ enableExpressionEvaluation: true }) + } + ] + }); + + fixture = TestBed.createComponent(DisplayTextWidgetComponent); + widget = fixture.componentInstance; + + const form = new FormModel({ + fields: [ + { id: 'displayText1', type: 'display-text', value: 'Hello ${field.name}' }, + { id: 'name', type: 'text', value: 'John' } + ] + }); + + widget.field = form.getFieldById('displayText1'); + fixture.detectChanges(); + + setTimeout(() => { + expect(widget.field.value).toBe('Hello John'); + done(); + }, 100); + }); + }); }); diff --git a/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.ts b/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.ts index 5735f10767..6cb7c5dc0e 100644 --- a/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.ts +++ b/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.ts @@ -19,7 +19,7 @@ import { Component, ViewEncapsulation } from '@angular/core'; import { TranslatePipe } from '@ngx-translate/core'; -import { WidgetComponent } from '../widget.component'; +import { BaseDisplayTextWidgetComponent } from '../base-display-text/base-display-text.widget'; @Component({ selector: 'display-text-widget', @@ -39,4 +39,22 @@ import { WidgetComponent } from '../widget.component'; imports: [TranslatePipe], encapsulation: ViewEncapsulation.None }) -export class DisplayTextWidgetComponent extends WidgetComponent {} +export class DisplayTextWidgetComponent extends BaseDisplayTextWidgetComponent { + protected storeOriginalValue(): void { + if (this.field) { + this.originalFieldValue = this.field.value; + } + } + + protected evaluateExpressions(): void { + if (this.field) { + this.field.value = this.resolveExpressions(this.field.value); + } + } + + protected reevaluateExpressions(): void { + if (this.field && this.originalFieldValue) { + this.field.value = this.resolveExpressions(this.originalFieldValue); + } + } +} diff --git a/lib/core/src/lib/form/components/widgets/index.ts b/lib/core/src/lib/form/components/widgets/index.ts index a45a3fa453..89cd0aff56 100644 --- a/lib/core/src/lib/form/components/widgets/index.ts +++ b/lib/core/src/lib/form/components/widgets/index.ts @@ -36,6 +36,7 @@ import { ButtonWidgetComponent } from './button/button.widget'; // core export * from './widget.component'; export * from './reactive-widget.interface'; +export * from './base-display-text/base-display-text.widget'; export * from './core'; // primitives diff --git a/lib/core/src/lib/form/public-api.ts b/lib/core/src/lib/form/public-api.ts index 2098c2726f..0f9bfe91cf 100644 --- a/lib/core/src/lib/form/public-api.ts +++ b/lib/core/src/lib/form/public-api.ts @@ -27,6 +27,7 @@ export * from './components/helpers/buttons-visibility'; export * from './services/form-rendering.service'; export * from './services/form.service'; +export * from './services/form-expression.service'; export * from './services/form-validation-service.interface'; export * from './services/widget-visibility.service'; diff --git a/lib/core/src/lib/form/services/form-expression.service.spec.ts b/lib/core/src/lib/form/services/form-expression.service.spec.ts new file mode 100644 index 0000000000..ffd8eb1948 --- /dev/null +++ b/lib/core/src/lib/form/services/form-expression.service.spec.ts @@ -0,0 +1,351 @@ +/*! + * @license + * Copyright © 2005-2025 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. + */ + +import { TestBed } from '@angular/core/testing'; +import { FormExpressionService } from './form-expression.service'; +import { FormModel } from '../components/widgets/core'; + +describe('FormExpressionService', () => { + let service: FormExpressionService; + let formModel: FormModel; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [FormExpressionService] + }); + service = TestBed.inject(FormExpressionService); + formModel = new FormModel(); + }); + + describe('resolveExpressions', () => { + it('should return the original string if there are no expressions', () => { + const input = 'plain text without expressions'; + const result = service.resolveExpressions(formModel, input); + expect(result).toBe(input); + }); + + it('should return empty string for null input', () => { + const result = service.resolveExpressions(formModel, null); + expect(result).toBe(''); + }); + + it('should return empty string for undefined input', () => { + const result = service.resolveExpressions(formModel, undefined); + expect(result).toBe(''); + }); + + it('should resolve field expression with value', () => { + const mockField = { + id: 'testField', + value: 'test value' + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + + const input = '${field.testField}'; + const result = service.resolveExpressions(formModel, input); + + expect(formModel.getFieldById).toHaveBeenCalledWith('testField'); + expect(result).toBe('test value'); + }); + + it('should resolve variable expression with value', () => { + spyOn(formModel, 'getProcessVariableValue').and.returnValue('variable value'); + + const input = '${variable.myVar}'; + const result = service.resolveExpressions(formModel, input); + + expect(formModel.getProcessVariableValue).toHaveBeenCalledWith('myVar'); + expect(result).toBe('variable value'); + }); + + it('should replace expression with empty string when field value is null', () => { + const mockField = { + id: 'testField', + value: null + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + + const input = '${field.testField}'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe(''); + }); + + it('should replace expression with empty string when field value is undefined', () => { + const mockField = { + id: 'testField', + value: undefined + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + + const input = '${field.testField}'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe(''); + }); + + it('should replace expression with empty string when field is not found', () => { + spyOn(formModel, 'getFieldById').and.returnValue(undefined); + + const input = '${field.nonExistentField}'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe(''); + }); + + it('should stringify non-string field values', () => { + const mockField = { + id: 'numericField', + value: 42 + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + + const input = '${field.numericField}'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe('42'); + }); + + it('should stringify object field values', () => { + const mockField = { + id: 'objectField', + value: { key: 'value', num: 123 } + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + + const input = '${field.objectField}'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe('{"key":"value","num":123}'); + }); + + it('should stringify array field values', () => { + const mockField = { + id: 'arrayField', + value: [1, 2, 3] + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + + const input = '${field.arrayField}'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe('[1,2,3]'); + }); + + it('should resolve multiple expressions in the same string', () => { + const mockField1 = { + id: 'field1', + value: 'value1' + }; + const mockField2 = { + id: 'field2', + value: 'value2' + }; + spyOn(formModel, 'getFieldById').and.callFake((id) => { + if (id === 'field1') { + return mockField1 as any; + } + if (id === 'field2') { + return mockField2 as any; + } + return undefined; + }); + + const input = 'Hello ${field.field1} and ${field.field2}!'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe('Hello value1 and value2!'); + }); + + it('should resolve mixed field and variable expressions', () => { + const mockField = { + id: 'myField', + value: 'field value' + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + spyOn(formModel, 'getProcessVariableValue').and.returnValue('var value'); + + const input = 'Field: ${field.myField}, Variable: ${variable.myVar}'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe('Field: field value, Variable: var value'); + }); + + it('should not resolve expressions with whitespace', () => { + const mockField = { + id: 'testField', + value: 'test value' + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + + const input = '${ field.testField }'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe('${ field.testField }'); + }); + + it('should handle field names with underscores', () => { + const mockField = { + id: 'test_field_name', + value: 'underscore value' + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + + const input = '${field.test_field_name}'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe('underscore value'); + }); + + it('should handle field names with numbers', () => { + const mockField = { + id: 'field123', + value: 'numeric value' + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + + const input = '${field.field123}'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe('numeric value'); + }); + + it('should not resolve expressions with multiple variables', () => { + const mockField1 = { + id: 'field1', + value: 'value1' + }; + const mockField2 = { + id: 'field2', + value: 'value2' + }; + spyOn(formModel, 'getFieldById').and.callFake((id) => { + if (id === 'field1') { + return mockField1 as any; + } + if (id === 'field2') { + return mockField2 as any; + } + return undefined; + }); + + const input = '${field.field1 field.field2}'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe(input); + }); + + it('should not resolve expressions without valid variable names', () => { + const input = '${sometext}'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe(input); + }); + + it('should handle boolean field values', () => { + const mockField = { + id: 'boolField', + value: true + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + + const input = '${field.boolField}'; + const result = service.resolveExpressions(formModel, input); + + expect(result).toBe('true'); + }); + }); + + describe('getFieldDependencies', () => { + it('should return empty array for string without expressions', () => { + const input = 'plain text without expressions'; + const result = service.getFieldDependencies(input); + + expect(result).toEqual([]); + }); + + it('should extract single field dependency', () => { + const input = '${field.testField}'; + const result = service.getFieldDependencies(input); + + expect(result).toEqual(['testField']); + }); + + it('should extract multiple field dependencies', () => { + const input = '${field.field1} and ${field.field2}'; + const result = service.getFieldDependencies(input); + + expect(result).toEqual(['field1', 'field2']); + }); + + it('should not include duplicate field dependencies', () => { + const input = '${field.testField} and ${field.testField}'; + const result = service.getFieldDependencies(input); + + expect(result).toEqual(['testField']); + }); + + it('should not include variable dependencies', () => { + const input = '${variable.myVar}'; + const result = service.getFieldDependencies(input); + + expect(result).toEqual([]); + }); + + it('should extract only field dependencies when mixed with variables', () => { + const input = '${field.myField} and ${variable.myVar}'; + const result = service.getFieldDependencies(input); + + expect(result).toEqual(['myField']); + }); + + it('should handle field names with underscores', () => { + const input = '${field.test_field_name}'; + const result = service.getFieldDependencies(input); + + expect(result).toEqual(['test_field_name']); + }); + + it('should handle field names with numbers', () => { + const input = '${field.field123}'; + const result = service.getFieldDependencies(input); + + expect(result).toEqual(['field123']); + }); + + it('should handle multiple different fields', () => { + const input = '${field.firstName} ${field.lastName} ${field.email}'; + const result = service.getFieldDependencies(input); + + expect(result).toEqual(['firstName', 'lastName', 'email']); + }); + + it('should handle complex expressions with text', () => { + const input = 'Hello ${field.firstName}, your email is ${field.email}'; + const result = service.getFieldDependencies(input); + + expect(result).toEqual(['firstName', 'email']); + }); + + it('should return empty array for expressions without field prefix', () => { + const input = '${sometext}'; + const result = service.getFieldDependencies(input); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/lib/core/src/lib/form/services/form-expression.service.ts b/lib/core/src/lib/form/services/form-expression.service.ts new file mode 100644 index 0000000000..706b4ed615 --- /dev/null +++ b/lib/core/src/lib/form/services/form-expression.service.ts @@ -0,0 +1,110 @@ +/*! + * @license + * Copyright © 2005-2025 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. + */ + +import { Injectable } from '@angular/core'; +import { FormModel } from '../components/widgets/core'; + +@Injectable({ + providedIn: 'root' +}) +export class FormExpressionService { + private readonly GLOBAL_EXPRESSION_REGEX = /\$\{[a-zA-Z0-9_.]+\}/g; + private readonly FIELD_PREFIX = 'field.'; + private readonly VARIABLE_PREFIX = 'variable.'; + private readonly VARIABLES_REGEX = /(?:field|variable)\.[a-zA-Z_$][a-zA-Z0-9_$]*/g; + + resolveExpressions(form: FormModel, formField: string): string { + let result = formField || ''; + + const matches = result.match(this.GLOBAL_EXPRESSION_REGEX); + + if (!matches) { + return result; + } + + for (const match of matches) { + let expressionResult = this.resolveExpression(form, match); + if (expressionResult === null || expressionResult === undefined) { + expressionResult = ''; + } else if (typeof expressionResult !== 'string') { + expressionResult = JSON.stringify(expressionResult); + } + result = result.replace(match, expressionResult); + } + + return result; + } + + private resolveExpression(form: FormModel, expression: any): any { + if (expression === undefined || expression === null) { + return expression; + } + + const expressionString = String(expression).trim(); + if (!expressionString.startsWith('${') || !expressionString.endsWith('}')) { + return expressionString; + } + + const variableNames = expressionString.match(this.VARIABLES_REGEX); + if (!variableNames || variableNames.length === 0) { + return expressionString; + } + + if (variableNames.length === 1 && variableNames[0].length === expressionString.length - 3) { + return this.resolveVariable(form, variableNames[0]); + } + + return expressionString; + } + + private resolveVariable(form: FormModel, variableName: string): any { + if (variableName.startsWith(this.FIELD_PREFIX)) { + const field = variableName.slice(this.FIELD_PREFIX.length); + return form.getFieldById(field)?.value; + } else if (variableName.startsWith(this.VARIABLE_PREFIX)) { + const variable = variableName.slice(this.VARIABLE_PREFIX.length); + return form.getProcessVariableValue(variable); + } else { + return ''; + } + } + + getFieldDependencies(expression: string): string[] { + const dependencies: string[] = []; + const matches = expression.match(this.GLOBAL_EXPRESSION_REGEX); + + if (!matches) { + return dependencies; + } + + for (const match of matches) { + const variableNames = match.match(this.VARIABLES_REGEX); + if (variableNames) { + for (const variableName of variableNames) { + if (variableName.startsWith(this.FIELD_PREFIX)) { + const fieldId = variableName.slice(this.FIELD_PREFIX.length); + if (!dependencies.includes(fieldId)) { + dependencies.push(fieldId); + } + } + } + } + } + + return dependencies; + } +} diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.spec.ts index c1e3f6acf3..74a0d23e72 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.spec.ts @@ -20,13 +20,15 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { DisplayRichTextWidgetComponent, RICH_TEXT_PARSER_TOKEN } from './display-rich-text.widget'; import { RichTextParserService } from '../../../services/rich-text-parser.service'; -import { FormFieldModel, FormModel } from '@alfresco/adf-core'; +import { ADF_DISPLAY_TEXT_SETTINGS, FormFieldModel, FormModel, FormService } from '@alfresco/adf-core'; +import { of } from 'rxjs'; describe('DisplayRichTextWidgetComponent', () => { let widget: DisplayRichTextWidgetComponent; let fixture: ComponentFixture; let debugEl: DebugElement; let mockRichTextParserService: jasmine.SpyObj; + let formService: FormService; const cssSelector = { parsedHTML: '.adf-display-rich-text-widget-parsed-html' @@ -89,11 +91,12 @@ describe('DisplayRichTextWidgetComponent', () => { TestBed.configureTestingModule({ imports: [DisplayRichTextWidgetComponent], - providers: [{ provide: RICH_TEXT_PARSER_TOKEN, useValue: mockRichTextParserService }] + providers: [FormService, { provide: RICH_TEXT_PARSER_TOKEN, useValue: mockRichTextParserService }] }); fixture = TestBed.createComponent(DisplayRichTextWidgetComponent); widget = fixture.componentInstance; debugEl = fixture.debugElement; + formService = TestBed.inject(FormService); widget.field = fakeFormField; }); @@ -119,7 +122,7 @@ describe('DisplayRichTextWidgetComponent', () => { fixture.detectChanges(); expect(mockRichTextParserService.parse).toHaveBeenCalledWith(fakeFormField.value); - expect(mockRichTextParserService.parse).toHaveBeenCalledTimes(1); + expect(mockRichTextParserService.parse).toHaveBeenCalled(); }); it('should parse editorjs data to html', async () => { @@ -142,4 +145,325 @@ describe('DisplayRichTextWidgetComponent', () => { const parsedHtmlEl = debugEl.query(By.css(cssSelector.parsedHTML)); expect(parsedHtmlEl.nativeElement.innerHTML.includes('')).toBe(false); }); + + describe('expression evaluation', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [DisplayRichTextWidgetComponent], + providers: [ + FormService, + { provide: RICH_TEXT_PARSER_TOKEN, useValue: mockRichTextParserService }, + { + provide: ADF_DISPLAY_TEXT_SETTINGS, + useValue: { enableExpressionEvaluation: true } + } + ] + }); + + fixture = TestBed.createComponent(DisplayRichTextWidgetComponent); + widget = fixture.componentInstance; + debugEl = fixture.debugElement; + formService = TestBed.inject(FormService); + mockRichTextParserService = TestBed.inject(RICH_TEXT_PARSER_TOKEN) as jasmine.SpyObj; + }); + + it('should resolve field expressions in rich text blocks', () => { + const form = new FormModel({ + fields: [ + { + id: 'richText1', + type: 'display-rich-text', + value: { + time: 1658154611110, + blocks: [ + { + id: '1', + type: 'paragraph', + data: { + text: 'Hello ${field.name}' + } + } + ], + version: 1 + } + }, + { id: 'name', type: 'text', value: 'John' } + ] + }); + + widget.field = form.getFieldById('richText1'); + fixture.detectChanges(); + + expect(widget.field.value.blocks[0].data.text).toBe('Hello John'); + }); + + it('should resolve expressions in multiple blocks', () => { + const form = new FormModel({ + fields: [ + { + id: 'richText1', + type: 'display-rich-text', + value: { + time: 1658154611110, + blocks: [ + { + id: '1', + type: 'header', + data: { + text: 'User: ${field.firstName}', + level: 1 + } + }, + { + id: '2', + type: 'paragraph', + data: { + text: 'Status: ${variable.status}' + } + } + ], + version: 1 + } + }, + { id: 'firstName', type: 'text', value: 'Jane' } + ], + variables: [{ id: 'status', name: 'status', type: 'string', value: 'Active' }] + }); + + widget.field = form.getFieldById('richText1'); + fixture.detectChanges(); + + expect(widget.field.value.blocks[0].data.text).toBe('User: Jane'); + expect(widget.field.value.blocks[1].data.text).toBe('Status: Active'); + }); + + it('should update rich text when dependent field value changes', (done) => { + const form = new FormModel({ + fields: [ + { + id: 'richText1', + type: 'display-rich-text', + value: { + time: 1658154611110, + blocks: [ + { + id: '1', + type: 'paragraph', + data: { + text: 'Hello ${field.name}' + } + } + ], + version: 1 + } + }, + { id: 'name', type: 'text', value: 'John' } + ] + }); + + widget.field = form.getFieldById('richText1'); + const nameField = form.getFieldById('name'); + fixture.detectChanges(); + + expect(widget.field.value.blocks[0].data.text).toBe('Hello John'); + + nameField.value = 'Jane'; + formService.formRulesEvent.next({ type: 'fieldValueChanged', field: nameField } as any); + + setTimeout(() => { + expect(widget.field.value.blocks[0].data.text).toBe('Hello Jane'); + done(); + }, 350); + }); + + it('should preserve original value structure for re-evaluation', () => { + const form = new FormModel({ + fields: [ + { + id: 'richText1', + type: 'display-rich-text', + value: { + time: 1658154611110, + blocks: [ + { + id: '1', + type: 'paragraph', + data: { + text: 'Hello ${field.name}' + } + } + ], + version: 1 + } + }, + { id: 'name', type: 'text', value: 'John' } + ] + }); + + widget.field = form.getFieldById('richText1'); + fixture.detectChanges(); + + const originalValue = JSON.parse(widget['originalFieldValue']); + expect(originalValue.blocks[0].data.text).toBe('Hello ${field.name}'); + }); + + it('should handle missing field references with empty string', () => { + const form = new FormModel({ + fields: [ + { + id: 'richText1', + type: 'display-rich-text', + value: { + time: 1658154611110, + blocks: [ + { + id: '1', + type: 'paragraph', + data: { + text: 'Hello ${field.nonExistent}' + } + } + ], + version: 1 + } + } + ] + }); + + widget.field = form.getFieldById('richText1'); + fixture.detectChanges(); + + expect(widget.field.value.blocks[0].data.text).toBe('Hello '); + }); + + it('should not resolve expressions when enableExpressionEvaluation is false', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [DisplayRichTextWidgetComponent], + providers: [ + FormService, + { provide: RICH_TEXT_PARSER_TOKEN, useValue: mockRichTextParserService }, + { + provide: ADF_DISPLAY_TEXT_SETTINGS, + useValue: { enableExpressionEvaluation: false } + } + ] + }); + + fixture = TestBed.createComponent(DisplayRichTextWidgetComponent); + widget = fixture.componentInstance; + + const form = new FormModel({ + fields: [ + { + id: 'richText1', + type: 'display-rich-text', + value: { + time: 1658154611110, + blocks: [ + { + id: '1', + type: 'paragraph', + data: { + text: 'Hello ${field.name}' + } + } + ], + version: 1 + } + }, + { id: 'name', type: 'text', value: 'John' } + ] + }); + + widget.field = form.getFieldById('richText1'); + fixture.detectChanges(); + + expect(widget.field.value.blocks[0].data.text).toBe('Hello ${field.name}'); + }); + + it('should re-parse HTML after expressions are evaluated', () => { + mockRichTextParserService.parse.and.returnValue('

Test HTML

'); + + const form = new FormModel({ + fields: [ + { + id: 'richText1', + type: 'display-rich-text', + value: { + time: 1658154611110, + blocks: [ + { + id: '1', + type: 'paragraph', + data: { + text: 'Hello ${field.name}' + } + } + ], + version: 1 + } + }, + { id: 'name', type: 'text', value: 'John' } + ] + }); + + widget.field = form.getFieldById('richText1'); + fixture.detectChanges(); + + expect(mockRichTextParserService.parse).toHaveBeenCalled(); + const lastCall = mockRichTextParserService.parse.calls.mostRecent(); + expect(lastCall.args[0].blocks[0].data.text).toBe('Hello John'); + }); + + it('should support observable settings', (done) => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [DisplayRichTextWidgetComponent], + providers: [ + FormService, + { provide: RICH_TEXT_PARSER_TOKEN, useValue: mockRichTextParserService }, + { + provide: ADF_DISPLAY_TEXT_SETTINGS, + useValue: of({ enableExpressionEvaluation: true }) + } + ] + }); + + fixture = TestBed.createComponent(DisplayRichTextWidgetComponent); + widget = fixture.componentInstance; + + const form = new FormModel({ + fields: [ + { + id: 'richText1', + type: 'display-rich-text', + value: { + time: 1658154611110, + blocks: [ + { + id: '1', + type: 'paragraph', + data: { + text: 'Hello ${field.name}' + } + } + ], + version: 1 + } + }, + { id: 'name', type: 'text', value: 'John' } + ] + }); + + widget.field = form.getFieldById('richText1'); + fixture.detectChanges(); + + setTimeout(() => { + expect(widget.field.value.blocks[0].data.text).toBe('Hello John'); + done(); + }, 100); + }); + }); }); diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.ts b/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.ts index b937def050..e6413b76f2 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.ts @@ -17,9 +17,10 @@ /* eslint-disable @angular-eslint/component-selector */ -import { Component, inject, InjectionToken, OnInit, SecurityContext, ViewEncapsulation } from '@angular/core'; -import { WidgetComponent } from '@alfresco/adf-core'; +import { Component, inject, InjectionToken, OnDestroy, OnInit, SecurityContext, ViewEncapsulation } from '@angular/core'; +import { BaseDisplayTextWidgetComponent } from '@alfresco/adf-core'; import { DomSanitizer } from '@angular/platform-browser'; +import { Subscription } from 'rxjs'; import { RichTextParserService } from '../../../services/rich-text-parser.service'; export const RICH_TEXT_PARSER_TOKEN = new InjectionToken('RichTextParserService', { @@ -43,13 +44,58 @@ export const RICH_TEXT_PARSER_TOKEN = new InjectionToken( }, encapsulation: ViewEncapsulation.None }) -export class DisplayRichTextWidgetComponent extends WidgetComponent implements OnInit { +export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetComponent implements OnInit, OnDestroy { parsedHTML: string | Error; private readonly richTextParserService = inject(RICH_TEXT_PARSER_TOKEN); private readonly sanitizer = inject(DomSanitizer); + private fieldChangedSubscription?: Subscription; ngOnInit(): void { + this.parseAndSanitize(); + + // Re-parse when field changes (after expressions are evaluated) + this.fieldChangedSubscription = this.fieldChanged.subscribe(() => { + this.parseAndSanitize(); + }); + } + + ngOnDestroy(): void { + this.fieldChangedSubscription?.unsubscribe(); + } + + protected storeOriginalValue(): void { + if (this.field) { + this.originalFieldValue = JSON.stringify(this.field.value); + } + } + + protected evaluateExpressions(): void { + if (!this.field) { + return; + } + + const value = JSON.parse(JSON.stringify(this.field.value)); + this.applyExpressionsToBlocks(value); + } + + protected reevaluateExpressions(): void { + if (!this.field || !this.originalFieldValue) { + return; + } + + const value = JSON.parse(this.originalFieldValue); + this.applyExpressionsToBlocks(value); + } + + private applyExpressionsToBlocks(value: any): void { + for (const block of value.blocks) { + block.data.text = this.resolveExpressions(block.data.text); + } + this.field.value = value; + } + + private parseAndSanitize(): void { this.parsedHTML = this.richTextParserService.parse(this.field.value); if (this.parsedHTML instanceof Error) {