Skip to content

Commit

Permalink
[MNT-24657] Add multiple selection support for people widget [ci:force]
Browse files Browse the repository at this point in the history
  • Loading branch information
nikita-web-ua committed Dec 4, 2024
1 parent c92d34f commit 52f19aa
Show file tree
Hide file tree
Showing 3 changed files with 200 additions and 62 deletions.
39 changes: 27 additions & 12 deletions lib/process-services/src/lib/form/widgets/people/people.widget.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
<input #inputValue
matInput
class="adf-input"
data-automation-id="adf-people-search-input"
type="text"
[id]="field.id"
[formControl]="searchTerm"
[placeholder]="field.placeholder"
[matAutocomplete]="auto"
(blur)="markAsTouched()"
[title]="field.tooltip">
<mat-chip-grid #chipGrid aria-label="People selection" data-automation-id="adf-people-widget-chip-list">
<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]="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">
Expand Down
157 changes: 128 additions & 29 deletions lib/process-services/src/lib/form/widgets/people/people.widget.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,15 @@ 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;

Expand All @@ -37,6 +41,7 @@ describe('PeopleWidgetComponent', () => {
});
fixture = TestBed.createComponent(PeopleWidgetComponent);
peopleProcessService = TestBed.inject(PeopleProcessService);
loader = TestbedHarnessEnvironment.loader(fixture);

translationService = TestBed.inject(TranslateService);
spyOn(translationService, 'instant').and.callFake((key) => key);
Expand All @@ -48,26 +53,28 @@ describe('PeopleWidgetComponent', () => {
fixture.detectChanges();
});

it('should return empty display name for missing model', () => {
expect(widget.getDisplayName(null)).toBe('');
});
describe('display name', () => {
it('should return empty display name for missing model', () => {
expect(widget.getDisplayName(null)).toBe('');
});

it('should return full name for a given model', () => {
const model = {
firstName: 'John',
lastName: 'Doe'
};
expect(widget.getDisplayName(model)).toBe('John Doe');
});
it('should return full name for a given model', () => {
const model = {
firstName: 'John',
lastName: 'Doe'
};
expect(widget.getDisplayName(model)).toBe('John Doe');
});

it('should skip first name for display name', () => {
const model = { firstName: null, lastName: 'Doe' };
expect(widget.getDisplayName(model)).toBe('Doe');
});
it('should skip first name for display name', () => {
const model = { firstName: null, lastName: 'Doe' };
expect(widget.getDisplayName(model)).toBe('Doe');
});

it('should skip last name for display name', () => {
const model = { firstName: 'John', lastName: null };
expect(widget.getDisplayName(model)).toBe('John');
it('should skip last name for display name', () => {
const model = { firstName: 'John', lastName: null };
expect(widget.getDisplayName(model)).toBe('John');
});
});

it('should init value from the field', async () => {
Expand All @@ -83,7 +90,33 @@ describe('PeopleWidgetComponent', () => {
fixture.detectChanges();
await fixture.whenStable();

expect((element.querySelector('input') as HTMLInputElement).value).toBe('John Doe');
const chip = await loader.getHarness(MatChipHarness.with({ selector: '[data-automation-id="adf-people-widget-chip-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 () => {
Expand All @@ -101,10 +134,30 @@ describe('PeopleWidgetComponent', () => {
fixture.detectChanges();
await fixture.whenStable();

expect((element.querySelector('input') as HTMLInputElement).value).toBe('John Doe');
const chip = await loader.getHarness(MatChipHarness.with({ selector: '[data-automation-id="adf-people-widget-chip-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 loader.getHarness(MatChipHarness.with({ selector: '[data-automation-id="adf-people-widget-chip-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();
Expand Down Expand Up @@ -138,13 +191,63 @@ describe('PeopleWidgetComponent', () => {
email: '[email protected]'
};
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 loader.getHarness(MatChipHarness.with({ selector: '[data-automation-id="adf-people-widget-chip-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', () => {
Expand Down Expand Up @@ -274,16 +377,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 () => {
Expand Down
66 changes: 45 additions & 21 deletions lib/process-services/src/lib/form/widgets/people/people.widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,6 +40,8 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete';
TranslateModule,
MatFormFieldModule,
MatInputModule,
MatChipsModule,
MatIconModule,
ReactiveFormsModule,
MatAutocompleteModule,
InitialUsernamePipe,
Expand Down Expand Up @@ -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([])));
}),
Expand All @@ -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;
}
}
}

Expand All @@ -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;
Expand All @@ -144,11 +143,36 @@ export class PeopleWidgetComponent extends WidgetComponent implements OnInit {
return '';
}

onItemSelect(item?: LightUserRepresentation) {
if (item) {
this.field.value = item;
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.field.value = null;
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 && this.selectedUsers.length > 0) {
const result = this.selectedUsers.find((selectedUser) => selectedUser.id === user.id);

return !!result;
}
return false;
}
}

0 comments on commit 52f19aa

Please sign in to comment.