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

This commit is contained in:
Alex Molodyh
2026-06-02 12:56:23 -07:00
committed by GitHub
parent aa2a357fb2
commit f39b9167b8
20 changed files with 1174 additions and 36 deletions
@@ -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,70 @@ 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]');
});
});
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,40 @@ 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;
if (this.formattingEnabled && value !== null && value !== undefined && typeof value !== 'string') {
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 pass through a plain string value unchanged', () => {
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) {