AAE-46247 Add type-aware form field value adapter service (#11968)

* AAE-46247 Add type-aware form field value adapter service
This commit is contained in:
Alex Molodyh
2026-06-23 10:05:53 -07:00
committed by GitHub
parent 2c3020c7fc
commit 20c20f2749
14 changed files with 967 additions and 28 deletions
@@ -263,11 +263,11 @@ describe('DisplayTextWidgetComponent', () => {
widget.field = new FormFieldModel(new FormModel(), {
id: 'f1',
type: FormFieldTypes.PEOPLE,
value: [{ firstName: 'Alyssa', lastName: 'Adcock' }]
value: [{ firstName: 'Test', lastName: 'User' }]
});
fixture.detectChanges();
expect(widget.field.value).toBe('Alyssa Adcock');
expect(widget.field.value).toBe('Test User');
});
it('should format a direct Group value to group name', () => {
@@ -296,21 +296,21 @@ describe('DisplayTextWidgetComponent', () => {
const form = new FormModel({
fields: [
{ id: 'displayText1', type: 'display-text', value: 'Selected: ${field.peopleField}' },
{ id: 'peopleField', type: FormFieldTypes.PEOPLE, value: [{ firstName: 'Alyssa', lastName: 'Adcock' }] }
{ id: 'peopleField', type: FormFieldTypes.PEOPLE, value: [{ firstName: 'Test', lastName: 'User' }] }
]
});
widget.field = form.getFieldById('displayText1');
fixture.detectChanges();
expect(widget.field.value).toBe('Selected: Alyssa Adcock');
expect(widget.field.value).toBe('Selected: Test User');
});
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' }] }
{ id: 'peopleField', type: FormFieldTypes.PEOPLE, value: [{ firstName: 'Test', lastName: 'User' }] }
]
});
formService = TestBed.inject(FormService);
@@ -319,7 +319,7 @@ describe('DisplayTextWidgetComponent', () => {
const peopleField = form.getFieldById('peopleField');
fixture.detectChanges();
expect(widget.field.value).toBe('Selected: Alyssa Adcock');
expect(widget.field.value).toBe('Selected: Test User');
peopleField.value = [{ firstName: 'Jane', lastName: 'Smith' }];
formService.formRulesEvent.next({ type: 'fieldValueChanged', field: peopleField } as any);
@@ -341,7 +341,7 @@ describe('DisplayTextWidgetComponent', () => {
fixture = TestBed.createComponent(DisplayTextWidgetComponent);
widget = fixture.componentInstance;
const rawValue = [{ firstName: 'Alyssa', lastName: 'Adcock' }];
const rawValue = [{ firstName: 'Test', lastName: 'User' }];
widget.field = new FormFieldModel(new FormModel(), {
id: 'f1',
type: FormFieldTypes.PEOPLE,
@@ -717,12 +717,12 @@ describe('TextWidgetComponent - ADF_CUSTOM_MESSAGE', () => {
widget.field = new FormFieldModel(new FormModel(), {
id: 'people-field',
type: FormFieldTypes.PEOPLE,
value: [{ firstName: 'Alyssa', lastName: 'Adcock' }],
value: [{ firstName: 'Test', lastName: 'User' }],
readOnly: true
});
fixture.detectChanges();
expect(widget.displayValue).toBe('Alyssa Adcock');
expect(widget.displayValue).toBe('Test User');
});
it('should return comma-separated labels for a multi-select dropdown in read-only mode', () => {
@@ -806,12 +806,12 @@ describe('TextWidgetComponent - ADF_CUSTOM_MESSAGE', () => {
widget.field = new FormFieldModel(new FormModel(), {
id: 'people-field',
type: FormFieldTypes.PEOPLE,
value: [{ firstName: 'Alyssa', lastName: 'Adcock' }],
value: [{ firstName: 'Test', lastName: 'User' }],
readOnly: true
});
fixture.detectChanges();
expect(widget.displayValue).not.toBe('Alyssa Adcock');
expect(widget.displayValue).not.toBe('Test User');
});
});
@@ -829,12 +829,12 @@ describe('TextWidgetComponent - ADF_CUSTOM_MESSAGE', () => {
widget.field = new FormFieldModel(new FormModel(), {
id: 'people-field',
type: FormFieldTypes.PEOPLE,
value: [{ firstName: 'Alyssa', lastName: 'Adcock' }],
value: [{ firstName: 'Test', lastName: 'User' }],
readOnly: true
});
fixture.detectChanges();
expect(widget.displayValue).toBe('Alyssa Adcock');
expect(widget.displayValue).toBe('Test User');
});
});
});
+1
View File
@@ -32,6 +32,7 @@ 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 './services/form-field-value-adapter.service';
export * from './pipes';
@@ -290,15 +290,15 @@ describe('FormExpressionService', () => {
const mockField = {
id: 'peopleField',
type: FormFieldTypes.PEOPLE,
value: { firstName: 'Alyssa', lastName: 'Adcock' }
value: { firstName: 'Test', lastName: 'User' }
};
spyOn(formModel, 'getFieldById').and.returnValue(mockField as any);
spyOn(formatter, 'hasFormatter').and.returnValue(true);
spyOn(formatter, 'formatValue').and.returnValue('Alyssa Adcock');
spyOn(formatter, 'formatValue').and.returnValue('Test User');
const result = formattingService.resolveExpressions(formModel, '${field.peopleField}');
expect(result).toBe('Alyssa Adcock');
expect(result).toBe('Test User');
});
it('should fall back to JSON.stringify when no formatter is registered', () => {
@@ -338,7 +338,7 @@ describe('FormExpressionService', () => {
const mockField = {
id: 'peopleField',
type: FormFieldTypes.PEOPLE,
value: { firstName: 'Alyssa', lastName: 'Adcock' }
value: { firstName: 'Test', lastName: 'User' }
};
spyOn(formModel, 'getFieldById').and.returnValue(mockField as any);
const hasFormatterSpy = spyOn(formatter, 'hasFormatter');
@@ -346,7 +346,7 @@ describe('FormExpressionService', () => {
const result = service.resolveExpressions(formModel, '${field.peopleField}');
expect(hasFormatterSpy).not.toHaveBeenCalled();
expect(result).toBe('{"firstName":"Alyssa","lastName":"Adcock"}');
expect(result).toBe('{"firstName":"Test","lastName":"User"}');
});
});
@@ -0,0 +1,289 @@
/*!
* @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 { FormFieldValueAdapterService } from './form-field-value-adapter.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 config optional dropdown/radio options and multi-select flag
* @param config.options dropdown/radio option list
* @param config.multiple whether the field accepts multiple values
* @returns a fully-wired FormFieldModel
*/
function makeField(type: string, value: any, config?: { options?: { id: string; name: string }[]; multiple?: boolean }): FormFieldModel {
const form = new FormModel();
const field = new FormFieldModel(form, {
id: 'f1',
type,
value,
selectionType: config?.multiple ? 'multiple' : 'single'
});
if (config?.options) {
field.options = config.options;
}
return field;
}
describe('FormFieldValueAdapterService', () => {
let service: FormFieldValueAdapterService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(FormFieldValueAdapterService);
});
describe('hasAdapter', () => {
it('should return true for registered types', () => {
expect(service.hasAdapter(FormFieldTypes.PEOPLE)).toBeTrue();
expect(service.hasAdapter(FormFieldTypes.FUNCTIONAL_GROUP)).toBeTrue();
expect(service.hasAdapter(FormFieldTypes.DROPDOWN)).toBeTrue();
expect(service.hasAdapter(FormFieldTypes.RADIO_BUTTONS)).toBeTrue();
});
it('should return false for unregistered types', () => {
expect(service.hasAdapter('text')).toBeFalse();
expect(service.hasAdapter('unknown-type')).toBeFalse();
});
});
describe('default adapter', () => {
it('should pass through values for unregistered types', () => {
const field = makeField('text', 'hello');
expect(service.adapt('hello', field)).toBe('hello');
});
it('should pass through value when field is null', () => {
expect(service.adapt('anything', null as any)).toBe('anything');
});
});
describe('People adapter', () => {
it('should pass through null', () => {
const field = makeField(FormFieldTypes.PEOPLE, null);
expect(service.adapt(null, field)).toBeNull();
});
it('should pass through undefined', () => {
const field = makeField(FormFieldTypes.PEOPLE, undefined);
expect(service.adapt(undefined, field)).toBeUndefined();
});
it('single-select: should wrap a user object into a single-element array', () => {
const field = makeField(FormFieldTypes.PEOPLE, null);
const user = { firstName: 'Test', lastName: 'User' };
expect(service.adapt(user, field)).toEqual([user]);
});
it('single-select: should parse a string into a single-element array of firstName/lastName', () => {
const field = makeField(FormFieldTypes.PEOPLE, null);
expect(service.adapt('Test User', field)).toEqual([{ firstName: 'Test', lastName: 'User' }]);
});
it('single-select: should parse a single-word string into a single-element array with firstName only', () => {
const field = makeField(FormFieldTypes.PEOPLE, null);
expect(service.adapt('Test', field)).toEqual([{ firstName: 'Test' }]);
});
it('single-select: should keep a single-element array', () => {
const field = makeField(FormFieldTypes.PEOPLE, null);
expect(service.adapt([{ firstName: 'Test' }], field)).toEqual([{ firstName: 'Test' }]);
});
it('single-select: should return null when given an empty array', () => {
const field = makeField(FormFieldTypes.PEOPLE, null);
expect(service.adapt([], field)).toBeNull();
});
it('single-select: should return null for an empty or whitespace string', () => {
const field = makeField(FormFieldTypes.PEOPLE, null);
expect(service.adapt('', field)).toBeNull();
expect(service.adapt(' ', field)).toBeNull();
});
it('single-select: should return null for stringified empty collections', () => {
const field = makeField(FormFieldTypes.PEOPLE, null);
expect(service.adapt('[]', field)).toBeNull();
expect(service.adapt('{}', field)).toBeNull();
});
it('multi-select: should drop blank entries and keep valid users', () => {
const field = makeField(FormFieldTypes.PEOPLE, null, { multiple: true });
expect(service.adapt(['', 'Test User'], field)).toEqual([{ firstName: 'Test', lastName: 'User' }]);
});
it('multi-select: should wrap a single object into an array', () => {
const field = makeField(FormFieldTypes.PEOPLE, null, { multiple: true });
expect(service.adapt({ firstName: 'Test' }, field)).toEqual([{ firstName: 'Test' }]);
});
it('multi-select: should keep an array of users (idempotent)', () => {
const field = makeField(FormFieldTypes.PEOPLE, null, { multiple: true });
const users = [{ firstName: 'Alice' }, { firstName: 'Bob' }];
expect(service.adapt(users, field)).toEqual(users);
});
it('multi-select: should parse an array of strings', () => {
const field = makeField(FormFieldTypes.PEOPLE, null, { multiple: true });
expect(service.adapt(['Test User'], field)).toEqual([{ firstName: 'Test', lastName: 'User' }]);
});
});
describe('Group adapter', () => {
it('should pass through null', () => {
const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null);
expect(service.adapt(null, field)).toBeNull();
});
it('single-select: should wrap a group object into a single-element array', () => {
const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null);
const group = { id: 'g1', name: 'Engineering' };
expect(service.adapt(group, field)).toEqual([group]);
});
it('single-select: should wrap a string into a single-element array of { name }', () => {
const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null);
expect(service.adapt('Engineering', field)).toEqual([{ name: 'Engineering' }]);
});
it('single-select: should return null for an empty string or stringified empty collection', () => {
const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null);
expect(service.adapt('', field)).toBeNull();
expect(service.adapt('[]', field)).toBeNull();
});
it('multi-select: should wrap a single group into an array', () => {
const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null, { multiple: true });
expect(service.adapt({ name: 'Engineering' }, field)).toEqual([{ name: 'Engineering' }]);
});
it('multi-select: should parse an array of strings', () => {
const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null, { multiple: true });
expect(service.adapt(['Eng', 'QA'], field)).toEqual([{ name: 'Eng' }, { name: 'QA' }]);
});
});
describe('Dropdown adapter', () => {
const options = [
{ id: 'a', name: 'Apple' },
{ id: 'b', name: 'Banana' }
];
it('should pass through null', () => {
const field = makeField(FormFieldTypes.DROPDOWN, null, { options });
expect(service.adapt(null, field)).toBeNull();
});
it('single-select: should pass through a string id', () => {
const field = makeField(FormFieldTypes.DROPDOWN, null, { options });
expect(service.adapt('a', field)).toBe('a');
});
it('single-select: should extract id from an object', () => {
const field = makeField(FormFieldTypes.DROPDOWN, null, { options });
expect(service.adapt({ id: 'a', name: 'Apple' }, field)).toBe('a');
});
it('single-select: should return null for an object without an id', () => {
const field = makeField(FormFieldTypes.DROPDOWN, null, { options });
expect(service.adapt({ name: 'Apple' }, field)).toBeNull();
});
it('single-select: should extract the first id from an array', () => {
const field = makeField(FormFieldTypes.DROPDOWN, null, { options });
expect(service.adapt(['a'], field)).toBe('a');
});
it('single-select: should return null for an empty array', () => {
const field = makeField(FormFieldTypes.DROPDOWN, null, { options });
expect(service.adapt([], field)).toBeNull();
});
it('multi-select: should keep FormFieldOption array (idempotent)', () => {
const field = makeField(FormFieldTypes.DROPDOWN, null, { options, multiple: true });
const value = [
{ id: 'a', name: 'Apple' },
{ id: 'b', name: 'Banana' }
];
expect(service.adapt(value, field)).toEqual(value);
});
it('multi-select: should resolve string ids against field options', () => {
const field = makeField(FormFieldTypes.DROPDOWN, null, { options, multiple: true });
expect(service.adapt(['a', 'b'], field)).toEqual([
{ id: 'a', name: 'Apple' },
{ id: 'b', name: 'Banana' }
]);
});
it('multi-select: should fall back to id/name when option not found', () => {
const field = makeField(FormFieldTypes.DROPDOWN, null, { options, multiple: true });
expect(service.adapt(['z'], field)).toEqual([{ id: 'z', name: 'z' }]);
});
});
describe('Radio adapter', () => {
const options = [
{ id: 'yes', name: 'Yes' },
{ id: 'no', name: 'No' }
];
it('should pass through null', () => {
const field = makeField(FormFieldTypes.RADIO_BUTTONS, null, { options });
expect(service.adapt(null, field)).toBeNull();
});
it('should pass through a string id', () => {
const field = makeField(FormFieldTypes.RADIO_BUTTONS, null, { options });
expect(service.adapt('yes', field)).toBe('yes');
});
it('should extract id from an object', () => {
const field = makeField(FormFieldTypes.RADIO_BUTTONS, null, { options });
expect(service.adapt({ id: 'yes', name: 'Yes' }, field)).toBe('yes');
});
it('should return null for an object without an id', () => {
const field = makeField(FormFieldTypes.RADIO_BUTTONS, null, { options });
expect(service.adapt({ name: 'Yes' }, field)).toBeNull();
});
it('should extract the first id from an array', () => {
const field = makeField(FormFieldTypes.RADIO_BUTTONS, null, { options });
expect(service.adapt(['yes'], field)).toBe('yes');
});
it('should return null for an empty array', () => {
const field = makeField(FormFieldTypes.RADIO_BUTTONS, null, { options });
expect(service.adapt([], field)).toBeNull();
});
});
describe('register', () => {
it('should allow overriding a registered adapter', () => {
service.register(FormFieldTypes.PEOPLE, () => 'custom-override');
const field = makeField(FormFieldTypes.PEOPLE, null);
expect(service.adapt('anything', field)).toBe('custom-override');
});
});
});
@@ -0,0 +1,157 @@
/*!
* @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';
export type FormFieldValueAdapter = (value: unknown, field: FormFieldModel) => unknown;
interface AdaptedUser {
firstName: string;
lastName?: string;
}
interface AdaptedGroup {
name: string;
}
@Injectable({ providedIn: 'root' })
export class FormFieldValueAdapterService {
private readonly adapters = new Map<string, FormFieldValueAdapter>();
constructor() {
this.register(FormFieldTypes.PEOPLE, (value) => this.adaptPeople(value));
this.register(FormFieldTypes.FUNCTIONAL_GROUP, (value) => this.adaptGroup(value));
this.register(FormFieldTypes.DROPDOWN, (value, field) => this.adaptDropdown(value, field));
this.register(FormFieldTypes.RADIO_BUTTONS, (value) => this.adaptRadio(value));
}
register(fieldType: string, adapter: FormFieldValueAdapter): void {
this.adapters.set(fieldType, adapter);
}
hasAdapter(fieldType: string): boolean {
return this.adapters.has(fieldType);
}
adapt(value: unknown, field: FormFieldModel): unknown {
const adapter = this.adapters.get(field?.type);
if (!adapter) {
return value;
}
return adapter(value, field);
}
private adaptPeople(value: unknown): unknown {
if (value === null || value === undefined) {
return value;
}
const entries = Array.isArray(value) ? value : [value];
const adapted = entries.map((entry) => this.toUser(entry)).filter((user) => user !== null);
return adapted.length > 0 ? adapted : null;
}
private adaptGroup(value: unknown): unknown {
if (value === null || value === undefined) {
return value;
}
const entries = Array.isArray(value) ? value : [value];
const adapted = entries.map((entry) => this.toGroup(entry)).filter((group) => group !== null);
return adapted.length > 0 ? adapted : null;
}
private adaptDropdown(value: unknown, field: FormFieldModel): unknown {
if (value === null || value === undefined) {
return value;
}
if (field.hasMultipleValues) {
const entries = Array.isArray(value) ? value : [value];
return entries.map((entry) => this.toOption(entry, field));
}
if (Array.isArray(value)) {
return value.length ? this.toOptionId(value[0]) : null;
}
return this.toOptionId(value);
}
private adaptRadio(value: unknown): unknown {
if (value === null || value === undefined) {
return value;
}
if (Array.isArray(value)) {
return value.length ? this.toOptionId(value[0]) : null;
}
return this.toOptionId(value);
}
private toUser(entry: unknown): unknown {
if (typeof entry !== 'string') {
return entry;
}
const trimmed = entry.trim();
if (this.isBlankToken(trimmed)) {
return null;
}
const separatorIndex = trimmed.indexOf(' ');
if (separatorIndex === -1) {
const user: AdaptedUser = { firstName: trimmed };
return user;
}
const user: AdaptedUser = {
firstName: trimmed.slice(0, separatorIndex),
lastName: trimmed.slice(separatorIndex + 1)
};
return user;
}
private toGroup(entry: unknown): unknown {
if (typeof entry !== 'string') {
return entry;
}
const trimmed = entry.trim();
if (this.isBlankToken(trimmed)) {
return null;
}
const group: AdaptedGroup = { name: trimmed };
return group;
}
private isBlankToken(value: string): boolean {
return value === '' || value === '[]' || value === '{}';
}
private toOptionId(entry: unknown): unknown {
if (entry && typeof entry === 'object') {
return (entry as Record<string, unknown>)['id'] ?? null;
}
return entry;
}
private toOption(entry: unknown, field: FormFieldModel): FormFieldOption {
if (entry && typeof entry === 'object') {
return entry as FormFieldOption;
}
const match = field.options?.find((option) => option.id === entry);
if (match) {
return match;
}
const optionId = entry as string;
return { id: optionId, name: optionId };
}
}
@@ -139,13 +139,13 @@ describe('FormFieldValueFormatterService', () => {
});
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');
const field = makeField(FormFieldTypes.PEOPLE, { firstName: 'Test', lastName: 'User' });
expect(service.format(field)).toBe('Test User');
});
it('should format an array with one user', () => {
const field = makeField(FormFieldTypes.PEOPLE, [{ firstName: 'Alyssa', lastName: 'Adcock' }]);
expect(service.format(field)).toBe('Alyssa Adcock');
const field = makeField(FormFieldTypes.PEOPLE, [{ firstName: 'Test', lastName: 'User' }]);
expect(service.format(field)).toBe('Test User');
});
it('should format multiple users separated by comma', () => {