ADW Saved Search (#4173)

* [ACS-8751] Adapt search results to handle query encoding and state propagation

* ADW Saved Search

* ADW Saved Search

* Changes after CR, bug fixed

* Changes after CR, bug fixed

* Changes after CR, bug fixed

* Changes after CR, bug fixed

* Changes after CR, bug fixed

* Changes after code review

* Changes after code review

* Changes after code review

* Changes after code review

* Changes after code review

* Changes after code review

* Changes after code review

* Changes after code review

* Changes after code review

* Changes after code review

* ACS-8751 fix e2e

* ACS-8751 fix e2e

* ACS-8751 fix e2e

* ACS-8751 fix e2e saved-search

* ACS-8751 fix e2e recent file

* ACS-8751 fix e2e recent file

* [ACS-8751] Change encoding from ascii to utf8 to handle special language characters

---------

Co-authored-by: MichalKinas <michal.kinas@hyland.com>
Co-authored-by: akash.rathod@hyland.com <akash.rathod@hyland.com>
This commit is contained in:
dominikiwanekhyland 2024-10-23 09:33:08 +02:00 committed by GitHub
parent b9213f345c
commit a7573dc933
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1161 additions and 323 deletions

View File

@ -335,7 +335,7 @@ test.describe('Library actions ', () => {
});
test('[C306959] Join a public library from Search Results', async ({ searchPage }) => {
await searchPage.navigate({ remoteUrl: `#/search-libraries;q=${adminLibrary2}` });
await searchPage.searchWithin(adminLibrary2, 'libraries');
await searchPage.reload({ waitUntil: loadString });
await expect(libraryTable.getCellByColumnNameAndRowItem(adminLibrary2, notMemberString)).toBeVisible();
await libraryTable.performActionFromExpandableMenu(adminLibrary2, joinButton);
@ -344,7 +344,7 @@ test.describe('Library actions ', () => {
});
test('[C306960] Join a moderated library from Search Results', async ({ myLibrariesPage, searchPage }) => {
await searchPage.navigate({ remoteUrl: `#/search-libraries;q=${adminModerateLibrary2}` });
await searchPage.searchWithin(adminModerateLibrary2, 'libraries');
await expect(libraryTable.getCellByColumnNameAndRowItem(adminModerateLibrary2, notMemberString)).toBeVisible();
await libraryTable.performActionFromExpandableMenu(adminModerateLibrary2, joinButton);
await expect.soft(snackBar.getByMessageLocator(requestToJoinMessage)).toBeVisible();
@ -356,7 +356,7 @@ test.describe('Library actions ', () => {
test('[C306961] Leave a library from Search Results', async ({ searchPage }) => {
const confirmDialog = searchPage.confirmDialogComponent;
await searchPage.navigate({ remoteUrl: `#/search-libraries;q=${user1Library3}` });
await searchPage.searchWithin(user1Library3, 'libraries');
await expect(libraryTable.getCellByColumnNameAndRowItem(user1Library3, 'Collaborator')).toBeVisible();
await libraryTable.performActionFromExpandableMenu(user1Library3, leaveLibraryButton);
@ -367,7 +367,7 @@ test.describe('Library actions ', () => {
test('[C306962] Cancel join from Search Results', async ({ searchPage }) => {
await user2SitesApi.createSiteMembershipRequestForPerson(username2, adminModerateLibrary4);
await searchPage.navigate({ remoteUrl: `#/search-libraries;q=${adminModerateLibrary4}` });
await searchPage.searchWithin(adminModerateLibrary4, 'libraries');
await expect(libraryTable.getCellByColumnNameAndRowItem(adminModerateLibrary4, notMemberString)).toBeVisible();
await libraryTable.performActionFromExpandableMenu(adminModerateLibrary4, cancelJoinRequestButton);
await expect.soft(snackBar.getByMessageLocator(cancelJoinRequestMessage)).toBeVisible();
@ -415,7 +415,7 @@ test.describe('Library actions ', () => {
const libraryMenu = searchPage.matMenu;
const trashTable = trashPage.dataTable;
await searchPage.navigate({ remoteUrl: `#/search-libraries;q=${user2Library7Delete}` });
await searchPage.searchWithin(user2Library7Delete, 'libraries');
await expect(libraryTable.getRowByName(user2Library7Delete)).toBeVisible();
await libraryTable.getRowByName(user2Library7Delete).click();
await searchHeader.clickMoreActions();

View File

@ -88,10 +88,10 @@ test.describe('Special permissions', () => {
test('[C213173] on Recent Files', async ({ recentFilesPage }) => {
await recentFilesPage.navigate();
expect(await recentFilesPage.dataTable.getRowsCount(), 'Incorrect number of items').toBe(1);
expect(await recentFilesPage.dataTable.getRowsCount(), 'Incorrect number of items').toBeGreaterThanOrEqual(1);
await siteApiAdmin.deleteSiteMember(sitePrivate, username);
await recentFilesPage.reload();
expect(await recentFilesPage.dataTable.isEmpty(), 'Items are still displayed').toBe(true);
expect(await recentFilesPage.dataTable.isItemPresent(fileName), 'Items are still displayed').toBe(false);
});
test('[C213227] on Favorites', async ({ favoritePage }) => {

View File

@ -168,7 +168,7 @@ test.describe('Remember sorting', () => {
test('[C261147] Sort order is retained when user changes the page from pagination', async ({ personalFiles }) => {
const lastFileInArray = testData.user1.files.jpg.slice(-1).pop();
const firstFileInArray = testData.user1.files.pdf[0];
const firstFileInArray = testData.user1.files.pdf[1];
await personalFiles.pagination.clickOnNextPage();
await personalFiles.dataTable.spinnerWaitForReload();

View File

@ -89,7 +89,7 @@ export function collaboratorTests(userCollaborator: string, siteName: string) {
});
test('on Search Results - [C297653]', async ({ searchPage, myLibrariesPage }) => {
await searchPage.navigate({ remoteUrl: `#/search;q=${testData.fileSharedFav.name}` });
await searchPage.searchWithin(testData.fileSharedFav.name, 'filesAndFolders');
await checkActionsAvailable(
myLibrariesPage,
testData.fileSharedFav.name,
@ -131,7 +131,7 @@ export function collaboratorTests(userCollaborator: string, siteName: string) {
});
test('file opened from Search Results - [C306992]', async ({ searchPage, myLibrariesPage }) => {
await searchPage.navigate({ remoteUrl: `#/search;q=${testData.fileDocxSharedFav.name}` });
await searchPage.searchWithin(testData.fileDocxSharedFav.name, 'filesAndFolders');
await checkActionsViewerAvailable(
myLibrariesPage,
testData.fileDocxSharedFav.name,
@ -183,7 +183,7 @@ export function filesLockedByCurrentUser(userDemoted: string, siteName?: string)
});
test('on Search Results - [C297660]', async ({ searchPage, myLibrariesPage }) => {
await searchPage.navigate({ remoteUrl: `#/search;q=${testData.fileLockedByUser}` });
await searchPage.searchWithin(testData.fileLockedByUser, 'filesAndFolders');
await checkActionsAvailable(
myLibrariesPage,
testData.fileLockedByUser,
@ -226,7 +226,7 @@ export function filesLockedByCurrentUser(userDemoted: string, siteName?: string)
});
test('file opened from Search Results - [C306993]', async ({ searchPage, myLibrariesPage }) => {
await searchPage.navigate({ remoteUrl: `#/search;q=${testData.fileLockedByUser}` });
await searchPage.searchWithin(testData.fileLockedByUser, 'filesAndFolders');
await checkActionsViewerAvailable(
myLibrariesPage,
testData.fileLockedByUser,
@ -281,7 +281,7 @@ export function filesLockedByOtherUser(userManager: string, siteName?: string) {
});
test('on Search Results - [C297667]', async ({ searchPage, myLibrariesPage }) => {
await searchPage.navigate({ remoteUrl: `#/search;q=${testData.fileLockedByUser}` });
await searchPage.searchWithin(testData.fileLockedByUser, 'filesAndFolders');
await checkActionsAvailable(
myLibrariesPage,
testData.fileLockedByUser,
@ -324,7 +324,7 @@ export function filesLockedByOtherUser(userManager: string, siteName?: string) {
// TODO: add 'Move' and 'Delete' when ACA-2319 is fixed
test('file opened from Search Results - [C306994]', async ({ searchPage, myLibrariesPage }) => {
await searchPage.navigate({ remoteUrl: `#/search;q=${testData.fileLockedByUser}` });
await searchPage.searchWithin(testData.fileLockedByUser, 'filesAndFolders');
await checkActionsViewerAvailable(
myLibrariesPage,
testData.fileLockedByUser,

View File

@ -330,7 +330,7 @@ export function viewerTests(userConsumer: string, siteName: string) {
): Promise<void> {
await loginPage.navigate();
await loginPage.loginUser({ username: userConsumer, password: userConsumer });
await searchPage.navigate({ remoteUrl: `#/search;q=${item}` });
await searchPage.searchWithin(item, 'filesAndFolders');
await searchPage.searchInput.performDoubleClickFolderOrFileToOpen(item);
expect(await searchPage.viewer.isViewerOpened(), 'Viewer is not opened').toBe(true);
await searchPage.viewer.verifyViewerPrimaryActions(expectedToolbarPrimary);

View File

@ -260,6 +260,15 @@
]
}
]
},
{
"id": "app.navbar.secondary",
"items": [
{
"id": "app.search.navbar",
"component": "app.search.navbar"
}
]
}
],
"toolbar": [

View File

@ -209,6 +209,18 @@
"ADVANCED_FILTERS": "Advanced Filters:",
"RESET": "Reset",
"RESET_ACTION": "Reset search filters",
"SAVE_SEARCH": {
"ACTION_BUTTON": "Save Search",
"MODAL_HEADER": "Save this search",
"NAME_LABEL": "Name",
"NAME_REQUIRED_ERROR": "This field is required",
"DESCRIPTION_LABEL": "Description",
"SAVE_SUCCESS": "Search Saved",
"SAVE_ERROR": "Error occured. Search could not be saved.",
"NAVBAR": {
"TITLE": "Saved Searches ({{ number }})"
}
},
"FOUND_RESULTS": "{{ number }} results found",
"FOUND_ONE_RESULT": "{{ number }} result found",
"CUSTOM_ROW": {

View File

@ -25,7 +25,7 @@
import { HammerModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { TRANSLATION_PROVIDER, AuthGuardEcm, LanguagePickerComponent, NotificationHistoryComponent } from '@alfresco/adf-core';
import { AuthGuardEcm, LanguagePickerComponent, NotificationHistoryComponent, TRANSLATION_PROVIDER } from '@alfresco/adf-core';
import {
ContentModule,
ContentVersionService,
@ -71,13 +71,14 @@ import { TagsColumnComponent } from './components/dl-custom-components/tags-colu
import { UserInfoComponent } from './components/common/user-info/user-info.component';
import { SidenavComponent } from './components/sidenav/sidenav.component';
import { ContentManagementService } from './services/content-management.service';
import { ShellLayoutComponent, SHELL_NAVBAR_MIN_WIDTH } from '@alfresco/adf-core/shell';
import { SHELL_NAVBAR_MIN_WIDTH, ShellLayoutComponent } from '@alfresco/adf-core/shell';
import { UserMenuComponent } from './components/sidenav/user-menu/user-menu.component';
import { ContextMenuComponent } from './components/context-menu/context-menu.component';
import { MAT_DIALOG_DEFAULT_OPTIONS } from '@angular/material/dialog';
import { SearchResultsRowComponent } from './components/search/search-results-row/search-results-row.component';
import { BulkActionsDropdownComponent } from './components/bulk-actions-dropdown/bulk-actions-dropdown.component';
import { AgentsButtonComponent } from './components/knowledge-retrieval/search-ai/agents-button/agents-button.component';
import { SaveSearchSidenavComponent } from './components/search/search-save/sidenav/save-search-sidenav.component';
@NgModule({
imports: [
@ -157,7 +158,8 @@ export class ContentServiceExtensionModule {
'app.user': UserInfoComponent,
'app.notification-center': NotificationHistoryComponent,
'app.user.menu': UserMenuComponent,
'app.search.columns.name': SearchResultsRowComponent
'app.search.columns.name': SearchResultsRowComponent,
'app.search.navbar': SaveSearchSidenavComponent
});
extensions.setEvaluators({

View File

@ -32,15 +32,18 @@ import { AppHookService, AppService } from '@alfresco/aca-shared';
import { map } from 'rxjs/operators';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { SearchNavigationService } from '../search-navigation.service';
import { BehaviorSubject, Subject } from 'rxjs';
import { BehaviorSubject, of, Subject } from 'rxjs';
import { NotificationService } from '@alfresco/adf-core';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { Buffer } from 'buffer';
import { ActivatedRoute } from '@angular/router';
describe('SearchInputComponent', () => {
let fixture: ComponentFixture<SearchInputComponent>;
let component: SearchInputComponent;
let actions$: Actions;
let appHookService: AppHookService;
let route: ActivatedRoute;
let searchInputService: SearchNavigationService;
let showErrorSpy: jasmine.Spy;
@ -50,6 +53,10 @@ describe('SearchInputComponent', () => {
toggleAppNavBar$: new Subject()
};
const encodeQuery = (query: any): string => {
return Buffer.from(JSON.stringify(query)).toString('base64');
};
beforeEach(() => {
appServiceMock.setAppNavbarMode.calls.reset();
TestBed.configureTestingModule({
@ -68,6 +75,7 @@ describe('SearchInputComponent', () => {
fixture = TestBed.createComponent(SearchInputComponent);
appHookService = TestBed.inject(AppHookService);
searchInputService = TestBed.inject(SearchNavigationService);
route = TestBed.inject(ActivatedRoute);
component = fixture.componentInstance;
const notificationService = TestBed.inject(NotificationService);
@ -147,35 +155,10 @@ describe('SearchInputComponent', () => {
});
describe('onSearchChange()', () => {
it('should call search action with correct search options', (done) => {
it('should call search action with correct searched term', () => {
const searchedTerm = 's';
const currentSearchOptions = [{ key: 'SEARCH.INPUT.FILES' }];
actions$
.pipe(
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
map((action) => {
expect(action.searchOptions[0].key).toBe(currentSearchOptions[0].key);
})
)
.subscribe(() => {
done();
});
component.onSearchChange(searchedTerm);
});
it('should call search action with correct searched term', (done) => {
const searchedTerm = 's';
actions$
.pipe(
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
map((action) => {
expect(action.payload).toBe(searchedTerm);
})
)
.subscribe(() => {
done();
});
component.onSearchChange(searchedTerm);
expect(component.searchedWord).toBe(searchedTerm);
});
it('should show snack for empty search', () => {
@ -246,4 +229,14 @@ describe('SearchInputComponent', () => {
expect(appServiceMock.setAppNavbarMode).toHaveBeenCalledWith('expanded');
});
it('should extract searched word from query params', (done) => {
route.queryParams = of({ q: encodeQuery({ userQuery: 'cm:name:"test*"' }) });
route.queryParams.subscribe(() => {
fixture.detectChanges();
expect(component.searchedWord).toBe('test');
done();
});
fixture.detectChanges();
});
});

View File

@ -28,10 +28,10 @@ import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { AppConfigService, NotificationService } from '@alfresco/adf-core';
import { Component, inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { NavigationEnd, PRIMARY_OUTLET, Router, RouterEvent, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router';
import { ActivatedRoute, Params, PRIMARY_OUTLET, Router, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { takeUntil } from 'rxjs/operators';
import { SearchInputControlComponent } from '../search-input-control/search-input-control.component';
import { SearchNavigationService } from '../search-navigation.service';
import { SearchLibrariesQueryBuilderService } from '../search-libraries-results/search-libraries-query-builder.service';
@ -44,6 +44,7 @@ import { MatInputModule } from '@angular/material/input';
import { A11yModule } from '@angular/cdk/a11y';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { FormsModule } from '@angular/forms';
import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-utils';
@Component({
standalone: true,
@ -67,12 +68,9 @@ import { FormsModule } from '@angular/forms';
host: { class: 'aca-search-input' }
})
export class SearchInputComponent implements OnInit, OnDestroy {
private notificationService = inject(NotificationService);
private readonly notificationService = inject(NotificationService);
onDestroy$: Subject<boolean> = new Subject<boolean>();
hasOneChange = false;
hasNewChange = false;
navigationTimer: any;
has400LibraryError = false;
hasLibrariesConstraint = false;
searchOnChange: boolean;
@ -106,14 +104,15 @@ export class SearchInputComponent implements OnInit, OnDestroy {
trigger: MatMenuTrigger;
constructor(
private queryBuilder: SearchQueryBuilderService,
private queryLibrariesBuilder: SearchLibrariesQueryBuilderService,
private config: AppConfigService,
private router: Router,
private store: Store<AppStore>,
private appHookService: AppHookService,
private appService: AppService,
public searchInputService: SearchNavigationService
private readonly queryBuilder: SearchQueryBuilderService,
private readonly queryLibrariesBuilder: SearchLibrariesQueryBuilderService,
private readonly config: AppConfigService,
private readonly router: Router,
private readonly route: ActivatedRoute,
private readonly store: Store<AppStore>,
private readonly appHookService: AppHookService,
private readonly appService: AppService,
public readonly searchInputService: SearchNavigationService
) {
this.searchOnChange = this.config.get<boolean>('search.aca:triggeredOnChange', true);
}
@ -121,14 +120,13 @@ export class SearchInputComponent implements OnInit, OnDestroy {
ngOnInit() {
this.showInputValue();
this.router.events
.pipe(takeUntil(this.onDestroy$))
.pipe(filter((e) => e instanceof RouterEvent))
.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.showInputValue();
}
});
this.route.queryParams.pipe(takeUntil(this.onDestroy$)).subscribe((params: Params) => {
const encodedQuery = params['q'];
if (encodedQuery && this.searchInputControl) {
this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery);
this.searchInputControl.searchTerm = this.searchedWord;
}
});
this.appHookService.library400Error.pipe(takeUntil(this.onDestroy$)).subscribe(() => {
this.has400LibraryError = true;
@ -192,24 +190,6 @@ export class SearchInputComponent implements OnInit, OnDestroy {
this.has400LibraryError = false;
this.hasLibrariesConstraint = this.evaluateLibrariesConstraint();
this.searchedWord = searchTerm;
if (this.hasOneChange) {
this.hasNewChange = true;
} else {
this.hasOneChange = true;
}
if (this.hasNewChange) {
clearTimeout(this.navigationTimer);
this.hasNewChange = false;
}
this.navigationTimer = setTimeout(() => {
if (searchTerm) {
this.store.dispatch(new SearchByTermAction(searchTerm, this.searchOptions));
}
this.hasOneChange = false;
}, 1000);
}
searchByOption() {
@ -305,7 +285,7 @@ export class SearchInputComponent implements OnInit, OnDestroy {
if (urlSegmentGroup) {
const urlSegments: UrlSegment[] = urlSegmentGroup.segments;
searchTerm = urlSegments[0].parameters['q'] ? decodeURIComponent(urlSegments[0].parameters['q']) : '';
searchTerm = extractSearchedWordFromEncodedQuery(urlSegments[0].parameters['q']);
}
}

View File

@ -27,13 +27,16 @@ import { AppTestingModule } from '../../../testing/app-testing.module';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { SearchLibrariesResultsComponent } from './search-libraries-results.component';
import { SearchLibrariesQueryBuilderService } from './search-libraries-query-builder.service';
import { BehaviorSubject, Subject } from 'rxjs';
import { BehaviorSubject, of, Subject } from 'rxjs';
import { AppService } from '@alfresco/aca-shared';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { Buffer } from 'buffer';
describe('SearchLibrariesResultsComponent', () => {
let component: SearchLibrariesResultsComponent;
let fixture: ComponentFixture<SearchLibrariesResultsComponent>;
let route: ActivatedRoute;
const emptyPage = { list: { pagination: { totalItems: 0 }, entries: [] } };
const appServiceMock = {
@ -42,6 +45,10 @@ describe('SearchLibrariesResultsComponent', () => {
setAppNavbarMode: jasmine.createSpy('setAppNavbarMode')
};
const encodeQuery = (query: any): string => {
return Buffer.from(JSON.stringify(query)).toString('base64');
};
beforeEach(() => {
appServiceMock.setAppNavbarMode.calls.reset();
TestBed.configureTestingModule({
@ -56,6 +63,7 @@ describe('SearchLibrariesResultsComponent', () => {
]
});
route = TestBed.inject(ActivatedRoute);
fixture = TestBed.createComponent(SearchLibrariesResultsComponent);
component = fixture.componentInstance;
});
@ -72,4 +80,14 @@ describe('SearchLibrariesResultsComponent', () => {
expect(appServiceMock.setAppNavbarMode).toHaveBeenCalledWith('collapsed');
});
it('should extract searched word from query params', (done) => {
route.queryParams = of({ q: encodeQuery({ userQuery: 'cm:name:"test*"' }) });
route.queryParams.subscribe(() => {
fixture.detectChanges();
expect(component.searchedWord).toBe('test');
done();
});
fixture.detectChanges();
});
});

View File

@ -45,6 +45,8 @@ import { CustomEmptyContentTemplateDirective, DataColumnComponent, DataColumnLis
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { DocumentListDirective } from '../../../directives/document-list.directive';
import { DocumentListComponent } from '@alfresco/adf-content-services';
import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-utils';
import { takeUntil } from 'rxjs/operators';
@Component({
standalone: true,
@ -128,14 +130,12 @@ export class SearchLibrariesResultsComponent extends PageComponent implements On
);
if (this.route) {
this.route.params.forEach((params: Params) => {
// eslint-disable-next-line no-prototype-builtins
this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
const query = this.formatSearchQuery(this.searchedWord);
if (query && query.length > 1) {
this.route.queryParams.pipe(takeUntil(this.onDestroy$)).subscribe((params: Params) => {
const encodedQuery = params[this.queryParamName] || null;
this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery);
if (this.searchedWord?.length > 1) {
this.librariesQueryBuilder.paging.skipCount = 0;
this.librariesQueryBuilder.userQuery = query;
this.librariesQueryBuilder.userQuery = this.searchedWord;
this.librariesQueryBuilder.update();
} else {
this.librariesQueryBuilder.userQuery = null;
@ -147,13 +147,6 @@ export class SearchLibrariesResultsComponent extends PageComponent implements On
}
}
private formatSearchQuery(userInput: string) {
if (!userInput) {
return null;
}
return userInput.trim();
}
onSearchResultLoaded(nodePaging: NodePaging) {
this.data = nodePaging;
this.totalResults = this.getNumberOfResults();

View File

@ -26,14 +26,26 @@
<div class="aca-content__advanced-filters">
<div class="aca-content__advanced-filters--header">
<p>{{ 'APP.BROWSE.SEARCH.ADVANCED_FILTERS' | translate }}</p>
<button
mat-button
adf-reset-search
class="aca-content__reset-action"
title="{{ 'APP.BROWSE.SEARCH.RESET_ACTION' | translate }}"
[attr.aria-label]="'APP.BROWSE.SEARCH.RESET_ACTION' | translate ">
{{ 'APP.BROWSE.SEARCH.RESET' | translate }}
</button>
<div class="aca-content__advanced-filters--header--action-buttons">
<button
mat-button
acaSaveSearch
[acaSaveSearchQuery]="encodedQuery"
[disabled]="!encodedQuery"
class="aca-content__save-search-action"
title="{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate }}"
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate ">
{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate }}
</button>
<button
mat-button
adf-reset-search
class="aca-content__reset-action"
title="{{ 'APP.BROWSE.SEARCH.RESET_ACTION' | translate }}"
[attr.aria-label]="'APP.BROWSE.SEARCH.RESET_ACTION' | translate ">
{{ 'APP.BROWSE.SEARCH.RESET' | translate }}
</button>
</div>
</div>
<adf-search-filter-chips></adf-search-filter-chips>
</div>

View File

@ -155,10 +155,15 @@ aca-search-results {
&--header {
display: flex;
justify-content: space-between;
&--action-buttons {
display: flex;
}
}
}
&__reset-action {
&__reset-action,
&__save-search-action {
margin-top: 5px;
margin-right: 2px;
line-height: 33px;

View File

@ -30,10 +30,11 @@ import { NavigateToFolder } from '@alfresco/aca-shared/store';
import { Pagination, SearchRequest } from '@alfresco/js-api';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, Subject } from 'rxjs';
import { BehaviorSubject, of, Subject } from 'rxjs';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { AppService } from '@alfresco/aca-shared';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { Buffer } from 'buffer';
import { testHeader } from '../../../testing/document-base-page-utils';
describe('SearchComponent', () => {
@ -44,10 +45,15 @@ describe('SearchComponent', () => {
let queryBuilder: SearchQueryBuilderService;
let translate: TranslationService;
let router: Router;
let route: ActivatedRoute;
const searchRequest = {} as SearchRequest;
let params: BehaviorSubject<any>;
let showErrorSpy: jasmine.Spy;
const encodeQuery = (query: any): string => {
return Buffer.from(JSON.stringify(query)).toString('base64');
};
beforeEach(() => {
params = new BehaviorSubject({ q: 'TYPE: "cm:folder" AND %28=cm: name: email OR cm: name: budget%29' });
TestBed.configureTestingModule({
@ -80,6 +86,8 @@ describe('SearchComponent', () => {
queryBuilder = TestBed.inject(SearchQueryBuilderService);
translate = TestBed.inject(TranslationService);
router = TestBed.inject(Router);
route = TestBed.inject(ActivatedRoute);
route.queryParams = of({});
const notificationService = TestBed.inject(NotificationService);
showErrorSpy = spyOn(notificationService, 'showError');
@ -150,79 +158,6 @@ describe('SearchComponent', () => {
expect(showErrorSpy).toHaveBeenCalledWith('Generic Error');
}));
it('should decode encoded URI', () => {
expect(queryBuilder.userQuery).toEqual('(TYPE: "cm:folder" AND (=cm: name: email OR cm: name: budget))');
});
it('should return null if formatting invalid query', () => {
expect(component.formatSearchQuery(null)).toBeNull();
expect(component.formatSearchQuery('')).toBeNull();
});
it('should use original user input if text contains colons', () => {
const query = 'TEXT:test OR TYPE:folder';
expect(component.formatSearchQuery(query)).toBe(query);
});
it('should be able to search if search input contains https url', () => {
const query = component.formatSearchQuery('https://alfresco.com');
expect(query).toBe(`(cm:name:"https://alfresco.com*")`);
});
it('should be able to search if search input contains http url', () => {
const query = component.formatSearchQuery('http://alfresco.com');
expect(query).toBe(`(cm:name:"http://alfresco.com*")`);
});
it('should use original user input if text contains quotes', () => {
const query = `"Hello World"`;
expect(component.formatSearchQuery(query)).toBe(query);
});
it('should format user input according to the configuration fields', () => {
const query = component.formatSearchQuery('hello', ['cm:name', 'cm:title']);
expect(query).toBe(`(cm:name:"hello*" OR cm:title:"hello*")`);
});
it('should format user input as cm:name if configuration not provided', () => {
const query = component.formatSearchQuery('hello');
expect(query).toBe(`(cm:name:"hello*")`);
});
it('should use AND operator when conjunction has no operators', () => {
const query = component.formatSearchQuery('big yellow banana', ['cm:name']);
expect(query).toBe(`(cm:name:"big*") AND (cm:name:"yellow*") AND (cm:name:"banana*")`);
});
it('should support conjunctions with AND operator', () => {
const query = component.formatSearchQuery('big AND yellow AND banana', ['cm:name', 'cm:title']);
expect(query).toBe(
`(cm:name:"big*" OR cm:title:"big*") AND (cm:name:"yellow*" OR cm:title:"yellow*") AND (cm:name:"banana*" OR cm:title:"banana*")`
);
});
it('should support conjunctions with OR operator', () => {
const query = component.formatSearchQuery('big OR yellow OR banana', ['cm:name', 'cm:title']);
expect(query).toBe(
`(cm:name:"big*" OR cm:title:"big*") OR (cm:name:"yellow*" OR cm:title:"yellow*") OR (cm:name:"banana*" OR cm:title:"banana*")`
);
});
it('should support exact term matching with default fields', () => {
const query = component.formatSearchQuery('=orange', ['cm:name', 'cm:title']);
expect(query).toBe(`(=cm:name:"orange" OR =cm:title:"orange")`);
});
it('should support exact term matching with operators', () => {
const query = component.formatSearchQuery('=test1.pdf or =test2.pdf', ['cm:name', 'cm:title']);
expect(query).toBe(`(=cm:name:"test1.pdf" OR =cm:title:"test1.pdf") or (=cm:name:"test2.pdf" OR =cm:title:"test2.pdf")`);
});
it('should navigate to folder on double click', () => {
const node: any = {
entry: {
@ -268,17 +203,26 @@ describe('SearchComponent', () => {
expect(queryBuilder.update).toHaveBeenCalled();
});
it('should update the user query whenever param changed', () => {
params.next({ q: '=orange' });
expect(queryBuilder.userQuery).toBe(`((=cm:name:"orange"))`);
expect(queryBuilder.update).toHaveBeenCalled();
it('should update the user query, populate filters state and execute query whenever param changed', (done) => {
spyOn(queryBuilder.populateFilters, 'next');
spyOn(queryBuilder, 'execute');
const query = { userQuery: 'cm:tag:"orange*"', filterProp: { prop: 'test' } };
route.queryParams = of({ q: encodeQuery(query) });
component.ngOnInit();
route.queryParams.subscribe(() => {
expect(component.searchedWord).toBe(`orange`);
expect(queryBuilder.userQuery).toBe(`(cm:tag:"orange*")`);
expect(queryBuilder.populateFilters.next).toHaveBeenCalledWith({ userQuery: 'cm:tag:"orange*"', filterProp: { prop: 'test' } });
queryBuilder.filterLoaded.next();
fixture.detectChanges();
done();
});
});
it('should update the user query whenever configuration changed', () => {
params.next({ q: '=orange' });
component.searchedWord = 'orange';
queryBuilder.configUpdated.next({ 'app:fields': ['cm:tag'] } as any);
expect(queryBuilder.userQuery).toBe(`((=cm:tag:"orange"))`);
expect(queryBuilder.update).toHaveBeenCalled();
expect(queryBuilder.userQuery).toBe(`((cm:tag:"orange*"))`);
});
testHeader(SearchResultsComponent, false);

View File

@ -22,13 +22,14 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { ChangeDetectorRef, Component, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { NodeEntry, Pagination, ResultSetPaging } from '@alfresco/js-api';
import { ActivatedRoute, Params } from '@angular/router';
import {
AlfrescoViewerComponent,
DocumentListComponent,
ResetSearchDirective,
SearchConfiguration,
SearchFilterChipsComponent,
SearchFormComponent,
SearchQueryBuilderService,
@ -39,8 +40,8 @@ import {
NavigateToFolder,
SetInfoDrawerPreviewStateAction,
SetInfoDrawerStateAction,
ShowInfoDrawerPreviewAction,
SetSearchItemsTotalCountAction
SetSearchItemsTotalCountAction,
ShowInfoDrawerPreviewAction
} from '@alfresco/aca-shared/store';
import {
CustomEmptyContentTemplateDirective,
@ -52,7 +53,6 @@ import {
TranslationService,
ViewerToolbarComponent
} from '@alfresco/adf-core';
import { combineLatest } from 'rxjs';
import {
ContextActionsDirective,
InfoDrawerComponent,
@ -78,6 +78,14 @@ import { SearchResultsRowComponent } from '../search-results-row/search-results-
import { DocumentListPresetRef, DynamicColumnComponent } from '@alfresco/adf-extensions';
import { BulkActionsDropdownComponent } from '../../bulk-actions-dropdown/bulk-actions-dropdown.component';
import { SearchAiInputContainerComponent } from '../../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
import {
extractFiltersFromEncodedQuery,
extractSearchedWordFromEncodedQuery,
extractUserQueryFromEncodedQuery,
formatSearchTerm
} from '../../../utils/aca-search-utils';
import { SaveSearchDirective } from '../search-save/directive/save-search.directive';
import { Subject } from 'rxjs';
@Component({
standalone: true,
@ -112,7 +120,8 @@ import { SearchAiInputContainerComponent } from '../../knowledge-retrieval/searc
CustomEmptyContentTemplateDirective,
ViewerToolbarComponent,
BulkActionsDropdownComponent,
SearchAiInputContainerComponent
SearchAiInputContainerComponent,
SaveSearchDirective
],
selector: 'aca-search-results',
templateUrl: './search-results.component.html',
@ -132,12 +141,17 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
totalResults: number;
isTagsEnabled = false;
columns: DocumentListPresetRef[] = [];
encodedQuery: string;
searchConfig: SearchConfiguration;
private readonly loadedFilters$ = new Subject<void>();
constructor(
tagsService: TagService,
private queryBuilder: SearchQueryBuilderService,
private route: ActivatedRoute,
private translationService: TranslationService
private readonly queryBuilder: SearchQueryBuilderService,
private readonly changeDetectorRef: ChangeDetectorRef,
private readonly route: ActivatedRoute,
private readonly translationService: TranslationService
) {
super();
@ -148,15 +162,12 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
maxItems: 25
};
combineLatest([this.route.params, this.queryBuilder.configUpdated])
this.queryBuilder.configUpdated
.asObservable()
.pipe(takeUntil(this.onDestroy$))
.subscribe(([params, searchConfig]) => {
// eslint-disable-next-line no-prototype-builtins
this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
const query = this.formatSearchQuery(this.searchedWord, searchConfig['app:fields']);
if (query) {
this.queryBuilder.userQuery = decodeURIComponent(query);
}
.subscribe((searchConfig) => {
this.searchConfig = searchConfig;
this.updateUserQuery();
});
}
@ -171,6 +182,7 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
if (query) {
this.sorting = this.getSorting();
this.isLoading = true;
this.changeDetectorRef.detectChanges();
}
}),
@ -179,6 +191,7 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
this.onSearchResultLoaded(data);
this.isLoading = false;
this.changeDetectorRef.detectChanges();
}),
this.queryBuilder.error.subscribe((err: any) => {
@ -189,17 +202,31 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
this.columns = this.extensions.documentListPresets.searchResults || [];
if (this.route) {
this.route.params.forEach((params: Params) => {
// eslint-disable-next-line no-prototype-builtins
this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
if (this.searchedWord) {
this.queryBuilder.update();
this.route.queryParams.pipe(takeUntil(this.onDestroy$)).subscribe((params: Params) => {
this.loadedFilters$.next();
this.encodedQuery = params[this.queryParamName] || null;
this.searchedWord = extractSearchedWordFromEncodedQuery(this.encodedQuery);
this.updateUserQuery();
const filtersFromEncodedQuery = extractFiltersFromEncodedQuery(this.encodedQuery);
if (filtersFromEncodedQuery !== null) {
const filtersToLoad = this.queryBuilder.categories.length;
let loadedFilters = this.searchedWord === '' ? 0 : 1;
this.queryBuilder.filterLoaded
.asObservable()
.pipe(takeUntil(this.onDestroy$), takeUntil(this.loadedFilters$))
.subscribe(() => {
loadedFilters++;
if (filtersToLoad === loadedFilters) {
this.loadedFilters$.next();
this.queryBuilder.execute(false);
}
});
this.queryBuilder.populateFilters.next(filtersFromEncodedQuery);
} else {
this.queryBuilder.userQuery = null;
this.queryBuilder.executed.next({
list: { pagination: { totalItems: 0 }, entries: [] }
});
this.queryBuilder.populateFilters.next({});
this.queryBuilder.execute(false);
}
this.queryBuilder.userQuery = extractUserQueryFromEncodedQuery(this.encodedQuery);
});
}
}
@ -217,59 +244,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
this.notificationService.showError(message);
}
private isOperator(input: string): boolean {
if (input) {
input = input.trim().toUpperCase();
const operators = ['AND', 'OR'];
return operators.includes(input);
}
return false;
}
private formatFields(fields: string[], term: string): string {
let prefix = '';
let suffix = '*';
if (term.startsWith('=')) {
prefix = '=';
suffix = '';
term = term.substring(1);
}
if (term === '*') {
prefix = '';
suffix = '';
}
return '(' + fields.map((field) => `${prefix}${field}:"${term}${suffix}"`).join(' OR ') + ')';
}
formatSearchQuery(userInput: string, fields = ['cm:name']) {
if (!userInput) {
return null;
}
if (/^http[s]?:\/\//.test(userInput)) {
return this.formatFields(fields, userInput);
}
userInput = userInput.trim();
if (userInput.includes(':') || userInput.includes('"')) {
return userInput;
}
const words = userInput.split(' ');
if (words.length > 1) {
const separator = words.some(this.isOperator) ? ' ' : ' AND ';
return words.map((term) => (this.isOperator(term) ? term : this.formatFields(fields, term))).join(separator);
}
return this.formatFields(fields, userInput);
}
onSearchResultLoaded(nodePaging: ResultSetPaging) {
this.data = nodePaging;
this.totalResults = this.getNumberOfResults();
@ -329,4 +303,9 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
this.queryBuilder.sorting = [{ ...option, ascending: option.ascending }];
this.queryBuilder.update();
}
private updateUserQuery(): void {
const updatedUserQuery = formatSearchTerm(this.searchedWord, this.searchConfig['app:fields']);
this.queryBuilder.userQuery = updatedUserQuery;
}
}

View File

@ -0,0 +1,53 @@
<h2 mat-dialog-title>{{"APP.BROWSE.SEARCH.SAVE_SEARCH.MODAL_HEADER" | translate}}</h2>
<mat-dialog-content>
<form [formGroup]="form" (submit)="submit()">
<mat-form-field class="aca-save-search-dialog__form-field">
<mat-label>{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAME_LABEL' | translate }}</mat-label>
<input
id="aca-save-search-dialog-name-input"
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.NAME_LABEL' | translate"
matInput
required
[formControlName]="'name'"
adf-auto-focus
/>
<mat-error *ngIf="form.controls['name'].touched">
<span *ngIf="form.controls['name'].errors?.required">
{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAME_REQUIRED_ERROR' | translate }}
</span>
<span *ngIf="!form.controls['name'].errors?.required && form.controls['name'].errors?.message">
{{ form.controls['name'].errors?.message | translate }}
</span>
</mat-error>
</mat-form-field>
<mat-form-field class="aca-save-search-dialog__form-field">
<mat-label>{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.DESCRIPTION_LABEL' | translate }}</mat-label>
<textarea
id="aca-save-search-dialog-description-input"
matInput
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.DESCRIPTION_LABEL' | translate"
rows="4"
[formControlName]="'description'"></textarea>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-button
id="aca-save-search-dialog-cancel-button"
mat-dialog-close>
{{ 'CANCEL' | titlecase | translate }}
</button>
<button id="aca-save-search-dialog-save-button"
mat-flat-button
color="primary"
(click)="submit()"
[disabled]="!form.valid || disableSubmitButton">
{{ 'SAVE' | titlecase | translate}}
</button>
</mat-dialog-actions>

View File

@ -0,0 +1,5 @@
.aca-save-search-dialog {
.aca-save-search-dialog__form-field {
width: 100%;
}
}

View File

@ -0,0 +1,101 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { of } from 'rxjs';
import { SaveSearchDialogComponent } from './save-search-dialog.component';
import { ContentTestingModule, SavedSearchesService } from '@alfresco/adf-content-services';
import { provideMockStore } from '@ngrx/store/testing';
import { AppTestingModule } from '../../../../testing/app-testing.module';
import { Store } from '@ngrx/store';
import { SnackbarErrorAction, SnackbarInfoAction } from '@alfresco/aca-shared/store';
describe('SaveSearchDialogComponent', () => {
let fixture: ComponentFixture<SaveSearchDialogComponent>;
let component: SaveSearchDialogComponent;
let savedSearchesService: SavedSearchesService;
let store: Store;
let submitButton: HTMLButtonElement;
const dialogRef = {
close: jasmine.createSpy('close')
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ContentTestingModule, AppTestingModule],
providers: [
{ provide: MatDialogRef, useValue: dialogRef },
provideMockStore(),
{ provide: SavedSearchesService, useValue: { saveSearch: () => of() } },
{ provide: MAT_DIALOG_DATA, useValue: { searchUrl: 'abcdef' } }
]
});
dialogRef.close.calls.reset();
fixture = TestBed.createComponent(SaveSearchDialogComponent);
component = fixture.componentInstance;
savedSearchesService = TestBed.inject(SavedSearchesService);
store = TestBed.inject(Store);
submitButton = fixture.nativeElement.querySelector('#aca-save-search-dialog-save-button');
});
afterEach(() => {
fixture.destroy();
});
it('should not save search if form is invalid', () => {
spyOn(savedSearchesService, 'saveSearch').and.callThrough();
submitButton.click();
expect(savedSearchesService.saveSearch).not.toHaveBeenCalled();
});
it('should save search, show snackbar message and close modal if form is valid', fakeAsync(() => () => {
spyOn(savedSearchesService, 'saveSearch').and.callThrough();
setFormValuesAndSubmit();
expect(store.dispatch).toHaveBeenCalledWith(new SnackbarInfoAction('APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_SUCCESS'));
expect(dialogRef.close).toHaveBeenCalled();
}));
it('should show snackbar error if there is save error', fakeAsync(() => () => {
spyOn(savedSearchesService, 'saveSearch').and.throwError('');
setFormValuesAndSubmit();
expect(store.dispatch).toHaveBeenCalledWith(new SnackbarErrorAction('APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_ERROR'));
expect(dialogRef.close).not.toHaveBeenCalled();
}));
function setFormValuesAndSubmit() {
spyOn(store, 'dispatch');
component.form.controls['name'].setValue('ABCDEF');
component.form.controls['description'].setValue('TEST');
submitButton.click();
tick();
expect(savedSearchesService.saveSearch).toHaveBeenCalledWith({
name: 'ABCDEF',
description: 'TEST',
encodedUrl: 'abcdef'
});
}
});

View File

@ -0,0 +1,104 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, Inject, ViewEncapsulation } from '@angular/core';
import { MatMenuModule } from '@angular/material/menu';
import { SearchInputControlComponent } from '../../search-input-control/search-input-control.component';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { A11yModule } from '@angular/cdk/a11y';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { FormControl, FormGroup, FormsModule, Validators } from '@angular/forms';
import { CoreModule } from '@alfresco/adf-core';
import { AutoFocusDirective, forbidOnlySpaces, SavedSearchesService } from '@alfresco/adf-content-services';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { take } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { AppStore, SnackbarErrorAction, SnackbarInfoAction } from '@alfresco/aca-shared/store';
@Component({
standalone: true,
imports: [
CommonModule,
TranslateModule,
MatMenuModule,
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
A11yModule,
MatCheckboxModule,
FormsModule,
SearchInputControlComponent,
CoreModule,
AutoFocusDirective
],
selector: 'aca-save-search-dialog',
templateUrl: './save-search-dialog.component.html',
styleUrls: ['./save-search-dialog.component.scss'],
encapsulation: ViewEncapsulation.None,
host: { class: 'aca-save-search-dialog' }
})
export class SaveSearchDialogComponent {
form = new FormGroup({
name: new FormControl('', [Validators.required, forbidOnlySpaces]),
description: new FormControl('')
});
disableSubmitButton = false;
constructor(
private readonly dialog: MatDialogRef<SaveSearchDialogComponent>,
private readonly store: Store<AppStore>,
private readonly savedSearchesService: SavedSearchesService,
@Inject(MAT_DIALOG_DATA) private readonly data: { searchUrl: string }
) {}
submit() {
if (this.form.invalid || this.disableSubmitButton) {
return;
}
this.disableSubmitButton = true;
const formValue = this.form.value;
const saveSearch = { name: formValue.name, description: formValue.description, encodedUrl: encodeURIComponent(this.data.searchUrl) };
this.savedSearchesService
.saveSearch(saveSearch)
.pipe(take(1))
.subscribe({
next: () => {
this.dialog.close();
this.store.dispatch(new SnackbarInfoAction('APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_SUCCESS'));
this.disableSubmitButton = false;
},
error: () => {
this.store.dispatch(new SnackbarErrorAction('APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_ERROR'));
this.disableSubmitButton = false;
}
});
}
}

View File

@ -0,0 +1,89 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { By } from '@angular/platform-browser';
import { of } from 'rxjs';
import { SaveSearchDirective } from './save-search.directive';
import { SaveSearchDialogComponent } from '../dialog/save-search-dialog.component';
@Component({
selector: 'app-test-component',
template: '<div acaSaveSearch="searchQuery" acaSaveSearchQuery="encodedQuery"></div>',
standalone: true,
imports: [SaveSearchDirective]
})
class TestComponent {
searchQuery = 'encodedQuery';
}
describe('SaveSearchDirective', () => {
let fixture: ComponentFixture<TestComponent>;
let element: DebugElement;
let dialog: MatDialog;
const event = {
type: 'click',
preventDefault: jasmine.createSpy('preventDefault')
};
beforeEach(() => {
void TestBed.configureTestingModule({
imports: [SaveSearchDirective, TestComponent],
providers: [
{
provide: MatDialog,
useValue: {
open: () => ({
afterClosed: jasmine.createSpy('afterClosed').and.returnValue(of(null))
})
}
}
]
}).compileComponents();
fixture = TestBed.createComponent(TestComponent);
element = fixture.debugElement.query(By.directive(SaveSearchDirective));
dialog = TestBed.inject(MatDialog);
fixture.detectChanges();
});
it('should prevent the default click action', () => {
element.triggerEventHandler('click', event);
expect(event.preventDefault).toHaveBeenCalled();
});
it('should open the dialog with the correct configuration', () => {
spyOn(dialog, 'open');
element.triggerEventHandler('click', event);
const expectedConfig = {
data: { searchUrl: 'encodedQuery' }
};
expect(dialog.open).toHaveBeenCalledWith(SaveSearchDialogComponent, expectedConfig);
});
});

View File

@ -0,0 +1,59 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Directive, HostListener, Input } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { SaveSearchDialogComponent } from '../dialog/save-search-dialog.component';
interface SaveSearchDirectiveDialogData {
searchUrl: string;
}
@Directive({
selector: '[acaSaveSearch]',
standalone: true
})
export class SaveSearchDirective {
/** Encoded search query */
@Input()
acaSaveSearchQuery: string;
constructor(private readonly dialogRef: MatDialog) {}
@HostListener('click', ['$event'])
onClick(event: MouseEvent) {
event.preventDefault();
this.openDialog();
}
private openDialog(): void {
this.dialogRef.open(SaveSearchDialogComponent, this.getDialogConfig());
}
private getDialogConfig(): { data: SaveSearchDirectiveDialogData } {
return {
data: { searchUrl: this.acaSaveSearchQuery }
};
}
}

View File

@ -0,0 +1 @@
<app-expand-menu *ngIf="item" [item]="item" (actionClicked)="onActionClick()"></app-expand-menu>

View File

@ -0,0 +1,84 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { SaveSearchSidenavComponent } from './save-search-sidenav.component';
import { SavedSearchesService } from '@alfresco/adf-content-services';
import { AppTestingModule } from '../../../../testing/app-testing.module';
import { of, ReplaySubject } from 'rxjs';
describe('SaveSearchSidenavComponent', () => {
let fixture: ComponentFixture<SaveSearchSidenavComponent>;
let component: SaveSearchSidenavComponent;
let savedSearchesService: SavedSearchesService;
beforeEach(() => {
const mockService = {
innit: () => {},
getSavedSearches: () => of(),
savedSearches$: new ReplaySubject(1)
};
TestBed.configureTestingModule({
imports: [AppTestingModule, SaveSearchSidenavComponent],
providers: [
{
provide: SavedSearchesService,
useValue: mockService
}
]
});
fixture = TestBed.createComponent(SaveSearchSidenavComponent);
component = fixture.componentInstance;
savedSearchesService = TestBed.inject(SavedSearchesService);
});
it('should set navbar object if no search is saved', async () => {
savedSearchesService.savedSearches$.next([]);
fixture.detectChanges();
await fixture.whenStable();
expect(component.item).toEqual({
icon: '',
title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAVBAR.TITLE',
children: [],
route: '/',
id: 'search-navbar'
});
});
it('should set navbar object with children is searches are saved', fakeAsync(() => {
savedSearchesService.savedSearches$.next([{ name: '1', order: 0, encodedUrl: 'abc' }]);
component.ngOnInit();
fixture.detectChanges();
tick();
expect(component.item.children[0]).toEqual({
icon: '',
title: '1',
route: 'search?q=abc',
url: 'search?q=abc',
id: 'search1'
});
}));
});

View File

@ -0,0 +1,88 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, inject, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { SavedSearch, SavedSearchesService } from '@alfresco/adf-content-services';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { CoreModule, TranslationService } from '@alfresco/adf-core';
import { DynamicExtensionComponent, NavBarLinkRef } from '@alfresco/adf-extensions';
import { ExpandMenuComponent } from '../../../sidenav/components/expand-menu.component';
import { SidenavHeaderComponent } from '../../../sidenav/components/sidenav-header.component';
import { AppService } from '@alfresco/aca-shared';
@Component({
selector: 'aca-save-search-sidenav',
standalone: true,
imports: [CoreModule, DynamicExtensionComponent, ExpandMenuComponent, SidenavHeaderComponent],
templateUrl: './save-search-sidenav.component.html',
encapsulation: ViewEncapsulation.None
})
export class SaveSearchSidenavComponent implements OnInit, OnDestroy {
savedSearchesService = inject(SavedSearchesService);
appService = inject(AppService);
translationService = inject(TranslationService);
destroy$ = new Subject<void>();
item: NavBarLinkRef;
ngOnInit() {
this.savedSearchesService.innit();
this.savedSearchesService.savedSearches$
.asObservable()
.pipe(takeUntil(this.destroy$))
.subscribe((savedSearches) => {
this.item = this.createNavBarLinkRef(savedSearches);
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
onActionClick(): void {
this.appService.appNavNarMode$.next('collapsed');
}
private createNavBarLinkRef(children: SavedSearch[]): NavBarLinkRef {
const mappedChildren = children
.map((child) => ({
id: 'search' + child.name,
icon: '',
title: child.name,
route: `search?q=${child.encodedUrl}`,
url: `search?q=${child.encodedUrl}`
}))
.slice(0, 5);
const title = this.translationService.instant('APP.BROWSE.SEARCH.SAVE_SEARCH.NAVBAR.TITLE', { number: children.length });
return {
icon: '',
title,
children: mappedChildren,
route: '/',
id: 'search-navbar'
};
}
}

View File

@ -20,7 +20,6 @@
<mat-expansion-panel
class="aca-expansion-panel"
[expanded]="true"
[acaExpansionPanel]="item"
[@.disabled]="true"
>
<mat-expansion-panel-header expandedHeight="32px" collapsedHeight="32px" role="group" class="aca-expansion-panel-header">
@ -44,6 +43,7 @@
<button
acaActiveLink="aca-action-button--active"
[action]="child"
(actionClicked)="actionClicked.emit()"
[attr.aria-label]="child.title | translate"
[id]="child.id"
[attr.data-automation-id]="child.id"

View File

@ -22,7 +22,7 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, OnInit, Input, ViewEncapsulation, ChangeDetectorRef } from '@angular/core';
import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { NavBarLinkRef } from '@alfresco/adf-extensions';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
@ -54,6 +54,9 @@ export class ExpandMenuComponent implements OnInit {
@Input()
item: NavBarLinkRef;
@Output()
actionClicked = new EventEmitter<void>();
constructor(private cd: ChangeDetectorRef) {}
ngOnInit() {

View File

@ -23,34 +23,46 @@
*/
import { ActionDirective } from './action.directive';
import { provideRouter, Router } from '@angular/router';
import { TestBed } from '@angular/core/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { Store } from '@ngrx/store';
import { AppStore } from '@alfresco/aca-shared/store';
describe('ActionDirective', () => {
let directive: ActionDirective;
const routeMock: any = {
navigate: jasmine.createSpy('navigate'),
parseUrl: () => ({
root: {
children: []
}
})
};
const storeMock: any = {
dispatch: jasmine.createSpy('dispatch')
};
let router: Router;
let store: Store<AppStore>;
beforeEach(() => {
directive = new ActionDirective(routeMock, storeMock);
TestBed.configureTestingModule({
imports: [ActionDirective],
providers: [provideRouter([]), provideMockStore()]
});
store = TestBed.inject(Store);
router = TestBed.inject(Router);
directive = new ActionDirective(router, store);
});
it('should navigate if action is route', () => {
spyOn(router, 'navigate');
directive.action = { url: 'dummy' };
directive.onClick();
expect(routeMock.navigate).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['dummy', {}], { queryParams: {} });
});
it('should get query params correctly from URL', () => {
spyOn(router, 'navigate');
directive.action = { url: 'dummy?q=12345' };
directive.onClick();
expect(router.navigate).toHaveBeenCalledWith(['dummy', {}], { queryParams: { q: '12345' } });
});
it('should dispatch store action', () => {
spyOn(store, 'dispatch');
directive.action = { click: {} };
directive.onClick();
expect(storeMock.dispatch).toHaveBeenCalled();
expect(store.dispatch).toHaveBeenCalled();
});
});

View File

@ -22,8 +22,8 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Directive, Input, HostListener } from '@angular/core';
import { PRIMARY_OUTLET, Router } from '@angular/router';
import { Directive, EventEmitter, HostListener, Input, Output } from '@angular/core';
import { Params, PRIMARY_OUTLET, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { AppStore } from '@alfresco/aca-shared/store';
@ -36,20 +36,22 @@ import { AppStore } from '@alfresco/aca-shared/store';
export class ActionDirective {
@Input() action;
@Output() actionClicked = new EventEmitter<void>();
@HostListener('click')
onClick() {
if (this.action.url) {
this.router.navigate(this.getNavigationCommands(this.action.url));
this.router.navigate(this.getNavigationCommands(this.action.url), { queryParams: this.getNavigationQueryParams(this.action.url) });
} else if (this.action.click) {
this.store.dispatch({
type: this.action.click.action,
payload: this.getNavigationCommands(this.action.click.payload)
});
}
this.actionClicked.next();
}
constructor(private router: Router, private store: Store<AppStore>) {}
private getNavigationCommands(url: string): any[] {
const urlTree = this.router.parseUrl(url);
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
@ -65,4 +67,8 @@ export class ActionDirective {
return acc;
}, []);
}
private getNavigationQueryParams(url: string): Params {
return this.router.parseUrl(url).queryParams;
}
}

View File

@ -22,8 +22,8 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Directive, OnInit, Input, ElementRef, Renderer2, ContentChildren, QueryList, AfterContentInit, Optional } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { AfterContentInit, ContentChildren, Directive, ElementRef, Input, OnInit, Optional, QueryList, Renderer2 } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter, takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { ActionDirective } from './action.directive';

View File

@ -29,10 +29,12 @@ import { EffectsModule } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
import { SearchOptionIds, SearchByTermAction, SearchAction } from '@alfresco/aca-shared/store';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
describe('SearchEffects', () => {
let store: Store<any>;
let router: Router;
let queryBuilder: SearchQueryBuilderService;
beforeEach(() => {
TestBed.configureTestingModule({
@ -41,18 +43,21 @@ describe('SearchEffects', () => {
store = TestBed.inject(Store);
router = TestBed.inject(Router);
queryBuilder = TestBed.inject(SearchQueryBuilderService);
spyOn(router, 'navigateByUrl').and.stub();
});
describe('searchByTerm$', () => {
it('should navigate to `search` when search options has library false', fakeAsync(() => {
spyOn(queryBuilder, 'navigateToSearch');
store.dispatch(new SearchByTermAction('test', []));
tick();
expect(router.navigateByUrl).toHaveBeenCalledWith('/search;q=test');
expect(queryBuilder.navigateToSearch).toHaveBeenCalledWith('(cm:name:"test*")', '/search');
}));
it('should navigate to `search-libraries` when search options has library true', fakeAsync(() => {
spyOn(queryBuilder, 'navigateToSearch');
store.dispatch(
new SearchByTermAction('test', [
{
@ -66,23 +71,7 @@ describe('SearchEffects', () => {
tick();
expect(router.navigateByUrl).toHaveBeenCalledWith('/search-libraries;q=test');
}));
it('should encode search string for parentheses', fakeAsync(() => {
store.dispatch(new SearchByTermAction('(test)', []));
tick();
expect(router.navigateByUrl).toHaveBeenCalledWith('/search;q=%2528test%2529');
}));
it('should encode %', fakeAsync(() => {
store.dispatch(new SearchByTermAction('%test%', []));
tick();
expect(router.navigateByUrl).toHaveBeenCalledWith('/search;q=%2525test%2525');
expect(queryBuilder.navigateToSearch).toHaveBeenCalledWith('(cm:name:"test*")', '/search-libraries');
}));
});

View File

@ -22,16 +22,19 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Actions, ofType, createEffect } from '@ngrx/effects';
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { inject, Injectable } from '@angular/core';
import { map } from 'rxjs/operators';
import { SearchAction, SearchActionTypes, SearchByTermAction, SearchOptionIds } from '@alfresco/aca-shared/store';
import { Router } from '@angular/router';
import { SearchNavigationService } from '../../components/search/search-navigation.service';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { formatSearchTerm } from '../../utils/aca-search-utils';
@Injectable()
export class SearchEffects {
constructor(private actions$: Actions, private router: Router, private searchNavigationService: SearchNavigationService) {}
private readonly actions$ = inject(Actions);
private readonly queryBuilder = inject(SearchQueryBuilderService);
private readonly searchNavigationService = inject(SearchNavigationService);
search$ = createEffect(
() =>
@ -49,15 +52,10 @@ export class SearchEffects {
this.actions$.pipe(
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
map((action) => {
const query = action.payload.replace(/%/g, '%25').replace(/[(]/g, '%28').replace(/[)]/g, '%29');
const query = formatSearchTerm(action.payload, this.queryBuilder.config['app:fields']);
const libItem = action.searchOptions.find((item) => item.id === SearchOptionIds.Libraries);
const librarySelected = !!libItem && libItem.value;
if (librarySelected) {
this.router.navigateByUrl('/search-libraries;q=' + encodeURIComponent(query));
} else {
this.router.navigateByUrl('/search;q=' + encodeURIComponent(query));
}
this.queryBuilder.navigateToSearch(query, librarySelected ? '/search-libraries' : '/search');
})
),
{ dispatch: false }

View File

@ -0,0 +1,155 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import {
extractFiltersFromEncodedQuery,
extractSearchedWordFromEncodedQuery,
extractUserQueryFromEncodedQuery,
formatSearchTerm,
formatSearchTermByFields,
isOperator
} from './aca-search-utils';
import { Buffer } from 'buffer';
describe('SearchUtils', () => {
const encodeQuery = (query: any): string => {
return Buffer.from(JSON.stringify(query)).toString('base64');
};
describe('isOperator', () => {
it('should detect AND operator', () => {
expect(isOperator('AND')).toBeTrue();
});
it('should detect OR operator', () => {
expect(isOperator('OR')).toBeTrue();
});
it('should return false when operator is not present', () => {
expect(isOperator('WITH')).toBeFalse();
});
it('should return false when input is not valid', () => {
expect(isOperator(null)).toBeFalse();
expect(isOperator(undefined)).toBeFalse();
});
});
describe('formatSearchTermByFields', () => {
it('should append "*" to search term', () => {
expect(formatSearchTermByFields('test', ['name'])).toBe('(name:"test*")');
});
it('should not prefix when search term equals "*"', () => {
expect(formatSearchTermByFields('*', ['name'])).toBe('(name:"*")');
});
it('should properly handle search terms starting with "="', () => {
expect(formatSearchTermByFields('=test', ['name'])).toBe('(=name:"test")');
});
it('should format search term with set of fields and join with OR', () => {
expect(formatSearchTermByFields('test', ['name', 'size'])).toBe('(name:"test*" OR size:"test*")');
});
});
describe('formatSearchTerm', () => {
it('should return empty string when input is invalid', () => {
expect(formatSearchTerm(null)).toEqual('');
expect(formatSearchTerm(undefined)).toEqual('');
});
it('should not transfer custom queries', () => {
expect(formatSearchTerm('test:"term"')).toBe('test:"term"');
expect(formatSearchTerm('"test"')).toBe('"test"');
});
it('should properly join multiple word search term', () => {
expect(formatSearchTerm('test word term')).toBe('(cm:name:"test*") AND (cm:name:"word*") AND (cm:name:"term*")');
expect(formatSearchTerm('test word term', ['name', 'size'])).toBe(
'(name:"test*" OR size:"test*") AND (name:"word*" OR size:"word*") AND (name:"term*" OR size:"term*")'
);
});
it('should format user input as cm:name if configuration not provided', () => {
expect(formatSearchTerm('hello')).toBe(`(cm:name:"hello*")`);
});
it('should support conjunctions with AND operator', () => {
expect(formatSearchTerm('big AND yellow AND banana', ['cm:name', 'cm:title'])).toBe(
`(cm:name:"big*" OR cm:title:"big*") AND (cm:name:"yellow*" OR cm:title:"yellow*") AND (cm:name:"banana*" OR cm:title:"banana*")`
);
});
it('should support conjunctions with OR operator', () => {
expect(formatSearchTerm('big OR yellow OR banana', ['cm:name', 'cm:title'])).toBe(
`(cm:name:"big*" OR cm:title:"big*") OR (cm:name:"yellow*" OR cm:title:"yellow*") OR (cm:name:"banana*" OR cm:title:"banana*")`
);
});
it('should support exact term matching with operators', () => {
expect(formatSearchTerm('=test1.pdf or =test2.pdf', ['cm:name', 'cm:title'])).toBe(
`(=cm:name:"test1.pdf" OR =cm:title:"test1.pdf") or (=cm:name:"test2.pdf" OR =cm:title:"test2.pdf")`
);
});
});
describe('extractUserQueryFromEncodedQuery', () => {
it('should return empty string when encoded query is invalid', () => {
expect(extractUserQueryFromEncodedQuery(null)).toBe('');
expect(extractUserQueryFromEncodedQuery(undefined)).toBe('');
});
it('should properly extract user query', () => {
const query = { userQuery: 'cm:name:"test"' };
expect(extractUserQueryFromEncodedQuery(encodeQuery(query))).toBe('cm:name:"test"');
});
});
describe('extractSearchedWordFromEncodedQuery', () => {
it('should return empty string when encoded query is invalid', () => {
const query = { otherProp: 'test' };
expect(extractSearchedWordFromEncodedQuery(null)).toBe('');
expect(extractSearchedWordFromEncodedQuery(undefined)).toBe('');
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('');
});
it('should properly extract search term', () => {
const query = { userQuery: 'cm:name:"test*"' };
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('test');
});
});
describe('extractFiltersFromEncodedQuery', () => {
it('should return null when encoded query is invalid', () => {
expect(extractFiltersFromEncodedQuery(null)).toBeNull();
expect(extractFiltersFromEncodedQuery(undefined)).toBeNull();
});
it('should properly parse encoded object', () => {
const query = { userQuery: 'cm:name:"test*"', filterProp: 'test' };
expect(extractFiltersFromEncodedQuery(encodeQuery(query))).toEqual(query);
});
});
});

View File

@ -0,0 +1,143 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Buffer } from 'buffer';
/**
* Checks if string is an AND or OR operator
*
* @param input string to check if it is an operator
* @returns boolean
*/
export function isOperator(input: string): boolean {
if (input) {
input = input.trim().toUpperCase();
const operators = ['AND', 'OR'];
return operators.includes(input);
}
return false;
}
/**
* Formats a search term by provided fields
*
* @param term search term
* @param fields array of fields
* @returns string
*/
export function formatSearchTermByFields(term: string, fields: string[]): string {
let prefix = '';
let suffix = '*';
if (term.startsWith('=')) {
prefix = '=';
suffix = '';
term = term.substring(1);
}
if (term === '*') {
prefix = '';
suffix = '';
}
return '(' + fields.map((field) => `${prefix}${field}:"${term}${suffix}"`).join(' OR ') + ')';
}
/**
* Formats a search term, splits by words, skips custom queries containing ':' or '"'
*
* @param userInput search term
* @param fields array of fields
* @returns string
*/
export function formatSearchTerm(userInput: string, fields = ['cm:name']): string {
if (!userInput) {
return '';
}
userInput = userInput.trim();
if (userInput.includes(':') || userInput.includes('"')) {
return userInput;
}
const words = userInput.split(' ');
if (words.length > 1) {
const separator = words.some(isOperator) ? ' ' : ' AND ';
return words.map((term) => (isOperator(term) ? term : formatSearchTermByFields(term, fields))).join(separator);
}
return formatSearchTermByFields(userInput, fields);
}
/**
* Decodes a query and extracts the user query
*
* @param encodedQuery encoded query
* @returns string
*/
export function extractUserQueryFromEncodedQuery(encodedQuery: string): string {
if (encodedQuery) {
const decodedQuery: { [key: string]: any } = JSON.parse(Buffer.from(encodedQuery, 'base64').toString('utf8'));
return decodedQuery.userQuery;
}
return '';
}
/**
* Extracts user query from encoded query and splits it to get a search term
*
* @param encodedQuery encoded query
* @returns string
*/
export function extractSearchedWordFromEncodedQuery(encodedQuery: string): string {
if (encodedQuery) {
const userQuery = extractUserQueryFromEncodedQuery(encodedQuery);
return userQuery !== '' && userQuery !== undefined
? userQuery
.split('AND')
.map((searchCondition) => {
const searchTerm = searchCondition.split('"')[1];
return searchTerm === '*' ? searchTerm : searchTerm.slice(0, -1);
})
.join(' ')
: '';
}
return '';
}
/**
* Extracts filters configuration from encoded query
*
* @param encodedQuery encoded query
* @returns object containing filters configuration
*/
export function extractFiltersFromEncodedQuery(encodedQuery: string): any {
if (encodedQuery) {
const decodedQuery = Buffer.from(encodedQuery, 'base64').toString('utf8');
return JSON.parse(decodedQuery);
}
return null;
}

View File

@ -31,3 +31,4 @@ export * from './lib/aca-content.routes';
export * from './lib/extensions/core.extensions.module';
export * from './lib/store/initial-state';
export * from './lib/services/content-url.service';
export * from './lib/utils/aca-search-utils';

View File

@ -32,7 +32,7 @@ export class SearchOverlayComponent extends BaseComponent {
public searchFoldersOption = this.getChild('input#folder-input');
public searchLibrariesOption = this.getChild('input#libraries-input');
public searchInput = this.page.locator('#app-control-input');
public searchButton = this.page.locator('.aca-search-input--search-button');
public searchButton = this.page.locator('app-search-input-control button[title="Search"]');
public searchInputControl = this.page.locator('.app-search-control');
public searchOptions = this.page.locator('.app-search-options');

View File

@ -72,7 +72,7 @@ export class SearchPage extends BasePage {
public uploadNewVersionDialog = new UploadNewVersionDialog(this.page);
public manageVersionsDialog = new ManageVersionsDialog(this.page);
async searchWithin(searchText: string, searchType: SearchType): Promise<void> {
async searchWithin(searchText: string, searchType?: SearchType): Promise<void> {
await this.acaHeader.searchButton.click();
await this.clickSearchButton();
switch (searchType) {