diff --git a/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts b/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts index 9211b5dffc..9c0d1d9373 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts @@ -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(), { diff --git a/lib/core/src/lib/form/components/widgets/core/form-field.model.ts b/lib/core/src/lib/form/components/widgets/core/form-field.model.ts index 07fb280272..20ae6c9a91 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field.model.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field.model.ts @@ -141,7 +141,7 @@ export class FormFieldModel extends FormWidgetModel { } get hasMultipleValues() { - return this.selectionType === 'multiple'; + return this.selectionType === 'multiple' || this.params.multiple; } markAsInvalid() { diff --git a/lib/core/src/lib/styles/_components-variables.scss b/lib/core/src/lib/styles/_components-variables.scss index 4f2467a563..2888884d6e 100644 --- a/lib/core/src/lib/styles/_components-variables.scss +++ b/lib/core/src/lib/styles/_components-variables.scss @@ -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) ); diff --git a/lib/core/src/lib/styles/_reference-variables.scss b/lib/core/src/lib/styles/_reference-variables.scss index f00594d133..e230964891 100644 --- a/lib/core/src/lib/styles/_reference-variables.scss +++ b/lib/core/src/lib/styles/_reference-variables.scss @@ -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; diff --git a/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.html b/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.html index aa96118aa9..cc5ed51083 100644 --- a/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.html +++ b/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.html @@ -3,23 +3,41 @@ [class.adf-invalid]="!field.isValid && isTouched()" [class.adf-readonly]="field.readOnly" id="functional-group-div"> - - - + + + + + {{ getDisplayName(group) }} + + + [matAutocomplete]="auto" + #inputValue> + + [id]="field.id +'-'+item.id" + [value]="item" + [disabled]="isGroupAlreadySelected(item)"> {{item.name}} diff --git a/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.scss b/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.scss index 2c23439a70..c271440016 100644 --- a/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.scss +++ b/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.scss @@ -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; + } + } } } diff --git a/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.spec.ts b/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.spec.ts index a3041bc4a4..6e79ba7cdb 100644 --- a/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.spec.ts +++ b/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.spec.ts @@ -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; @@ -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(); + }); + }); }); diff --git a/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.ts b/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.ts index 4024d16ae3..f141c705b0 100644 --- a/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.ts +++ b/lib/process-services/src/lib/form/widgets/functional-group/functional-group.widget.ts @@ -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); + } } diff --git a/lib/process-services/src/lib/form/widgets/people/people.widget.html b/lib/process-services/src/lib/form/widgets/people/people.widget.html index e5e74474be..902ec248da 100644 --- a/lib/process-services/src/lib/form/widgets/people/people.widget.html +++ b/lib/process-services/src/lib/form/widgets/people/people.widget.html @@ -2,17 +2,20 @@ [class.adf-invalid]="!field.isValid && isTouched()" [class.adf-readonly]="field.readOnly" id="people-widget-content"> - - + + + [attr.data-automation-id]="'adf-people-widget-chip-' + user.id" + class="adf-people-widget-field-chip"> {{ getDisplayName(user) }} { 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: '' }), { diff --git a/lib/process-services/src/lib/form/widgets/people/people.widget.ts b/lib/process-services/src/lib/form/widgets/people/people.widget.ts index 2a0bae91be..d7d7a3402c 100644 --- a/lib/process-services/src/lib/form/widgets/people/people.widget.ts +++ b/lib/process-services/src/lib/form/widgets/people/people.widget.ts @@ -109,6 +109,7 @@ export class PeopleWidgetComponent extends WidgetComponent implements OnInit { } if (params?.multiple) { this.multiSelect = params.multiple; + this.field.value = this.selectedUsers; } } }