From ec18f6b9cb63f0eb0c7cdc97c9544d54aa2193aa Mon Sep 17 00:00:00 2001 From: swapnil-verma-gl <92505353+swapnil-verma-gl@users.noreply.github.com> Date: Wed, 11 Oct 2023 18:00:44 +0530 Subject: [PATCH] [ACS-4130] Added autocomplete to folder rules 'Has Category' condition (#3464) * [ACS-4130] Added autocomplete for 'Has Category' option in manage rules * [ACS-4130] Added loading spinner and 'No options found' template for Has Category rule condition. Options are now fetched as soon as user selected 'Has Category' option * [ACS-4130] Added code to fetch category name when viewing/editing existing rule with has category option selected * [ACS-4130] Resolved issues related to editing existing rules with 'Has Category' condition * [ACS-4130] Added safety checks and minor code refactoring * [ACS-4130] Added unit tests for new autocomplete functionality * [ACS-4130] Added feature to auto select first option from autocomplete dropdown when user focuses out of autocomplete input field * [ACS-4130] Minor code refactoring. Moved constants from global scope to local scope * [ACS-4130] Moved mock data to conditions.mock.ts. Removed redundant return type * [ACS-4130] Resolved PR review comments - AutoCompleteOption is now an interface. Changed occurences of autocomplete with auto-complete. Removed/Added types * [ACS-4130] Resolved PR review comments - AutoCompleteOption is now built using a single common helper method * [ACS-4130] Added missed types --- .../folder-rules/assets/i18n/en.json | 3 + .../folder-rules/src/mock/conditions.mock.ts | 57 +++++++- .../conditions/rule-condition-fields.ts | 6 +- .../rule-simple-condition.ui-component.html | 46 ++++++- .../rule-simple-condition.ui-component.scss | 7 + ...rule-simple-condition.ui-component.spec.ts | 128 +++++++++++++++++- .../rule-simple-condition.ui-component.ts | 126 +++++++++++++++-- 7 files changed, 350 insertions(+), 23 deletions(-) diff --git a/projects/aca-content/folder-rules/assets/i18n/en.json b/projects/aca-content/folder-rules/assets/i18n/en.json index fc89fd29b2..7387ef8242 100644 --- a/projects/aca-content/folder-rules/assets/i18n/en.json +++ b/projects/aca-content/folder-rules/assets/i18n/en.json @@ -139,6 +139,9 @@ }, "ERRORS": { "DELETE_RULE_SET_LINK_FAILED": "Error while trying to delete a link from a rule set" + }, + "AUTOCOMPLETE": { + "NO_OPTIONS_FOUND": "No options found" } } } diff --git a/projects/aca-content/folder-rules/src/mock/conditions.mock.ts b/projects/aca-content/folder-rules/src/mock/conditions.mock.ts index e471e49d27..e3aae15494 100644 --- a/projects/aca-content/folder-rules/src/mock/conditions.mock.ts +++ b/projects/aca-content/folder-rules/src/mock/conditions.mock.ts @@ -37,12 +37,65 @@ export const mimeTypeMock: RuleSimpleCondition = { parameter: '' }; -export const categoryMock: RuleSimpleCondition = { - field: 'category', +export const tagMock: RuleSimpleCondition = { + field: 'tag', comparator: 'equals', parameter: '' }; +export const categoriesListMock = { + list: { + pagination: { + count: 3, + hasMoreItems: false, + totalItems: 0, + skipCount: 0, + maxItems: 25 + }, + entries: [ + { + entry: { + path: { + name: '/a/fake/category/path/1' + }, + hasChildren: false, + name: 'FakeCategory1', + id: 'fake-category-id-1', + nodeType: 'cm:category', + isFile: false, + isFolder: false + } + }, + { + entry: { + path: { + name: '/a/fake/category/path/2' + }, + hasChildren: false, + name: 'FakeCategory2', + id: 'fake-category-id-2', + nodeType: 'cm:category', + isFile: false, + isFolder: false + } + }, + { + entry: { + path: { + name: '/a/fake/category/path/3' + }, + hasChildren: false, + name: 'FakeCategory3', + id: 'fake-category-id-3', + nodeType: 'cm:category', + isFile: false, + isFolder: false + } + } + ] + } +}; + export const simpleConditionUnknownFieldMock: RuleSimpleCondition = { field: 'unknown-field', comparator: 'equals', diff --git a/projects/aca-content/folder-rules/src/rule-details/conditions/rule-condition-fields.ts b/projects/aca-content/folder-rules/src/rule-details/conditions/rule-condition-fields.ts index af77ab683e..7575e226a4 100644 --- a/projects/aca-content/folder-rules/src/rule-details/conditions/rule-condition-fields.ts +++ b/projects/aca-content/folder-rules/src/rule-details/conditions/rule-condition-fields.ts @@ -22,7 +22,7 @@ * from Hyland Software. If not, see . */ -export type RuleConditionFieldType = 'string' | 'number' | 'date' | 'type' | 'special' | 'mimeType'; +export type RuleConditionFieldType = 'string' | 'number' | 'date' | 'type' | 'special' | 'mimeType' | 'auto-complete'; export interface RuleConditionField { name: string; @@ -30,7 +30,7 @@ export interface RuleConditionField { type: RuleConditionFieldType; } -export const comparatorHiddenForConditionFieldType: string[] = ['special', 'mimeType']; +export const comparatorHiddenForConditionFieldType: string[] = ['special', 'mimeType', 'auto-complete']; export const ruleConditionFields: RuleConditionField[] = [ { @@ -56,7 +56,7 @@ export const ruleConditionFields: RuleConditionField[] = [ { name: 'category', label: 'ACA_FOLDER_RULES.RULE_DETAILS.FIELDS.HAS_CATEGORY', - type: 'special' + type: 'auto-complete' }, { name: 'tag', diff --git a/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.html b/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.html index 290e850577..8cff2654dd 100644 --- a/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.html +++ b/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.html @@ -21,14 +21,54 @@ - - + + {{ mimeType.label }} - + + + + + + + + + + + + {{ option.displayLabel }} + + + + + {{ 'ACA_FOLDER_RULES.AUTOCOMPLETE.NO_OPTIONS_FOUND' | translate }} + + + + + + diff --git a/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.scss b/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.scss index 1d9e989ed4..958e977805 100644 --- a/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.scss +++ b/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.scss @@ -15,4 +15,11 @@ } } } + + &__auto-complete-loading-spinner { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + } } diff --git a/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.spec.ts b/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.spec.ts index 5185027c09..adbb87c3e3 100644 --- a/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.spec.ts +++ b/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.spec.ts @@ -22,16 +22,21 @@ * from Hyland Software. If not, see . */ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, discardPeriodicTasks, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { RuleSimpleConditionUiComponent } from './rule-simple-condition.ui-component'; import { CoreTestingModule } from '@alfresco/adf-core'; import { By } from '@angular/platform-browser'; import { DebugElement } from '@angular/core'; -import { categoryMock, mimeTypeMock, simpleConditionUnknownFieldMock } from '../../mock/conditions.mock'; +import { tagMock, mimeTypeMock, simpleConditionUnknownFieldMock, categoriesListMock } from '../../mock/conditions.mock'; import { MimeType } from './rule-mime-types'; +import { CategoryService } from '@alfresco/adf-content-services'; +import { of } from 'rxjs'; +import { RuleSimpleCondition } from '../../model/rule-simple-condition.model'; +import { delay } from 'rxjs/operators'; describe('RuleSimpleConditionUiComponent', () => { let fixture: ComponentFixture; + let categoryService: CategoryService; const getByDataAutomationId = (dataAutomationId: string): DebugElement => fixture.debugElement.query(By.css(`[data-automation-id="${dataAutomationId}"]`)); @@ -45,12 +50,20 @@ describe('RuleSimpleConditionUiComponent', () => { fixture.detectChanges(); }; + const setValueInInputField = (inputFieldDataAutomationId: string, value: string) => { + const inputField = fixture.debugElement.query(By.css(`[data-automation-id="${inputFieldDataAutomationId}"]`)).nativeElement; + inputField.value = value; + inputField.dispatchEvent(new Event('input')); + fixture.detectChanges(); + }; + beforeEach(() => { TestBed.configureTestingModule({ imports: [CoreTestingModule, RuleSimpleConditionUiComponent] }); fixture = TestBed.createComponent(RuleSimpleConditionUiComponent); + categoryService = TestBed.inject(CategoryService); }); it('should default the field to the name, the comparator to equals and the value empty', () => { @@ -87,6 +100,20 @@ describe('RuleSimpleConditionUiComponent', () => { expect(getComputedStyle(comparatorFormField).display).toBe('none'); }); + it('should hide the comparator select box if the type of the field is autoComplete', () => { + const autoCompleteField = 'category'; + fixture.detectChanges(); + const comparatorFormField = getByDataAutomationId('comparator-form-field').nativeElement; + + expect(fixture.componentInstance.isComparatorHidden).toBeFalsy(); + expect(getComputedStyle(comparatorFormField).display).not.toBe('none'); + + changeMatSelectValue('field-select', autoCompleteField); + + expect(fixture.componentInstance.isComparatorHidden).toBeTruthy(); + expect(getComputedStyle(comparatorFormField).display).toBe('none'); + }); + it('should set the comparator to equals if the field is set to a type with different comparators', () => { const onChangeFieldSpy = spyOn(fixture.componentInstance, 'onChangeField').and.callThrough(); fixture.detectChanges(); @@ -165,9 +192,104 @@ describe('RuleSimpleConditionUiComponent', () => { expect(getByDataAutomationId('simple-condition-value-select')).toBeTruthy(); - fixture.componentInstance.writeValue(categoryMock); + fixture.componentInstance.writeValue(tagMock); fixture.detectChanges(); expect(getByDataAutomationId('value-input').nativeElement.value).toBe(''); }); + + it('should provide auto-complete option when category is selected', () => { + fixture.detectChanges(); + changeMatSelectValue('field-select', 'category'); + + expect(getByDataAutomationId('auto-complete-input-field')).toBeTruthy(); + expect(fixture.componentInstance.form.get('parameter').value).toEqual(''); + }); + + it('should fetch category list when category option is selected', fakeAsync(() => { + spyOn(categoryService, 'searchCategories').and.returnValue(of(categoriesListMock)); + + fixture.detectChanges(); + changeMatSelectValue('field-select', 'category'); + tick(500); + + expect(categoryService.searchCategories).toHaveBeenCalledWith(''); + })); + + it('should fetch new category list with user input when user types into parameter field after category option is select', fakeAsync(() => { + const categoryValue = 'a new category'; + spyOn(categoryService, 'searchCategories').and.returnValue(of(categoriesListMock)); + + fixture.detectChanges(); + changeMatSelectValue('field-select', 'category'); + tick(500); + expect(categoryService.searchCategories).toHaveBeenCalledWith(''); + + setValueInInputField('auto-complete-input-field', categoryValue); + tick(500); + expect(categoryService.searchCategories).toHaveBeenCalledWith(categoryValue); + })); + + it('should fetch category details when a saved rule with category condition is edited', () => { + const savedCategoryMock: RuleSimpleCondition = { + field: 'category', + comparator: 'equals', + parameter: 'a-fake-category-id' + }; + + const fakeCategory = { + entry: { + path: '/a/fake/category/path', + hasChildren: false, + name: 'FakeCategory', + id: 'fake-category-id-1' + } + }; + spyOn(categoryService, 'getCategory').and.returnValue(of(fakeCategory)); + + fixture.componentInstance.writeValue(savedCategoryMock); + fixture.detectChanges(); + + expect(categoryService.getCategory).toHaveBeenCalledWith(savedCategoryMock.parameter, { include: ['path'] }); + }); + + it('should show loading spinner while auto-complete options are fetched, and then remove it once it is received', fakeAsync(() => { + spyOn(categoryService, 'searchCategories').and.returnValue(of(categoriesListMock).pipe(delay(1000))); + fixture.detectChanges(); + changeMatSelectValue('field-select', 'category'); + tick(500); + getByDataAutomationId('auto-complete-input-field')?.nativeElement?.click(); + let loadingSpinner = getByDataAutomationId('auto-complete-loading-spinner'); + expect(loadingSpinner).not.toBeNull(); + tick(1000); + fixture.detectChanges(); + loadingSpinner = getByDataAutomationId('auto-complete-loading-spinner'); + expect(loadingSpinner).toBeNull(); + discardPeriodicTasks(); + })); + + it('should display correct label for category when user selects a category from auto-complete dropdown', fakeAsync(() => { + spyOn(categoryService, 'searchCategories').and.returnValue(of(categoriesListMock)); + fixture.detectChanges(); + changeMatSelectValue('field-select', 'category'); + tick(500); + getByDataAutomationId('auto-complete-input-field')?.nativeElement?.click(); + changeMatSelectValue('folder-rule-auto-complete', categoriesListMock.list.entries[0].entry.id); + const displayValue = getByDataAutomationId('auto-complete-input-field')?.nativeElement?.value; + expect(displayValue).toBe('category/path/1/FakeCategory1'); + discardPeriodicTasks(); + })); + + it('should automatically select first category when user focuses out of parameter form field with category option selected', fakeAsync(() => { + spyOn(categoryService, 'searchCategories').and.returnValue(of(categoriesListMock)); + fixture.detectChanges(); + changeMatSelectValue('field-select', 'category'); + tick(500); + const autoCompleteInputField = getByDataAutomationId('auto-complete-input-field')?.nativeElement; + autoCompleteInputField.value = 'FakeCat'; + autoCompleteInputField.dispatchEvent(new Event('focusout')); + const parameterValue = fixture.componentInstance.form.get('parameter').value; + expect(parameterValue).toEqual(categoriesListMock.list.entries[0].entry.id); + discardPeriodicTasks(); + })); }); diff --git a/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.ts b/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.ts index cc3f4193be..a3cda946ab 100644 --- a/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.ts +++ b/projects/aca-content/folder-rules/src/rule-details/conditions/rule-simple-condition.ui-component.ts @@ -22,22 +22,47 @@ * from Hyland Software. If not, see . */ -import { Component, forwardRef, Input, OnDestroy, ViewEncapsulation } from '@angular/core'; +import { Component, forwardRef, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { AbstractControl, ControlValueAccessor, FormControl, FormGroup, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; import { RuleSimpleCondition } from '../../model/rule-simple-condition.model'; import { comparatorHiddenForConditionFieldType, RuleConditionField, ruleConditionFields } from './rule-condition-fields'; import { RuleConditionComparator, ruleConditionComparators } from './rule-condition-comparators'; import { AppConfigService } from '@alfresco/adf-core'; import { MimeType } from './rule-mime-types'; -import { CommonModule } from '@angular/common'; +import { AsyncPipe, CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatInputModule } from '@angular/material/input'; +import { CategoryService } from '@alfresco/adf-content-services'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { debounceTime, distinctUntilChanged, first, takeUntil } from 'rxjs/operators'; +import { Subject, Subscription } from 'rxjs'; +import { MatOptionModule } from '@angular/material/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { CategoryEntry } from '@alfresco/js-api'; + +interface AutoCompleteOption { + displayLabel: string; + value: string; +} + +const AUTOCOMPLETE_OPTIONS_DEBOUNCE_TIME = 500; @Component({ standalone: true, - imports: [CommonModule, TranslateModule, ReactiveFormsModule, MatFormFieldModule, MatSelectModule, MatInputModule], + imports: [ + CommonModule, + TranslateModule, + ReactiveFormsModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + MatAutocompleteModule, + AsyncPipe, + MatOptionModule, + MatProgressSpinnerModule + ], selector: 'aca-rule-simple-condition', templateUrl: './rule-simple-condition.ui-component.html', styleUrls: ['./rule-simple-condition.ui-component.scss'], @@ -51,7 +76,7 @@ import { MatInputModule } from '@angular/material/input'; } ] }) -export class RuleSimpleConditionUiComponent implements ControlValueAccessor, OnDestroy { +export class RuleSimpleConditionUiComponent implements OnInit, ControlValueAccessor, OnDestroy { readonly fields = ruleConditionFields; form = new FormGroup({ @@ -62,6 +87,12 @@ export class RuleSimpleConditionUiComponent implements ControlValueAccessor, OnD mimeTypes: MimeType[] = []; + autoCompleteOptions: AutoCompleteOption[] = []; + + showLoadingSpinner: boolean; + + private onDestroy$ = new Subject(); + private autoCompleteOptionsSubscription: Subscription; private _readOnly = false; @Input() get readOnly(): boolean { @@ -71,15 +102,9 @@ export class RuleSimpleConditionUiComponent implements ControlValueAccessor, OnD this.setDisabledState(isReadOnly); } - constructor(private config: AppConfigService) { + constructor(private config: AppConfigService, private categoryService: CategoryService) { this.mimeTypes = this.config.get>('mimeTypes'); } - - private formSubscription = this.form.valueChanges.subscribe((value: any) => { - this.onChange(value); - this.onTouch(); - }); - get isSelectedFieldKnown(): boolean { const selectedFieldName = this.form.get('field').value; return this.fields.findIndex((field: RuleConditionField) => selectedFieldName === field.name) > -1; @@ -102,6 +127,7 @@ export class RuleSimpleConditionUiComponent implements ControlValueAccessor, OnD get isComparatorHidden(): boolean { return comparatorHiddenForConditionFieldType.includes(this.selectedField?.type); } + get comparatorControl(): AbstractControl { return this.form.get('comparator'); } @@ -115,6 +141,18 @@ export class RuleSimpleConditionUiComponent implements ControlValueAccessor, OnD writeValue(value: RuleSimpleCondition) { this.form.setValue(value); + if (value?.field === 'category') { + this.showLoadingSpinner = true; + this.categoryService + .getCategory(value.parameter, { include: ['path'] }) + .pipe(first()) + .subscribe((category: CategoryEntry) => { + this.showLoadingSpinner = false; + const option = this.buildAutocompleteOptionFromCategory(category.entry.id, category.entry.path, category.entry.name); + this.autoCompleteOptions.push(option); + this.parameterControl.setValue(option.value); + }); + } } registerOnChange(fn: () => void) { @@ -147,6 +185,70 @@ export class RuleSimpleConditionUiComponent implements ControlValueAccessor, OnD } ngOnDestroy() { - this.formSubscription.unsubscribe(); + this.onDestroy$.next(); + this.onDestroy$.complete(); + } + + ngOnInit() { + this.form.valueChanges.pipe(takeUntil(this.onDestroy$)).subscribe((value: RuleSimpleCondition) => { + this.onChange(value); + this.onTouch(); + }); + + this.form + .get('field') + .valueChanges.pipe(distinctUntilChanged(), takeUntil(this.onDestroy$)) + .subscribe((field: string) => { + if (field === 'category') { + this.autoCompleteOptionsSubscription = this.form + .get('parameter') + .valueChanges.pipe(distinctUntilChanged(), debounceTime(AUTOCOMPLETE_OPTIONS_DEBOUNCE_TIME), takeUntil(this.onDestroy$)) + .subscribe((categoryName) => { + this.getCategories(categoryName); + }); + this.parameterControl.setValue(''); + } else { + this.autoCompleteOptionsSubscription?.unsubscribe(); + } + }); + } + + private getCategories(categoryName: string) { + this.showLoadingSpinner = true; + this.categoryService + .searchCategories(categoryName) + .pipe(first()) + .subscribe((existingCategoriesResult) => { + this.showLoadingSpinner = false; + const options: AutoCompleteOption[] = existingCategoriesResult?.list?.entries?.map((rowEntry) => + this.buildAutocompleteOptionFromCategory(rowEntry.entry.id, rowEntry.entry.path.name, rowEntry.entry.name) + ); + if (options.length > 0) { + this.autoCompleteOptions = this.sortAutoCompleteOptions(options); + } + }); + } + + private sortAutoCompleteOptions(autoCompleteOptions: AutoCompleteOption[]): AutoCompleteOption[] { + return autoCompleteOptions.sort((option1, option2) => option1.displayLabel.localeCompare(option2.displayLabel)); + } + + autoCompleteDisplayFunction = (optionValue: string): string => + optionValue && this.autoCompleteOptions ? this.autoCompleteOptions.find((option) => option.value === optionValue)?.displayLabel : optionValue; + + autoSelectValidOption() { + const currentValue = this.parameterControl.value; + const isValidValueSelected = !!this.autoCompleteOptions?.find((option) => option.value === currentValue); + if (!isValidValueSelected) { + this.parameterControl.setValue(this.autoCompleteOptions?.[0].value); + } + } + + buildAutocompleteOptionFromCategory(categoryId: string, categoryPath: string, categoryName: string): AutoCompleteOption { + const path = categoryPath.split('/').splice(3).join('/'); + return { + value: categoryId, + displayLabel: path ? `${path}/${categoryName}` : categoryName + }; } }