diff --git a/lib/core/src/lib/form/components/widgets/base-display-text/base-display-text.widget.ts b/lib/core/src/lib/form/components/widgets/base-display-text/base-display-text.widget.ts index b3a6e93bea..6a18278577 100644 --- a/lib/core/src/lib/form/components/widgets/base-display-text/base-display-text.widget.ts +++ b/lib/core/src/lib/form/components/widgets/base-display-text/base-display-text.widget.ts @@ -15,10 +15,12 @@ * limitations under the License. */ -import { ChangeDetectorRef, Component, inject, AfterViewInit, DestroyRef, InjectionToken } from '@angular/core'; +import { ChangeDetectorRef, Component, inject, AfterViewInit, OnInit, DestroyRef, InjectionToken } from '@angular/core'; import { debounceTime, filter, isObservable, Observable } from 'rxjs'; import { FormRulesEvent } from '../../../events'; import { FormExpressionService } from '../../../services/form-expression.service'; +import { FormFieldValueFormatterService } from '../../../services/form-field-value-formatter.service'; +import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token'; import { WidgetComponent } from '../widget.component'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -33,11 +35,14 @@ export const ADF_DISPLAY_TEXT_SETTINGS = new InjectionToken | DisplayTextWidgetSettings>(ADF_DISPLAY_TEXT_SETTINGS, { optional: true @@ -45,6 +50,13 @@ export abstract class BaseDisplayTextWidgetComponent extends WidgetComponent imp constructor() { super(); + if (isObservable(this.formattingEnabledToken)) { + this.formattingEnabledToken.pipe(takeUntilDestroyed()).subscribe((enabled: boolean) => { + this.formattingEnabled = enabled ?? false; + }); + } else { + this.formattingEnabled = this.formattingEnabledToken ?? false; + } if (isObservable(this.settings)) { this.settings.pipe(takeUntilDestroyed()).subscribe((data: DisplayTextWidgetSettings) => { this.updateSettingsBasedProperties(data); @@ -54,6 +66,16 @@ export abstract class BaseDisplayTextWidgetComponent extends WidgetComponent imp } } + ngOnInit() { + const value = this.field?.value; + const shouldFormatValue = + this.formattingEnabled && value != null && typeof value !== 'string' && this.formatter.hasFormatter(this.field?.type ?? ''); + + if (shouldFormatValue) { + this.field.value = this.formatter.format(this.field); + } + } + override ngAfterViewInit() { if (this.enableExpressionEvaluation) { this.storeOriginalValue(); diff --git a/lib/core/src/lib/form/components/widgets/core/formattable-text.widget.ts b/lib/core/src/lib/form/components/widgets/core/formattable-text.widget.ts new file mode 100644 index 0000000000..d50bfe8463 --- /dev/null +++ b/lib/core/src/lib/form/components/widgets/core/formattable-text.widget.ts @@ -0,0 +1,96 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, DestroyRef, inject, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { isObservable } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { FormRulesEvent } from '../../../events/form-rules.event'; +import { ADF_CUSTOM_MESSAGE } from './custom-validation-message.token'; +import { WidgetComponent } from '../widget.component'; +import { FormFieldValueFormatterService } from '../../../services/form-field-value-formatter.service'; +import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token'; + +@Component({ + template: '', + standalone: true +}) +export abstract class FormattableTextWidgetComponent extends WidgetComponent implements OnInit { + protected readonly destroyRef = inject(DestroyRef); + private readonly enableCustomMessage = inject(ADF_CUSTOM_MESSAGE, { optional: true }); + private readonly formatter = inject(FormFieldValueFormatterService); + private readonly formattingEnabledToken = inject(ADF_TYPED_VALUE_FORMATTING_ENABLED, { optional: true }); + private formattingEnabled = false; + displayValue = ''; + + onValueChange(value: string): void { + this.field.value = value; + this.displayValue = this.computeDisplayValue(); + this.onFieldChanged(this.field); + } + + protected computeDisplayValue(): string { + const value = this.field.value; + const isReadOnly = this.field.readOnly || this.readOnly; + if (this.formattingEnabled && isReadOnly && value != null && typeof value !== 'string' && this.formatter.hasFormatter(this.field.type)) { + return this.formatter.format(this.field); + } + return value as string; + } + + ngOnInit(): void { + this.initValueFormatting(); + this.initCustomValidationMessage(); + } + + private initValueFormatting(): void { + if (isObservable(this.formattingEnabledToken)) { + this.formattingEnabledToken.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => { + this.formattingEnabled = enabled ?? false; + this.displayValue = this.computeDisplayValue(); + }); + } else { + this.formattingEnabled = this.formattingEnabledToken ?? false; + this.displayValue = this.computeDisplayValue(); + } + + this.formService.formRulesEvent + .pipe( + filter((event: FormRulesEvent) => event?.type === 'fieldValueChanged'), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.displayValue = this.computeDisplayValue(); + }); + } + + private initCustomValidationMessage(): void { + if (this.enableCustomMessage != null) { + if (isObservable(this.enableCustomMessage)) { + this.enableCustomMessage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => { + if (this.field) { + this.field.enableCustomValidationMessage = enabled ?? false; + } + }); + } else { + this.field.enableCustomValidationMessage = this.enableCustomMessage; + } + } else { + this.field.enableCustomValidationMessage = false; + } + } +} diff --git a/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.spec.ts b/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.spec.ts index 30fc30a7a8..94dec1370c 100644 --- a/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.spec.ts +++ b/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.spec.ts @@ -17,8 +17,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormFieldModel, FormModel } from '../core'; +import { FormFieldTypes } from '../core/form-field-types'; import { DisplayTextWidgetComponent } from './display-text.widget'; import { ADF_DISPLAY_TEXT_SETTINGS } from '../base-display-text/base-display-text.widget'; +import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token'; import { FormService } from '../../../services/form.service'; import { of } from 'rxjs'; @@ -240,4 +242,117 @@ describe('DisplayTextWidgetComponent', () => { }, 100); }); }); + + describe('typed value formatting', () => { + describe('when flag is on', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [DisplayTextWidgetComponent], + providers: [ + FormService, + { provide: ADF_DISPLAY_TEXT_SETTINGS, useValue: { enableExpressionEvaluation: true } }, + { provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: true } + ] + }); + fixture = TestBed.createComponent(DisplayTextWidgetComponent); + widget = fixture.componentInstance; + }); + + it('should format a direct People value to full name', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'f1', + type: FormFieldTypes.PEOPLE, + value: [{ firstName: 'Alyssa', lastName: 'Adcock' }] + }); + fixture.detectChanges(); + + expect(widget.field.value).toBe('Alyssa Adcock'); + }); + + it('should format a direct Group value to group name', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'f1', + type: FormFieldTypes.FUNCTIONAL_GROUP, + value: [{ id: 'g1', name: 'Engineering' }] + }); + fixture.detectChanges(); + + expect(widget.field.value).toBe('Engineering'); + }); + + it('should not contain [object Object] for a complex value', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'f1', + type: FormFieldTypes.PEOPLE, + value: [{ firstName: 'Alice', lastName: 'Brown' }] + }); + fixture.detectChanges(); + + expect(String(widget.field.value)).not.toContain('[object Object]'); + }); + + it('should resolve expression templates for People via FormExpressionService', () => { + const form = new FormModel({ + fields: [ + { id: 'displayText1', type: 'display-text', value: 'Selected: ${field.peopleField}' }, + { id: 'peopleField', type: FormFieldTypes.PEOPLE, value: [{ firstName: 'Alyssa', lastName: 'Adcock' }] } + ] + }); + + widget.field = form.getFieldById('displayText1'); + fixture.detectChanges(); + + expect(widget.field.value).toBe('Selected: Alyssa Adcock'); + }); + + it('should re-evaluate expression with formatted value when dependent People field changes', (done) => { + const form = new FormModel({ + fields: [ + { id: 'displayText1', type: 'display-text', value: 'Selected: ${field.peopleField}' }, + { id: 'peopleField', type: FormFieldTypes.PEOPLE, value: [{ firstName: 'Alyssa', lastName: 'Adcock' }] } + ] + }); + formService = TestBed.inject(FormService); + + widget.field = form.getFieldById('displayText1'); + const peopleField = form.getFieldById('peopleField'); + fixture.detectChanges(); + + expect(widget.field.value).toBe('Selected: Alyssa Adcock'); + + peopleField.value = [{ firstName: 'Jane', lastName: 'Smith' }]; + formService.formRulesEvent.next({ type: 'fieldValueChanged', field: peopleField } as any); + + setTimeout(() => { + expect(widget.field.value).toBe('Selected: Jane Smith'); + done(); + }, 350); + }); + }); + + describe('when flag is off', () => { + it('should not format a complex field value (default behaviour preserved)', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [DisplayTextWidgetComponent], + providers: [FormService] + }); + fixture = TestBed.createComponent(DisplayTextWidgetComponent); + widget = fixture.componentInstance; + + const rawValue = [{ firstName: 'Alyssa', lastName: 'Adcock' }]; + widget.field = new FormFieldModel(new FormModel(), { + id: 'f1', + type: FormFieldTypes.PEOPLE, + value: rawValue + }); + + // Trigger only ngOnInit (no full render — raw array would crash TranslatePipe without the flag) + widget.ngOnInit(); + + expect(widget.field.value).toEqual(rawValue); + }); + }); + }); }); diff --git a/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.ts b/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.ts index 6cb7c5dc0e..274f86f572 100644 --- a/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.ts +++ b/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.ts @@ -48,7 +48,13 @@ export class DisplayTextWidgetComponent extends BaseDisplayTextWidgetComponent { protected evaluateExpressions(): void { if (this.field) { - this.field.value = this.resolveExpressions(this.field.value); + const value = this.field.value; + const isFormattableValue = value != null && typeof value !== 'string'; + if (this.formattingEnabled && isFormattableValue && this.formatter.hasFormatter(this.field.type)) { + this.field.value = this.formatter.format(this.field); + } else { + this.field.value = this.resolveExpressions(value); + } } } diff --git a/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.html b/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.html index ef5b3d11cc..ccc6a97448 100644 --- a/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.html +++ b/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.html @@ -19,8 +19,8 @@ rows="3" [id]="field.id" [required]="field.required" - [(ngModel)]="field.value" - (ngModelChange)="onFieldChanged(field)" + [ngModel]="displayValue" + (ngModelChange)="onValueChange($event)" [disabled]="field.readOnly || readOnly" [placeholder]="field.placeholder" [title]="field.tooltip" @@ -29,7 +29,7 @@
- {{ field?.value?.length || 0 }}/{{ field.maxLength }} + {{ displayValue?.length || 0 }}/{{ field.maxLength }}
diff --git a/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.spec.ts b/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.spec.ts index abb846e264..6a09b40ebe 100644 --- a/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.spec.ts +++ b/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.spec.ts @@ -24,6 +24,7 @@ import { MultilineTextWidgetComponentComponent } from './multiline-text.widget'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { UnitTestingUtils } from '../../../../testing/unit-testing-utils'; import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token'; +import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token'; import { of, Subject } from 'rxjs'; describe('MultilineTextWidgetComponentComponent', () => { @@ -298,4 +299,92 @@ describe('MultilineTextWidgetComponentComponent - ADF_CUSTOM_MESSAGE', () => { expect(widget.field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.INVALID_VALUE'); }); }); + + describe('typed value formatting', () => { + describe('when flag is on', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [MultilineTextWidgetComponentComponent], + providers: [{ provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: true }] + }); + fixture = TestBed.createComponent(MultilineTextWidgetComponentComponent); + widget = fixture.componentInstance; + testingUtils = new UnitTestingUtils(fixture.debugElement); + }); + + it('should return formatted name for a People value in read-only mode', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'people-field', + type: FormFieldTypes.PEOPLE, + value: [{ firstName: 'Alice', lastName: 'Brown' }], + readOnly: true + }); + fixture.detectChanges(); + + expect(widget.displayValue).toBe('Alice Brown'); + }); + + it('should not return [object Object] for a complex field value', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'people-field', + type: FormFieldTypes.PEOPLE, + value: [{ firstName: 'Alice', lastName: 'Brown' }], + readOnly: true + }); + fixture.detectChanges(); + + expect(String(widget.displayValue)).not.toContain('[object Object]'); + }); + + it('should pass through plain string values unchanged', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'multiline-id', + type: FormFieldTypes.MULTILINE_TEXT, + value: 'plain text', + readOnly: true + }); + fixture.detectChanges(); + + expect(widget.displayValue).toBe('plain text'); + }); + + it('should not JSON-stringify a Date value for an unregistered type', () => { + const date = new Date('2026-06-02T14:30:00.000Z'); + widget.field = new FormFieldModel(new FormModel(), { + id: 'date-id', + type: FormFieldTypes.MULTILINE_TEXT, + value: date, + readOnly: true + }); + fixture.detectChanges(); + + expect(String(widget.displayValue)).toBe(String(date)); + expect(String(widget.displayValue)).not.toContain('"'); + }); + }); + + describe('when flag is off', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [MultilineTextWidgetComponentComponent] + }); + fixture = TestBed.createComponent(MultilineTextWidgetComponentComponent); + widget = fixture.componentInstance; + }); + + it('should not format complex field values (default behaviour preserved)', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'people-field', + type: FormFieldTypes.PEOPLE, + value: [{ firstName: 'Alice', lastName: 'Brown' }], + readOnly: true + }); + fixture.detectChanges(); + + expect(widget.displayValue).not.toBe('Alice Brown'); + }); + }); + }); }); diff --git a/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.ts b/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.ts index 541706b69a..015641b81e 100644 --- a/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.ts +++ b/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.ts @@ -18,16 +18,13 @@ /* eslint-disable @angular-eslint/component-selector */ import { NgIf } from '@angular/common'; -import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Component, ViewEncapsulation } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; -import { isObservable } from 'rxjs'; -import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token'; import { ErrorWidgetComponent } from '../error/error.component'; -import { WidgetComponent } from '../widget.component'; +import { FormattableTextWidgetComponent } from '../core/formattable-text.widget'; @Component({ selector: 'multiline-text-widget', @@ -47,23 +44,4 @@ import { WidgetComponent } from '../widget.component'; imports: [MatFormFieldModule, NgIf, TranslatePipe, MatInputModule, FormsModule, ErrorWidgetComponent], encapsulation: ViewEncapsulation.None }) -export class MultilineTextWidgetComponentComponent extends WidgetComponent implements OnInit { - private readonly destroyRef = inject(DestroyRef); - private readonly enableCustomMessage = inject(ADF_CUSTOM_MESSAGE, { optional: true }); - - ngOnInit(): void { - if (this.enableCustomMessage != null) { - if (isObservable(this.enableCustomMessage)) { - this.enableCustomMessage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => { - if (this.field) { - this.field.enableCustomValidationMessage = enabled ?? false; - } - }); - } else { - this.field.enableCustomValidationMessage = this.enableCustomMessage; - } - } else { - this.field.enableCustomValidationMessage = false; - } - } -} +export class MultilineTextWidgetComponentComponent extends FormattableTextWidgetComponent {} diff --git a/lib/core/src/lib/form/components/widgets/text/text.widget.html b/lib/core/src/lib/form/components/widgets/text/text.widget.html index 13e1c165ef..5312d8451d 100644 --- a/lib/core/src/lib/form/components/widgets/text/text.widget.html +++ b/lib/core/src/lib/form/components/widgets/text/text.widget.html @@ -18,9 +18,8 @@ type="text" [id]="field.id" [required]="field.required" - [value]="field.value" - [(ngModel)]="field.value" - (ngModelChange)="onFieldChanged(field)" + [ngModel]="displayValue" + (ngModelChange)="onValueChange($event)" [disabled]="field.readOnly || readOnly" [textMask]="{mask: mask, isReversed: isMaskReversed}" [placeholder]="placeholder" diff --git a/lib/core/src/lib/form/components/widgets/text/text.widget.spec.ts b/lib/core/src/lib/form/components/widgets/text/text.widget.spec.ts index 341b6d8d15..b058ee3337 100644 --- a/lib/core/src/lib/form/components/widgets/text/text.widget.spec.ts +++ b/lib/core/src/lib/form/components/widgets/text/text.widget.spec.ts @@ -25,6 +25,7 @@ import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { UnitTestingUtils } from '../../../../testing/unit-testing-utils'; import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token'; +import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token'; import { of, Subject } from 'rxjs'; describe('TextWidgetComponent', () => { @@ -698,4 +699,143 @@ describe('TextWidgetComponent - ADF_CUSTOM_MESSAGE', () => { expect(widget.field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.INVALID_VALUE'); }); }); + + describe('typed value formatting', () => { + describe('when flag is on', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [TextWidgetComponent], + providers: [{ provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: true }] + }); + fixture = TestBed.createComponent(TextWidgetComponent); + widget = fixture.componentInstance; + testingUtils = new UnitTestingUtils(fixture.debugElement); + }); + + it('should return formatted name for a People value in read-only mode', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'people-field', + type: FormFieldTypes.PEOPLE, + value: [{ firstName: 'Alyssa', lastName: 'Adcock' }], + readOnly: true + }); + fixture.detectChanges(); + + expect(widget.displayValue).toBe('Alyssa Adcock'); + }); + + it('should return comma-separated labels for a multi-select dropdown in read-only mode', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'dropdown-field', + type: FormFieldTypes.DROPDOWN, + value: [ + { id: 'a', name: 'Apple' }, + { id: 'b', name: 'Banana' } + ], + readOnly: true + }); + fixture.detectChanges(); + + expect(widget.displayValue).toBe('Apple, Banana'); + }); + + it('should not return [object Object] for a complex field value', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'people-field', + type: FormFieldTypes.PEOPLE, + value: { firstName: 'Alice', lastName: 'Brown' }, + readOnly: true + }); + fixture.detectChanges(); + + expect(String(widget.displayValue)).not.toContain('[object Object]'); + }); + + it('should pass through plain string values unchanged', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'text-id', + type: FormFieldTypes.TEXT, + value: 'hello', + readOnly: true + }); + fixture.detectChanges(); + + expect(widget.displayValue).toBe('hello'); + }); + + it('should not JSON-stringify a Date value for an unregistered type', () => { + const date = new Date('2026-06-02T14:30:00.000Z'); + widget.field = new FormFieldModel(new FormModel(), { + id: 'date-id', + type: FormFieldTypes.TEXT, + value: date, + readOnly: true + }); + fixture.detectChanges(); + + expect(String(widget.displayValue)).toBe(String(date)); + expect(String(widget.displayValue)).not.toContain('"'); + }); + + it('should not format a value for an unregistered type', () => { + const value = { foo: 'bar' }; + widget.field = new FormFieldModel(new FormModel(), { + id: 'object-id', + type: FormFieldTypes.TEXT, + value, + readOnly: true + }); + fixture.detectChanges(); + + expect(widget.displayValue).toBe(value as unknown as string); + }); + }); + + describe('when flag is off', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [TextWidgetComponent] + }); + fixture = TestBed.createComponent(TextWidgetComponent); + widget = fixture.componentInstance; + }); + + it('should not format complex field values (default behaviour preserved)', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'people-field', + type: FormFieldTypes.PEOPLE, + value: [{ firstName: 'Alyssa', lastName: 'Adcock' }], + readOnly: true + }); + fixture.detectChanges(); + + expect(widget.displayValue).not.toBe('Alyssa Adcock'); + }); + }); + + describe('when flag emits via observable', () => { + it('should format value after observable emits true', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [TextWidgetComponent], + providers: [{ provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: of(true) }] + }); + fixture = TestBed.createComponent(TextWidgetComponent); + widget = fixture.componentInstance; + testingUtils = new UnitTestingUtils(fixture.debugElement); + + widget.field = new FormFieldModel(new FormModel(), { + id: 'people-field', + type: FormFieldTypes.PEOPLE, + value: [{ firstName: 'Alyssa', lastName: 'Adcock' }], + readOnly: true + }); + fixture.detectChanges(); + + expect(widget.displayValue).toBe('Alyssa Adcock'); + }); + }); + }); }); diff --git a/lib/core/src/lib/form/components/widgets/text/text.widget.ts b/lib/core/src/lib/form/components/widgets/text/text.widget.ts index ef6ddbb515..2ca8ae9aaf 100644 --- a/lib/core/src/lib/form/components/widgets/text/text.widget.ts +++ b/lib/core/src/lib/form/components/widgets/text/text.widget.ts @@ -18,16 +18,14 @@ /* eslint-disable @angular-eslint/component-selector */ import { NgIf, NgTemplateOutlet } from '@angular/common'; -import { Component, DestroyRef, Directive, inject, InjectionToken, Input, OnInit, TemplateRef, ViewEncapsulation } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Component, Directive, inject, InjectionToken, Input, TemplateRef, ViewEncapsulation } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; -import { isObservable } from 'rxjs'; -import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token'; import { ErrorWidgetComponent } from '../error/error.component'; import { WidgetComponent } from '../widget.component'; +import { FormattableTextWidgetComponent } from '../core/formattable-text.widget'; import { InputMaskDirective } from './text-mask.component'; type FieldStatusTemplate = TemplateRef<{ $implicit: WidgetComponent }>; @@ -66,29 +64,14 @@ export class FieldStatusTemplateDirective { imports: [NgIf, TranslatePipe, MatFormFieldModule, MatInputModule, FormsModule, ErrorWidgetComponent, InputMaskDirective, NgTemplateOutlet], encapsulation: ViewEncapsulation.None }) -export class TextWidgetComponent extends WidgetComponent implements OnInit { +export class TextWidgetComponent extends FormattableTextWidgetComponent { mask: string; placeholder: string; isMaskReversed: boolean; fieldStatusTemplate = inject(FIELD_STATUS_TEMPLATE, { optional: true }); - private readonly destroyRef = inject(DestroyRef); - private readonly enableCustomMessage = inject(ADF_CUSTOM_MESSAGE, { optional: true }); - - ngOnInit() { - if (this.enableCustomMessage != null) { - if (isObservable(this.enableCustomMessage)) { - this.enableCustomMessage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => { - if (this.field) { - this.field.enableCustomValidationMessage = enabled ?? false; - } - }); - } else { - this.field.enableCustomValidationMessage = this.enableCustomMessage; - } - } else { - this.field.enableCustomValidationMessage = false; - } + override ngOnInit() { + super.ngOnInit(); if (this.field.params) { this.mask = this.field.params['inputMask']; diff --git a/lib/core/src/lib/form/public-api.ts b/lib/core/src/lib/form/public-api.ts index 0f9bfe91cf..0b3dc24535 100644 --- a/lib/core/src/lib/form/public-api.ts +++ b/lib/core/src/lib/form/public-api.ts @@ -30,6 +30,8 @@ export * from './services/form.service'; export * from './services/form-expression.service'; export * from './services/form-validation-service.interface'; export * from './services/widget-visibility.service'; +export * from './services/form-field-value-formatter.service'; +export * from './services/form-field-value-formatter.token'; export * from './pipes'; diff --git a/lib/core/src/lib/form/services/form-expression.service.spec.ts b/lib/core/src/lib/form/services/form-expression.service.spec.ts index 0758ee8d6c..18602563bd 100644 --- a/lib/core/src/lib/form/services/form-expression.service.spec.ts +++ b/lib/core/src/lib/form/services/form-expression.service.spec.ts @@ -18,6 +18,10 @@ import { TestBed } from '@angular/core/testing'; import { FormExpressionService } from './form-expression.service'; import { FormModel } from '../components/widgets/core'; +import { FormFieldValueFormatterService } from './form-field-value-formatter.service'; +import { FormFieldTypes } from '../components/widgets/core/form-field-types'; +import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from './form-field-value-formatter.token'; +import { of } from 'rxjs'; describe('FormExpressionService', () => { let service: FormExpressionService; @@ -269,6 +273,83 @@ describe('FormExpressionService', () => { expect(result).toBe('true'); }); + describe('when ADF_TYPED_VALUE_FORMATTING_ENABLED token is provided as true', () => { + let formattingService: FormExpressionService; + let formatter: FormFieldValueFormatterService; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [FormExpressionService, { provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: of(true) }] + }); + formattingService = TestBed.inject(FormExpressionService); + formatter = TestBed.inject(FormFieldValueFormatterService); + }); + + it('should format object field values via the registered formatter', () => { + const mockField = { + id: 'peopleField', + type: FormFieldTypes.PEOPLE, + value: { firstName: 'Alyssa', lastName: 'Adcock' } + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + spyOn(formatter, 'hasFormatter').and.returnValue(true); + spyOn(formatter, 'formatValue').and.returnValue('Alyssa Adcock'); + + const result = formattingService.resolveExpressions(formModel, '${field.peopleField}'); + + expect(result).toBe('Alyssa Adcock'); + }); + + it('should fall back to JSON.stringify when no formatter is registered', () => { + const mockField = { + id: 'objectField', + type: 'unregistered-type', + value: { a: 1 } + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + spyOn(formatter, 'hasFormatter').and.returnValue(false); + + const result = formattingService.resolveExpressions(formModel, '${field.objectField}'); + + expect(result).toBe('{"a":1}'); + }); + + it('should render a Date result as a native string instead of quoted ISO', () => { + const date = new Date('2026-06-02T14:30:00.000Z'); + const mockField = { + id: 'datetimeField', + type: FormFieldTypes.DATETIME, + value: date + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + spyOn(formatter, 'hasFormatter').and.returnValue(false); + + const result = formattingService.resolveExpressions(formModel, '${field.datetimeField}'); + + expect(result).toBe(String(date)); + expect(result).not.toContain('"'); + }); + }); + + describe('when ADF_TYPED_VALUE_FORMATTING_ENABLED token is not provided', () => { + it('should default to JSON.stringify even if a formatter exists', () => { + const formatter = TestBed.inject(FormFieldValueFormatterService); + const mockField = { + id: 'peopleField', + type: FormFieldTypes.PEOPLE, + value: { firstName: 'Alyssa', lastName: 'Adcock' } + }; + spyOn(formModel, 'getFieldById').and.returnValue(mockField as any); + const hasFormatterSpy = spyOn(formatter, 'hasFormatter'); + + const result = service.resolveExpressions(formModel, '${field.peopleField}'); + + expect(hasFormatterSpy).not.toHaveBeenCalled(); + expect(result).toBe('{"firstName":"Alyssa","lastName":"Adcock"}'); + }); + }); + describe('when escapeHtml is true', () => { let getFieldSpy: jasmine.Spy; diff --git a/lib/core/src/lib/form/services/form-expression.service.ts b/lib/core/src/lib/form/services/form-expression.service.ts index 01faf4669c..a9439645b0 100644 --- a/lib/core/src/lib/form/services/form-expression.service.ts +++ b/lib/core/src/lib/form/services/form-expression.service.ts @@ -15,8 +15,12 @@ * limitations under the License. */ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { isObservable } from 'rxjs'; import { FormModel } from '../components/widgets/core'; +import { FormFieldValueFormatterService } from './form-field-value-formatter.service'; +import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from './form-field-value-formatter.token'; @Injectable({ providedIn: 'root' @@ -27,6 +31,20 @@ export class FormExpressionService { private readonly VARIABLE_PREFIX = 'variable.'; private readonly VARIABLES_REGEX = /(?:field|variable)\.[a-zA-Z_$][a-zA-Z0-9_$]*/g; + private readonly formFieldValueFormatter = inject(FormFieldValueFormatterService); + private readonly formattingEnabledToken = inject(ADF_TYPED_VALUE_FORMATTING_ENABLED, { optional: true }); + private formattingEnabled = false; + + constructor() { + if (isObservable(this.formattingEnabledToken)) { + this.formattingEnabledToken.pipe(takeUntilDestroyed()).subscribe((enabled: boolean) => { + this.formattingEnabled = enabled ?? false; + }); + } else { + this.formattingEnabled = this.formattingEnabledToken ?? false; + } + } + resolveExpressions(form: FormModel, formField: string, escapeHtml?: boolean): string { let result = formField || ''; @@ -37,24 +55,10 @@ export class FormExpressionService { } for (const match of matches) { - let expressionResult = this.resolveExpression(form, match); - if (expressionResult === null || expressionResult === undefined) { - expressionResult = ''; - } else if (typeof expressionResult !== 'string') { - expressionResult = JSON.stringify(expressionResult); - } + const rawResult = this.resolveExpression(form, match); + let expressionResult = this.normalizeExpressionResult(form, match, rawResult); if (escapeHtml) { - expressionResult = expressionResult - .split('&') - .join('&') - .split('<') - .join('<') - .split('>') - .join('>') - .split('"') - .join('"') - .split("'") - .join('''); + expressionResult = this.escapeHtmlEntities(expressionResult); } result = result.replace(match, expressionResult); } @@ -62,6 +66,37 @@ export class FormExpressionService { return result; } + private normalizeExpressionResult(form: FormModel, match: string, expressionResult: any): string { + if (expressionResult == null) { + return ''; + } + + if (typeof expressionResult === 'string') { + return expressionResult; + } + + return this.formatTypedExpressionResult(form, match, expressionResult); + } + + private formatTypedExpressionResult(form: FormModel, match: string, expressionResult: any): string { + if (!this.formattingEnabled) { + return JSON.stringify(expressionResult); + } + + const fieldId = this.extractFieldIdFromMatch(match); + const sourceField = fieldId ? form.getFieldById(fieldId) : undefined; + + if (sourceField && this.formFieldValueFormatter.hasFormatter(sourceField.type)) { + return this.formFieldValueFormatter.formatValue(expressionResult, sourceField); + } + + return this.formFieldValueFormatter.stringifyValue(expressionResult); + } + + private escapeHtmlEntities(value: string): string { + return value.split('&').join('&').split('<').join('<').split('>').join('>').split('"').join('"').split("'").join('''); + } + private resolveExpression(form: FormModel, expression: any): any { if (expression === undefined || expression === null) { return expression; @@ -96,6 +131,14 @@ export class FormExpressionService { } } + private extractFieldIdFromMatch(match: string): string | null { + const inner = match.slice(2, -1).trim(); + if (inner.startsWith(this.FIELD_PREFIX)) { + return inner.slice(this.FIELD_PREFIX.length); + } + return null; + } + getFieldDependencies(expression: string): string[] { const dependencies: string[] = []; const matches = expression.match(this.GLOBAL_EXPRESSION_REGEX); diff --git a/lib/core/src/lib/form/services/form-field-value-formatter.service.spec.ts b/lib/core/src/lib/form/services/form-field-value-formatter.service.spec.ts new file mode 100644 index 0000000000..34530b51e9 --- /dev/null +++ b/lib/core/src/lib/form/services/form-field-value-formatter.service.spec.ts @@ -0,0 +1,300 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { FormFieldValueFormatterService } from './form-field-value-formatter.service'; +import { FormFieldModel } from '../components/widgets/core/form-field.model'; +import { FormFieldTypes } from '../components/widgets/core/form-field-types'; +import { FormModel } from '../components/widgets/core'; + +/** + * Test helper that creates a FormFieldModel attached to a fresh FormModel for unit tests. + * + * @param type form field type identifier (e.g., FormFieldTypes.PEOPLE) + * @param value initial field value + * @param options optional dropdown/radio option list + * @returns a fully-wired FormFieldModel + */ +function makeField(type: string, value: any, options?: { id: string; name: string }[]): FormFieldModel { + const form = new FormModel(); + const field = new FormFieldModel(form, { id: 'f1', type, value }); + if (options) { + field.options = options; + } + return field; +} + +describe('FormFieldValueFormatterService', () => { + let service: FormFieldValueFormatterService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(FormFieldValueFormatterService); + }); + + describe('hasFormatter', () => { + it('should return true for registered types', () => { + expect(service.hasFormatter(FormFieldTypes.PEOPLE)).toBeTrue(); + expect(service.hasFormatter(FormFieldTypes.FUNCTIONAL_GROUP)).toBeTrue(); + expect(service.hasFormatter(FormFieldTypes.DROPDOWN)).toBeTrue(); + expect(service.hasFormatter(FormFieldTypes.RADIO_BUTTONS)).toBeTrue(); + }); + + it('should return false for unregistered types', () => { + expect(service.hasFormatter('unknown-type')).toBeFalse(); + expect(service.hasFormatter('text')).toBeFalse(); + }); + }); + + describe('formatValue - base cases', () => { + it('should return empty string for null', () => { + const field = makeField('text', null); + expect(service.formatValue(null, field)).toBe(''); + }); + + it('should return empty string for undefined', () => { + const field = makeField('text', undefined); + expect(service.formatValue(undefined, field)).toBe(''); + }); + + it('should pass through string values unchanged', () => { + const field = makeField('text', 'hello'); + expect(service.formatValue('hello', field)).toBe('hello'); + }); + + it('should JSON.stringify unknown object types', () => { + const field = makeField('text', { foo: 'bar' }); + expect(service.formatValue({ foo: 'bar' }, field)).toBe('{"foo":"bar"}'); + }); + + it('should render Date values using native string instead of JSON', () => { + const date = new Date('2026-06-02T14:30:00.000Z'); + const field = makeField('datetime', date); + expect(service.formatValue(date, field)).toBe(String(date)); + expect(service.formatValue(date, field)).not.toContain('"'); + }); + + it('should render numeric values without JSON quoting', () => { + const field = makeField('integer', 42); + expect(service.formatValue(42, field)).toBe('42'); + }); + + it('should render boolean values without JSON quoting', () => { + const field = makeField('boolean', true); + expect(service.formatValue(true, field)).toBe('true'); + }); + }); + + describe('stringifyValue', () => { + it('should return empty string for null and undefined', () => { + expect(service.stringifyValue(null)).toBe(''); + expect(service.stringifyValue(undefined)).toBe(''); + }); + + it('should pass through strings unchanged', () => { + expect(service.stringifyValue('hello')).toBe('hello'); + }); + + it('should render Date using native string, not ISO JSON', () => { + const date = new Date('2026-06-02T14:30:00.000Z'); + expect(service.stringifyValue(date)).toBe(String(date)); + expect(service.stringifyValue(date)).not.toBe(JSON.stringify(date)); + }); + + it('should render primitives without JSON quoting', () => { + expect(service.stringifyValue(42)).toBe('42'); + expect(service.stringifyValue(true)).toBe('true'); + }); + + it('should JSON.stringify plain objects and arrays', () => { + expect(service.stringifyValue({ foo: 'bar' })).toBe('{"foo":"bar"}'); + expect(service.stringifyValue([{ id: 'a' }])).toBe('[{"id":"a"}]'); + expect(service.stringifyValue({ foo: 'bar' })).not.toContain('[object Object]'); + }); + }); + + describe('People formatter', () => { + it('should return empty string for null', () => { + const field = makeField(FormFieldTypes.PEOPLE, null); + expect(service.format(field)).toBe(''); + }); + + it('should return empty string for empty array', () => { + const field = makeField(FormFieldTypes.PEOPLE, []); + expect(service.format(field)).toBe(''); + }); + + it('should format a single user object (single-select mode)', () => { + const field = makeField(FormFieldTypes.PEOPLE, { firstName: 'Alyssa', lastName: 'Adcock' }); + expect(service.format(field)).toBe('Alyssa Adcock'); + }); + + it('should format an array with one user', () => { + const field = makeField(FormFieldTypes.PEOPLE, [{ firstName: 'Alyssa', lastName: 'Adcock' }]); + expect(service.format(field)).toBe('Alyssa Adcock'); + }); + + it('should format multiple users separated by comma', () => { + const field = makeField(FormFieldTypes.PEOPLE, [ + { firstName: 'Alice', lastName: 'Brown' }, + { firstName: 'Bob', lastName: 'Smith' } + ]); + expect(service.format(field)).toBe('Alice Brown, Bob Smith'); + }); + + it('should fall back to username when no first/last name', () => { + const field = makeField(FormFieldTypes.PEOPLE, [{ username: 'jdoe' }]); + expect(service.format(field)).toBe('jdoe'); + }); + + it('should fall back to email when no first/last/username', () => { + const field = makeField(FormFieldTypes.PEOPLE, [{ email: 'a@b.com' }]); + expect(service.format(field)).toBe('a@b.com'); + }); + }); + + describe('Group formatter', () => { + it('should return empty string for null', () => { + const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null); + expect(service.format(field)).toBe(''); + }); + + it('should return empty string for empty array', () => { + const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, []); + expect(service.format(field)).toBe(''); + }); + + it('should format a single group object', () => { + const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, { id: 'g1', name: 'Engineering' }); + expect(service.format(field)).toBe('Engineering'); + }); + + it('should format an array of groups', () => { + const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, [{ name: 'Eng' }, { name: 'QA' }]); + expect(service.format(field)).toBe('Eng, QA'); + }); + + it('should skip groups with no name', () => { + const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, [{ id: 'g1' }, { name: 'QA' }]); + expect(service.format(field)).toBe('QA'); + }); + }); + + describe('Dropdown formatter', () => { + const options = [ + { id: 'a', name: 'Apple' }, + { id: 'b', name: 'Banana' } + ]; + + it('should return empty string for null', () => { + const field = makeField(FormFieldTypes.DROPDOWN, null, options); + expect(service.format(field)).toBe(''); + }); + + it('should return empty string for empty string', () => { + const field = makeField(FormFieldTypes.DROPDOWN, '', options); + expect(service.formatValue('', field)).toBe(''); + }); + + it('Shape A: should look up label for string id', () => { + const field = makeField(FormFieldTypes.DROPDOWN, 'a', options); + expect(service.format(field)).toBe('Apple'); + }); + + it('Shape A fallback: should return the id when not found in options', () => { + const field = makeField(FormFieldTypes.DROPDOWN, 'unknown', options); + expect(service.format(field)).toBe('unknown'); + }); + + it('Shape B: should use .name from object value', () => { + const field = makeField(FormFieldTypes.DROPDOWN, { id: 'a', name: 'Apple' }, options); + expect(service.format(field)).toBe('Apple'); + }); + + it('Shape B no name: should JSON.stringify', () => { + const field = makeField(FormFieldTypes.DROPDOWN, { id: 'a' }, options); + expect(service.format(field)).toBe('{"id":"a"}'); + }); + + it('Shape C: should format array of objects', () => { + const field = makeField( + FormFieldTypes.DROPDOWN, + [ + { id: 'a', name: 'Apple' }, + { id: 'b', name: 'Banana' } + ], + options + ); + expect(service.format(field)).toBe('Apple, Banana'); + }); + + it('Shape C string array: should look up labels for each id', () => { + const field = makeField(FormFieldTypes.DROPDOWN, ['a', 'b'], options); + expect(service.format(field)).toBe('Apple, Banana'); + }); + + it('Shape C empty array: should return empty string', () => { + const field = makeField(FormFieldTypes.DROPDOWN, [], options); + expect(service.format(field)).toBe(''); + }); + }); + + describe('Radio formatter', () => { + const options = [ + { id: 'yes', name: 'Yes' }, + { id: 'no', name: 'No' } + ]; + + it('should return empty string for null', () => { + const field = makeField(FormFieldTypes.RADIO_BUTTONS, null, options); + expect(service.format(field)).toBe(''); + }); + + it('should return empty string for empty string', () => { + const field = makeField(FormFieldTypes.RADIO_BUTTONS, '', options); + expect(service.formatValue('', field)).toBe(''); + }); + + it('Shape A: should look up label for string id', () => { + const field = makeField(FormFieldTypes.RADIO_BUTTONS, 'yes', options); + expect(service.format(field)).toBe('Yes'); + }); + + it('Shape A fallback: should return the id when not found', () => { + const field = makeField(FormFieldTypes.RADIO_BUTTONS, 'unknown', options); + expect(service.format(field)).toBe('unknown'); + }); + + it('Shape B: should use .name from FormFieldOption object (post-click value)', () => { + const field = makeField(FormFieldTypes.RADIO_BUTTONS, { id: 'yes', name: 'Yes' }, options); + expect(service.format(field)).toBe('Yes'); + }); + + it('Shape B no name: should JSON.stringify', () => { + const field = makeField(FormFieldTypes.RADIO_BUTTONS, { id: 'yes' }, options); + expect(service.format(field)).toBe('{"id":"yes"}'); + }); + }); + + describe('register', () => { + it('should allow overriding a registered formatter', () => { + service.register(FormFieldTypes.PEOPLE, () => 'custom-override'); + const field = makeField(FormFieldTypes.PEOPLE, [{ firstName: 'Alice' }]); + expect(service.format(field)).toBe('custom-override'); + }); + }); +}); diff --git a/lib/core/src/lib/form/services/form-field-value-formatter.service.ts b/lib/core/src/lib/form/services/form-field-value-formatter.service.ts new file mode 100644 index 0000000000..ee59f9ed29 --- /dev/null +++ b/lib/core/src/lib/form/services/form-field-value-formatter.service.ts @@ -0,0 +1,148 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable } from '@angular/core'; +import { FormFieldModel } from '../components/widgets/core/form-field.model'; +import { FormFieldTypes } from '../components/widgets/core/form-field-types'; +import { FormFieldOption } from '../components/widgets/core/form-field-option'; +import { GroupModel } from '../components/widgets/core/group.model'; +import { FullNamePipe } from '../../pipes/full-name.pipe'; +import { UserLike } from '../../pipes/user-like.interface'; + +export type FormFieldValueFormatter = (value: any, field: FormFieldModel) => string; + +@Injectable({ providedIn: 'root' }) +export class FormFieldValueFormatterService { + private readonly formatters = new Map(); + private readonly fullNamePipe = new FullNamePipe(); + + constructor() { + this.register(FormFieldTypes.PEOPLE, (value) => this.formatPeople(value)); + this.register(FormFieldTypes.FUNCTIONAL_GROUP, (value) => this.formatGroup(value)); + this.register(FormFieldTypes.DROPDOWN, (value, field) => this.formatDropdown(value, field)); + this.register(FormFieldTypes.RADIO_BUTTONS, (value, field) => this.formatRadio(value, field)); + } + + register(fieldType: string, formatter: FormFieldValueFormatter): void { + this.formatters.set(fieldType, formatter); + } + + hasFormatter(fieldType: string): boolean { + return this.formatters.has(fieldType); + } + + format(field: FormFieldModel): string { + return this.formatValue(field.value, field); + } + + formatValue(value: any, field: FormFieldModel): string { + if (value === null || value === undefined) { + return ''; + } + const formatter = this.formatters.get(field.type); + if (formatter) { + return formatter(value, field); + } + return this.stringifyValue(value); + } + + stringifyValue(value: any): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (value instanceof Date) { + return String(value); + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); + } + + private formatPeople(value: UserLike | UserLike[]): string { + if (!value) { + return ''; + } + const users = Array.isArray(value) ? value : [value]; + if (users.length === 0) { + return ''; + } + return users + .map((u) => this.fullNamePipe.transform(u)) + .filter((s) => !!s) + .join(', '); + } + + private formatGroup(value: GroupModel | GroupModel[]): string { + if (!value) { + return ''; + } + const groups = Array.isArray(value) ? value : [value]; + if (groups.length === 0) { + return ''; + } + return groups + .map((g) => g?.name ?? '') + .filter((s) => !!s) + .join(', '); + } + + private formatDropdown(value: string | FormFieldOption | Array, field: FormFieldModel): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + if (value === '') { + return ''; + } + const option = field.options?.find((o) => o.id === value); + return option?.name ?? value; + } + if (Array.isArray(value)) { + if (value.length === 0) { + return ''; + } + return value + .map((v) => { + if (typeof v === 'string') { + return field.options?.find((o) => o.id === v)?.name ?? v; + } + return v?.name ?? ''; + }) + .filter((s) => !!s) + .join(', '); + } + return value?.name ?? JSON.stringify(value); + } + + private formatRadio(value: string | FormFieldOption, field: FormFieldModel): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + if (value === '') { + return ''; + } + const option = field.options?.find((o) => o.id === value); + return option?.name ?? value; + } + return value?.name ?? JSON.stringify(value); + } +} diff --git a/lib/core/src/lib/form/services/form-field-value-formatter.token.ts b/lib/core/src/lib/form/services/form-field-value-formatter.token.ts new file mode 100644 index 0000000000..17b4de2857 --- /dev/null +++ b/lib/core/src/lib/form/services/form-field-value-formatter.token.ts @@ -0,0 +1,21 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InjectionToken } from '@angular/core'; +import { Observable } from 'rxjs'; + +export const ADF_TYPED_VALUE_FORMATTING_ENABLED = new InjectionToken | boolean>('adf-typed-value-formatting-enabled'); diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.spec.ts index 8a838753c1..de3520de4f 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.spec.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { FormFieldModel, FormModel, FormFieldTypes, UnitTestingUtils } from '@alfresco/adf-core'; +import { FormFieldModel, FormModel, FormFieldTypes, UnitTestingUtils, ADF_TYPED_VALUE_FORMATTING_ENABLED } from '@alfresco/adf-core'; import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -207,4 +207,85 @@ describe('DisplayExternalPropertyWidgetComponent', () => { expect(adfLeftLabel).toBeNull(); }); }); + + describe('typed value formatting', () => { + describe('when flag is on', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [DisplayExternalPropertyWidgetComponent], + providers: [{ provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: true }] + }); + fixture = TestBed.createComponent(DisplayExternalPropertyWidgetComponent); + widget = fixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(fixture); + }); + + it('should display formatted full name for a People value', async () => { + widget.field = new FormFieldModel(new FormModel({ taskId: '' }), { + type: FormFieldTypes.PEOPLE, + readOnly: true, + value: [{ firstName: 'Alyssa', lastName: 'Adcock' }] + }); + fixture.detectChanges(); + + const input = await loader.getHarness(MatInputHarness); + expect(await input.getValue()).toBe('Alyssa Adcock'); + }); + + it('should display comma-separated group names for a Group value', async () => { + widget.field = new FormFieldModel(new FormModel({ taskId: '' }), { + type: FormFieldTypes.FUNCTIONAL_GROUP, + readOnly: true, + value: [{ name: 'Eng' }, { name: 'QA' }] + }); + fixture.detectChanges(); + + const input = await loader.getHarness(MatInputHarness); + expect(await input.getValue()).toBe('Eng, QA'); + }); + + it('should not contain [object Object] for a complex value', async () => { + widget.field = new FormFieldModel(new FormModel({ taskId: '' }), { + type: FormFieldTypes.PEOPLE, + readOnly: true, + value: [{ firstName: 'Alice', lastName: 'Brown' }] + }); + fixture.detectChanges(); + + const input = await loader.getHarness(MatInputHarness); + expect(await input.getValue()).not.toContain('[object Object]'); + }); + + it('should not JSON-stringify a Date value for an unregistered type', async () => { + const date = new Date('2026-06-02T14:30:00.000Z'); + widget.field = new FormFieldModel(new FormModel({ taskId: '' }), { + type: FormFieldTypes.DISPLAY_EXTERNAL_PROPERTY, + readOnly: true, + externalProperty: 'prop', + value: date + }); + fixture.detectChanges(); + + const input = await loader.getHarness(MatInputHarness); + expect(await input.getValue()).toBe(String(date)); + expect(await input.getValue()).not.toContain('"'); + }); + }); + + describe('when flag is off', () => { + it('should leave raw string value unchanged (default behaviour preserved)', async () => { + widget.field = new FormFieldModel(new FormModel({ taskId: '' }), { + type: FormFieldTypes.DISPLAY_EXTERNAL_PROPERTY, + readOnly: true, + externalProperty: 'prop', + value: 'banana' + }); + fixture.detectChanges(); + + const input = await loader.getHarness(MatInputHarness); + expect(await input.getValue()).toBe('banana'); + }); + }); + }); }); diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.ts b/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.ts index b159394bb8..c6bdcd0f6f 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.ts @@ -15,14 +15,16 @@ * limitations under the License. */ -import { ChangeDetectionStrategy, Component, inject, OnInit, ViewEncapsulation } from '@angular/core'; -import { WidgetComponent, FormBaseModule } from '@alfresco/adf-core'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { WidgetComponent, FormBaseModule, FormFieldValueFormatterService, ADF_TYPED_VALUE_FORMATTING_ENABLED } from '@alfresco/adf-core'; import { CommonModule } from '@angular/common'; import { TranslatePipe } from '@ngx-translate/core'; import { FormCloudService } from '../../../services/form-cloud.service'; import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { isObservable } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ standalone: true, @@ -50,18 +52,41 @@ export class DisplayExternalPropertyWidgetComponent extends WidgetComponent impl propertyControl: FormControl; private readonly formCloudService = inject(FormCloudService); + private readonly formatter = inject(FormFieldValueFormatterService); + private readonly formattingEnabledToken = inject(ADF_TYPED_VALUE_FORMATTING_ENABLED, { optional: true }); + private readonly destroyRef = inject(DestroyRef); + private formattingEnabled = false; ngOnInit(): void { + if (isObservable(this.formattingEnabledToken)) { + this.formattingEnabledToken.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => { + this.formattingEnabled = enabled ?? false; + if (this.propertyControl) { + this.propertyControl.setValue(this.computeDisplayValue()); + } + }); + } else { + this.formattingEnabled = this.formattingEnabledToken ?? false; + } this.initFormControl(); this.initPreviewState(); this.handleFailedPropertyLoad(); } + private computeDisplayValue(): unknown { + const value = this.field?.value; + const isFormattableValue = value != null && typeof value !== 'string'; + if (this.formattingEnabled && isFormattableValue && this.formatter.hasFormatter(this.field?.type ?? '')) { + return this.formatter.format(this.field); + } + return value; + } + private initFormControl(): void { this.propertyControl = new FormControl( { - value: this.field?.value, - disabled: this.field?.readOnly || this.readOnly + value: this.computeDisplayValue(), + disabled: !!(this.field?.readOnly || this.readOnly) }, this.isRequired() ? [Validators.required] : [] ); diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.html b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.html index dc3c0d227e..c84fca1b7c 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.html +++ b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.html @@ -43,7 +43,7 @@ [id]="'readonlyOption-' + field.id" [value]="field.value" > - {{field.value}} + {{readOnlyDisplayValue}} } diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.spec.ts index aef71b54bd..b3c2984a4c 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.spec.ts @@ -27,7 +27,8 @@ import { FormFieldTypes, UnitTestingUtils, FormFieldComponent, - FormRenderingService + FormRenderingService, + ADF_TYPED_VALUE_FORMATTING_ENABLED } from '@alfresco/adf-core'; import { FormCloudService } from '../../../services/form-cloud.service'; import { @@ -1515,4 +1516,100 @@ describe('DropdownCloudWidgetComponent instantiated by FormFieldComponent wrappe expect(selectedOption).toEqual('option1'); expect(setValueSpy).toHaveBeenCalledTimes(1); }); + + describe('typed value formatting (readOnlyDisplayValue)', () => { + const options = [ + { id: 'a', name: 'Apple' }, + { id: 'b', name: 'Banana' } + ]; + + let fixture: ComponentFixture; + let widget: DropdownCloudWidgetComponent; + + describe('when flag is on', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [DropdownCloudWidgetComponent], + providers: [{ provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: true }] + }); + fixture = TestBed.createComponent(DropdownCloudWidgetComponent); + widget = fixture.componentInstance; + }); + + it('should return formatted label for an object value', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'dropdown-field', + type: FormFieldTypes.DROPDOWN, + value: { id: 'a', name: 'Apple' } + }); + widget.field.options = options; + fixture.detectChanges(); + + expect(widget.readOnlyDisplayValue).toBe('Apple'); + }); + + it('should return comma-separated labels for an array value', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'dropdown-field', + type: FormFieldTypes.DROPDOWN, + value: [ + { id: 'a', name: 'Apple' }, + { id: 'b', name: 'Banana' } + ] + }); + widget.field.options = options; + fixture.detectChanges(); + + expect(widget.readOnlyDisplayValue).toBe('Apple, Banana'); + }); + + it('should not contain [object Object] for an object value', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'dropdown-field', + type: FormFieldTypes.DROPDOWN, + value: { id: 'a', name: 'Apple' } + }); + widget.field.options = options; + fixture.detectChanges(); + + expect(widget.readOnlyDisplayValue).not.toContain('[object Object]'); + }); + + it('should resolve a plain string value to its matching option label', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'dropdown-field', + type: FormFieldTypes.DROPDOWN, + value: 'a' + }); + widget.field.options = options; + fixture.detectChanges(); + + expect(widget.readOnlyDisplayValue).toBe('Apple'); + }); + }); + + describe('when flag is off', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [DropdownCloudWidgetComponent] + }); + fixture = TestBed.createComponent(DropdownCloudWidgetComponent); + widget = fixture.componentInstance; + }); + + it('should return the raw field value (default behaviour preserved)', () => { + widget.field = new FormFieldModel(new FormModel(), { + id: 'dropdown-field', + type: FormFieldTypes.DROPDOWN, + value: { id: 'a', name: 'Apple' } + }); + widget.field.options = options; + fixture.detectChanges(); + + expect(widget.readOnlyDisplayValue).not.toBe('Apple'); + }); + }); + }); }); diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.ts b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.ts index 682a6391b9..02795e73e6 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.ts @@ -23,6 +23,8 @@ import { FormFieldModel, FormFieldOption, FormFieldTypes, + FormFieldValueFormatterService, + ADF_TYPED_VALUE_FORMATTING_ENABLED, FormService, ReactiveFormWidget, RuleEntry, @@ -31,15 +33,15 @@ import { } from '@alfresco/adf-core'; import { AsyncPipe, NgClass } from '@angular/common'; import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { TranslatePipe } from '@ngx-translate/core'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { BehaviorSubject, isObservable, Subject } from 'rxjs'; import { debounceTime, filter, map } from 'rxjs/operators'; import { TaskVariableCloud } from '../../../models/task-variable-cloud.model'; import { FormCloudService } from '../../../services/form-cloud.service'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormUtilsService } from '../../../services/form-utils.service'; import { defaultValueValidator } from './validators'; @@ -77,6 +79,10 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI private readonly appConfig = inject(AppConfigService); private readonly formUtilsService = inject(FormUtilsService); private readonly destroyRef = inject(DestroyRef); + private readonly formatter = inject(FormFieldValueFormatterService); + private readonly formattingEnabledToken = inject(ADF_TYPED_VALUE_FORMATTING_ENABLED, { optional: true }); + private formattingEnabled = false; + readOnlyDisplayValue: string | undefined; typeId = 'DropdownCloudWidgetComponent'; showInputFilter = false; @@ -132,6 +138,16 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI } ngOnInit() { + if (isObservable(this.formattingEnabledToken)) { + this.formattingEnabledToken.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => { + this.formattingEnabled = enabled ?? false; + this.readOnlyDisplayValue = this.computeReadOnlyDisplayValue(); + }); + } else { + this.formattingEnabled = this.formattingEnabledToken ?? false; + this.readOnlyDisplayValue = this.computeReadOnlyDisplayValue(); + } + /* We can have a lot of 'control.setValue' caused by form rules events e.g. every time if we focusin/focusout etc. we are calling a setValue. @@ -161,8 +177,17 @@ export class DropdownCloudWidgetComponent extends WidgetComponent implements OnI }); } + private computeReadOnlyDisplayValue(): string | undefined { + const value = this.field.value; + const isFormattableValue = value != null && (typeof value !== 'string' || this.formatter.hasFormatter(this.field.type)); + const shouldFormatValue = this.formattingEnabled && isFormattableValue; + + return shouldFormatValue ? this.formatter.format(this.field) : value; + } + updateReactiveFormControl(): void { this.setFormControlValue(); + this.readOnlyDisplayValue = this.computeReadOnlyDisplayValue(); this.updateFormControlState(); if (this.field?.form?.showAllValidationErrors) {