AAE-50098 Fix unrendered rich text expressions in form submissions (#12169)

This commit is contained in:
Alex Molodyh
2026-08-18 09:56:10 -07:00
committed by GitHub
parent d7b1d74b05
commit dfbe1adbd5
13 changed files with 830 additions and 23 deletions
@@ -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: '<fieldType>',
@@ -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);
@@ -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: {
@@ -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<Observable<DisplayTextWidgetSettings> | 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 = {};
@@ -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: [
@@ -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>('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 {
@@ -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<string, unknown> }>;
};
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}');
});
});
@@ -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<string, unknown>;
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;
};
@@ -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';
@@ -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: '<b>John</b>' }
],
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 - - &lt;b&gt;John&lt;/b&gt;'
}
}
]
});
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);
});
});
@@ -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<string, unknown>;
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> | DisplayTextWidgetSettings | null | undefined
): Observable<boolean> =>
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;
};
@@ -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<FormContent>();
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 = [
{
@@ -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<boolean>(false);
private readonly dialog = inject(MatDialog);
private readonly taskTypeResolverService = inject(TaskTypeResolverService);
private readonly expressions = inject(FormExpressionService);
private readonly displayTextSettings = inject<Observable<DisplayTextWidgetSettings> | 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<ProcessInstanceCloud>;
@@ -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();