AAE-41699 Evaluate variables in form display text (#11620)

This commit is contained in:
David Olson
2026-02-09 15:40:31 -06:00
committed by GitHub
parent 14899931d2
commit 4d99262ba3
9 changed files with 1165 additions and 9 deletions

View File

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

View File

@@ -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<DisplayTextWidgetComponent>;
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);
});
});
});

View File

@@ -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);
}
}
}

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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<DisplayRichTextWidgetComponent>;
let debugEl: DebugElement;
let mockRichTextParserService: jasmine.SpyObj<RichTextParserService>;
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('<img src="x" onerror="alert(\'XSS\')">')).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<RichTextParserService>;
});
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('<p>Test HTML</p>');
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);
});
});
});

View File

@@ -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>('RichTextParserService', {
@@ -43,13 +44,58 @@ export const RICH_TEXT_PARSER_TOKEN = new InjectionToken<RichTextParserService>(
},
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) {