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
@@ -225,12 +225,12 @@ describe('DisplayExternalPropertyWidgetComponent', () => {
widget.field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
type: FormFieldTypes.PEOPLE,
readOnly: true,
value: [{ firstName: 'Alyssa', lastName: 'Adcock' }]
value: [{ firstName: 'Test', lastName: 'User' }]
});
fixture.detectChanges();
const input = await loader.getHarness(MatInputHarness);
expect(await input.getValue()).toBe('Alyssa Adcock');
expect(await input.getValue()).toBe('Test User');
});
it('should display comma-separated group names for a Group value', async () => {
@@ -15,7 +15,15 @@
* limitations under the License.
*/
import { FormFieldModel, FormFieldTypes, FormModel, IdentityGroupModel, NoopAuthModule } from '@alfresco/adf-core';
import {
ADF_TYPED_VALUE_FORMATTING_ENABLED,
FormFieldModel,
FormFieldTypes,
FormModel,
FormService,
IdentityGroupModel,
NoopAuthModule
} from '@alfresco/adf-core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GroupCloudWidgetComponent } from './group-cloud.widget';
import { HarnessLoader } from '@angular/cdk/testing';
@@ -246,4 +254,85 @@ describe('GroupCloudWidgetComponent', () => {
expect(adfLeftLabel).toBeNull();
});
});
describe('reactive sync on form rules event', () => {
describe('when flag is on', () => {
let formService: FormService;
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [NoopAuthModule, GroupCloudWidgetComponent],
providers: [{ provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: true }]
});
formService = TestBed.inject(FormService);
fixture = TestBed.createComponent(GroupCloudWidgetComponent);
widget = fixture.componentInstance;
});
it('should sync preSelectGroup with a new array reference when the selection changes', () => {
const groups = [{ id: 'g1', name: 'Engineering' }];
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.FUNCTIONAL_GROUP, value: null });
fixture.detectChanges();
widget.field.value = groups;
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
expect(widget.preSelectGroup).toEqual(groups);
expect(widget.preSelectGroup).not.toBe(groups);
});
it('should not reassign preSelectGroup when the selection is unchanged', () => {
widget.field = new FormFieldModel(new FormModel(), {
type: FormFieldTypes.FUNCTIONAL_GROUP,
value: [{ id: 'g1', name: 'Engineering' }]
});
fixture.detectChanges();
const initial = widget.preSelectGroup;
widget.field.value = [{ id: 'g1', name: 'Engineering' }];
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
expect(widget.preSelectGroup).toBe(initial);
});
it('should wrap a single group object into an array', () => {
const group = { id: 'g1', name: 'Engineering' };
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.FUNCTIONAL_GROUP, value: null });
fixture.detectChanges();
widget.field.value = group;
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
expect(widget.preSelectGroup).toEqual([group]);
});
it('should reset preSelectGroup to empty array when value is cleared', () => {
widget.field = new FormFieldModel(new FormModel(), {
type: FormFieldTypes.FUNCTIONAL_GROUP,
value: [{ id: 'g1', name: 'Engineering' }]
});
fixture.detectChanges();
widget.field.value = null;
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
expect(widget.preSelectGroup).toEqual([]);
});
});
describe('when flag is off', () => {
it('should not react to form rules events', () => {
const formService = TestBed.inject(FormService);
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.FUNCTIONAL_GROUP, value: [] });
fixture.detectChanges();
const initial = widget.preSelectGroup;
widget.field.value = [{ id: 'g1', name: 'Engineering' }];
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
expect(widget.preSelectGroup).toBe(initial);
});
});
});
});
@@ -22,6 +22,7 @@ import { filter } from 'rxjs/operators';
import { ComponentSelectionMode } from '../../../../types';
import { IdentityGroupModel } from '../../../../group/models/identity-group.model';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactivePreselectionService } from '../reactive-preselection.service';
import { CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { GroupCloudComponent } from '../../../../group/components/group-cloud.component';
@@ -43,9 +44,12 @@ import { GroupCloudComponent } from '../../../../group/components/group-cloud.co
'(invalid)': 'event($event)',
'(select)': 'event($event)'
},
encapsulation: ViewEncapsulation.None
encapsulation: ViewEncapsulation.None,
providers: [ReactivePreselectionService]
})
export class GroupCloudWidgetComponent extends WidgetComponent implements OnInit {
private readonly reactivePreselection: ReactivePreselectionService<IdentityGroupModel> = inject(ReactivePreselectionService);
typeId = 'GroupCloudWidgetComponent';
roles: string[];
mode: ComponentSelectionMode;
@@ -57,6 +61,15 @@ export class GroupCloudWidgetComponent extends WidgetComponent implements OnInit
private readonly destroyRef = inject(DestroyRef);
ngOnInit() {
this.reactivePreselection.connect({
getFieldId: () => this.field?.id,
getFormId: () => this.field?.form?.id,
getFieldValue: () => this.field?.value,
getPreselection: () => this.preSelectGroup,
setPreselection: (value) => (this.preSelectGroup = value),
identityOf: (group) => group?.id ?? group?.name
});
if (this.field) {
this.roles = this.field.roles;
this.mode = this.field.optionType as ComponentSelectionMode;
@@ -85,6 +98,7 @@ export class GroupCloudWidgetComponent extends WidgetComponent implements OnInit
this.field.form.validateForm();
});
}
onChangedGroup(groups: IdentityGroupModel[]): void {
this.field.value = groups?.length ? [...groups] : null;
this.onFieldChanged(this.field);
@@ -15,7 +15,15 @@
* limitations under the License.
*/
import { FormFieldModel, FormFieldTypes, FormModel, IdentityUserModel, NoopAuthModule } from '@alfresco/adf-core';
import {
ADF_TYPED_VALUE_FORMATTING_ENABLED,
FormFieldModel,
FormFieldTypes,
FormModel,
FormService,
IdentityUserModel,
NoopAuthModule
} from '@alfresco/adf-core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PeopleCloudWidgetComponent } from './people-cloud.widget';
import { IdentityUserService } from '../../../../people/services/identity-user.service';
@@ -276,4 +284,79 @@ describe('PeopleCloudWidgetComponent', () => {
expect(adfLeftLabel).toBeNull();
});
});
describe('reactive sync on form rules event', () => {
describe('when flag is on', () => {
let formService: FormService;
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [NoopAuthModule, PeopleCloudWidgetComponent],
providers: [{ provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: true }]
});
formService = TestBed.inject(FormService);
fixture = TestBed.createComponent(PeopleCloudWidgetComponent);
widget = fixture.componentInstance;
});
it('should sync preSelectUsers with a new array reference when the selection changes', () => {
const users = [{ id: 'a', username: 'alpha' }];
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.PEOPLE, value: null });
fixture.detectChanges();
widget.field.value = users;
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
expect(widget.preSelectUsers).toEqual(users);
expect(widget.preSelectUsers).not.toBe(users);
});
it('should not reassign preSelectUsers when the selection is unchanged', () => {
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.PEOPLE, value: [{ id: 'a', username: 'alpha' }] });
fixture.detectChanges();
const initial = widget.preSelectUsers;
widget.field.value = [{ id: 'a', username: 'alpha' }];
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
expect(widget.preSelectUsers).toBe(initial);
});
it('should wrap a single user object into an array', () => {
const user = { id: 'a', username: 'alpha' };
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.PEOPLE, value: null });
fixture.detectChanges();
widget.field.value = user;
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
expect(widget.preSelectUsers).toEqual([user]);
});
it('should reset preSelectUsers to empty array when value is cleared', () => {
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.PEOPLE, value: [{ id: 'a', username: 'alpha' }] });
fixture.detectChanges();
widget.field.value = null;
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
expect(widget.preSelectUsers).toEqual([]);
});
});
describe('when flag is off', () => {
it('should not react to form rules events', () => {
const formService = TestBed.inject(FormService);
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.PEOPLE, value: [] });
fixture.detectChanges();
const initial = widget.preSelectUsers;
widget.field.value = [{ id: 'a', username: 'alpha' }];
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
expect(widget.preSelectUsers).toBe(initial);
});
});
});
});
@@ -23,6 +23,7 @@ import { ComponentSelectionMode } from '../../../../types';
import { IdentityUserModel } from '../../../../people/models/identity-user.model';
import { IdentityUserService } from '../../../../people/services/identity-user.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactivePreselectionService } from '../reactive-preselection.service';
import { CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { PeopleCloudComponent } from '../../../../people/components/people-cloud.component';
@@ -45,10 +46,12 @@ import { MatFormFieldModule } from '@angular/material/form-field';
'(invalid)': 'event($event)',
'(select)': 'event($event)'
},
encapsulation: ViewEncapsulation.None
encapsulation: ViewEncapsulation.None,
providers: [ReactivePreselectionService]
})
export class PeopleCloudWidgetComponent extends WidgetComponent implements OnInit {
private readonly identityUserService = inject(IdentityUserService);
private readonly reactivePreselection: ReactivePreselectionService<IdentityUserModel> = inject(ReactivePreselectionService);
typeId = 'PeopleCloudWidgetComponent';
appName: string;
@@ -63,6 +66,15 @@ export class PeopleCloudWidgetComponent extends WidgetComponent implements OnIni
private readonly destroyRef = inject(DestroyRef);
ngOnInit() {
this.reactivePreselection.connect({
getFieldId: () => this.field?.id,
getFormId: () => this.field?.form?.id,
getFieldValue: () => this.field?.value,
getPreselection: () => this.preSelectUsers,
setPreselection: (value) => (this.preSelectUsers = value),
identityOf: (user) => user?.id ?? user?.username ?? user?.email
});
if (this.field) {
this.roles = this.field.roles;
this.mode = this.field.optionType as ComponentSelectionMode;
@@ -0,0 +1,185 @@
/*!
* @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 { ADF_TYPED_VALUE_FORMATTING_ENABLED, FormService } from '@alfresco/adf-core';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { ReactivePreselectionHost, ReactivePreselectionService } from './reactive-preselection.service';
interface TestItem {
id?: string;
name?: string;
}
describe('ReactivePreselectionService', () => {
let formRulesEvent: Subject<any>;
let fieldValue: unknown;
let preselection: TestItem[];
let host: ReactivePreselectionHost<TestItem>;
const HOST_FIELD_ID = 'target';
const HOST_FORM_ID = 'form-1';
const emit = () => formRulesEvent.next({ type: 'fieldValueChanged' });
const createService = (token: Observable<boolean> | boolean | null): ReactivePreselectionService<TestItem> => {
TestBed.resetTestingModule();
formRulesEvent = new Subject<any>();
TestBed.configureTestingModule({
providers: [
ReactivePreselectionService,
{ provide: FormService, useValue: { formRulesEvent } },
{ provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: token }
]
});
return TestBed.inject<ReactivePreselectionService<TestItem>>(ReactivePreselectionService);
};
beforeEach(() => {
fieldValue = null;
preselection = [];
host = {
getFieldId: () => HOST_FIELD_ID,
getFormId: () => HOST_FORM_ID,
getFieldValue: () => fieldValue,
getPreselection: () => preselection,
setPreselection: (value) => (preselection = value),
identityOf: (item) => item?.id ?? item?.name
};
});
describe('when the flag is enabled', () => {
beforeEach(() => createService(true).connect(host));
it('should sync the preselection with a new array reference when the value changes', () => {
const next = [{ id: 'a' }];
fieldValue = next;
emit();
expect(preselection).toEqual(next);
expect(preselection).not.toBe(next);
});
it('should wrap a single object into an array', () => {
fieldValue = { id: 'a' };
emit();
expect(preselection).toEqual([{ id: 'a' }]);
});
it('should not reassign the preselection when the value is unchanged', () => {
preselection = [{ id: 'a' }];
const initial = preselection;
fieldValue = [{ id: 'a' }];
emit();
expect(preselection).toBe(initial);
});
it('should clear the preselection when the value is cleared', () => {
preselection = [{ id: 'a' }];
fieldValue = null;
emit();
expect(preselection).toEqual([]);
});
it('should ignore events other than fieldValueChanged', () => {
const initial = preselection;
fieldValue = [{ id: 'a' }];
formRulesEvent.next({ type: 'formLoaded' });
expect(preselection).toBe(initial);
});
it('should ignore changes originating from the host field itself', () => {
const initial = preselection;
fieldValue = [{ id: 'a' }];
formRulesEvent.next({ type: 'fieldValueChanged', field: { id: HOST_FIELD_ID } });
expect(preselection).toBe(initial);
});
it('should ignore changes from a different form', () => {
const initial = preselection;
fieldValue = [{ id: 'a' }];
formRulesEvent.next({ type: 'fieldValueChanged', form: { id: 'other-form' } });
expect(preselection).toBe(initial);
});
it('should react to changes from another field in the same form', () => {
fieldValue = [{ id: 'a' }];
formRulesEvent.next({ type: 'fieldValueChanged', field: { id: 'source' }, form: { id: HOST_FORM_ID } });
expect(preselection).toEqual([{ id: 'a' }]);
});
it('should drop non-object entries instead of preselecting primitives', () => {
fieldValue = 'AMolodyh';
emit();
expect(preselection).toEqual([]);
});
});
describe('when the flag is disabled', () => {
it('should not react to form rules events', () => {
createService(false).connect(host);
const initial = preselection;
fieldValue = [{ id: 'a' }];
emit();
expect(preselection).toBe(initial);
});
it('should treat a null token as disabled', () => {
createService(null).connect(host);
const initial = preselection;
fieldValue = [{ id: 'a' }];
emit();
expect(preselection).toBe(initial);
});
});
describe('when the flag is provided as an observable', () => {
it('should stay inert until the observable emits true', () => {
const token = new BehaviorSubject<boolean>(false);
createService(token).connect(host);
fieldValue = [{ id: 'a' }];
emit();
expect(preselection).toEqual([]);
token.next(true);
emit();
expect(preselection).toEqual([{ id: 'a' }]);
});
});
});
@@ -0,0 +1,109 @@
/*!
* @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 { DestroyRef, inject, Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ADF_TYPED_VALUE_FORMATTING_ENABLED, FormRulesEvent, FormService } from '@alfresco/adf-core';
import { isObservable } from 'rxjs';
import { filter } from 'rxjs/operators';
export interface ReactivePreselectionHost<T> {
getFieldId(): string;
getFormId(): string;
getFieldValue(): unknown;
getPreselection(): T[];
setPreselection(value: T[]): void;
identityOf(item: T): string;
}
@Injectable()
export class ReactivePreselectionService<T = unknown> {
private readonly formService = inject(FormService);
private readonly destroyRef = inject(DestroyRef);
private readonly formattingToken = inject(ADF_TYPED_VALUE_FORMATTING_ENABLED, { optional: true });
private host: ReactivePreselectionHost<T>;
private formattingEnabled = false;
private subscribed = false;
connect(host: ReactivePreselectionHost<T>): void {
this.host = host;
if (isObservable(this.formattingToken)) {
this.formattingToken.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled) => this.setEnabled(enabled ?? false));
} else {
this.setEnabled(this.formattingToken ?? false);
}
}
private setEnabled(enabled: boolean): void {
this.formattingEnabled = enabled;
if (enabled) {
this.subscribeToFormRules();
}
}
private subscribeToFormRules(): void {
if (this.subscribed) {
return;
}
this.subscribed = true;
this.formService.formRulesEvent
.pipe(
filter((event) => event?.type === 'fieldValueChanged' && this.isExternalChange(event)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => this.sync());
}
private isExternalChange(event: FormRulesEvent): boolean {
const eventFieldId = event?.field?.id;
if (eventFieldId && eventFieldId === this.host.getFieldId()) {
return false;
}
const eventFormId = event?.form?.id;
return !eventFormId || eventFormId === this.host.getFormId();
}
private sync(): void {
if (!this.formattingEnabled || !this.host) {
return;
}
const next = this.toPreselection(this.host.getFieldValue());
if (this.isSamePreselection(this.host.getPreselection(), next)) {
return;
}
this.host.setPreselection(next);
}
private toPreselection(value: unknown): T[] {
if (!value) {
return [];
}
const entries = Array.isArray(value) ? value : [value];
return entries.filter((entry): entry is T => !!entry && typeof entry === 'object');
}
private isSamePreselection(current: T[], next: T[]): boolean {
if (current === next) {
return true;
}
if (current?.length !== next.length) {
return false;
}
return current.every((item, index) => this.host.identityOf(item) === this.host.identityOf(next[index]));
}
}