mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
[MNT-25235] Add folder searching capability in autocomplete filter
This commit is contained in:
+43
-27
@@ -1,22 +1,23 @@
|
||||
<mat-form-field class="adf-chip-list">
|
||||
<mat-label>{{ placeholder | translate }}</mat-label>
|
||||
<mat-chip-grid #chipList [attr.aria-label]="'SEARCH.FILTER.ARIA-LABEL.OPTIONS-SELECTION' | translate">
|
||||
<mat-chip-row
|
||||
class="adf-option-chips adf-autocomplete-added-option-chips"
|
||||
*ngFor="let option of selectedOptions"
|
||||
(removed)="remove(option)">
|
||||
<span [title]="option.fullPath ? ('SEARCH.RESULTS.WILL_CONTAIN' | translate:{searchTerm: option.fullPath}) : undefined">
|
||||
{{ option.value }}
|
||||
</span>
|
||||
<button
|
||||
matChipRemove
|
||||
class="adf-option-chips-delete-button adf-autocomplete-added-option-chips-delete-button"
|
||||
[title]="('SEARCH.FILTER.BUTTONS.REMOVE' | translate) + ' ' + option.value"
|
||||
[attr.aria-label]="('SEARCH.FILTER.BUTTONS.REMOVE' | translate) + ' ' + option.value"
|
||||
>
|
||||
<mat-icon class="adf-option-chips-delete-icon adf-autocomplete-added-option-chips-delete-icon" adf-icon="close" />
|
||||
</button>
|
||||
</mat-chip-row>
|
||||
@for (option of selectedOptions; track $index) {
|
||||
<mat-chip-row
|
||||
class="adf-option-chips adf-autocomplete-added-option-chips"
|
||||
(removed)="remove(option)">
|
||||
<span [title]="option.fullPath ? ('SEARCH.RESULTS.WILL_CONTAIN' | translate:{searchTerm: option.fullPath}) : undefined">
|
||||
{{ option.value }}
|
||||
</span>
|
||||
<button
|
||||
matChipRemove
|
||||
class="adf-option-chips-delete-button adf-autocomplete-added-option-chips-delete-button"
|
||||
[title]="('SEARCH.FILTER.BUTTONS.REMOVE' | translate) + ' ' + option.value"
|
||||
[attr.aria-label]="('SEARCH.FILTER.BUTTONS.REMOVE' | translate) + ' ' + option.value"
|
||||
>
|
||||
<mat-icon class="adf-option-chips-delete-icon adf-autocomplete-added-option-chips-delete-icon" adf-icon="close" />
|
||||
</button>
|
||||
</mat-chip-row>
|
||||
}
|
||||
<input
|
||||
placeholder="{{ placeholder | translate }}"
|
||||
aria-controls="adf-search-chip-autocomplete"
|
||||
@@ -34,17 +35,32 @@
|
||||
</mat-chip-grid>
|
||||
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="selected($event)" id="adf-search-chip-autocomplete"
|
||||
(optionActivated)="activeAnyOption = true" (closed)="activeAnyOption = false">
|
||||
<mat-option
|
||||
*ngFor="let option of filteredOptions"
|
||||
[value]="option"
|
||||
(mousedown)=$event.preventDefault()
|
||||
[disabled]="isOptionSelected(option)"
|
||||
[attr.data-automation-id]="'option-' + option.value"
|
||||
[title]="option.fullPath ? ('SEARCH.RESULTS.WILL_CONTAIN' | translate : { searchTerm: option.fullPath || option.value }) : undefined"
|
||||
class="adf-search-chip-autocomplete-added-option"
|
||||
[ngClass]="isOptionSelected(option) && 'adf-autocomplete-added-option'"
|
||||
>
|
||||
{{ option.fullPath || option.value }}
|
||||
@if (loading | async) {
|
||||
<mat-option class="adf-search-chip-autocomplete-loading" disabled data-automation-id="adf-search-chip-autocomplete-loading">
|
||||
<mat-progress-spinner mode="indeterminate" [diameter]="24" />
|
||||
</mat-option>
|
||||
} @else {
|
||||
@for (option of filteredOptions; track $index) {
|
||||
<mat-option
|
||||
[value]="option"
|
||||
(mousedown)=$event.preventDefault()
|
||||
[disabled]="isOptionSelected(option)"
|
||||
[attr.data-automation-id]="'option-' + option.value"
|
||||
[attr.aria-label]="option.fullPath ? ('SEARCH.RESULTS.WILL_CONTAIN' | translate : { searchTerm: option.fullPath || option.value }) : undefined"
|
||||
class="adf-search-chip-autocomplete-added-option"
|
||||
[ngClass]="isOptionSelected(option) && 'adf-autocomplete-added-option'"
|
||||
>
|
||||
<div class="adf-search-chip-autocomplete-added-option-container">
|
||||
<span>{{ option.value }}</span>
|
||||
@if (option.fullPath) {
|
||||
<mat-icon
|
||||
class="adf-info-icon"
|
||||
[matTooltip]="'SEARCH.RESULTS.WILL_CONTAIN' | translate : { searchTerm: option.fullPath }"
|
||||
adf-icon="info" />
|
||||
}
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
}
|
||||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
|
||||
+25
@@ -43,7 +43,32 @@ adf-search-chip-autocomplete-input {
|
||||
}
|
||||
}
|
||||
|
||||
.adf-search-chip-autocomplete-added-option {
|
||||
#{ms.$mat-list-item-primary-text} {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.adf-info-icon#{ms.$mat-icon} {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.adf-search-chip-autocomplete-added-option.adf-autocomplete-added-option {
|
||||
background: var(--mat-sys-surface-variant);
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
.adf-search-chip-autocomplete-loading {
|
||||
#{ms.$mat-list-item-primary-text} {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
+61
-4
@@ -18,19 +18,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatChipRemove } from '@angular/material/chips';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { Subject } from 'rxjs';
|
||||
import { of, Subject } from 'rxjs';
|
||||
import { SearchChipAutocompleteInputComponent } from './search-chip-autocomplete-input.component';
|
||||
import { DebugElement, SimpleChanges } from '@angular/core';
|
||||
import { HarnessLoader } from '@angular/cdk/testing';
|
||||
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
|
||||
import { MatChipHarness, MatChipGridHarness } from '@angular/material/chips/testing';
|
||||
import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing';
|
||||
import { MatAutocompleteTrigger } from '@angular/material/autocomplete';
|
||||
import { MatOptionHarness } from '@angular/material/core/testing';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { UnitTestingUtils } from '@alfresco/adf-core';
|
||||
|
||||
describe('SearchChipAutocompleteInputComponent', () => {
|
||||
let component: SearchChipAutocompleteInputComponent;
|
||||
let fixture: ComponentFixture<SearchChipAutocompleteInputComponent>;
|
||||
let loader: HarnessLoader;
|
||||
let testingUtils: UnitTestingUtils;
|
||||
const onResetSubject = new Subject<void>();
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -40,6 +44,7 @@ describe('SearchChipAutocompleteInputComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(SearchChipAutocompleteInputComponent);
|
||||
loader = TestbedHarnessEnvironment.loader(fixture);
|
||||
testingUtils = new UnitTestingUtils(fixture.debugElement, loader);
|
||||
component = fixture.componentInstance;
|
||||
component.onReset$ = onResetSubject.asObservable();
|
||||
component.autocompleteOptions = [{ value: 'option1' }, { value: 'option2' }];
|
||||
@@ -121,6 +126,15 @@ describe('SearchChipAutocompleteInputComponent', () => {
|
||||
return fixture.debugElement.queryAll(By.css('.adf-autocomplete-added-option'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the autocomplete panel to open regardless of the current options
|
||||
*/
|
||||
function openAutocompletePanel() {
|
||||
const trigger = fixture.debugElement.query(By.directive(MatAutocompleteTrigger)).injector.get(MatAutocompleteTrigger);
|
||||
trigger.openPanel();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('should assign preselected values to selected options on init', () => {
|
||||
component.preselectedOptions = [{ value: 'option1' }];
|
||||
component.ngOnInit();
|
||||
@@ -293,13 +307,24 @@ describe('SearchChipAutocompleteInputComponent', () => {
|
||||
expect((await getChipList()).length).toEqual(1);
|
||||
});
|
||||
|
||||
it('should show full category path when fullPath provided', async () => {
|
||||
it('should display the option value and render the info icon when fullPath is provided', () => {
|
||||
component.filteredOptions = [{ id: 'test-id', value: 'test-value', fullPath: 'test-full-path' }];
|
||||
|
||||
enterNewInputValue('test-value');
|
||||
|
||||
const matOption = fixture.debugElement.query(By.css('.adf-search-chip-autocomplete-added-option')).nativeElement;
|
||||
expect(matOption.textContent).toEqual(' test-full-path ');
|
||||
const matOption = testingUtils.getByCSS('.adf-search-chip-autocomplete-added-option').nativeElement;
|
||||
expect(matOption.textContent).toContain('test-value');
|
||||
expect(testingUtils.getByCSS('.adf-search-chip-autocomplete-added-option .adf-info-icon')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not render the info icon when fullPath is not provided', () => {
|
||||
component.filteredOptions = [{ id: 'test-id', value: 'test-value' }];
|
||||
|
||||
enterNewInputValue('test-value');
|
||||
|
||||
const matOption = testingUtils.getByCSS('.adf-search-chip-autocomplete-added-option').nativeElement;
|
||||
expect(matOption.textContent).toContain('test-value');
|
||||
expect(testingUtils.getAllByCSS('.adf-search-chip-autocomplete-added-option .adf-info-icon').length).toBe(0);
|
||||
});
|
||||
|
||||
it('should emit input value when input changed', async () => {
|
||||
@@ -316,6 +341,38 @@ describe('SearchChipAutocompleteInputComponent', () => {
|
||||
expect(inputChangedSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('loading', () => {
|
||||
it('should show a loading spinner in the autocomplete panel when loading emits true', async () => {
|
||||
component.loading = of(true);
|
||||
fixture.detectChanges();
|
||||
openAutocompletePanel();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(testingUtils.getAllByDirective(MatProgressSpinner).length).toBe(1);
|
||||
});
|
||||
|
||||
it('should not render selectable options while loading emits true', async () => {
|
||||
component.filteredOptions = [{ value: 'option1' }, { value: 'option2' }];
|
||||
component.loading = of(true);
|
||||
fixture.detectChanges();
|
||||
openAutocompletePanel();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(testingUtils.getAllByCSS('.adf-search-chip-autocomplete-added-option').length).toBe(0);
|
||||
});
|
||||
|
||||
it('should render options and no spinner when loading emits false', async () => {
|
||||
component.filteredOptions = [{ value: 'option1' }, { value: 'option2' }];
|
||||
component.loading = of(false);
|
||||
fixture.detectChanges();
|
||||
openAutocompletePanel();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect((await getOptionElements()).length).toBe(2);
|
||||
expect(testingUtils.getAllByCSS('.adf-search-chip-autocomplete-loading').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOptionSelected', () => {
|
||||
beforeEach(() => {
|
||||
component.autocompleteOptions = [{ value: 'option1' }, { value: 'option2' }];
|
||||
|
||||
+17
-2
@@ -33,7 +33,7 @@ import { ENTER } from '@angular/cdk/keycodes';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
|
||||
import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips';
|
||||
import { Observable, timer } from 'rxjs';
|
||||
import { Observable, of, timer } from 'rxjs';
|
||||
import { debounce, startWith, tap } from 'rxjs/operators';
|
||||
import { AutocompleteOption } from '../../models/autocomplete-option.interface';
|
||||
import { CommonModule } from '@angular/common';
|
||||
@@ -41,10 +41,22 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { IconModule } from '@alfresco/adf-core';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
|
||||
@Component({
|
||||
selector: 'adf-search-chip-autocomplete-input',
|
||||
imports: [CommonModule, MatFormFieldModule, MatChipsModule, TranslatePipe, IconModule, ReactiveFormsModule, MatAutocompleteModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatFormFieldModule,
|
||||
MatChipsModule,
|
||||
TranslatePipe,
|
||||
IconModule,
|
||||
ReactiveFormsModule,
|
||||
MatAutocompleteModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule
|
||||
],
|
||||
templateUrl: './search-chip-autocomplete-input.component.html',
|
||||
styleUrls: ['./search-chip-autocomplete-input.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
@@ -68,6 +80,9 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnChanges {
|
||||
@Input()
|
||||
placeholder = 'SEARCH.FILTER.ACTIONS.ADD_OPTION';
|
||||
|
||||
@Input()
|
||||
loading: Observable<boolean> = of(false);
|
||||
|
||||
@Input()
|
||||
compareOption?: (option1: AutocompleteOption, option2: AutocompleteOption) => boolean;
|
||||
|
||||
|
||||
+12
-8
@@ -5,13 +5,17 @@
|
||||
[allowOnlyPredefinedValues]="settings.allowOnlyPredefinedValues"
|
||||
(inputChanged)="onInputChange($event)"
|
||||
[compareOption]="optionComparator"
|
||||
[placeholder]="settings?.label"
|
||||
[loading]="loading$"
|
||||
(optionsChanged)="onOptionsChange($event)" />
|
||||
|
||||
<div class="adf-facet-buttons" *ngIf="!settings?.hideDefaultAction">
|
||||
<button mat-button data-automation-id="adf-search-chip-autocomplete-btn-clear" (click)="reset()">
|
||||
{{ 'SEARCH.FILTER.ACTIONS.CLEAR' | translate }}
|
||||
</button>
|
||||
<button mat-button data-automation-id="adf-search-chip-autocomplete-btn-apply" (click)="submitValues()">
|
||||
{{ 'SEARCH.FILTER.ACTIONS.APPLY' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (!settings?.hideDefaultAction) {
|
||||
<div class="adf-facet-buttons">
|
||||
<button mat-button data-automation-id="adf-search-chip-autocomplete-btn-clear" (click)="reset()">
|
||||
{{ 'SEARCH.FILTER.ACTIONS.CLEAR' | translate }}
|
||||
</button>
|
||||
<button mat-button data-automation-id="adf-search-chip-autocomplete-btn-apply" (click)="submitValues()">
|
||||
{{ 'SEARCH.FILTER.ACTIONS.APPLY' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
+177
-3
@@ -18,18 +18,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { SearchFilterAutocompleteChipsComponent } from './search-filter-autocomplete-chips.component';
|
||||
import { of, ReplaySubject } from 'rxjs';
|
||||
import { of, ReplaySubject, Subject, throwError } from 'rxjs';
|
||||
import { AutocompleteField, AutocompleteOption } from '../../models/autocomplete-option.interface';
|
||||
import { TagService } from '../../../tag/services/tag.service';
|
||||
import { SitesService } from '../../../common/services/sites.service';
|
||||
import { SitePaging } from '@alfresco/js-api';
|
||||
import { ResultSetPaging, SitePaging } from '@alfresco/js-api';
|
||||
import { CategoryService } from '../../../category';
|
||||
import { SearchService } from '../../services/search.service';
|
||||
import { AppConfigService } from '@alfresco/adf-core';
|
||||
|
||||
describe('SearchFilterAutocompleteChipsComponent', () => {
|
||||
let component: SearchFilterAutocompleteChipsComponent;
|
||||
let fixture: ComponentFixture<SearchFilterAutocompleteChipsComponent>;
|
||||
let tagService: TagService;
|
||||
let sitesService: SitesService;
|
||||
let categoryService: CategoryService;
|
||||
let searchService: SearchService;
|
||||
let appConfig: AppConfigService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -40,6 +45,9 @@ describe('SearchFilterAutocompleteChipsComponent', () => {
|
||||
component = fixture.componentInstance;
|
||||
tagService = TestBed.inject(TagService);
|
||||
sitesService = TestBed.inject(SitesService);
|
||||
categoryService = TestBed.inject(CategoryService);
|
||||
searchService = TestBed.inject(SearchService);
|
||||
appConfig = TestBed.inject(AppConfigService);
|
||||
component.id = 'test-id';
|
||||
component.context = {
|
||||
queryFragments: {
|
||||
@@ -47,7 +55,10 @@ describe('SearchFilterAutocompleteChipsComponent', () => {
|
||||
},
|
||||
filterRawParams: {},
|
||||
populateFilters: new ReplaySubject(1),
|
||||
execute: jasmine.createSpy('execute')
|
||||
execute: jasmine.createSpy('execute'),
|
||||
get wildcardsEnabled(): boolean {
|
||||
return appConfig.get<boolean>('search-wildcards-enabled', true);
|
||||
}
|
||||
} as any;
|
||||
component.settings = {
|
||||
field: 'test',
|
||||
@@ -240,6 +251,169 @@ describe('SearchFilterAutocompleteChipsComponent', () => {
|
||||
expect(searchSpy).toHaveBeenCalledWith('tag', { orderBy: 'tag', direction: 'asc' }, false, 0, 15);
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
function categoriesResult(name: string): ResultSetPaging {
|
||||
return { list: { pagination: {}, entries: [{ entry: { id: `${name}-id`, name, path: { name: '/a/b' } } }] } } as ResultSetPaging;
|
||||
}
|
||||
|
||||
it('should be false initially', (done) => {
|
||||
component.loading$.subscribe((loading) => {
|
||||
expect(loading).toBeFalse();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should toggle true while fetching and back to false once results arrive', () => {
|
||||
component.settings.field = AutocompleteField.CATEGORIES;
|
||||
const response$ = new Subject<any>();
|
||||
spyOn(categoryService, 'searchCategories').and.returnValue(response$.asObservable());
|
||||
const emitted: boolean[] = [];
|
||||
component.loading$.subscribe((loading) => emitted.push(loading));
|
||||
|
||||
component.onInputChange('mark');
|
||||
expect(emitted).toEqual([false, true]);
|
||||
|
||||
response$.next(categoriesResult('Marketing'));
|
||||
response$.complete();
|
||||
expect(emitted).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it('should clear loading and emit empty options when the fetch fails', () => {
|
||||
component.settings.field = AutocompleteField.CATEGORIES;
|
||||
spyOn(categoryService, 'searchCategories').and.returnValue(throwError(() => new Error('failure')));
|
||||
const loadingStates: boolean[] = [];
|
||||
const optionResults: AutocompleteOption[][] = [];
|
||||
component.loading$.subscribe((loading) => loadingStates.push(loading));
|
||||
component.autocompleteOptions$.subscribe((options) => optionResults.push(options));
|
||||
|
||||
component.onInputChange('mark');
|
||||
|
||||
expect(loadingStates).toEqual([false, true, false]);
|
||||
expect(optionResults[optionResults.length - 1]).toEqual([]);
|
||||
});
|
||||
|
||||
it('should keep the stream alive after a failed fetch', () => {
|
||||
component.settings.field = AutocompleteField.CATEGORIES;
|
||||
const searchSpy = spyOn(categoryService, 'searchCategories').and.returnValues(
|
||||
throwError(() => new Error('failure')),
|
||||
of(categoriesResult('Marketing'))
|
||||
);
|
||||
const optionResults: AutocompleteOption[][] = [];
|
||||
component.autocompleteOptions$.subscribe((options) => optionResults.push(options));
|
||||
|
||||
component.onInputChange('mark');
|
||||
component.onInputChange('mark');
|
||||
|
||||
expect(searchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(optionResults[optionResults.length - 1]).toEqual([{ id: 'Marketing-id', value: 'Marketing', fullPath: 'Marketing' }]);
|
||||
});
|
||||
|
||||
it('should ignore results from a superseded request', () => {
|
||||
component.settings.field = AutocompleteField.CATEGORIES;
|
||||
const firstResponse$ = new Subject<any>();
|
||||
const secondResponse$ = new Subject<any>();
|
||||
spyOn(categoryService, 'searchCategories').and.returnValues(firstResponse$.asObservable(), secondResponse$.asObservable());
|
||||
const optionResults: AutocompleteOption[][] = [];
|
||||
component.autocompleteOptions$.subscribe((options) => optionResults.push(options));
|
||||
|
||||
component.onInputChange('ma');
|
||||
component.onInputChange('mark');
|
||||
|
||||
secondResponse$.next(categoriesResult('Fresh'));
|
||||
secondResponse$.complete();
|
||||
firstResponse$.next(categoriesResult('Stale'));
|
||||
firstResponse$.complete();
|
||||
|
||||
expect(optionResults[optionResults.length - 1]).toEqual([{ id: 'Fresh-id', value: 'Fresh', fullPath: 'Fresh' }]);
|
||||
expect(optionResults.some((result) => result.some((option) => option.value === 'Stale'))).toBeFalse();
|
||||
});
|
||||
|
||||
it('should not trigger a fetch for a non-async field', () => {
|
||||
component.settings.field = 'test';
|
||||
const searchSpy = spyOn(categoryService, 'searchCategories');
|
||||
component.onInputChange('mark');
|
||||
expect(searchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PARENT_FOLDER field', () => {
|
||||
const folderPaging: ResultSetPaging = {
|
||||
list: {
|
||||
pagination: {},
|
||||
entries: [{ entry: { id: 'folder1', name: 'Documents', path: { name: '/Company Home/Sites/ws/folderA' } } }]
|
||||
}
|
||||
} as ResultSetPaging;
|
||||
|
||||
function mockWildcardsEnabled(enabled: boolean) {
|
||||
spyOn(appConfig, 'get').and.callFake((key: string, defaultValue?: any) => (key === 'search-wildcards-enabled' ? enabled : defaultValue));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
component.settings.field = AutocompleteField.PARENT_FOLDER;
|
||||
component.context.config = { filterQueries: [{ query: 'existing' }] } as any;
|
||||
});
|
||||
|
||||
it('should search folders and map results into options with full paths', (done) => {
|
||||
spyOn(searchService, 'searchByQueryBody').and.returnValue(of(folderPaging));
|
||||
component.onInputChange('doc');
|
||||
component.autocompleteOptions$.subscribe((result) => {
|
||||
expect(result).toEqual([{ id: 'folder1', value: 'Documents', fullPath: '/Company Home/Sites/ws/folderA/Documents' }]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to the folder name when the path name is empty', (done) => {
|
||||
const folderWithoutPath: ResultSetPaging = {
|
||||
list: {
|
||||
pagination: {},
|
||||
entries: [{ entry: { id: 'folder2', name: 'Documents', path: { name: '' } } }]
|
||||
}
|
||||
} as ResultSetPaging;
|
||||
spyOn(searchService, 'searchByQueryBody').and.returnValue(of(folderWithoutPath));
|
||||
component.onInputChange('doc');
|
||||
component.autocompleteOptions$.subscribe((result) => {
|
||||
expect(result).toEqual([{ id: 'folder2', value: 'Documents', fullPath: 'Documents' }]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should build a folder-scoped query without emitting the dataLoaded event', () => {
|
||||
mockWildcardsEnabled(true);
|
||||
const searchSpy = spyOn(searchService, 'searchByQueryBody').and.returnValue(of(folderPaging));
|
||||
component.onInputChange('doc');
|
||||
|
||||
const [queryBody, shouldEmit] = searchSpy.calls.mostRecent().args;
|
||||
expect(shouldEmit).toBeFalse();
|
||||
expect(queryBody.query.query).toBe(`cm:name:"*doc*"`);
|
||||
expect(queryBody.include).toEqual(['path']);
|
||||
expect(queryBody.filterQueries).toEqual([{ query: 'existing' }, { query: "TYPE:'cm:folder'" }]);
|
||||
});
|
||||
|
||||
it('should not mutate the shared context filter queries', () => {
|
||||
spyOn(searchService, 'searchByQueryBody').and.returnValue(of(folderPaging));
|
||||
component.onInputChange('doc');
|
||||
component.onInputChange('docs');
|
||||
expect(component.context.config.filterQueries).toEqual([{ query: 'existing' }]);
|
||||
});
|
||||
|
||||
it('should not wrap the search term with wildcards when wildcards are disabled', () => {
|
||||
mockWildcardsEnabled(false);
|
||||
component.context.config = {} as any;
|
||||
const searchSpy = spyOn(searchService, 'searchByQueryBody').and.returnValue(of(folderPaging));
|
||||
component.onInputChange('doc');
|
||||
|
||||
const [queryBody] = searchSpy.calls.mostRecent().args;
|
||||
expect(queryBody.query.query).toBe(`cm:name:"doc"`);
|
||||
expect(queryBody.filterQueries).toEqual([{ query: "TYPE:'cm:folder'" }]);
|
||||
});
|
||||
|
||||
it('should compose the query fragment using the node reference', () => {
|
||||
component.selectedOptions = [{ id: 'folder1', value: 'Documents' }];
|
||||
component.submitValues();
|
||||
expect(component.context.queryFragments[component.id]).toBe('ANCESTOR:"workspace://SpacesStore/folder1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('optionComparator', () => {
|
||||
it('should return false if either option is undefined', () => {
|
||||
expect(component.optionComparator(undefined, { value: 'A' })).toBe(false);
|
||||
|
||||
+105
-42
@@ -16,8 +16,8 @@
|
||||
*/
|
||||
|
||||
import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, ReplaySubject, Subject } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { BehaviorSubject, Observable, of, ReplaySubject, Subject } from 'rxjs';
|
||||
import { catchError, map, startWith, switchMap } from 'rxjs/operators';
|
||||
import { SearchWidget } from '../../models/search-widget.interface';
|
||||
import { SearchWidgetSettings } from '../../models/search-widget-settings.interface';
|
||||
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
|
||||
@@ -31,6 +31,13 @@ import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { SitesService } from '../../../common/services/sites.service';
|
||||
import { SearchService } from '../../services/search.service';
|
||||
import { SearchRequest } from '@alfresco/js-api';
|
||||
|
||||
interface AutocompleteFetchState {
|
||||
options: AutocompleteOption[] | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'adf-search-filter-autocomplete-chips',
|
||||
@@ -42,6 +49,18 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
|
||||
private readonly tagService = inject(TagService);
|
||||
private readonly categoryService = inject(CategoryService);
|
||||
private readonly sitesService = inject(SitesService);
|
||||
private readonly searchService = inject(SearchService);
|
||||
private readonly loadingSubject$ = new BehaviorSubject<boolean>(false);
|
||||
private readonly inputChange$ = new Subject<string>();
|
||||
private readonly asyncFields: string[] = [
|
||||
AutocompleteField.CATEGORIES,
|
||||
AutocompleteField.TAG,
|
||||
AutocompleteField.LOCATION,
|
||||
AutocompleteField.PARENT_FOLDER
|
||||
];
|
||||
private readonly resetSubject$ = new Subject<void>();
|
||||
private readonly autocompleteOptionsSubject$ = new BehaviorSubject<AutocompleteOption[]>([]);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
id: string;
|
||||
settings?: SearchWidgetSettings;
|
||||
@@ -51,13 +70,9 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
|
||||
displayValue$ = new ReplaySubject<string>(1);
|
||||
selectedOptions: AutocompleteOption[] = [];
|
||||
enableChangeUpdate: boolean;
|
||||
|
||||
private readonly resetSubject$ = new Subject<void>();
|
||||
reset$: Observable<void> = this.resetSubject$.asObservable();
|
||||
private readonly autocompleteOptionsSubject$ = new BehaviorSubject<AutocompleteOption[]>([]);
|
||||
autocompleteOptions$: Observable<AutocompleteOption[]> = this.autocompleteOptionsSubject$.asObservable();
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
loading$: Observable<boolean> = this.loadingSubject$.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.options = new SearchFilterList<AutocompleteOption[]>();
|
||||
@@ -71,6 +86,23 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
|
||||
}
|
||||
this.enableChangeUpdate = this.settings.allowUpdateOnChange ?? true;
|
||||
}
|
||||
this.inputChange$
|
||||
.pipe(
|
||||
switchMap((value) =>
|
||||
this.fetchOptions(value).pipe(
|
||||
map((options): AutocompleteFetchState => ({ options, loading: false })),
|
||||
catchError((): Observable<AutocompleteFetchState> => of({ options: [], loading: false })),
|
||||
startWith<AutocompleteFetchState>({ options: null, loading: true })
|
||||
)
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(({ options, loading }) => {
|
||||
this.loadingSubject$.next(loading);
|
||||
if (options) {
|
||||
this.autocompleteOptionsSubject$.next(options);
|
||||
}
|
||||
});
|
||||
this.context.populateFilters
|
||||
.asObservable()
|
||||
.pipe(
|
||||
@@ -121,12 +153,9 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
|
||||
}
|
||||
|
||||
onInputChange(value: string) {
|
||||
if (this.settings.field === AutocompleteField.CATEGORIES) {
|
||||
this.searchForExistingCategories(value);
|
||||
} else if (this.settings.field === AutocompleteField.TAG) {
|
||||
this.searchForExistingTags(value);
|
||||
} else if (this.settings.field === AutocompleteField.LOCATION) {
|
||||
this.populateSitesOptions();
|
||||
const field = this.settings?.field;
|
||||
if (field && this.asyncFields.includes(field)) {
|
||||
this.inputChange$.next(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +177,7 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
|
||||
let queryFragments;
|
||||
switch (this.settings.field) {
|
||||
case AutocompleteField.CATEGORIES:
|
||||
case AutocompleteField.PARENT_FOLDER:
|
||||
queryFragments = this.selectedOptions.map((val) => `${this.settings.field}:"workspace://SpacesStore/${val.id}"`);
|
||||
break;
|
||||
case AutocompleteField.LOCATION:
|
||||
@@ -165,14 +195,11 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
|
||||
}
|
||||
|
||||
private setOptions() {
|
||||
switch (this.settings.field) {
|
||||
switch (this.settings?.field) {
|
||||
case AutocompleteField.TAG:
|
||||
this.autocompleteOptionsSubject$.next([]);
|
||||
break;
|
||||
case AutocompleteField.CATEGORIES:
|
||||
this.autocompleteOptionsSubject$.next([]);
|
||||
break;
|
||||
case AutocompleteField.LOCATION:
|
||||
case AutocompleteField.PARENT_FOLDER:
|
||||
this.autocompleteOptionsSubject$.next([]);
|
||||
break;
|
||||
default:
|
||||
@@ -180,44 +207,80 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
|
||||
}
|
||||
}
|
||||
|
||||
private searchForExistingCategories(searchTerm: string) {
|
||||
this.categoryService.searchCategories(searchTerm, 0, 15).subscribe((existingCategoriesResult) => {
|
||||
this.autocompleteOptionsSubject$.next(
|
||||
private fetchOptions(searchTerm: string): Observable<AutocompleteOption[]> {
|
||||
switch (this.settings?.field) {
|
||||
case AutocompleteField.CATEGORIES:
|
||||
return this.searchForExistingCategories(searchTerm);
|
||||
case AutocompleteField.TAG:
|
||||
return this.searchForExistingTags(searchTerm);
|
||||
case AutocompleteField.LOCATION:
|
||||
return this.getSitesOptions();
|
||||
case AutocompleteField.PARENT_FOLDER:
|
||||
return this.searchFolders(searchTerm);
|
||||
default:
|
||||
return of([]);
|
||||
}
|
||||
}
|
||||
|
||||
private searchForExistingCategories(searchTerm: string): Observable<AutocompleteOption[]> {
|
||||
return this.categoryService.searchCategories(searchTerm, 0, 15).pipe(
|
||||
map((existingCategoriesResult) =>
|
||||
existingCategoriesResult.list.entries.map((rowEntry) => {
|
||||
const path = rowEntry.entry.path.name.split('/').splice(3).join('/');
|
||||
const fullPath = path ? `${path}/${rowEntry.entry.name}` : rowEntry.entry.name;
|
||||
return { id: rowEntry.entry.id, value: rowEntry.entry.name, fullPath };
|
||||
})
|
||||
);
|
||||
});
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private searchForExistingTags(searchTerm: string) {
|
||||
this.tagService.searchTags(searchTerm, { orderBy: 'tag', direction: 'asc' }, false, 0, 15).subscribe((existingTagsResult) => {
|
||||
this.autocompleteOptionsSubject$.next(
|
||||
private searchForExistingTags(searchTerm: string): Observable<AutocompleteOption[]> {
|
||||
return this.tagService.searchTags(searchTerm, { orderBy: 'tag', direction: 'asc' }, false, 0, 15).pipe(
|
||||
map((existingTagsResult) =>
|
||||
existingTagsResult.list.entries.map((tag) => ({
|
||||
id: tag.entry.id,
|
||||
value: tag.entry.tag
|
||||
}))
|
||||
);
|
||||
});
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private populateSitesOptions(): void {
|
||||
this.sitesService
|
||||
.getSites()
|
||||
.pipe(
|
||||
map((sites) => {
|
||||
const predefinedOptions = this.settings?.autocompleteOptions || [];
|
||||
const sitesOptions = sites.list.entries
|
||||
.filter((siteEntry) => siteEntry.entry.visibility === 'public' || siteEntry.entry?.role)
|
||||
.map<AutocompleteOption>((siteEntry) => ({
|
||||
id: siteEntry.entry.id,
|
||||
value: siteEntry.entry.title
|
||||
}));
|
||||
return [...sitesOptions, ...predefinedOptions];
|
||||
private getSitesOptions(): Observable<AutocompleteOption[]> {
|
||||
return this.sitesService.getSites().pipe(
|
||||
map((sites) => {
|
||||
const predefinedOptions = this.settings?.autocompleteOptions || [];
|
||||
const sitesOptions = sites.list.entries
|
||||
.filter((siteEntry) => siteEntry.entry.visibility === 'public' || siteEntry.entry?.role)
|
||||
.map<AutocompleteOption>((siteEntry) => ({
|
||||
id: siteEntry.entry.id,
|
||||
value: siteEntry.entry.title
|
||||
}));
|
||||
return [...sitesOptions, ...predefinedOptions];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private searchFolders(searchTerm: string): Observable<AutocompleteOption[]> {
|
||||
const wildcard = this.context?.wildcardsEnabled ? '*' : '';
|
||||
const filterQueries = [...(this.context?.config.filterQueries ?? []), { query: "TYPE:'cm:folder'" }];
|
||||
const queryBody: SearchRequest = {
|
||||
query: {
|
||||
language: 'afts',
|
||||
query: `cm:name:"${wildcard}${searchTerm}${wildcard}"`
|
||||
},
|
||||
include: ['path'],
|
||||
filterQueries
|
||||
};
|
||||
|
||||
return this.searchService.searchByQueryBody(queryBody, false).pipe(
|
||||
map((folders) =>
|
||||
folders.list.entries.map((folderEntry) => {
|
||||
const fullPath = folderEntry.entry.path.name
|
||||
? `${folderEntry.entry.path.name}/${folderEntry.entry.name}`
|
||||
: folderEntry.entry.name;
|
||||
return { id: folderEntry.entry.id, value: folderEntry.entry.name, fullPath };
|
||||
})
|
||||
)
|
||||
.subscribe((options) => this.autocompleteOptionsSubject$.next(options));
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export interface AutocompleteOption {
|
||||
export const AutocompleteField = {
|
||||
TAG: 'TAG',
|
||||
CATEGORIES: 'cm:categories',
|
||||
LOCATION: 'SITE'
|
||||
LOCATION: 'SITE',
|
||||
PARENT_FOLDER: 'ANCESTOR'
|
||||
} as const;
|
||||
|
||||
export type AutocompleteField = (typeof AutocompleteField)[keyof typeof AutocompleteField];
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface SearchWidgetSettings {
|
||||
allowOnlyPredefinedValues?: boolean;
|
||||
/* allow the user to predefine autocomplete options */
|
||||
autocompleteOptions?: AutocompleteOption[];
|
||||
/* label that will be displayed for autocomplete input */
|
||||
label?: string;
|
||||
|
||||
[indexer: string]: any;
|
||||
}
|
||||
|
||||
@@ -76,14 +76,17 @@ export class SearchService {
|
||||
* Performs a search with its parameters supplied by a request object.
|
||||
*
|
||||
* @param queryBody Object containing the search parameters
|
||||
* @param shouldEmit Should emit dataLoaded event
|
||||
* @returns List of search results
|
||||
*/
|
||||
searchByQueryBody(queryBody: SearchRequest): Observable<ResultSetPaging> {
|
||||
searchByQueryBody(queryBody: SearchRequest, shouldEmit = true): Observable<ResultSetPaging> {
|
||||
const promise = this.searchApi.search(queryBody);
|
||||
|
||||
promise.then((nodePaging) => {
|
||||
this.dataLoaded.next(nodePaging);
|
||||
});
|
||||
if (shouldEmit) {
|
||||
promise.then((nodePaging) => {
|
||||
this.dataLoaded.next(nodePaging);
|
||||
});
|
||||
}
|
||||
|
||||
return from(promise);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user