AAE-46246 Format typed values in form display widgets v2 (#11952)

* AAE-46246 Format typed values in form display widgets
This commit is contained in:
Alex Molodyh
2026-06-08 15:24:25 -07:00
committed by GitHub
parent a445569139
commit 70038bea26
21 changed files with 1334 additions and 83 deletions
@@ -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<DisplayTextWidgetSet
template: '',
standalone: true
})
export abstract class BaseDisplayTextWidgetComponent extends WidgetComponent implements AfterViewInit {
export abstract class BaseDisplayTextWidgetComponent extends WidgetComponent implements OnInit, AfterViewInit {
private readonly formExpressionService = inject(FormExpressionService);
private readonly cdr = inject(ChangeDetectorRef);
private enableExpressionEvaluation: boolean = false;
protected originalFieldValue?: string;
protected readonly formatter = inject(FormFieldValueFormatterService);
private readonly formattingEnabledToken = inject(ADF_TYPED_VALUE_FORMATTING_ENABLED, { optional: true });
protected formattingEnabled = false;
private readonly settings = inject<Observable<DisplayTextWidgetSettings> | 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();
@@ -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;
}
}
}
@@ -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);
});
});
});
});
@@ -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);
}
}
}
@@ -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 @@
</textarea>
</mat-form-field>
<div *ngIf="field.maxLength > 0" class="adf-multiline-word-counter">
<span class="adf-multiline-word-counter-value">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</span>
<span class="adf-multiline-word-counter-value">{{ displayValue?.length || 0 }}/{{ field.maxLength }}</span>
</div>
<div class="adf-error-messages-container">
<error-widget [error]="field.validationSummary" />
@@ -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');
});
});
});
});
@@ -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 {}
@@ -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"
@@ -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');
});
});
});
});
@@ -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'];
+2
View File
@@ -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';
@@ -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;
@@ -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('&amp;')
.split('<')
.join('&lt;')
.split('>')
.join('&gt;')
.split('"')
.join('&quot;')
.split("'")
.join('&#039;');
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('&amp;').split('<').join('&lt;').split('>').join('&gt;').split('"').join('&quot;').split("'").join('&#039;');
}
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);
@@ -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');
});
});
});
@@ -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<string, FormFieldValueFormatter>();
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<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;
}
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);
}
}
@@ -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<Observable<boolean> | boolean>('adf-typed-value-formatting-enabled');
@@ -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: '<id>' }), {
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: '<id>' }), {
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: '<id>' }), {
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: '<id>' }), {
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: '<id>' }), {
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');
});
});
});
});
@@ -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] : []
);
@@ -43,7 +43,7 @@
[id]="'readonlyOption-' + field.id"
[value]="field.value"
>
{{field.value}}
{{readOnlyDisplayValue}}
</mat-option>
}
</mat-select>
@@ -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<DropdownCloudWidgetComponent>;
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');
});
});
});
});
@@ -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) {