From dfbe1adbd5f8137ea411bc3abd60c179950228e2 Mon Sep 17 00:00:00 2001 From: Alex Molodyh <140214274+amolodyh-hyland@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:56:10 -0700 Subject: [PATCH] AAE-50098 Fix unrendered rich text expressions in form submissions (#12169) --- .../widgets/core/form-field.model.spec.ts | 52 +++++ .../widgets/core/form-field.model.ts | 21 ++ .../components/form-cloud.component.spec.ts | 70 ++++++- .../form/components/form-cloud.component.ts | 31 ++- .../display-rich-text.widget.spec.ts | 45 +++++ .../display-rich-text.widget.ts | 23 +-- .../rich-text-expression-resolver.spec.ts | 137 +++++++++++++ .../rich-text-expression-resolver.ts | 111 +++++++++++ .../src/lib/form/public-api.ts | 1 + .../form-cloud-submission-values.spec.ts | 185 ++++++++++++++++++ .../services/form-cloud-submission-values.ts | 80 ++++++++ .../start-process-cloud.component.spec.ts | 59 +++++- .../start-process-cloud.component.ts | 38 +++- 13 files changed, 830 insertions(+), 23 deletions(-) create mode 100644 lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/rich-text-expression-resolver.spec.ts create mode 100644 lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/rich-text-expression-resolver.ts create mode 100644 lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.spec.ts create mode 100644 lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.ts diff --git a/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts b/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts index 0e4c65e9b4..6649655f66 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts @@ -36,6 +36,58 @@ describe('FormFieldModel', () => { expect(model.json).toBe(json); }); + it('should return an isolated authored value snapshot', () => { + const authoredValue = { blocks: [{ data: { text: '${field.name}' } }] }; + const model = new FormFieldModel(new FormModel(), { id: 'richText', type: FormFieldTypes.DISPLAY_RICH_TEXT, value: authoredValue }); + + const snapshot = model.authoredValue as typeof authoredValue; + snapshot.blocks[0].data.text = 'changed'; + + expect((model.authoredValue as typeof authoredValue).blocks[0].data.text).toBe('${field.name}'); + expect(authoredValue.blocks[0].data.text).toBe('${field.name}'); + }); + + it('should not capture authored values for other field types', () => { + const model = new FormFieldModel(new FormModel(), { id: 'json', type: FormFieldTypes.JSON, value: { content: 'value' } }); + + expect(model.authoredValue).toBeUndefined(); + }); + + it('should return undefined for authored values that cannot be cloned', () => { + const circularValue: { self?: unknown } = {}; + circularValue.self = circularValue; + + const circularValueModel = new FormFieldModel(new FormModel(), { + id: 'circular', + type: FormFieldTypes.DISPLAY_RICH_TEXT, + value: circularValue + }); + const bigintValueModel = new FormFieldModel(new FormModel(), { + id: 'bigint', + type: FormFieldTypes.DISPLAY_RICH_TEXT, + value: BigInt(1) + }); + + expect(circularValueModel.authoredValue).toBeUndefined(); + expect(bigintValueModel.authoredValue).toBeUndefined(); + }); + + it('should preserve authored value when form data overrides the field value', () => { + const authoredValue = { blocks: [{ data: { text: '${field.name}' } }] }; + const savedValue = { blocks: [{ data: { text: 'John' } }] }; + const form = new FormModel( + { + fields: [{ id: 'richText', name: 'richText', type: FormFieldTypes.DISPLAY_RICH_TEXT, value: authoredValue }] + }, + { richText: savedValue } + ); + const model = form.getFieldById('richText'); + + expect(model.value).toEqual(savedValue); + expect(model.authoredValue).toEqual(authoredValue); + expect(model.authoredValue).not.toBe(authoredValue); + }); + it('should setup with json config', () => { const json = { fieldType: '', diff --git a/lib/core/src/lib/form/components/widgets/core/form-field.model.ts b/lib/core/src/lib/form/components/widgets/core/form-field.model.ts index 6241cf8f84..d38d692ab6 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field.model.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field.model.ts @@ -38,12 +38,28 @@ export type FieldOptionType = 'rest' | 'manual' | 'variable'; export type FieldSelectionType = 'single' | 'multiple'; export type FieldAlignmentType = 'vertical' | 'horizontal'; +const isJsonPrimitive = (value: unknown): value is null | string | number | boolean => + value === null || ['string', 'number', 'boolean'].includes(typeof value); + +const cloneJsonCompatibleValue = (value: unknown): unknown => { + if (value === undefined || isJsonPrimitive(value)) { + return value; + } + + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return undefined; + } +}; + // Maps to FormFieldRepresentation export class FormFieldModel extends FormWidgetModel { private _value: string; private _readOnly: boolean = false; private _isValid: boolean = true; private _required: boolean = false; + private readonly _authoredValue: unknown; readonly defaultDateFormat: string = 'D-M-YYYY'; readonly defaultDateTimeFormat: string = 'D-M-YYYY hh:mm A'; @@ -123,6 +139,10 @@ export class FormFieldModel extends FormWidgetModel { } } + get authoredValue(): unknown { + return cloneJsonCompatibleValue(this._authoredValue); + } + get readOnly(): boolean { if (this.form?.readOnly) { return true; @@ -183,6 +203,7 @@ export class FormFieldModel extends FormWidgetModel { constructor(form: any, json?: any, parent?: RepeatableSectionModel) { super(form, json); + this._authoredValue = json?.type === FormFieldTypes.DISPLAY_RICH_TEXT ? cloneJsonCompatibleValue(json.value) : undefined; if (json) { this.fieldType = json.fieldType; this.id = this.getId(json.id, parent); 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 ba87073a06..5ef60cf0b2 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 @@ -17,6 +17,7 @@ import { VersionCompatibilityService, AlfrescoApiService } from '@alfresco/adf-content-services'; import { + ADF_DISPLAY_TEXT_SETTINGS, ContentLinkModel, CoreModule, FormFieldModel, @@ -117,7 +118,8 @@ describe('FormCloudComponent', () => { useValue: {} }, { provide: FormRenderingService, useClass: CloudFormRenderingService }, - { provide: FORM_CLOUD_FIELD_VALIDATORS_TOKEN, useValue: [fakeValidator] } + { provide: FORM_CLOUD_FIELD_VALIDATORS_TOKEN, useValue: [fakeValidator] }, + { provide: ADF_DISPLAY_TEXT_SETTINGS, useValue: { enableExpressionEvaluation: true } } ] }); const apiService = TestBed.inject(AlfrescoApiService); @@ -874,6 +876,39 @@ describe('FormCloudComponent', () => { expect(savedForm).toEqual(formModel); }); + it('should materialize unrendered rich text expressions when saving a task form', () => { + spyOn(formCloudService, 'saveTaskForm').and.returnValue(of(undefined)); + const formModel = new FormModel({ + id: '23', + taskId: '123-223', + fields: [ + { + id: 'richText', + type: FormFieldTypes.DISPLAY_RICH_TEXT, + value: { blocks: [{ type: 'paragraph', data: { text: 'Hello ${field.name}' } }] } + }, + { id: 'name', type: FormFieldTypes.TEXT, value: 'John' } + ] + }); + const originalValues = JSON.parse(JSON.stringify(formModel.values)); + formComponent.form = formModel; + formComponent.taskId = formModel.taskId; + formComponent.appName = 'test-app'; + + formComponent.saveTaskForm(); + + expect(formCloudService.saveTaskForm).toHaveBeenCalledWith( + 'test-app', + formModel.taskId, + undefined, + formModel.id, + jasmine.objectContaining({ + richText: { blocks: [{ type: 'paragraph', data: { text: 'Hello John' } }] } + }) + ); + expect(formModel.values).toEqual(originalValues); + }); + it('should handle error during form save', () => { const error = 'Error'; spyOn(formCloudService, 'saveTaskForm').and.callFake(() => throwError(error)); @@ -981,6 +1016,39 @@ describe('FormCloudComponent', () => { expect(completedForm).toBe(formComponent.form); }); + it('should materialize unrendered rich text expressions when completing a task form', () => { + spyOn(formCloudService, 'completeTaskForm').and.returnValue(of(undefined)); + const formModel = new FormModel({ + id: '23', + taskId: '123-223', + fields: [ + { + id: 'richText', + type: FormFieldTypes.DISPLAY_RICH_TEXT, + value: { blocks: [{ type: 'paragraph', data: { text: '${field.name}' } }] } + }, + { id: 'name', type: FormFieldTypes.TEXT, value: 'John' } + ] + }); + formComponent.form = formModel; + formComponent.taskId = formModel.taskId; + formComponent.appName = 'test-app'; + + formComponent.completeTaskForm('complete'); + + expect(formCloudService.completeTaskForm).toHaveBeenCalledWith( + 'test-app', + formModel.taskId, + undefined, + formModel.id, + jasmine.objectContaining({ + richText: { blocks: [{ type: 'paragraph', data: { text: 'John' } }] } + }), + 'complete', + undefined + ); + }); + it('should open confirmation dialog on complete task', async () => { formComponent.form = new FormModel({ confirmMessage: { 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 33f50d4fb8..fd2a681e99 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 @@ -35,9 +35,12 @@ import { filter, map, switchMap } from 'rxjs/operators'; import { ConfirmDialogComponent, ContentLinkModel, + ADF_DISPLAY_TEXT_SETTINGS, + DisplayTextWidgetSettings, FormatSpacePipe, FormBaseComponent, FormEvent, + FormExpressionService, FormFieldModel, FormRulesEvent, FormFieldValidator, @@ -67,6 +70,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { A11yModule } from '@angular/cdk/a11y'; +import { getExpressionEvaluationEnabled$, materializeSubmissionValues } from '../services/form-cloud-submission-values'; interface FormFieldRuntimeState { value: any; @@ -228,6 +232,8 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges, protected changeDetector = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); + private readonly expressions = inject(FormExpressionService); + private enableExpressionEvaluation = false; private get currentForm(): FormModel | undefined { return super.form; @@ -252,6 +258,9 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges, constructor() { const injectedFieldValidators = inject(FORM_CLOUD_FIELD_VALIDATORS_TOKEN, { optional: true }); const tabNavEnabledToken = inject(ADF_FORM_TAB_NAV_ENABLED, { optional: true }); + const displayTextSettings = inject | DisplayTextWidgetSettings>(ADF_DISPLAY_TEXT_SETTINGS, { + optional: true + }); super(); this.loadInjectedFieldValidators(injectedFieldValidators); @@ -270,6 +279,12 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges, } } + getExpressionEvaluationEnabled$(displayTextSettings) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((enabled) => { + this.enableExpressionEvaluation = enabled; + }); + this.formService.formContentClicked.pipe(takeUntilDestroyed()).subscribe((content) => { if (content instanceof UploadWidgetContentLinkModel) { this.form.setNodeIdValueForViewersLinkedToUploadWidget(content); @@ -482,7 +497,7 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges, saveTaskForm() { if (this.form && this.appName && this.taskId) { this.formCloudService - .saveTaskForm(this.appName, this.taskId, this.processInstanceId, `${this.form.id}`, this.form.values) + .saveTaskForm(this.appName, this.taskId, this.processInstanceId, `${this.form.id}`, this.getSubmissionValues()) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: () => { @@ -523,7 +538,15 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges, private completeForm(outcome?: string, outcomeId?: string) { if (this.form && this.appName && this.taskId) { this.formCloudService - .completeTaskForm(this.appName, this.taskId, this.processInstanceId, `${this.form.id}`, this.form.values, outcome, this.appVersion) + .completeTaskForm( + this.appName, + this.taskId, + this.processInstanceId, + `${this.form.id}`, + this.getSubmissionValues(), + outcome, + this.appVersion + ) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: () => { @@ -536,6 +559,10 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges, } } + private getSubmissionValues(): FormValues { + return materializeSubmissionValues(this.form, { enableExpressionEvaluation: this.enableExpressionEvaluation }, this.expressions); + } + parseForm(formCloudRepresentationJSON?: any): FormModel | null { if (formCloudRepresentationJSON) { const formValues: FormValues = {}; 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 80062e72f7..cc646237b5 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 @@ -200,6 +200,51 @@ describe('DisplayRichTextWidgetComponent', () => { expect(widget.field.value.blocks[0].data.text).toBe('Hello John'); }); + it('should resolve from authored value after saved data rehydrates the field', () => { + const form = new FormModel( + { + fields: [ + { + id: 'richText1', + name: 'richText1', + type: 'display-rich-text', + value: { + blocks: [{ type: 'paragraph', data: { text: 'Hello ${field.name}' } }] + } + }, + { id: 'name', name: 'name', type: 'text', value: 'John' } + ] + }, + { + richText1: { + blocks: [{ type: 'paragraph', data: { text: 'Hello John' } }] + }, + name: 'Jane' + } + ); + + widget.field = form.getFieldById('richText1'); + fixture.detectChanges(); + + expect(widget.field.value.blocks[0].data.text).toBe('Hello Jane'); + }); + + it('should preserve the current value when the authored value is unavailable', () => { + const form = new FormModel({ + fields: [{ id: 'richText1', type: 'display-rich-text' }] + }); + const currentValue = { + blocks: [{ type: 'paragraph', data: { text: 'Current value' } }] + }; + const field = form.getFieldById('richText1'); + field.value = currentValue; + widget.field = field; + + fixture.detectChanges(); + + expect(widget.field.value).toBe(currentValue); + }); + it('should resolve expressions in multiple blocks', () => { const form = new FormModel({ fields: [ 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 92708cf69c..785059ac9f 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 @@ -22,6 +22,7 @@ import { BaseDisplayTextWidgetComponent } from '@alfresco/adf-core'; import { DomSanitizer } from '@angular/platform-browser'; import { Subscription } from 'rxjs'; import { RichTextParserService } from '../../../services/rich-text-parser.service'; +import { resolveRichTextExpressions } from './rich-text-expression-resolver'; export const RICH_TEXT_PARSER_TOKEN = new InjectionToken('RichTextParserService', { factory: () => new RichTextParserService() @@ -66,7 +67,10 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone protected storeOriginalValue(): void { if (this.field) { - this.originalFieldValue = JSON.stringify(this.field.value); + const authoredValue = this.field.authoredValue; + if (authoredValue !== undefined) { + this.originalFieldValue = JSON.stringify(authoredValue); + } } } @@ -75,8 +79,10 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone return; } - const value = JSON.parse(JSON.stringify(this.field.value)); - this.applyExpressionsToBlocks(value); + const authoredValue = this.field.authoredValue; + if (authoredValue !== undefined) { + this.applyExpressionsToBlocks(authoredValue); + } } protected reevaluateExpressions(): void { @@ -89,16 +95,7 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone } private applyExpressionsToBlocks(value: any): void { - for (const block of value.blocks) { - if (block.type === 'list') { - for (const item of block.data.items) { - item.content = this.resolveExpressions(item.content, true); - } - } else { - block.data.text = this.resolveExpressions(block.data.text, true); - } - } - this.field.value = value; + this.field.value = resolveRichTextExpressions(value, (content) => this.resolveExpressions(content, true)); } private parseAndSanitize(): void { diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/rich-text-expression-resolver.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/rich-text-expression-resolver.spec.ts new file mode 100644 index 0000000000..07c3b3c5da --- /dev/null +++ b/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/rich-text-expression-resolver.spec.ts @@ -0,0 +1,137 @@ +/*! + * @license + * Copyright © 2005-2026 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 { resolveRichTextExpressions } from './rich-text-expression-resolver'; + +describe('resolveRichTextExpressions', () => { + const resolve = (value: string) => value.replaceAll('${field.name}', 'John').replaceAll('${variable.status}', 'Active'); + + it('should resolve supported rich text content without mutating the input', () => { + const value = { + time: 1, + blocks: [ + { + type: 'paragraph', + data: { + text: 'Hello ${field.name}', + caption: 'Status: ${variable.status}', + content: [ + ['Cell ${field.name}'], + { + label: '${variable.status}' + } + ] + } + }, + { + type: 'list', + data: { + items: [ + { + content: '${field.name}', + items: [{ content: '${variable.status}' }] + } + ] + } + } + ], + version: '2.30.0' + }; + const originalValue = JSON.parse(JSON.stringify(value)); + + const result = resolveRichTextExpressions(value, resolve); + + expect(result).toEqual({ + time: 1, + blocks: [ + { + type: 'paragraph', + data: { + text: 'Hello John', + caption: 'Status: Active', + content: [['Cell John'], { label: 'Active' }] + } + }, + { + type: 'list', + data: { + items: [{ content: 'John', items: [{ content: 'Active' }] }] + } + } + ], + version: '2.30.0' + }); + expect(value).toEqual(originalValue); + expect(result).not.toBe(value); + }); + + it('should resolve a caller-owned clone without cloning it again', () => { + const value = { + blocks: [{ type: 'paragraph', data: { text: 'Hello ${field.name}' } }] + }; + + const result = resolveRichTextExpressions(value, resolve, { cloneValue: false }) as typeof value; + + expect(result).toBe(value); + expect(result.blocks[0].data.text).toBe('Hello John'); + }); + + it('should preserve unknown blocks and properties', () => { + const value = { + blocks: [ + { + type: 'custom', + data: { + label: '${field.name}' + }, + metadata: '${variable.status}' + } + ] + }; + + expect(resolveRichTextExpressions(value, resolve)).toEqual(value); + }); + + it('should not introduce missing content properties', () => { + const result = resolveRichTextExpressions({ blocks: [{ type: 'paragraph', data: {} }] }, resolve) as { + blocks: Array<{ data: Record }>; + }; + + expect(result.blocks[0].data).toEqual({}); + }); + + it('should return malformed values unchanged', () => { + const malformedValues = [null, undefined, 'text', [], {}, { blocks: null }]; + + malformedValues.forEach((value) => { + expect(resolveRichTextExpressions(value, resolve)).toBe(value); + }); + }); + + it('should return non-cloneable values without mutating them', () => { + const value: { + blocks: Array<{ data: { text: string } }>; + self?: unknown; + } = { + blocks: [{ data: { text: '${field.name}' } }] + }; + value.self = value; + + expect(resolveRichTextExpressions(value, resolve)).toBe(value); + expect(value.blocks[0].data.text).toBe('${field.name}'); + }); +}); diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/rich-text-expression-resolver.ts b/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/rich-text-expression-resolver.ts new file mode 100644 index 0000000000..9606b31f76 --- /dev/null +++ b/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/rich-text-expression-resolver.ts @@ -0,0 +1,111 @@ +/*! + * @license + * Copyright © 2005-2026 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. + */ + +type JsonObject = Record; + +export type RichTextExpressionResolver = (value: string) => string; + +export interface RichTextExpressionResolverOptions { + cloneValue?: boolean; +} + +const isJsonObject = (value: unknown): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value); + +const cloneJsonValue = (value: unknown): unknown => { + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return undefined; + } +}; + +const resolveNestedContent = (value: unknown, resolve: RichTextExpressionResolver): unknown => { + if (typeof value === 'string') { + return resolve(value); + } + + if (Array.isArray(value)) { + return value.map((entry) => resolveNestedContent(entry, resolve)); + } + + if (isJsonObject(value)) { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, resolveNestedContent(entry, resolve)])); + } + + return value; +}; + +const resolveListItems = (items: unknown, resolve: RichTextExpressionResolver): unknown => { + if (!Array.isArray(items)) { + return items; + } + + return items.map((item) => { + if (!isJsonObject(item)) { + return item; + } + + if (Object.hasOwn(item, 'content')) { + item.content = resolveNestedContent(item.content, resolve); + } + + if (Object.hasOwn(item, 'items')) { + item.items = resolveListItems(item.items, resolve); + } + + return item; + }); +}; + +export const resolveRichTextExpressions = ( + value: unknown, + resolve: RichTextExpressionResolver, + options: RichTextExpressionResolverOptions = {} +): unknown => { + if (!isJsonObject(value) || !Array.isArray(value.blocks)) { + return value; + } + + const resolvedValue = options.cloneValue === false ? value : cloneJsonValue(value); + if (!isJsonObject(resolvedValue) || !Array.isArray(resolvedValue.blocks)) { + return value; + } + + resolvedValue.blocks.forEach((block) => { + if (!isJsonObject(block) || !isJsonObject(block.data)) { + return; + } + + if (typeof block.data.text === 'string') { + block.data.text = resolve(block.data.text); + } + + if (typeof block.data.caption === 'string') { + block.data.caption = resolve(block.data.caption); + } + + if (Object.hasOwn(block.data, 'content')) { + block.data.content = resolveNestedContent(block.data.content, resolve); + } + + if (block.type === 'list' && Object.hasOwn(block.data, 'items')) { + block.data.items = resolveListItems(block.data.items, resolve); + } + }); + + return resolvedValue; +}; diff --git a/lib/process-services-cloud/src/lib/form/public-api.ts b/lib/process-services-cloud/src/lib/form/public-api.ts index 758c688f72..10256da84f 100644 --- a/lib/process-services-cloud/src/lib/form/public-api.ts +++ b/lib/process-services-cloud/src/lib/form/public-api.ts @@ -43,5 +43,6 @@ export * from './services/form-cloud.service'; export * from './services/content-cloud-node-selector.service'; export * from './services/process-cloud-content.service'; export * from './services/display-mode.service'; +export * from './services/form-cloud-submission-values'; export * from './form-cloud.module'; diff --git a/lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.spec.ts b/lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.spec.ts new file mode 100644 index 0000000000..c5df7f6b1e --- /dev/null +++ b/lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.spec.ts @@ -0,0 +1,185 @@ +/*! + * @license + * Copyright © 2005-2026 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, FormFieldModel, FormFieldTypes, FormModel } from '@alfresco/adf-core'; +import { firstValueFrom, of } from 'rxjs'; +import { getExpressionEvaluationEnabled$, materializeSubmissionValues } from './form-cloud-submission-values'; + +describe('getExpressionEvaluationEnabled$', () => { + it('should return the configured static value', async () => { + const enabled = await firstValueFrom(getExpressionEvaluationEnabled$({ enableExpressionEvaluation: true })); + + expect(enabled).toBe(true); + }); + + it('should return values emitted by observable settings', async () => { + const enabled = await firstValueFrom(getExpressionEvaluationEnabled$(of({ enableExpressionEvaluation: true }))); + + expect(enabled).toBe(true); + }); + + it('should return false when settings are unavailable', async () => { + const enabled = await firstValueFrom(getExpressionEvaluationEnabled$(undefined)); + + expect(enabled).toBe(false); + }); +}); + +describe('materializeSubmissionValues', () => { + let expressions: FormExpressionService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [FormExpressionService] + }); + expressions = TestBed.inject(FormExpressionService); + }); + + it('should resolve root rich text values from the authored template without mutating the form', () => { + const authoredValue = { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Hello ${field.name} - ${variable.status} - ${field.missing} - ${field.unsafe}' + } + } + ] + }; + const form = new FormModel({ + fields: [ + { id: 'richText', name: 'richText', type: FormFieldTypes.DISPLAY_RICH_TEXT, value: authoredValue }, + { id: 'name', name: 'name', type: FormFieldTypes.TEXT, value: 'John' }, + { id: 'unsafe', name: 'unsafe', type: FormFieldTypes.TEXT, value: 'John' } + ], + variables: [{ id: 'status', name: 'status', type: 'string', value: 'Active' }] + }); + const richTextField = form.getFieldById('richText'); + richTextField.value = { blocks: [{ type: 'paragraph', data: { text: 'stale rendered value' } }] }; + const originalValues = JSON.parse(JSON.stringify(form.values)); + const originalDefinition = JSON.parse(JSON.stringify(form.json)); + + const values = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions); + + expect(values.richText).toEqual({ + blocks: [ + { + type: 'paragraph', + data: { + text: 'Hello John - Active - - <b>John</b>' + } + } + ] + }); + expect(form.values).toEqual(originalValues); + expect(form.json).toEqual(originalDefinition); + expect(richTextField.value).toEqual({ blocks: [{ type: 'paragraph', data: { text: 'stale rendered value' } }] }); + }); + + it('should produce stable values across repeated materialization', () => { + const form = new FormModel({ + fields: [ + { + id: 'richText', + type: FormFieldTypes.DISPLAY_RICH_TEXT, + value: { blocks: [{ type: 'paragraph', data: { text: '${field.name}' } }] } + }, + { id: 'name', type: FormFieldTypes.TEXT, value: 'John' } + ] + }); + + const firstValues = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions); + const secondValues = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions); + + expect(secondValues).toEqual(firstValues); + }); + + it('should return a shallow clone without resolving expressions when evaluation is disabled', () => { + const form = new FormModel({ + fields: [ + { + id: 'richText', + type: FormFieldTypes.DISPLAY_RICH_TEXT, + value: { blocks: [{ type: 'paragraph', data: { text: '${field.name}' } }] } + }, + { id: 'name', type: FormFieldTypes.TEXT, value: 'John' } + ] + }); + + const values = materializeSubmissionValues(form, { enableExpressionEvaluation: false }, expressions); + + expect(values).toEqual(form.values); + expect(values).not.toBe(form.values); + expect(values.richText).toBe(form.values.richText); + }); + + it('should isolate materialized repeatable section rows', () => { + const form = new FormModel(); + form.values = { + section: [ + { richText: 'saved row one', untouched: 'one' }, + { richText: 'saved row two', untouched: 'two' } + ], + name: 'John' + }; + const nameField = new FormFieldModel(form, { id: 'name', type: FormFieldTypes.TEXT, value: 'John' }); + const firstField = new FormFieldModel( + form, + { + id: 'richText', + type: FormFieldTypes.DISPLAY_RICH_TEXT, + value: { blocks: [{ type: 'paragraph', data: { text: 'First ${field.name}' } }] } + }, + { id: 'section', uid: 'richText-Row1', fields: {}, rowIndex: 0 } + ); + const secondField = new FormFieldModel( + form, + { + id: 'richText', + type: FormFieldTypes.DISPLAY_RICH_TEXT, + value: { blocks: [{ type: 'paragraph', data: { text: 'Second ${field.name}' } }] } + }, + { id: 'section', uid: 'richText-Row2', fields: {}, rowIndex: 1 } + ); + form.fieldsCache = [nameField, firstField, secondField]; + form.values.section = [ + { richText: 'saved row one', untouched: 'one' }, + { richText: 'saved row two', untouched: 'two' } + ]; + const originalSection = form.values.section; + const originalFirstRow = form.values.section[0]; + const originalSecondRow = form.values.section[1]; + + const values = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions); + + expect(values.section).toEqual([ + { + richText: { blocks: [{ type: 'paragraph', data: { text: 'First John' } }] }, + untouched: 'one' + }, + { + richText: { blocks: [{ type: 'paragraph', data: { text: 'Second John' } }] }, + untouched: 'two' + } + ]); + expect(values.section).not.toBe(originalSection); + expect(values.section[0]).not.toBe(originalFirstRow); + expect(values.section[1]).not.toBe(originalSecondRow); + expect(form.values.section).toBe(originalSection); + }); +}); diff --git a/lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.ts b/lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.ts new file mode 100644 index 0000000000..a43709e4e2 --- /dev/null +++ b/lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.ts @@ -0,0 +1,80 @@ +/*! + * @license + * Copyright © 2005-2026 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 { DisplayTextWidgetSettings, FormExpressionService, FormFieldTypes, FormModel, FormValues, ROW_ID_PREFIX } from '@alfresco/adf-core'; +import { isObservable, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { resolveRichTextExpressions } from '../components/widgets/display-rich-text/rich-text-expression-resolver'; + +type SubmissionRow = Record; + +const isSubmissionRow = (value: unknown): value is SubmissionRow => typeof value === 'object' && value !== null && !Array.isArray(value); + +export interface FormCloudSubmissionValuesOptions { + enableExpressionEvaluation: boolean; +} + +export const getExpressionEvaluationEnabled$ = ( + settings: Observable | DisplayTextWidgetSettings | null | undefined +): Observable => + isObservable(settings) + ? settings.pipe(map((value) => value?.enableExpressionEvaluation ?? false)) + : of(settings?.enableExpressionEvaluation ?? false); + +export const materializeSubmissionValues = ( + form: FormModel, + options: FormCloudSubmissionValuesOptions, + expressions: FormExpressionService +): FormValues => { + const values = { ...form.values }; + + if (!options.enableExpressionEvaluation) { + return values; + } + + for (const field of form.getFormFields([FormFieldTypes.DISPLAY_RICH_TEXT])) { + const { authoredValue, parent } = field; + if (authoredValue === undefined || parent?.isTemplate) { + continue; + } + + const materializedValue = resolveRichTextExpressions(authoredValue, (content) => expressions.resolveExpressions(form, content, true), { + cloneValue: false + }); + + if (!parent) { + values[field.id] = materializedValue; + continue; + } + + const sectionValues = values[parent.id]; + const sectionRow = Array.isArray(sectionValues) ? sectionValues[parent.rowIndex] : undefined; + if (!isSubmissionRow(sectionRow)) { + continue; + } + + const materializedRows = [...sectionValues]; + const fieldId = field.id.split(ROW_ID_PREFIX)[0]; + materializedRows[parent.rowIndex] = { + ...sectionRow, + [fieldId]: materializedValue + }; + values[parent.id] = materializedRows; + } + + return values; +}; diff --git a/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.spec.ts index 71136e5c5b..0f9559bc03 100755 --- a/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.spec.ts +++ b/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.spec.ts @@ -17,8 +17,8 @@ import { DebugElement, SimpleChange } from '@angular/core'; import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { FormModel, FormOutcomeEvent, FormOutcomeModel } from '@alfresco/adf-core'; -import { of, throwError } from 'rxjs'; +import { ADF_DISPLAY_TEXT_SETTINGS, FormFieldTypes, FormModel, FormOutcomeEvent, FormOutcomeModel } from '@alfresco/adf-core'; +import { Subject, of, throwError } from 'rxjs'; import { StartProcessCloudService } from '../services/start-process-cloud.service'; import { FormCloudService } from '../../../form/services/form-cloud.service'; import { FormCloudComponent } from '../../../form/components/form-cloud.component'; @@ -47,7 +47,7 @@ import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing'; import { MatButtonHarness } from '@angular/material/button/testing'; -import { FormCloudDisplayMode } from '../../../services/form-fields.interfaces'; +import { FormCloudDisplayMode, FormContent } from '../../../services/form-fields.interfaces'; import { MatDialogHarness } from '@angular/material/dialog/testing'; import { MatDialog } from '@angular/material/dialog'; import { ReactiveFormsModule } from '@angular/forms'; @@ -92,7 +92,10 @@ describe('StartProcessCloudComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [StartProcessCloudComponent, ReactiveFormsModule, StartProcessScreenCloudComponent], - providers: [provideScreen(screenId, MockedTaskScreenCloudComponent)] + providers: [ + provideScreen(screenId, MockedTaskScreenCloudComponent), + { provide: ADF_DISPLAY_TEXT_SETTINGS, useValue: { enableExpressionEvaluation: true } } + ] }); processService = TestBed.inject(StartProcessCloudService); formCloudService = TestBed.inject(FormCloudService); @@ -417,6 +420,54 @@ describe('StartProcessCloudComponent', () => { expect(startBtn.disabled).toBe(false); }); + it('should keep the start action unavailable while the form definition is loading', async () => { + const formDefinition = new Subject(); + formDefinitionSpy.and.returnValue(formDefinition); + typeValueInto('[data-automation-id="adf-inplace-input"]', 'My new process with form'); + await selectOptionByName('processwithform'); + + const startButton = fixture.nativeElement.querySelector('#button-start'); + expect(startButton).toBeNull(); + expect(startProcessWithFormSpy).not.toHaveBeenCalled(); + }); + + it('should materialize unrendered rich text expressions when starting a process', async () => { + formDefinitionSpy.and.returnValue(of(fakeStartForm)); + component.processDefinitionCurrent = fakeProcessDefinitions[2]; + component.processPayloadCloud.processDefinitionKey = fakeProcessDefinitions[2].key; + component.processInstanceName.setValue('My process'); + fixture.detectChanges(); + await fixture.whenStable(); + + const form = new FormModel({ + fields: [ + { + id: 'richText', + type: FormFieldTypes.DISPLAY_RICH_TEXT, + value: { blocks: [{ type: 'paragraph', data: { text: 'Hello ${field.name}' } }] } + }, + { id: 'name', type: FormFieldTypes.TEXT, value: 'John' } + ] + }); + const formElement = fixture.debugElement.query(By.css('adf-cloud-form')); + const startButton = fixture.debugElement.query(By.css('#button-start')); + + formElement.triggerEventHandler('formLoaded', form); + fixture.detectChanges(); + startButton.triggerEventHandler('click', null); + + expect(startProcessWithFormSpy).toHaveBeenCalledWith( + component.appName, + fakeProcessDefinitions[2].formKey, + fakeProcessDefinitions[2].version, + jasmine.objectContaining({ + values: jasmine.objectContaining({ + richText: { blocks: [{ type: 'paragraph', data: { text: 'Hello John' } }] } + }) + }) + ); + }); + it('should be able to start a process with form full display mode', async () => { component.displayModeConfigurations = [ { diff --git a/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.ts b/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.ts index c06299d703..40126c5722 100755 --- a/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.ts +++ b/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.ts @@ -30,10 +30,14 @@ import { ViewEncapsulation } from '@angular/core'; import { + ADF_DISPLAY_TEXT_SETTINGS, ConfirmDialogComponent, ContentLinkModel, + DisplayTextWidgetSettings, + FormExpressionService, FormModel, FormOutcomeEvent, + FormValues, IconModule, InplaceFormInputComponent, LocalizedDatePipe, @@ -65,6 +69,7 @@ import { FormCustomOutcomesComponent } from '../../../form/components/form-cloud import { MatDialog } from '@angular/material/dialog'; import { StartProcessScreenCloudComponent } from '../../../screen/components/screen-cloud/start-process-event-screen/start-process-screen-cloud.component'; import { TaskTypeResolverService } from '../../../services/task-type-resolver/task-type-resolver.service'; +import { getExpressionEvaluationEnabled$, materializeSubmissionValues } from '../../../form/services/form-cloud-submission-values'; const MAX_NAME_LENGTH: number = 255; const PROCESS_DEFINITION_DEBOUNCE: number = 300; @@ -211,6 +216,11 @@ export class StartProcessCloudComponent implements OnChanges, OnInit { private readonly hasVisibleOutcomesSubject = new BehaviorSubject(false); private readonly dialog = inject(MatDialog); private readonly taskTypeResolverService = inject(TaskTypeResolverService); + private readonly expressions = inject(FormExpressionService); + private readonly displayTextSettings = inject | DisplayTextWidgetSettings>(ADF_DISPLAY_TEXT_SETTINGS, { + optional: true + }); + private enableExpressionEvaluation = false; private screenSubmitPayload: unknown; @@ -218,8 +228,12 @@ export class StartProcessCloudComponent implements OnChanges, OnInit { showCompleteButton = false; get isProcessFormValid(): boolean { - if (this.hasForm && this.isFormCloudLoaded) { - return (this.formCloud ? !Object.keys(this.formCloud.values).length : false) || this.formCloud?.isValid || this.isProcessStarting; + if (this.hasForm) { + if (!this.isFormCloudLoaded || !this.formCloud) { + return false; + } + + return !Object.keys(this.formCloud.values).length || this.formCloud.isValid || this.isProcessStarting; } else if (this.hasScreen) { return true; } else { @@ -268,6 +282,12 @@ export class StartProcessCloudComponent implements OnChanges, OnInit { constructor() { this.startProcessButtonLabel = this.defaultStartProcessButtonLabel; this.cancelButtonLabel = this.defaultCancelProcessButtonLabel; + + getExpressionEvaluationEnabled$(this.displayTextSettings) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((enabled) => { + this.enableExpressionEvaluation = enabled; + }); } ngOnInit() { @@ -482,6 +502,14 @@ export class StartProcessCloudComponent implements OnChanges, OnInit { } startProcessWithoutConfirmation() { + let submissionValues = this.screenSubmitPayload; + if (this.hasForm) { + if (!this.formCloud) { + return; + } + submissionValues = this.getFormSubmissionValues(this.formCloud); + } + this.isProcessStarting = true; let action: Observable; @@ -495,7 +523,7 @@ export class StartProcessCloudComponent implements OnChanges, OnInit { processName: this.processInstanceName.value, processDefinitionKey: this.processPayloadCloud.processDefinitionKey, variables: this.variables ?? {}, - values: this.hasForm ? this.formCloud.values : this.screenSubmitPayload, + values: submissionValues, outcome: this.customOutcomeName }) ); @@ -524,6 +552,10 @@ export class StartProcessCloudComponent implements OnChanges, OnInit { }); } + private getFormSubmissionValues(form: FormModel): FormValues { + return materializeSubmissionValues(form, { enableExpressionEvaluation: this.enableExpressionEvaluation }, this.expressions); + } + startProcess() { if (!this.formCloud?.confirmMessage?.show) { this.startProcessWithoutConfirmation();