[MNT-24456] aps with adw multi user and group fields do not accept multiple values (#10636)

* [MNT-24456] Allow to select multiple users and groups for task form

* [MNT-24456] Inputs styling

* [MNT-24456] Unit tests, fixed issue for selecting single group

* [MNT-24456] Fixed issue when completing forms with null instead of empty array

* [MNT-24456] Used UnitTestingUtils where possible
This commit is contained in:
AleksanderSklorz 2025-02-13 10:43:02 +01:00 committed by GitHub
parent 430ca84c77
commit 808f836259
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 313 additions and 20 deletions

View File

@ -221,6 +221,40 @@ describe('FormFieldModel', () => {
expect(field.hasEmptyValue).toBe(false);
});
it('should detect multiple values when multiple property of params is true', () => {
const field = new FormFieldModel(new FormModel(), {
type: FormFieldTypes.DROPDOWN,
options: [
{ id: 'fake-option-1', name: 'fake label 1' },
{ id: 'fake-option-2', name: 'fake label 2' },
{ id: 'fake-option-3', name: 'fake label 3' }
],
value: [],
params: {
multiple: true
}
});
expect(field.hasMultipleValues).toBeTrue();
});
it('should not detect multiple values when multiple property of params is false', () => {
const field = new FormFieldModel(new FormModel(), {
type: FormFieldTypes.DROPDOWN,
options: [
{ id: 'fake-option-1', name: 'fake label 1' },
{ id: 'fake-option-2', name: 'fake label 2' },
{ id: 'fake-option-3', name: 'fake label 3' }
],
value: [],
params: {
multiple: false
}
});
expect(field.hasMultipleValues).toBeFalse();
});
describe('should leave not resolved value (in case of delayed options)', () => {
it('when string', () => {
const field = new FormFieldModel(new FormModel(), {

View File

@ -141,7 +141,7 @@ export class FormFieldModel extends FormWidgetModel {
}
get hasMultipleValues() {
return this.selectionType === 'multiple';
return this.selectionType === 'multiple' || this.params.multiple;
}
markAsInvalid() {

View File

@ -90,6 +90,7 @@
--adf-secondary-button-background: $adf-secondary-button-background,
--adf-secondary-modal-text-color: $adf-secondary-modal-text-color,
--adf-disabled-button-background: $adf-disabled-button-background,
--adf-chip-border-color: $adf-chip-border-color,
--adf-display-external-property-widget-preview-selection-color: mat.get-color-from-palette($foreground, secondary-text)
);

View File

@ -29,3 +29,4 @@ $adf-error-color: #ba1b1b;
$adf-secondary-button-background: #2121210d;
$adf-secondary-modal-text-color: #212121;
$adf-disabled-button-background: rgba(0, 0, 0, 0.12);
$adf-chip-border-color: #757575;

View File

@ -3,23 +3,41 @@
[class.adf-invalid]="!field.isValid && isTouched()"
[class.adf-readonly]="field.readOnly"
id="functional-group-div">
<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>
<mat-form-field
appearance="outline"
class="adf-group-widget-field">
<mat-chip-grid #chipGrid>
<mat-chip-row
*ngFor="let group of selectedGroups"
(removed)="onRemove(group)"
[disabled]="field.readOnly"
[attr.data-automation-id]="'adf-group-widget-chip-' + group.id"
class="adf-group-widget-field-chip">
{{ getDisplayName(group) }}
<button matChipRemove [attr.aria-label]="'remove ' + group.name">
<mat-icon>close</mat-icon>
</button>
</mat-chip-row>
<input matInput
class="adf-input"
type="text"
data-automation-id="adf-group-search-input"
[matChipInputFor]="chipGrid"
[id]="field.id"
[formControl]="searchTerm"
[disabled]="!multiSelect && selectedGroups.length > 0 || field.readOnly"
[placeholder]="field.placeholder"
(blur)="markAsTouched()"
[matAutocomplete]="auto">
[matAutocomplete]="auto"
#inputValue>
</mat-chip-grid>
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="updateOption($event.option.value)" [displayWith]="getDisplayName">
<mat-option *ngFor="let item of groups$ | async; let i = index"
id="adf-group-widget-user-{{i}}"
[id]="field.id +'-'+item.id"
[value]="item">
[id]="field.id +'-'+item.id"
[value]="item"
[disabled]="isGroupAlreadySelected(item)">
<span id="adf-group-label-name">{{item.name}}</span>
</mat-option>
</mat-autocomplete>

View File

@ -1,5 +1,16 @@
.adf {
&-group-widget {
width: 100%;
.adf-group-widget-field {
.adf-group-widget-field-chip {
border: 1px solid var(--adf-chip-border-color);
border-radius: 10px;
background-color: var(--theme-primary-color-default-contrast);
height: auto;
word-break: break-word;
padding: 4px 0;
}
}
}
}

View File

@ -16,10 +16,14 @@
*/
import { of, timer } from 'rxjs';
import { FormFieldModel, FormModel, GroupModel, CoreTestingModule, FormFieldTypes } from '@alfresco/adf-core';
import { FormFieldModel, FormModel, GroupModel, CoreTestingModule, FormFieldTypes, UnitTestingUtils } from '@alfresco/adf-core';
import { FunctionalGroupWidgetComponent } from './functional-group.widget';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PeopleProcessService } from '../../../services/people-process.service';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { HarnessLoader } from '@angular/cdk/testing';
import { MatChipRowHarness } from '@angular/material/chips/testing';
import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing';
describe('FunctionalGroupWidgetComponent', () => {
let fixture: ComponentFixture<FunctionalGroupWidgetComponent>;
@ -27,6 +31,9 @@ describe('FunctionalGroupWidgetComponent', () => {
let peopleProcessService: PeopleProcessService;
let getWorkflowGroupsSpy: jasmine.Spy;
let element: HTMLElement;
let loader: HarnessLoader;
let unitTestingUtils: UnitTestingUtils;
const groups: GroupModel[] = [
{ id: '1', name: 'group 1' },
{ id: '2', name: 'group 2' }
@ -41,6 +48,8 @@ describe('FunctionalGroupWidgetComponent', () => {
fixture = TestBed.createComponent(FunctionalGroupWidgetComponent);
component = fixture.componentInstance;
unitTestingUtils = new UnitTestingUtils(fixture.debugElement);
loader = TestbedHarnessEnvironment.loader(fixture);
component.field = new FormFieldModel(new FormModel());
element = fixture.nativeElement;
fixture.detectChanges();
@ -102,7 +111,7 @@ describe('FunctionalGroupWidgetComponent', () => {
component.updateOption(groups[1]);
expect(component.field.value).toBe(groups[1]);
expect(component.field.value).toEqual(groups[1]);
});
it('should fetch groups and show popup on key up', async () => {
@ -173,4 +182,143 @@ describe('FunctionalGroupWidgetComponent', () => {
expect(asterisk.textContent).toEqual('*');
});
});
describe('Groups chips', () => {
beforeEach(() => {
component.field.value = groups;
component.ngOnInit();
});
it('should display chip for each selected group', async () => {
fixture.detectChanges();
expect(await loader.getAllHarnesses(MatChipRowHarness)).toHaveSize(2);
});
it('should disable chips based on field readOnly property', async () => {
component.field.readOnly = true;
fixture.detectChanges();
expect((await loader.getAllHarnesses(MatChipRowHarness)).every((chip) => chip.isDisabled())).toBeTrue();
});
it('should display correct group name for each chip', async () => {
fixture.detectChanges();
const chips = await loader.getAllHarnesses(MatChipRowHarness);
expect(await chips[0].getText()).toBe('group 1');
expect(await chips[1].getText()).toBe('group 2');
});
it('should allow to remove chips', async () => {
fixture.detectChanges();
const chips = await loader.getAllHarnesses(MatChipRowHarness);
await chips[0].remove();
const chipsAfterRemoving = await loader.getAllHarnesses(MatChipRowHarness);
expect(component.field.value).toEqual([groups[1]]);
expect(chipsAfterRemoving).toHaveSize(1);
expect(await chipsAfterRemoving[0].getText()).toBe('group 2');
});
});
describe('Groups input', () => {
const getInputElement = (): HTMLInputElement => unitTestingUtils.getByDataAutomationId('adf-group-search-input').nativeElement;
it('should disable input if multiple property of params is false, some group is selected and field is not readOnly', () => {
component.field.params.multiple = false;
component.field.value = [groups[0]];
component.field.readOnly = false;
component.ngOnInit();
fixture.detectChanges();
expect(getInputElement().disabled).toBeTrue();
});
it('should enable input if multiple property of params is false, none group is selected and field is not readOnly', () => {
component.field.params.multiple = false;
component.field.value = [];
component.field.readOnly = false;
component.ngOnInit();
fixture.detectChanges();
expect(getInputElement().disabled).toBeFalse();
});
it('should enable input if multiple property of params is true, none group is selected and field is not readOnly', () => {
component.field.params.multiple = true;
component.field.value = [];
component.field.readOnly = false;
component.ngOnInit();
fixture.detectChanges();
expect(getInputElement().disabled).toBeFalse();
});
it('should enable input if multiple property of params is true, some group is selected and field is not readOnly', () => {
component.field.params.multiple = true;
component.field.value = [groups[0]];
component.field.readOnly = false;
component.ngOnInit();
fixture.detectChanges();
expect(getInputElement().disabled).toBeFalse();
});
it('should disable input if multiple property of params is false, some group is selected and field is readOnly', () => {
component.field.params.multiple = false;
component.field.value = [groups[0]];
component.field.readOnly = true;
component.ngOnInit();
fixture.detectChanges();
expect(getInputElement().disabled).toBeTrue();
});
it('should disable input if multiple property of params is false, none group is selected and field is readOnly', () => {
component.field.params.multiple = false;
component.field.value = [];
component.field.readOnly = true;
component.ngOnInit();
fixture.detectChanges();
expect(getInputElement().disabled).toBeTrue();
});
it('should disable input if multiple property of params is true, none group is selected and field is readOnly', () => {
component.field.params.multiple = true;
component.field.value = [];
component.field.readOnly = true;
component.ngOnInit();
fixture.detectChanges();
expect(getInputElement().disabled).toBeTrue();
});
it('should disable input if multiple property of params is true, some group is selected and field is readOnly', () => {
component.field.params.multiple = true;
component.field.value = [groups[0]];
component.field.readOnly = true;
component.ngOnInit();
fixture.detectChanges();
expect(getInputElement().disabled).toBeTrue();
});
});
describe('Autocomplete options', () => {
it('should have disabled already selected groups', async () => {
component.field.params.multiple = true;
component.ngOnInit();
getWorkflowGroupsSpy.and.returnValue(of(groups));
await typeIntoInput('group');
const autocompleteHarness = await loader.getHarness(MatAutocompleteHarness);
await autocompleteHarness.selectOption({
text: groups[0].name
});
await typeIntoInput('group');
const options = await autocompleteHarness.getOptions();
expect(await options[0].isDisabled()).toBeTrue();
expect(await options[1].isDisabled()).toBeFalse();
});
});
});

View File

@ -17,9 +17,9 @@
/* eslint-disable @angular-eslint/component-selector */
import { Component, ElementRef, OnInit, ViewEncapsulation } from '@angular/core';
import { Component, ElementRef, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { ErrorWidgetComponent, FormService, GroupModel, WidgetComponent } from '@alfresco/adf-core';
import { catchError, debounceTime, filter, switchMap, tap } from 'rxjs/operators';
import { catchError, debounceTime, distinctUntilChanged, switchMap, tap } from 'rxjs/operators';
import { merge, of } from 'rxjs';
import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { PeopleProcessService } from '../../../services/people-process.service';
@ -28,11 +28,23 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { TranslateModule } from '@ngx-translate/core';
import { MatInputModule } from '@angular/material/input';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'functional-group-widget',
standalone: true,
imports: [CommonModule, MatFormFieldModule, TranslateModule, MatInputModule, ReactiveFormsModule, MatAutocompleteModule, ErrorWidgetComponent],
imports: [
CommonModule,
MatFormFieldModule,
TranslateModule,
MatInputModule,
ReactiveFormsModule,
MatAutocompleteModule,
ErrorWidgetComponent,
MatChipsModule,
MatIconModule
],
templateUrl: './functional-group.widget.html',
styleUrls: ['./functional-group.widget.scss'],
host: {
@ -53,16 +65,25 @@ export class FunctionalGroupWidgetComponent extends WidgetComponent implements O
groupId: string;
searchTerm = new UntypedFormControl();
groups$ = merge(this.searchTerm.valueChanges).pipe(
distinctUntilChanged(),
tap((search: GroupModel | string) => {
const isValid = typeof search !== 'string';
const empty = search === '';
this.updateOption(isValid ? (search as GroupModel) : null);
this.validateGroup(isValid, empty);
}),
filter((group: string | GroupModel) => typeof group === 'string' && group.length >= this.minTermLength),
debounceTime(300),
switchMap((searchTerm: string) => this.peopleProcessService.getWorkflowGroups(searchTerm, this.groupId).pipe(catchError(() => of([]))))
switchMap((searchTerm) => {
if (typeof searchTerm !== 'string' || searchTerm.length < this.minTermLength) {
return of([]);
}
return this.peopleProcessService.getWorkflowGroups(searchTerm, this.groupId).pipe(catchError(() => of([])));
})
);
selectedGroups: GroupModel[] = [];
multiSelect = false;
@ViewChild('inputValue', { static: true })
input: ElementRef;
constructor(public peopleProcessService: PeopleProcessService, public formService: FormService, public elementRef: ElementRef) {
super(formService);
@ -70,6 +91,9 @@ export class FunctionalGroupWidgetComponent extends WidgetComponent implements O
ngOnInit() {
if (this.field) {
if (this.field.value) {
Array.isArray(this.field.value) ? this.selectedGroups.push(...this.field.value) : this.selectedGroups.push(this.field.value);
}
if (this.field.readOnly) {
this.searchTerm.disable();
}
@ -79,6 +103,10 @@ export class FunctionalGroupWidgetComponent extends WidgetComponent implements O
const restrictWithGroup = params.restrictWithGroup;
this.groupId = restrictWithGroup.id;
}
if (params?.multiple) {
this.multiSelect = params.multiple;
this.field.value = this.selectedGroups;
}
if (this.field.value?.name) {
this.searchTerm.setValue(this.field.value.name);
@ -88,11 +116,22 @@ export class FunctionalGroupWidgetComponent extends WidgetComponent implements O
updateOption(option?: GroupModel) {
if (option) {
this.field.value = option;
if (this.multiSelect) {
if (!this.isGroupAlreadySelected(option)) {
this.field.value = this.selectedGroups;
} else {
return;
}
} else {
this.field.value = option;
}
this.selectedGroups.push(option);
} else {
this.field.value = null;
}
this.searchTerm.setValue('');
this.input.nativeElement.value = '';
this.field.updateForm();
}
@ -117,4 +156,16 @@ export class FunctionalGroupWidgetComponent extends WidgetComponent implements O
}
return '';
}
onRemove(group: GroupModel): void {
const index = this.selectedGroups.indexOf(group);
if (index >= 0) {
this.selectedGroups.splice(index, 1);
this.field.value = this.selectedGroups;
}
}
isGroupAlreadySelected(group: GroupModel): boolean {
return this.selectedGroups?.some((selectedGroup) => selectedGroup.id === group.id);
}
}

View File

@ -2,17 +2,20 @@
[class.adf-invalid]="!field.isValid && isTouched()"
[class.adf-readonly]="field.readOnly"
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>
<label class="adf-label" [attr.for]="field.id">{{field.name | translate }}<span class="adf-asterisk" *ngIf="isRequired()">*</span></label>
<mat-form-field
appearance="outline"
class="adf-people-widget-field">
<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">
[attr.data-automation-id]="'adf-people-widget-chip-' + user.id"
class="adf-people-widget-field-chip">
{{ getDisplayName(user) }}
<button matChipRemove [attr.aria-label]="'remove ' + user.firstName">
<mat-icon>cancel</mat-icon>
<mat-icon>close</mat-icon>
</button>
</mat-chip-row>
<input #inputValue

View File

@ -7,6 +7,17 @@
#{$mat-form-field-label} {
top: 10px;
}
.adf-people-widget-field {
.adf-people-widget-field-chip {
border: 1px solid var(--adf-chip-border-color);
border-radius: 10px;
background-color: var(--theme-primary-color-default-contrast);
height: auto;
word-break: break-word;
padding: 4px 0;
}
}
}
&-people-widget-list {

View File

@ -253,6 +253,20 @@ describe('PeopleWidgetComponent', () => {
expect(widget.selectedUsers).toEqual([selectedUser]);
});
it('should set default value to field value if field can have multiple values', () => {
widget.field.params.multiple = true;
widget.ngOnInit();
expect(widget.field.value).toEqual([]);
});
it('should not set default value to field value if field can not have multiple values', () => {
widget.field.params.multiple = false;
widget.ngOnInit();
expect(widget.field.value).toBeUndefined();
});
describe('when is required', () => {
beforeEach(() => {
widget.field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {

View File

@ -109,6 +109,7 @@ export class PeopleWidgetComponent extends WidgetComponent implements OnInit {
}
if (params?.multiple) {
this.multiSelect = params.multiple;
this.field.value = this.selectedUsers;
}
}
}