[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
@@ -2,7 +2,7 @@
Title: Search Chip Autocomplete Input component
Added: v6.1.0
Status: Active
Last reviewed: 2023-06-13
Last reviewed: 2026-09-09
---
# [Search Chip Autocomplete Input component](../../../lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts "Defined in search-chip-autocomplete-input.component.ts")
@@ -31,6 +31,7 @@ Represents an input with autocomplete options.
| preselectedOptions | `AutocompleteOption[]` | [] | Options which are selected from start |
| onReset$ | [`Observable`](https://rxjs.dev/guide/observable)`<void>` | | Observable that will listen to any reset event causing component to clear the chips and input |
| allowOnlyPredefinedValues | boolean | true | A flag that indicates whether it is possible to add a value not from the predefined ones |
| loading | [`Observable`](https://rxjs.dev/guide/observable)`<boolean>` | `of(false)` | Stream driving a loading spinner shown inside the autocomplete panel while a new batch of options is being fetched. While it emits `true`, the options list is replaced by the spinner. |
| placeholder | string | 'SEARCH.FILTER.ACTIONS.ADD_OPTION' | Placeholder which should be displayed in input. |
| compareOption | (option1: AutocompleteOption, option2: AutocompleteOption) => boolean | | Function which is used to selected options with all options so it allows to detect which options are already selected. |
| formatChipValue | (option: string) => string | | Function which is used to format custom typed options. |
@@ -2,7 +2,7 @@
Title: Search Filter Autocomplete Chips component
Added: v6.1.0
Status: Active
Last reviewed: 2023-06-13
Last reviewed: 2026-09-09
---
# [Search Filter Autocomplete Chips component](../../../lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts "Defined in search-filter-autocomplete-chips.component.ts")
@@ -41,16 +41,59 @@ Implements a [search widget](../../../lib/content-services/src/lib/search/models
| Name | Type | Description |
| ---- |----------|--------------------------------------------------------------------------------------------------------------------|
| field | `string` | Field to apply the query to. Required value |
| field | `string` | Field to apply the query to. Required value. See [Supported fields](#supported-fields) for the values that enable dynamically fetched options |
| label | `string` | Label displayed for the autocomplete input |
| autocompleteOptions | `AutocompleteOption[]` | Predefined options for autocomplete |
| allowOnlyPredefinedValues | `boolean` | Specifies whether the input values should only be from predefined |
| allowUpdateOnChange | `boolean` | Enable/Disable the update fire event when text has been changed. By default is true |
| hideDefaultAction | `boolean` | Show/hide the widget actions. By default is false |
### Supported fields
Besides using static `autocompleteOptions`, the `field` value can be set to one of the following to fetch options dynamically from the repository as the user types:
| `field` value | Source of options |
| ------------- | ----------------- |
| `TAG` | Existing tags |
| `cm:categories` | Existing categories |
| `SITE` | Available sites (plus any predefined `autocompleteOptions`) |
| `ANCESTOR` | Folders matching the typed name (parent folder search) |
While a batch of options is being fetched for any of these fields, a loading spinner is shown inside the autocomplete panel until the results arrive. Only the results of the latest request are applied, so quickly changing the input does not display stale options.
## Details
This component allows the user to choose filter options for the search query.
See the [Search Chip Autocomplete Input component](search-chip-autocomplete-input.component.md) for more details.
### Parent folder search
Set `field` to `ANCESTOR` to let the user search for and select a parent folder to scope the search to. The following example also uses `label` to set the input label:
```json
{
"search": {
"categories": [
{
"id": "parentFolder",
"name": "Parent folder",
"enabled": true,
"component": {
"selector": "autocomplete-chips",
"settings": {
"allowUpdateOnChange": false,
"hideDefaultAction": true,
"allowOnlyPredefinedValues": false,
"field": "ANCESTOR",
"label": "Parent folder"
}
}
}
]
}
}
```
## See also
- [Search Configuration Guide](../../user-guide/search-configuration-guide.md)
+2 -1
View File
@@ -24,9 +24,10 @@ Accesses the Content Services Search API.
- _maxResults:_ `number` - Maximum number of items in the list of results
- _skipCount:_ `number` - Number of higher-ranked items to skip over in the list
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/search-rest-api/docs/ResultSetPaging.md)`>` - List of search results
- **searchByQueryBody**(queryBody: `SearchRequest`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/search-rest-api/docs/ResultSetPaging.md)`>`<br/>
- **searchByQueryBody**(queryBody: `SearchRequest`, shouldEmit: `boolean` = `true`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/search-rest-api/docs/ResultSetPaging.md)`>`<br/>
Performs a search with its parameters supplied by a SearchRequest object.
- _queryBody:_ `SearchRequest` - Object containing the search parameters
- _shouldEmit:_ `boolean` - Whether the `dataLoaded` event should be emitted with the results. Set to `false` for auxiliary searches (for example populating autocomplete options) that should not notify the main results subscribers. Defaults to `true`
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/search-rest-api/docs/ResultSetPaging.md)`>` - List of search results
## Details
@@ -412,6 +412,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;
}
@@ -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);
}