[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">
<mat-form-field>
<label class="adf-label" [attr.for]="field.id">{{field.name | translate }}<span class="adf-asterisk" *ngIf="isRequired()">*</span></label>
<mat-chip-grid #chipGrid [attr.aria-label]="'ADF_PROCESS_LIST.START_PROCESS.FORM.LABEL.SELECTED_PEOPLE' | translate">
<mat-chip-row
*ngFor="let user of selectedUsers"
(removed)="onRemove(user)"
[disabled]="field.readOnly"
[attr.data-automation-id]="'adf-people-widget-chip-' + user.id">
{{ getDisplayName(user) }}
<button matChipRemove [attr.aria-label]="'remove ' + user.firstName">
<mat-icon>cancel</mat-icon>
</button>
</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]="field.placeholder"
[placeholder]="selectedUsers.length > 0 ? '' : field.placeholder"
[matAutocomplete]="auto"
(blur)="markAsTouched()"
[title]="field.tooltip">
</mat-chip-grid>
<mat-autocomplete class="adf-people-widget-list"
#auto="matAutocomplete"
(optionSelected)="onItemSelect($event.option.value)"
[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 [outerHTML]="user | usernameInitials:'adf-people-widget-pic'"></div>
<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 { PeopleProcessService } from '../../../services/people-process.service';
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', () => {
let widget: PeopleWidgetComponent;
let fixture: ComponentFixture<PeopleWidgetComponent>;
let element: HTMLElement;
let loader: HarnessLoader;
let translationService: TranslateService;
let peopleProcessService: PeopleProcessService;
const getChipById = async (id: string) =>
loader.getHarness(MatChipHarness.with({ selector: `[data-automation-id="adf-people-widget-chip-${id}"]` }));
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreTestingModule]
});
fixture = TestBed.createComponent(PeopleWidgetComponent);
peopleProcessService = TestBed.inject(PeopleProcessService);
loader = TestbedHarnessEnvironment.loader(fixture);
translationService = TestBed.inject(TranslateService);
spyOn(translationService, 'instant').and.callFake((key) => key);
@ -48,6 +56,7 @@ describe('PeopleWidgetComponent', () => {
fixture.detectChanges();
});
describe('display name', () => {
it('should return empty display name for missing model', () => {
expect(widget.getDisplayName(null)).toBe('');
});
@ -69,6 +78,7 @@ describe('PeopleWidgetComponent', () => {
const model = { firstName: 'John', lastName: null };
expect(widget.getDisplayName(model)).toBe('John');
});
});
it('should init value from the field', async () => {
widget.field.value = {
@ -83,7 +93,33 @@ describe('PeopleWidgetComponent', () => {
fixture.detectChanges();
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 () => {
@ -101,10 +137,30 @@ describe('PeopleWidgetComponent', () => {
fixture.detectChanges();
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();
});
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', () => {
widget.field.value = null;
widget.ngOnInit();
@ -138,13 +194,63 @@ describe('PeopleWidgetComponent', () => {
email: 'john@test.com'
};
widget.ngOnInit();
const involvedUser = fixture.debugElement.nativeElement.querySelector('input[data-automation-id="adf-people-search-input"]');
fixture.detectChanges();
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', () => {
@ -274,16 +380,12 @@ describe('PeopleWidgetComponent', () => {
it('should emit peopleSelected if option is valid', async () => {
const selectEmitSpy = spyOn(widget.peopleSelected, 'emit');
const peopleHTMLElement = element.querySelector<HTMLInputElement>('input');
peopleHTMLElement.focus();
peopleHTMLElement.value = 'Test01 Test01';
peopleHTMLElement.dispatchEvent(new Event('keyup'));
peopleHTMLElement.dispatchEvent(new Event('input'));
widget.onItemSelect(fakeUserResult[0]);
fixture.detectChanges();
await fixture.whenStable();
expect(selectEmitSpy).toHaveBeenCalledWith(1001);
expect(selectEmitSpy).toHaveBeenCalledWith(fakeUserResult[0].id);
});
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 { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild, ViewEncapsulation } from '@angular/core';
import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
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 { LightUserRepresentation } from '@alfresco/js-api';
import { CommonModule } from '@angular/common';
@ -38,6 +40,8 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete';
TranslateModule,
MatFormFieldModule,
MatInputModule,
MatChipsModule,
MatIconModule,
ReactiveFormsModule,
MatAutocompleteModule,
InitialUsernamePipe,
@ -66,20 +70,19 @@ export class PeopleWidgetComponent extends WidgetComponent implements OnInit {
@Output()
peopleSelected: EventEmitter<number> = new EventEmitter();
selectedUsers: LightUserRepresentation[] = [];
multiSelect = false;
groupId: number;
value: any;
searchTerm = new UntypedFormControl();
searchTerms$: Observable<any> = this.searchTerm.valueChanges;
users$ = this.searchTerms$.pipe(
tap((searchInput) => {
if (typeof searchInput === 'string') {
this.onItemSelect();
}
}),
users$: Observable<LightUserRepresentation[]> = this.searchTerms$.pipe(
distinctUntilChanged(),
switchMap((searchTerm) => {
if (!searchTerm) {
return of([]);
}
const value = searchTerm.email ? this.getDisplayName(searchTerm) : searchTerm;
return this.peopleProcessService.getWorkflowUsers(undefined, value, this.groupId).pipe(catchError(() => of([])));
}),
@ -97,16 +100,16 @@ export class PeopleWidgetComponent extends WidgetComponent implements OnInit {
ngOnInit() {
if (this.field) {
if (this.field.value) {
this.searchTerm.setValue(this.field.value);
}
if (this.field.readOnly) {
this.searchTerm.disable();
Array.isArray(this.field.value) ? this.selectedUsers.push(...this.field.value) : this.selectedUsers.push(this.field.value);
}
const params = this.field.params;
if (params?.restrictWithGroup) {
const restrictWithGroup = params.restrictWithGroup;
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 {
if (users) {
return !!users.find((user) => {
const selectedUser = this.getDisplayName(user).toLocaleLowerCase() === name.toLocaleLowerCase();
if (selectedUser) {
this.peopleSelected.emit(user?.id || undefined);
}
return selectedUser;
return this.getDisplayName(user).toLocaleLowerCase() === name.toLocaleLowerCase();
});
}
return false;
@ -144,11 +143,36 @@ export class PeopleWidgetComponent extends WidgetComponent implements OnInit {
return '';
}
onItemSelect(item?: LightUserRepresentation) {
if (item) {
this.field.value = item;
} else {
this.field.value = null;
onRemove(user: LightUserRepresentation) {
const index = this.selectedUsers.indexOf(user);
if (index >= 0) {
this.selectedUsers.splice(index, 1);
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": {
"SELECT_APPLICATION": "Select Application",
"TYPE": "Select Process",
"NAME": "Process Name"
"NAME": "Process Name",
"SELECTED_PEOPLE": "Selected people"
},
"TYPE_PLACEHOLDER": "Choose one...",
"ACTION": {