[MNT-24657] Add multiple selection support for people widget (#10458)

* [MNT-24657] Add multiple selection support for people widget [ci:force]

* [MNT-24657] cr fixes
This commit is contained in:
Mykyta Maliarchuk 2024-12-05 14:01:16 +01:00 committed by GitHub
parent 60a9565c71
commit 2b0100466a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 206 additions and 64 deletions

View File

@ -4,22 +4,37 @@
id="people-widget-content"> id="people-widget-content">
<mat-form-field> <mat-form-field>
<label class="adf-label" [attr.for]="field.id">{{field.name | translate }}<span class="adf-asterisk" *ngIf="isRequired()">*</span></label> <label class="adf-label" [attr.for]="field.id">{{field.name | translate }}<span class="adf-asterisk" *ngIf="isRequired()">*</span></label>
<input #inputValue <mat-chip-grid #chipGrid [attr.aria-label]="'ADF_PROCESS_LIST.START_PROCESS.FORM.LABEL.SELECTED_PEOPLE' | translate">
matInput <mat-chip-row
class="adf-input" *ngFor="let user of selectedUsers"
data-automation-id="adf-people-search-input" (removed)="onRemove(user)"
type="text" [disabled]="field.readOnly"
[id]="field.id" [attr.data-automation-id]="'adf-people-widget-chip-' + user.id">
[formControl]="searchTerm" {{ getDisplayName(user) }}
[placeholder]="field.placeholder" <button matChipRemove [attr.aria-label]="'remove ' + user.firstName">
[matAutocomplete]="auto" <mat-icon>cancel</mat-icon>
(blur)="markAsTouched()" </button>
[title]="field.tooltip"> </mat-chip-row>
<input #inputValue
matInput
class="adf-input"
[matChipInputFor]="chipGrid"
data-automation-id="adf-people-search-input"
type="text"
[disabled]="!multiSelect && selectedUsers.length > 0 || field.readOnly"
[id]="field.id"
[formControl]="searchTerm"
[placeholder]="selectedUsers.length > 0 ? '' : field.placeholder"
[matAutocomplete]="auto"
(blur)="markAsTouched()"
[title]="field.tooltip">
</mat-chip-grid>
<mat-autocomplete class="adf-people-widget-list" <mat-autocomplete class="adf-people-widget-list"
#auto="matAutocomplete" #auto="matAutocomplete"
(optionSelected)="onItemSelect($event.option.value)" (optionSelected)="onItemSelect($event.option.value)"
[displayWith]="getDisplayName"> [displayWith]="getDisplayName">
<mat-option *ngFor="let user of users$ | async; let i = index" [value]="user"> <mat-option *ngFor="let user of users$ | async; let i = index" [value]="user" [disabled]="isUserAlreadySelected(user)">
<div class="adf-people-widget-row" id="adf-people-widget-user-{{i}}"> <div class="adf-people-widget-row" id="adf-people-widget-user-{{i}}">
<div [outerHTML]="user | usernameInitials:'adf-people-widget-pic'"></div> <div [outerHTML]="user | usernameInitials:'adf-people-widget-pic'"></div>
<div *ngIf="user.pictureId" class="adf-people-widget-image-row"> <div *ngIf="user.pictureId" class="adf-people-widget-image-row">

View File

@ -23,20 +23,28 @@ import { PeopleWidgetComponent } from './people.widget';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { PeopleProcessService } from '../../../services/people-process.service'; import { PeopleProcessService } from '../../../services/people-process.service';
import { LightUserRepresentation } from '@alfresco/js-api'; import { LightUserRepresentation } from '@alfresco/js-api';
import { MatChipHarness } from '@angular/material/chips/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { HarnessLoader } from '@angular/cdk/testing';
describe('PeopleWidgetComponent', () => { describe('PeopleWidgetComponent', () => {
let widget: PeopleWidgetComponent; let widget: PeopleWidgetComponent;
let fixture: ComponentFixture<PeopleWidgetComponent>; let fixture: ComponentFixture<PeopleWidgetComponent>;
let element: HTMLElement; let element: HTMLElement;
let loader: HarnessLoader;
let translationService: TranslateService; let translationService: TranslateService;
let peopleProcessService: PeopleProcessService; let peopleProcessService: PeopleProcessService;
const getChipById = async (id: string) =>
loader.getHarness(MatChipHarness.with({ selector: `[data-automation-id="adf-people-widget-chip-${id}"]` }));
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CoreTestingModule] imports: [CoreTestingModule]
}); });
fixture = TestBed.createComponent(PeopleWidgetComponent); fixture = TestBed.createComponent(PeopleWidgetComponent);
peopleProcessService = TestBed.inject(PeopleProcessService); peopleProcessService = TestBed.inject(PeopleProcessService);
loader = TestbedHarnessEnvironment.loader(fixture);
translationService = TestBed.inject(TranslateService); translationService = TestBed.inject(TranslateService);
spyOn(translationService, 'instant').and.callFake((key) => key); spyOn(translationService, 'instant').and.callFake((key) => key);
@ -48,26 +56,28 @@ describe('PeopleWidgetComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should return empty display name for missing model', () => { describe('display name', () => {
expect(widget.getDisplayName(null)).toBe(''); it('should return empty display name for missing model', () => {
}); expect(widget.getDisplayName(null)).toBe('');
});
it('should return full name for a given model', () => { it('should return full name for a given model', () => {
const model = { const model = {
firstName: 'John', firstName: 'John',
lastName: 'Doe' lastName: 'Doe'
}; };
expect(widget.getDisplayName(model)).toBe('John Doe'); expect(widget.getDisplayName(model)).toBe('John Doe');
}); });
it('should skip first name for display name', () => { it('should skip first name for display name', () => {
const model = { firstName: null, lastName: 'Doe' }; const model = { firstName: null, lastName: 'Doe' };
expect(widget.getDisplayName(model)).toBe('Doe'); expect(widget.getDisplayName(model)).toBe('Doe');
}); });
it('should skip last name for display name', () => { it('should skip last name for display name', () => {
const model = { firstName: 'John', lastName: null }; const model = { firstName: 'John', lastName: null };
expect(widget.getDisplayName(model)).toBe('John'); expect(widget.getDisplayName(model)).toBe('John');
});
}); });
it('should init value from the field', async () => { it('should init value from the field', async () => {
@ -83,7 +93,33 @@ describe('PeopleWidgetComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect((element.querySelector('input') as HTMLInputElement).value).toBe('John Doe'); const chip = await getChipById('people-id');
expect(await chip.getText()).toBe('John Doe');
});
it('should show correct number of chips if multiple users provided', async () => {
widget.field.readOnly = false;
widget.field.params.multiple = true;
widget.field.value = [
{
id: 'people-id-1',
firstName: 'John',
lastName: 'Doe'
},
{
id: 'people-id-2',
firstName: 'Rick',
lastName: 'Grimes'
}
];
widget.ngOnInit();
fixture.detectChanges();
await fixture.whenStable();
const chips = await loader.getAllHarnesses(MatChipHarness);
expect(chips.length).toBe(2);
expect(await chips[0].getText()).toBe('John Doe');
expect(await chips[1].getText()).toBe('Rick Grimes');
}); });
it('should show the readonly value when the form is readonly', async () => { it('should show the readonly value when the form is readonly', async () => {
@ -101,10 +137,30 @@ describe('PeopleWidgetComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect((element.querySelector('input') as HTMLInputElement).value).toBe('John Doe'); const chip = await getChipById('people-id');
expect(await chip.getText()).toBe('John Doe');
expect(await chip.isDisabled()).toBe(true);
expect((element.querySelector('input') as HTMLInputElement).disabled).toBeTruthy(); expect((element.querySelector('input') as HTMLInputElement).disabled).toBeTruthy();
}); });
it('should display the cancel button in the chip', async () => {
widget.field.value = {
id: 'people-id',
firstName: 'John',
lastName: 'Doe'
};
spyOn(peopleProcessService, 'getWorkflowUsers').and.returnValue(of(null));
widget.ngOnInit();
fixture.detectChanges();
await fixture.whenStable();
const chip = await getChipById('people-id');
const cancelIcon = await chip.getRemoveButton();
expect(cancelIcon).toBeDefined();
});
it('should require form field to setup values on init', () => { it('should require form field to setup values on init', () => {
widget.field.value = null; widget.field.value = null;
widget.ngOnInit(); widget.ngOnInit();
@ -138,13 +194,63 @@ describe('PeopleWidgetComponent', () => {
email: 'john@test.com' email: 'john@test.com'
}; };
widget.ngOnInit(); widget.ngOnInit();
const involvedUser = fixture.debugElement.nativeElement.querySelector('input[data-automation-id="adf-people-search-input"]');
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect(involvedUser.value).toBe('John Doe'); const chip = await getChipById('people-id');
expect(await chip.getText()).toBe('John Doe');
});
it('should add user to selectedUsers when multiSelect is false and user is not already selected', () => {
const user: LightUserRepresentation = { id: 1, firstName: 'John', lastName: 'Doe' };
widget.multiSelect = false;
widget.onItemSelect(user);
expect(widget.selectedUsers).toContain(user);
expect(widget.field.value).toEqual(widget.selectedUsers[0]);
});
it('should not add user to selectedUsers when multiSelect is true and user is already selected', () => {
const user: LightUserRepresentation = { id: 1, firstName: 'John', lastName: 'Doe' };
widget.multiSelect = true;
widget.selectedUsers = [user];
widget.onItemSelect(user);
expect(widget.selectedUsers.length).toBe(1);
});
it('should clear the input value after selection', () => {
const user: LightUserRepresentation = { id: 1, firstName: 'John', lastName: 'Doe' };
widget.input.nativeElement.value = 'test';
widget.onItemSelect(user);
expect(widget.input.nativeElement.value).toBe('');
});
it('should reset the search term after selection', () => {
spyOn(peopleProcessService, 'getWorkflowUsers').and.returnValue(of(null));
const user: LightUserRepresentation = { id: 1, firstName: 'John', lastName: 'Doe' };
widget.searchTerm.setValue('test');
widget.onItemSelect(user);
expect(widget.searchTerm.value).toBe('');
});
it('should remove user from selectedUsers if user exists', () => {
const users: LightUserRepresentation[] = [
{ id: 1, firstName: 'John', lastName: 'Doe' },
{ id: 2, firstName: 'Jane', lastName: 'Doe' }
];
widget.selectedUsers = [...users];
widget.onRemove(users[0]);
expect(widget.selectedUsers).not.toContain(users[0]);
expect(widget.field.value).toEqual([users[1]]);
});
it('should not change selectedUsers if user does not exist', () => {
const selectedUser: LightUserRepresentation = { id: 1, firstName: 'John', lastName: 'Doe' };
const anotherUser: LightUserRepresentation = { id: 2, firstName: 'Jane', lastName: 'Doe' };
widget.selectedUsers = [selectedUser];
widget.onRemove(anotherUser);
expect(widget.selectedUsers).toEqual([selectedUser]);
}); });
describe('when is required', () => { describe('when is required', () => {
@ -274,16 +380,12 @@ describe('PeopleWidgetComponent', () => {
it('should emit peopleSelected if option is valid', async () => { it('should emit peopleSelected if option is valid', async () => {
const selectEmitSpy = spyOn(widget.peopleSelected, 'emit'); const selectEmitSpy = spyOn(widget.peopleSelected, 'emit');
const peopleHTMLElement = element.querySelector<HTMLInputElement>('input'); widget.onItemSelect(fakeUserResult[0]);
peopleHTMLElement.focus();
peopleHTMLElement.value = 'Test01 Test01';
peopleHTMLElement.dispatchEvent(new Event('keyup'));
peopleHTMLElement.dispatchEvent(new Event('input'));
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect(selectEmitSpy).toHaveBeenCalledWith(1001); expect(selectEmitSpy).toHaveBeenCalledWith(fakeUserResult[0].id);
}); });
it('should display tooltip when tooltip is set', async () => { it('should display tooltip when tooltip is set', async () => {

View File

@ -20,8 +20,10 @@
import { ErrorWidgetComponent, FormService, InitialUsernamePipe, WidgetComponent } from '@alfresco/adf-core'; import { ErrorWidgetComponent, FormService, InitialUsernamePipe, WidgetComponent } from '@alfresco/adf-core';
import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild, ViewEncapsulation } from '@angular/core'; import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild, ViewEncapsulation } from '@angular/core';
import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators';
import { PeopleProcessService } from '../../../services/people-process.service'; import { PeopleProcessService } from '../../../services/people-process.service';
import { LightUserRepresentation } from '@alfresco/js-api'; import { LightUserRepresentation } from '@alfresco/js-api';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@ -38,6 +40,8 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete';
TranslateModule, TranslateModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
MatChipsModule,
MatIconModule,
ReactiveFormsModule, ReactiveFormsModule,
MatAutocompleteModule, MatAutocompleteModule,
InitialUsernamePipe, InitialUsernamePipe,
@ -66,20 +70,19 @@ export class PeopleWidgetComponent extends WidgetComponent implements OnInit {
@Output() @Output()
peopleSelected: EventEmitter<number> = new EventEmitter(); peopleSelected: EventEmitter<number> = new EventEmitter();
selectedUsers: LightUserRepresentation[] = [];
multiSelect = false;
groupId: number; groupId: number;
value: any;
searchTerm = new UntypedFormControl(); searchTerm = new UntypedFormControl();
searchTerms$: Observable<any> = this.searchTerm.valueChanges; searchTerms$: Observable<any> = this.searchTerm.valueChanges;
users$ = this.searchTerms$.pipe( users$: Observable<LightUserRepresentation[]> = this.searchTerms$.pipe(
tap((searchInput) => {
if (typeof searchInput === 'string') {
this.onItemSelect();
}
}),
distinctUntilChanged(), distinctUntilChanged(),
switchMap((searchTerm) => { switchMap((searchTerm) => {
if (!searchTerm) {
return of([]);
}
const value = searchTerm.email ? this.getDisplayName(searchTerm) : searchTerm; const value = searchTerm.email ? this.getDisplayName(searchTerm) : searchTerm;
return this.peopleProcessService.getWorkflowUsers(undefined, value, this.groupId).pipe(catchError(() => of([]))); return this.peopleProcessService.getWorkflowUsers(undefined, value, this.groupId).pipe(catchError(() => of([])));
}), }),
@ -97,16 +100,16 @@ export class PeopleWidgetComponent extends WidgetComponent implements OnInit {
ngOnInit() { ngOnInit() {
if (this.field) { if (this.field) {
if (this.field.value) { if (this.field.value) {
this.searchTerm.setValue(this.field.value); Array.isArray(this.field.value) ? this.selectedUsers.push(...this.field.value) : this.selectedUsers.push(this.field.value);
}
if (this.field.readOnly) {
this.searchTerm.disable();
} }
const params = this.field.params; const params = this.field.params;
if (params?.restrictWithGroup) { if (params?.restrictWithGroup) {
const restrictWithGroup = params.restrictWithGroup; const restrictWithGroup = params.restrictWithGroup;
this.groupId = restrictWithGroup.id; this.groupId = restrictWithGroup.id;
} }
if (params?.multiple) {
this.multiSelect = params.multiple;
}
} }
} }
@ -126,11 +129,7 @@ export class PeopleWidgetComponent extends WidgetComponent implements OnInit {
isValidUser(users: LightUserRepresentation[], name: string): boolean { isValidUser(users: LightUserRepresentation[], name: string): boolean {
if (users) { if (users) {
return !!users.find((user) => { return !!users.find((user) => {
const selectedUser = this.getDisplayName(user).toLocaleLowerCase() === name.toLocaleLowerCase(); return this.getDisplayName(user).toLocaleLowerCase() === name.toLocaleLowerCase();
if (selectedUser) {
this.peopleSelected.emit(user?.id || undefined);
}
return selectedUser;
}); });
} }
return false; return false;
@ -144,11 +143,36 @@ export class PeopleWidgetComponent extends WidgetComponent implements OnInit {
return ''; return '';
} }
onItemSelect(item?: LightUserRepresentation) { onRemove(user: LightUserRepresentation) {
if (item) { const index = this.selectedUsers.indexOf(user);
this.field.value = item; if (index >= 0) {
} else { this.selectedUsers.splice(index, 1);
this.field.value = null; this.field.value = this.selectedUsers;
} }
} }
onItemSelect(user: LightUserRepresentation) {
if (this.multiSelect) {
if (!this.isUserAlreadySelected(user)) {
this.selectedUsers.push(user);
}
this.field.value = this.selectedUsers;
} else {
this.selectedUsers = [user];
this.field.value = user;
}
this.peopleSelected.emit(user?.id || undefined);
this.input.nativeElement.value = '';
this.searchTerm.setValue('');
}
isUserAlreadySelected(user: LightUserRepresentation): boolean {
if (this.selectedUsers?.length > 0) {
const result = this.selectedUsers.find((selectedUser) => selectedUser.id === user.id);
return !!result;
}
return false;
}
} }

View File

@ -281,7 +281,8 @@
"LABEL": { "LABEL": {
"SELECT_APPLICATION": "Select Application", "SELECT_APPLICATION": "Select Application",
"TYPE": "Select Process", "TYPE": "Select Process",
"NAME": "Process Name" "NAME": "Process Name",
"SELECTED_PEOPLE": "Selected people"
}, },
"TYPE_PLACEHOLDER": "Choose one...", "TYPE_PLACEHOLDER": "Choose one...",
"ACTION": { "ACTION": {