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 }}
-
+
+
+
+
+
+
+
+
+
+ 0; else noOptionsTemplate">
+
+ {{ 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
+ };
}
}