[MNT-25235] Add folder searching capability in autocomplete filter

This commit is contained in:
MichalKinas
2026-09-09 13:13:38 +02:00
parent 27c700165c
commit 53a4ae086f
14 changed files with 502 additions and 95 deletions
@@ -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>
@@ -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%;
}
}
@@ -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' }];
@@ -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;
@@ -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>
}
@@ -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);
@@ -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);
}