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 index 98a9a1278a..49c93f182a 100644 --- 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 @@ -91,7 +91,7 @@ describe('FormFieldValueAdapterService', () => { 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' }; + const user = { username: 'testuser', firstName: 'Test', lastName: 'User' }; expect(service.adapt(user, field)).toEqual([user]); }); @@ -107,7 +107,7 @@ describe('FormFieldValueAdapterService', () => { it('single-select: should keep a single-element array', () => { const field = makeField(FormFieldTypes.PEOPLE, null); - expect(service.adapt([{ firstName: 'Test' }], field)).toEqual([{ firstName: 'Test' }]); + expect(service.adapt([{ username: 'test', firstName: 'Test' }], field)).toEqual([{ username: 'test', firstName: 'Test' }]); }); it('single-select: should return null when given an empty array', () => { @@ -134,12 +134,15 @@ describe('FormFieldValueAdapterService', () => { 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' }]); + expect(service.adapt({ username: 'testuser', firstName: 'Test' }, field)).toEqual([{ username: 'testuser', 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' }]; + const users = [ + { username: 'alice', firstName: 'Alice' }, + { username: 'bob', firstName: 'Bob' } + ]; expect(service.adapt(users, field)).toEqual(users); }); @@ -147,6 +150,58 @@ describe('FormFieldValueAdapterService', () => { const field = makeField(FormFieldTypes.PEOPLE, null, { multiple: true }); expect(service.adapt(['Test User'], field)).toEqual([{ firstName: 'Test', lastName: 'User' }]); }); + + it('should canonicalize a process-response user object (userName → username, drop displayName)', () => { + const field = makeField(FormFieldTypes.PEOPLE, null); + const processUser = { + id: 'u1', + email: 'k@x.io', + lastName: 'Richards', + userName: 'krichards', + firstName: 'Keith', + displayName: 'Keith Richards' + }; + expect(service.adapt(processUser, field)).toEqual([ + { id: 'u1', username: 'krichards', firstName: 'Keith', lastName: 'Richards', email: 'k@x.io' } + ]); + }); + + it('should canonicalize an array-wrapped process-response user object', () => { + const field = makeField(FormFieldTypes.PEOPLE, null); + const processUser = { + id: 'u1', + email: 'k@x.io', + lastName: 'Richards', + userName: 'krichards', + firstName: 'Keith', + displayName: 'Keith Richards' + }; + expect(service.adapt([processUser], field)).toEqual([ + { id: 'u1', username: 'krichards', firstName: 'Keith', lastName: 'Richards', email: 'k@x.io' } + ]); + }); + + it('should preserve an already-canonical user object (idempotent)', () => { + const field = makeField(FormFieldTypes.PEOPLE, null); + const canonical = { id: 'u1', username: 'krichards', firstName: 'Keith', lastName: 'Richards' }; + expect(service.adapt(canonical, field)).toEqual([canonical]); + }); + + it('should keep a user with only id', () => { + const field = makeField(FormFieldTypes.PEOPLE, null); + expect(service.adapt({ id: 'u1' }, field)).toEqual([{ id: 'u1', username: '' }]); + }); + + it('should filter out an object with none of id, username, or email', () => { + const field = makeField(FormFieldTypes.PEOPLE, null); + expect(service.adapt({ firstName: 'Keith', lastName: 'Richards' }, field)).toBeNull(); + }); + + it('should drop non-string and blank fields from a process-response user object', () => { + const field = makeField(FormFieldTypes.PEOPLE, null); + const processUser = { id: null, username: 'krichards', firstName: 42, lastName: ' ', email: 'k@x.io' }; + expect(service.adapt(processUser, field)).toEqual([{ username: 'krichards', email: 'k@x.io' }]); + }); }); describe('Group adapter', () => { @@ -181,6 +236,16 @@ describe('FormFieldValueAdapterService', () => { const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null, { multiple: true }); expect(service.adapt(['Eng', 'QA'], field)).toEqual([{ name: 'Eng' }, { name: 'QA' }]); }); + + it('should canonicalize a process-response group object', () => { + const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null); + expect(service.adapt({ id: 'grp1', name: 'Finance' }, field)).toEqual([{ id: 'grp1', name: 'Finance' }]); + }); + + it('should filter out a group object with neither id nor name', () => { + const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null); + expect(service.adapt({}, field)).toBeNull(); + }); }); describe('Dropdown adapter', () => { 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 index 5252acf623..593454a7f0 100644 --- 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 @@ -27,10 +27,23 @@ interface AdaptedUser { lastName?: string; } +interface CanonicalUser { + id?: string; + username: string; + firstName?: string; + lastName?: string; + email?: string; +} + interface AdaptedGroup { name: string; } +interface CanonicalGroup { + id?: string; + name: string; +} + @Injectable({ providedIn: 'root' }) export class FormFieldValueAdapterService { private readonly adapters = new Map(); @@ -102,7 +115,26 @@ export class FormFieldValueAdapterService { private toUser(entry: unknown): unknown { if (typeof entry !== 'string') { - return entry; + if (!entry || typeof entry !== 'object') { + return null; + } + const source = entry as Record; + const username = this.toStringField(source['username'] ?? source['userName']); + const id = this.toStringField(source['id']); + const email = this.toStringField(source['email']); + const firstName = this.toStringField(source['firstName']); + const lastName = this.toStringField(source['lastName']); + if (!id && !username && !email) { + return null; + } + const user: CanonicalUser = { + ...(id !== undefined ? { id } : {}), + username: username ?? '', + ...(firstName !== undefined ? { firstName } : {}), + ...(lastName !== undefined ? { lastName } : {}), + ...(email !== undefined ? { email } : {}) + }; + return user; } const trimmed = entry.trim(); if (this.isBlankToken(trimmed)) { @@ -122,7 +154,20 @@ export class FormFieldValueAdapterService { private toGroup(entry: unknown): unknown { if (typeof entry !== 'string') { - return entry; + if (!entry || typeof entry !== 'object') { + return null; + } + const source = entry as Record; + const name = this.toStringField(source['name']); + const id = this.toStringField(source['id']); + if (!name && !id) { + return null; + } + const group: CanonicalGroup = { + ...(id !== undefined ? { id } : {}), + name: name ?? '' + }; + return group; } const trimmed = entry.trim(); if (this.isBlankToken(trimmed)) { @@ -136,6 +181,10 @@ export class FormFieldValueAdapterService { return value === '' || value === '[]' || value === '{}'; } + private toStringField(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value : undefined; + } + private toOptionId(entry: unknown): unknown { if (entry && typeof entry === 'object') { return (entry as Record)['id'] ?? null; 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 994e4198d9..25e5aaa175 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 @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, DestroyRef, inject, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { ErrorWidgetComponent, WidgetComponent } from '@alfresco/adf-core'; import { UntypedFormControl } from '@angular/forms'; import { filter } from 'rxjs/operators'; @@ -50,6 +50,9 @@ import { GroupCloudComponent } from '../../../../group/components/group-cloud.co export class GroupCloudWidgetComponent extends WidgetComponent implements OnInit { private readonly reactivePreselection: ReactivePreselectionService = inject(ReactivePreselectionService); + @ViewChild(GroupCloudComponent) + private readonly groupCloud: GroupCloudComponent; + typeId = 'GroupCloudWidgetComponent'; roles: string[]; mode: ComponentSelectionMode; @@ -65,7 +68,7 @@ export class GroupCloudWidgetComponent extends WidgetComponent implements OnInit getFieldId: () => this.field?.id, getFormId: () => this.field?.form?.id, getFieldValue: () => this.field?.value, - getPreselection: () => this.preSelectGroup, + getSelection: () => this.groupCloud?.selectedGroups ?? [], setPreselection: (value) => (this.preSelectGroup = value), identityOf: (group) => group?.id ?? group?.name }); 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 500615cdc0..66d0dff671 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 @@ -25,7 +25,9 @@ import { NoopAuthModule } from '@alfresco/adf-core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { PeopleCloudWidgetComponent } from './people-cloud.widget'; +import { PeopleCloudComponent } from '../../../../people/components/people-cloud.component'; import { IdentityUserService } from '../../../../people/services/identity-user.service'; import { mockShepherdsPie, mockYorkshirePudding } from '../../../../people/mock/people-cloud.mock'; import { HarnessLoader } from '@angular/cdk/testing'; @@ -358,5 +360,37 @@ describe('PeopleCloudWidgetComponent', () => { expect(widget.preSelectUsers).toBe(initial); }); }); + + describe('single-event population (two-click repro)', () => { + 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; + element = fixture.nativeElement; + }); + + it('should populate the rendered people component after a SINGLE rule event', async () => { + const users = [{ id: 'a', username: 'alpha' }]; + widget.field = new FormFieldModel(new FormModel(), { id: 'target', type: FormFieldTypes.PEOPLE, value: null }); + fixture.detectChanges(); + await fixture.whenStable(); + + widget.field.value = users; + formService.formRulesEvent.next({ type: 'fieldValueChanged', field: { id: 'target' } } as any); + + fixture.detectChanges(); + await fixture.whenStable(); + + const peopleComponent = fixture.debugElement.query(By.directive(PeopleCloudComponent)).componentInstance as PeopleCloudComponent; + expect(peopleComponent.getSelectedUsers()).toEqual(users); + }); + }); }); }); 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 73bc7f93fd..b796fd1646 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 @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, DestroyRef, inject, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { ErrorWidgetComponent, WidgetComponent } from '@alfresco/adf-core'; import { UntypedFormControl } from '@angular/forms'; import { filter } from 'rxjs/operators'; @@ -53,6 +53,9 @@ export class PeopleCloudWidgetComponent extends WidgetComponent implements OnIni private readonly identityUserService = inject(IdentityUserService); private readonly reactivePreselection: ReactivePreselectionService = inject(ReactivePreselectionService); + @ViewChild(PeopleCloudComponent) + private readonly peopleCloud: PeopleCloudComponent; + typeId = 'PeopleCloudWidgetComponent'; appName: string; roles: string[]; @@ -70,7 +73,7 @@ export class PeopleCloudWidgetComponent extends WidgetComponent implements OnIni getFieldId: () => this.field?.id, getFormId: () => this.field?.form?.id, getFieldValue: () => this.field?.value, - getPreselection: () => this.preSelectUsers, + getSelection: () => this.peopleCloud?.getSelectedUsers() ?? [], setPreselection: (value) => (this.preSelectUsers = value), identityOf: (user) => user?.id ?? user?.username ?? user?.email }); 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 index ed8a7610ee..408bb38239 100644 --- 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 @@ -56,7 +56,7 @@ describe('ReactivePreselectionService', () => { getFieldId: () => HOST_FIELD_ID, getFormId: () => HOST_FORM_ID, getFieldValue: () => fieldValue, - getPreselection: () => preselection, + getSelection: () => preselection, setPreselection: (value) => (preselection = value), identityOf: (item) => item?.id ?? item?.name }; @@ -111,11 +111,20 @@ describe('ReactivePreselectionService', () => { expect(preselection).toBe(initial); }); - it('should ignore changes originating from the host field itself', () => { + it('should react to changes originating from the host field itself', () => { + fieldValue = [{ id: 'a' }]; + + formRulesEvent.next({ type: 'fieldValueChanged', field: { id: HOST_FIELD_ID }, form: { id: HOST_FORM_ID } }); + + expect(preselection).toEqual([{ id: 'a' }]); + }); + + it('should not reassign when the value already matches the current selection', () => { + preselection = [{ id: 'a' }]; const initial = preselection; fieldValue = [{ id: 'a' }]; - formRulesEvent.next({ type: 'fieldValueChanged', field: { id: HOST_FIELD_ID } }); + formRulesEvent.next({ type: 'fieldValueChanged', field: { id: HOST_FIELD_ID }, form: { id: HOST_FORM_ID } }); expect(preselection).toBe(initial); }); 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 index e100453fed..8a4099e7e2 100644 --- 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 @@ -25,7 +25,7 @@ export interface ReactivePreselectionHost { getFieldId(): string; getFormId(): string; getFieldValue(): unknown; - getPreselection(): T[]; + getSelection(): T[]; setPreselection(value: T[]): void; identityOf(item: T): string; } @@ -63,17 +63,13 @@ export class ReactivePreselectionService { this.subscribed = true; this.formService.formRulesEvent .pipe( - filter((event) => event?.type === 'fieldValueChanged' && this.isExternalChange(event)), + filter((event) => event?.type === 'fieldValueChanged' && this.isSameForm(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; - } + private isSameForm(event: FormRulesEvent): boolean { const eventFormId = event?.form?.id; return !eventFormId || eventFormId === this.host.getFormId(); } @@ -83,7 +79,7 @@ export class ReactivePreselectionService { return; } const next = this.toPreselection(this.host.getFieldValue()); - if (this.isSamePreselection(this.host.getPreselection(), next)) { + if (this.isSamePreselection(this.host.getSelection(), next)) { return; } this.host.setPreselection(next);