mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-02 17:53:30 +00:00
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:
+24
-2
@@ -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'];
|
||||
|
||||
@@ -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('&')
|
||||
.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);
|
||||
|
||||
@@ -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');
|
||||
+82
-1
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+29
-4
@@ -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] : []
|
||||
);
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@
|
||||
[id]="'readonlyOption-' + field.id"
|
||||
[value]="field.value"
|
||||
>
|
||||
{{field.value}}
|
||||
{{readOnlyDisplayValue}}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
|
||||
+98
-1
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+27
-2
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user