mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-02 17:53:30 +00:00
AAE-50098 Fix unrendered rich text expressions in form submissions (#12169)
This commit is contained in:
@@ -36,6 +36,58 @@ describe('FormFieldModel', () => {
|
|||||||
expect(model.json).toBe(json);
|
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', () => {
|
it('should setup with json config', () => {
|
||||||
const json = {
|
const json = {
|
||||||
fieldType: '<fieldType>',
|
fieldType: '<fieldType>',
|
||||||
|
|||||||
@@ -38,12 +38,28 @@ export type FieldOptionType = 'rest' | 'manual' | 'variable';
|
|||||||
export type FieldSelectionType = 'single' | 'multiple';
|
export type FieldSelectionType = 'single' | 'multiple';
|
||||||
export type FieldAlignmentType = 'vertical' | 'horizontal';
|
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
|
// Maps to FormFieldRepresentation
|
||||||
export class FormFieldModel extends FormWidgetModel {
|
export class FormFieldModel extends FormWidgetModel {
|
||||||
private _value: string;
|
private _value: string;
|
||||||
private _readOnly: boolean = false;
|
private _readOnly: boolean = false;
|
||||||
private _isValid: boolean = true;
|
private _isValid: boolean = true;
|
||||||
private _required: boolean = false;
|
private _required: boolean = false;
|
||||||
|
private readonly _authoredValue: unknown;
|
||||||
|
|
||||||
readonly defaultDateFormat: string = 'D-M-YYYY';
|
readonly defaultDateFormat: string = 'D-M-YYYY';
|
||||||
readonly defaultDateTimeFormat: string = 'D-M-YYYY hh:mm A';
|
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 {
|
get readOnly(): boolean {
|
||||||
if (this.form?.readOnly) {
|
if (this.form?.readOnly) {
|
||||||
return true;
|
return true;
|
||||||
@@ -183,6 +203,7 @@ export class FormFieldModel extends FormWidgetModel {
|
|||||||
|
|
||||||
constructor(form: any, json?: any, parent?: RepeatableSectionModel) {
|
constructor(form: any, json?: any, parent?: RepeatableSectionModel) {
|
||||||
super(form, json);
|
super(form, json);
|
||||||
|
this._authoredValue = json?.type === FormFieldTypes.DISPLAY_RICH_TEXT ? cloneJsonCompatibleValue(json.value) : undefined;
|
||||||
if (json) {
|
if (json) {
|
||||||
this.fieldType = json.fieldType;
|
this.fieldType = json.fieldType;
|
||||||
this.id = this.getId(json.id, parent);
|
this.id = this.getId(json.id, parent);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
import { VersionCompatibilityService, AlfrescoApiService } from '@alfresco/adf-content-services';
|
import { VersionCompatibilityService, AlfrescoApiService } from '@alfresco/adf-content-services';
|
||||||
import {
|
import {
|
||||||
|
ADF_DISPLAY_TEXT_SETTINGS,
|
||||||
ContentLinkModel,
|
ContentLinkModel,
|
||||||
CoreModule,
|
CoreModule,
|
||||||
FormFieldModel,
|
FormFieldModel,
|
||||||
@@ -117,7 +118,8 @@ describe('FormCloudComponent', () => {
|
|||||||
useValue: {}
|
useValue: {}
|
||||||
},
|
},
|
||||||
{ provide: FormRenderingService, useClass: CloudFormRenderingService },
|
{ 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);
|
const apiService = TestBed.inject(AlfrescoApiService);
|
||||||
@@ -874,6 +876,39 @@ describe('FormCloudComponent', () => {
|
|||||||
expect(savedForm).toEqual(formModel);
|
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', () => {
|
it('should handle error during form save', () => {
|
||||||
const error = 'Error';
|
const error = 'Error';
|
||||||
spyOn(formCloudService, 'saveTaskForm').and.callFake(() => throwError(error));
|
spyOn(formCloudService, 'saveTaskForm').and.callFake(() => throwError(error));
|
||||||
@@ -981,6 +1016,39 @@ describe('FormCloudComponent', () => {
|
|||||||
expect(completedForm).toBe(formComponent.form);
|
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 () => {
|
it('should open confirmation dialog on complete task', async () => {
|
||||||
formComponent.form = new FormModel({
|
formComponent.form = new FormModel({
|
||||||
confirmMessage: {
|
confirmMessage: {
|
||||||
|
|||||||
@@ -35,9 +35,12 @@ import { filter, map, switchMap } from 'rxjs/operators';
|
|||||||
import {
|
import {
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
ContentLinkModel,
|
ContentLinkModel,
|
||||||
|
ADF_DISPLAY_TEXT_SETTINGS,
|
||||||
|
DisplayTextWidgetSettings,
|
||||||
FormatSpacePipe,
|
FormatSpacePipe,
|
||||||
FormBaseComponent,
|
FormBaseComponent,
|
||||||
FormEvent,
|
FormEvent,
|
||||||
|
FormExpressionService,
|
||||||
FormFieldModel,
|
FormFieldModel,
|
||||||
FormRulesEvent,
|
FormRulesEvent,
|
||||||
FormFieldValidator,
|
FormFieldValidator,
|
||||||
@@ -67,6 +70,7 @@ import { TranslatePipe } from '@ngx-translate/core';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { A11yModule } from '@angular/cdk/a11y';
|
import { A11yModule } from '@angular/cdk/a11y';
|
||||||
|
import { getExpressionEvaluationEnabled$, materializeSubmissionValues } from '../services/form-cloud-submission-values';
|
||||||
|
|
||||||
interface FormFieldRuntimeState {
|
interface FormFieldRuntimeState {
|
||||||
value: any;
|
value: any;
|
||||||
@@ -228,6 +232,8 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
|
|||||||
protected changeDetector = inject(ChangeDetectorRef);
|
protected changeDetector = inject(ChangeDetectorRef);
|
||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly expressions = inject(FormExpressionService);
|
||||||
|
private enableExpressionEvaluation = false;
|
||||||
|
|
||||||
private get currentForm(): FormModel | undefined {
|
private get currentForm(): FormModel | undefined {
|
||||||
return super.form;
|
return super.form;
|
||||||
@@ -252,6 +258,9 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
|
|||||||
constructor() {
|
constructor() {
|
||||||
const injectedFieldValidators = inject(FORM_CLOUD_FIELD_VALIDATORS_TOKEN, { optional: true });
|
const injectedFieldValidators = inject(FORM_CLOUD_FIELD_VALIDATORS_TOKEN, { optional: true });
|
||||||
const tabNavEnabledToken = inject(ADF_FORM_TAB_NAV_ENABLED, { 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();
|
super();
|
||||||
this.loadInjectedFieldValidators(injectedFieldValidators);
|
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) => {
|
this.formService.formContentClicked.pipe(takeUntilDestroyed()).subscribe((content) => {
|
||||||
if (content instanceof UploadWidgetContentLinkModel) {
|
if (content instanceof UploadWidgetContentLinkModel) {
|
||||||
this.form.setNodeIdValueForViewersLinkedToUploadWidget(content);
|
this.form.setNodeIdValueForViewersLinkedToUploadWidget(content);
|
||||||
@@ -482,7 +497,7 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
|
|||||||
saveTaskForm() {
|
saveTaskForm() {
|
||||||
if (this.form && this.appName && this.taskId) {
|
if (this.form && this.appName && this.taskId) {
|
||||||
this.formCloudService
|
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))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
@@ -523,7 +538,15 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
|
|||||||
private completeForm(outcome?: string, outcomeId?: string) {
|
private completeForm(outcome?: string, outcomeId?: string) {
|
||||||
if (this.form && this.appName && this.taskId) {
|
if (this.form && this.appName && this.taskId) {
|
||||||
this.formCloudService
|
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))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
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 {
|
parseForm(formCloudRepresentationJSON?: any): FormModel | null {
|
||||||
if (formCloudRepresentationJSON) {
|
if (formCloudRepresentationJSON) {
|
||||||
const formValues: FormValues = {};
|
const formValues: FormValues = {};
|
||||||
|
|||||||
+45
@@ -200,6 +200,51 @@ describe('DisplayRichTextWidgetComponent', () => {
|
|||||||
expect(widget.field.value.blocks[0].data.text).toBe('Hello John');
|
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', () => {
|
it('should resolve expressions in multiple blocks', () => {
|
||||||
const form = new FormModel({
|
const form = new FormModel({
|
||||||
fields: [
|
fields: [
|
||||||
|
|||||||
+10
-13
@@ -22,6 +22,7 @@ import { BaseDisplayTextWidgetComponent } from '@alfresco/adf-core';
|
|||||||
import { DomSanitizer } from '@angular/platform-browser';
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { RichTextParserService } from '../../../services/rich-text-parser.service';
|
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', {
|
export const RICH_TEXT_PARSER_TOKEN = new InjectionToken<RichTextParserService>('RichTextParserService', {
|
||||||
factory: () => new RichTextParserService()
|
factory: () => new RichTextParserService()
|
||||||
@@ -66,7 +67,10 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone
|
|||||||
|
|
||||||
protected storeOriginalValue(): void {
|
protected storeOriginalValue(): void {
|
||||||
if (this.field) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = JSON.parse(JSON.stringify(this.field.value));
|
const authoredValue = this.field.authoredValue;
|
||||||
this.applyExpressionsToBlocks(value);
|
if (authoredValue !== undefined) {
|
||||||
|
this.applyExpressionsToBlocks(authoredValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected reevaluateExpressions(): void {
|
protected reevaluateExpressions(): void {
|
||||||
@@ -89,16 +95,7 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyExpressionsToBlocks(value: any): void {
|
private applyExpressionsToBlocks(value: any): void {
|
||||||
for (const block of value.blocks) {
|
this.field.value = resolveRichTextExpressions(value, (content) => this.resolveExpressions(content, true));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseAndSanitize(): void {
|
private parseAndSanitize(): void {
|
||||||
|
|||||||
+137
@@ -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}');
|
||||||
|
});
|
||||||
|
});
|
||||||
+111
@@ -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/content-cloud-node-selector.service';
|
||||||
export * from './services/process-cloud-content.service';
|
export * from './services/process-cloud-content.service';
|
||||||
export * from './services/display-mode.service';
|
export * from './services/display-mode.service';
|
||||||
|
export * from './services/form-cloud-submission-values';
|
||||||
|
|
||||||
export * from './form-cloud.module';
|
export * from './form-cloud.module';
|
||||||
|
|||||||
+185
@@ -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 - - <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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
+55
-4
@@ -17,8 +17,8 @@
|
|||||||
|
|
||||||
import { DebugElement, SimpleChange } from '@angular/core';
|
import { DebugElement, SimpleChange } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||||
import { FormModel, FormOutcomeEvent, FormOutcomeModel } from '@alfresco/adf-core';
|
import { ADF_DISPLAY_TEXT_SETTINGS, FormFieldTypes, FormModel, FormOutcomeEvent, FormOutcomeModel } from '@alfresco/adf-core';
|
||||||
import { of, throwError } from 'rxjs';
|
import { Subject, of, throwError } from 'rxjs';
|
||||||
import { StartProcessCloudService } from '../services/start-process-cloud.service';
|
import { StartProcessCloudService } from '../services/start-process-cloud.service';
|
||||||
import { FormCloudService } from '../../../form/services/form-cloud.service';
|
import { FormCloudService } from '../../../form/services/form-cloud.service';
|
||||||
import { FormCloudComponent } from '../../../form/components/form-cloud.component';
|
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 { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
|
||||||
import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing';
|
import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing';
|
||||||
import { MatButtonHarness } from '@angular/material/button/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 { MatDialogHarness } from '@angular/material/dialog/testing';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { ReactiveFormsModule } from '@angular/forms';
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
@@ -92,7 +92,10 @@ describe('StartProcessCloudComponent', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [StartProcessCloudComponent, ReactiveFormsModule, StartProcessScreenCloudComponent],
|
imports: [StartProcessCloudComponent, ReactiveFormsModule, StartProcessScreenCloudComponent],
|
||||||
providers: [provideScreen(screenId, MockedTaskScreenCloudComponent)]
|
providers: [
|
||||||
|
provideScreen(screenId, MockedTaskScreenCloudComponent),
|
||||||
|
{ provide: ADF_DISPLAY_TEXT_SETTINGS, useValue: { enableExpressionEvaluation: true } }
|
||||||
|
]
|
||||||
});
|
});
|
||||||
processService = TestBed.inject(StartProcessCloudService);
|
processService = TestBed.inject(StartProcessCloudService);
|
||||||
formCloudService = TestBed.inject(FormCloudService);
|
formCloudService = TestBed.inject(FormCloudService);
|
||||||
@@ -417,6 +420,54 @@ describe('StartProcessCloudComponent', () => {
|
|||||||
expect(startBtn.disabled).toBe(false);
|
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 () => {
|
it('should be able to start a process with form full display mode', async () => {
|
||||||
component.displayModeConfigurations = [
|
component.displayModeConfigurations = [
|
||||||
{
|
{
|
||||||
|
|||||||
+35
-3
@@ -30,10 +30,14 @@ import {
|
|||||||
ViewEncapsulation
|
ViewEncapsulation
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
|
ADF_DISPLAY_TEXT_SETTINGS,
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
ContentLinkModel,
|
ContentLinkModel,
|
||||||
|
DisplayTextWidgetSettings,
|
||||||
|
FormExpressionService,
|
||||||
FormModel,
|
FormModel,
|
||||||
FormOutcomeEvent,
|
FormOutcomeEvent,
|
||||||
|
FormValues,
|
||||||
IconModule,
|
IconModule,
|
||||||
InplaceFormInputComponent,
|
InplaceFormInputComponent,
|
||||||
LocalizedDatePipe,
|
LocalizedDatePipe,
|
||||||
@@ -65,6 +69,7 @@ import { FormCustomOutcomesComponent } from '../../../form/components/form-cloud
|
|||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { StartProcessScreenCloudComponent } from '../../../screen/components/screen-cloud/start-process-event-screen/start-process-screen-cloud.component';
|
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 { 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 MAX_NAME_LENGTH: number = 255;
|
||||||
const PROCESS_DEFINITION_DEBOUNCE: number = 300;
|
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 hasVisibleOutcomesSubject = new BehaviorSubject<boolean>(false);
|
||||||
private readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
private readonly taskTypeResolverService = inject(TaskTypeResolverService);
|
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;
|
private screenSubmitPayload: unknown;
|
||||||
|
|
||||||
@@ -218,8 +228,12 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
|
|||||||
showCompleteButton = false;
|
showCompleteButton = false;
|
||||||
|
|
||||||
get isProcessFormValid(): boolean {
|
get isProcessFormValid(): boolean {
|
||||||
if (this.hasForm && this.isFormCloudLoaded) {
|
if (this.hasForm) {
|
||||||
return (this.formCloud ? !Object.keys(this.formCloud.values).length : false) || this.formCloud?.isValid || this.isProcessStarting;
|
if (!this.isFormCloudLoaded || !this.formCloud) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !Object.keys(this.formCloud.values).length || this.formCloud.isValid || this.isProcessStarting;
|
||||||
} else if (this.hasScreen) {
|
} else if (this.hasScreen) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@@ -268,6 +282,12 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.startProcessButtonLabel = this.defaultStartProcessButtonLabel;
|
this.startProcessButtonLabel = this.defaultStartProcessButtonLabel;
|
||||||
this.cancelButtonLabel = this.defaultCancelProcessButtonLabel;
|
this.cancelButtonLabel = this.defaultCancelProcessButtonLabel;
|
||||||
|
|
||||||
|
getExpressionEvaluationEnabled$(this.displayTextSettings)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe((enabled) => {
|
||||||
|
this.enableExpressionEvaluation = enabled;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@@ -482,6 +502,14 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startProcessWithoutConfirmation() {
|
startProcessWithoutConfirmation() {
|
||||||
|
let submissionValues = this.screenSubmitPayload;
|
||||||
|
if (this.hasForm) {
|
||||||
|
if (!this.formCloud) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submissionValues = this.getFormSubmissionValues(this.formCloud);
|
||||||
|
}
|
||||||
|
|
||||||
this.isProcessStarting = true;
|
this.isProcessStarting = true;
|
||||||
|
|
||||||
let action: Observable<ProcessInstanceCloud>;
|
let action: Observable<ProcessInstanceCloud>;
|
||||||
@@ -495,7 +523,7 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
|
|||||||
processName: this.processInstanceName.value,
|
processName: this.processInstanceName.value,
|
||||||
processDefinitionKey: this.processPayloadCloud.processDefinitionKey,
|
processDefinitionKey: this.processPayloadCloud.processDefinitionKey,
|
||||||
variables: this.variables ?? {},
|
variables: this.variables ?? {},
|
||||||
values: this.hasForm ? this.formCloud.values : this.screenSubmitPayload,
|
values: submissionValues,
|
||||||
outcome: this.customOutcomeName
|
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() {
|
startProcess() {
|
||||||
if (!this.formCloud?.confirmMessage?.show) {
|
if (!this.formCloud?.confirmMessage?.show) {
|
||||||
this.startProcessWithoutConfirmation();
|
this.startProcessWithoutConfirmation();
|
||||||
|
|||||||
Reference in New Issue
Block a user