From 20c20f27498667cda7f4430441e0e2361338bf26 Mon Sep 17 00:00:00 2001 From: Alex Molodyh <140214274+amolodyh-hyland@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:05:53 -0700 Subject: [PATCH] AAE-46247 Add type-aware form field value adapter service (#11968) * AAE-46247 Add type-aware form field value adapter service --- .../display-text/display-text.widget.spec.ts | 14 +- .../widgets/text/text.widget.spec.ts | 12 +- lib/core/src/lib/form/public-api.ts | 1 + .../services/form-expression.service.spec.ts | 10 +- .../form-field-value-adapter.service.spec.ts | 289 ++++++++++++++++++ .../form-field-value-adapter.service.ts | 157 ++++++++++ ...form-field-value-formatter.service.spec.ts | 8 +- .../display-external-property.widget.spec.ts | 4 +- .../widgets/group/group-cloud.widget.spec.ts | 91 +++++- .../widgets/group/group-cloud.widget.ts | 16 +- .../people/people-cloud.widget.spec.ts | 85 +++++- .../widgets/people/people-cloud.widget.ts | 14 +- .../reactive-preselection.service.spec.ts | 185 +++++++++++ .../widgets/reactive-preselection.service.ts | 109 +++++++ 14 files changed, 967 insertions(+), 28 deletions(-) create mode 100644 lib/core/src/lib/form/services/form-field-value-adapter.service.spec.ts create mode 100644 lib/core/src/lib/form/services/form-field-value-adapter.service.ts create mode 100644 lib/process-services-cloud/src/lib/form/components/widgets/reactive-preselection.service.spec.ts create mode 100644 lib/process-services-cloud/src/lib/form/components/widgets/reactive-preselection.service.ts diff --git a/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.spec.ts b/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.spec.ts index 94dec1370c..6f4e95bc36 100644 --- a/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.spec.ts +++ b/lib/core/src/lib/form/components/widgets/display-text/display-text.widget.spec.ts @@ -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, diff --git a/lib/core/src/lib/form/components/widgets/text/text.widget.spec.ts b/lib/core/src/lib/form/components/widgets/text/text.widget.spec.ts index b058ee3337..f7f8b57578 100644 --- a/lib/core/src/lib/form/components/widgets/text/text.widget.spec.ts +++ b/lib/core/src/lib/form/components/widgets/text/text.widget.spec.ts @@ -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'); }); }); }); diff --git a/lib/core/src/lib/form/public-api.ts b/lib/core/src/lib/form/public-api.ts index 0b3dc24535..5505bdc40f 100644 --- a/lib/core/src/lib/form/public-api.ts +++ b/lib/core/src/lib/form/public-api.ts @@ -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'; diff --git a/lib/core/src/lib/form/services/form-expression.service.spec.ts b/lib/core/src/lib/form/services/form-expression.service.spec.ts index 18602563bd..a97869de72 100644 --- a/lib/core/src/lib/form/services/form-expression.service.spec.ts +++ b/lib/core/src/lib/form/services/form-expression.service.spec.ts @@ -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"}'); }); }); diff --git a/lib/core/src/lib/form/services/form-field-value-adapter.service.spec.ts b/lib/core/src/lib/form/services/form-field-value-adapter.service.spec.ts new file mode 100644 index 0000000000..98a9a1278a --- /dev/null +++ b/lib/core/src/lib/form/services/form-field-value-adapter.service.spec.ts @@ -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'); + }); + }); +}); diff --git a/lib/core/src/lib/form/services/form-field-value-adapter.service.ts b/lib/core/src/lib/form/services/form-field-value-adapter.service.ts new file mode 100644 index 0000000000..5252acf623 --- /dev/null +++ b/lib/core/src/lib/form/services/form-field-value-adapter.service.ts @@ -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(); + + 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)['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 }; + } +} diff --git a/lib/core/src/lib/form/services/form-field-value-formatter.service.spec.ts b/lib/core/src/lib/form/services/form-field-value-formatter.service.spec.ts index 34530b51e9..e012a84d1b 100644 --- a/lib/core/src/lib/form/services/form-field-value-formatter.service.spec.ts +++ b/lib/core/src/lib/form/services/form-field-value-formatter.service.spec.ts @@ -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', () => { diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.spec.ts index de3520de4f..0459b3af07 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.spec.ts @@ -225,12 +225,12 @@ describe('DisplayExternalPropertyWidgetComponent', () => { widget.field = new FormFieldModel(new FormModel({ taskId: '' }), { 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 () => { diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.spec.ts index b712a0501d..0d3d257544 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.spec.ts @@ -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); + }); + }); + }); }); diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.ts b/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.ts index 9b9e3dbe2a..994e4198d9 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.ts @@ -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 = 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); diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/people/people-cloud.widget.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/people/people-cloud.widget.spec.ts index 7b2aa0a181..500615cdc0 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/people/people-cloud.widget.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/people/people-cloud.widget.spec.ts @@ -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); + }); + }); + }); }); diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/people/people-cloud.widget.ts b/lib/process-services-cloud/src/lib/form/components/widgets/people/people-cloud.widget.ts index 892d65962e..73bc7f93fd 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/people/people-cloud.widget.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/people/people-cloud.widget.ts @@ -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 = 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; diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/reactive-preselection.service.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/reactive-preselection.service.spec.ts new file mode 100644 index 0000000000..ed8a7610ee --- /dev/null +++ b/lib/process-services-cloud/src/lib/form/components/widgets/reactive-preselection.service.spec.ts @@ -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; + let fieldValue: unknown; + let preselection: TestItem[]; + let host: ReactivePreselectionHost; + + const HOST_FIELD_ID = 'target'; + const HOST_FORM_ID = 'form-1'; + + const emit = () => formRulesEvent.next({ type: 'fieldValueChanged' }); + + const createService = (token: Observable | boolean | null): ReactivePreselectionService => { + TestBed.resetTestingModule(); + formRulesEvent = new Subject(); + TestBed.configureTestingModule({ + providers: [ + ReactivePreselectionService, + { provide: FormService, useValue: { formRulesEvent } }, + { provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: token } + ] + }); + return TestBed.inject>(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(false); + createService(token).connect(host); + fieldValue = [{ id: 'a' }]; + + emit(); + expect(preselection).toEqual([]); + + token.next(true); + emit(); + expect(preselection).toEqual([{ id: 'a' }]); + }); + }); +}); diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/reactive-preselection.service.ts b/lib/process-services-cloud/src/lib/form/components/widgets/reactive-preselection.service.ts new file mode 100644 index 0000000000..e100453fed --- /dev/null +++ b/lib/process-services-cloud/src/lib/form/components/widgets/reactive-preselection.service.ts @@ -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 { + getFieldId(): string; + getFormId(): string; + getFieldValue(): unknown; + getPreselection(): T[]; + setPreselection(value: T[]): void; + identityOf(item: T): string; +} + +@Injectable() +export class ReactivePreselectionService { + private readonly formService = inject(FormService); + private readonly destroyRef = inject(DestroyRef); + private readonly formattingToken = inject(ADF_TYPED_VALUE_FORMATTING_ENABLED, { optional: true }); + + private host: ReactivePreselectionHost; + private formattingEnabled = false; + private subscribed = false; + + connect(host: ReactivePreselectionHost): 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])); + } +}