From a7573dc9339d2780258c666b11a3054e4039a0ff Mon Sep 17 00:00:00 2001 From: dominikiwanekhyland <141320833+dominikiwanekhyland@users.noreply.github.com> Date: Wed, 23 Oct 2024 09:33:08 +0200 Subject: [PATCH] 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 Co-authored-by: akash.rathod@hyland.com --- .../src/tests/library-actions.e2e.ts | 10 +- .../list-views/src/tests/permissions.e2e.ts | 4 +- .../list-views/src/tests/sort-list.e2e.ts | 2 +- .../src/tests/other-permissions.ts | 12 +- .../src/tests/viewer.ts | 2 +- .../aca-content/assets/app.extensions.json | 9 + projects/aca-content/assets/i18n/en.json | 12 ++ .../aca-content/src/lib/aca-content.module.ts | 8 +- .../search-input.component.spec.ts | 49 +++--- .../search-input/search-input.component.ts | 62 +++---- ...search-libraries-results.component.spec.ts | 20 ++- .../search-libraries-results.component.ts | 21 +-- .../search-results.component.html | 28 +++- .../search-results.component.scss | 7 +- .../search-results.component.spec.ts | 106 +++--------- .../search-results.component.ts | 135 +++++++-------- .../dialog/save-search-dialog.component.html | 53 ++++++ .../dialog/save-search-dialog.component.scss | 5 + .../save-search-dialog.component.spec.ts | 101 ++++++++++++ .../dialog/save-search-dialog.component.ts | 104 ++++++++++++ .../directive/save-search.directive.spec.ts | 89 ++++++++++ .../directive/save-search.directive.ts | 59 +++++++ .../save-search-sidenav.component.html | 1 + .../save-search-sidenav.component.spec.ts | 84 ++++++++++ .../sidenav/save-search-sidenav.component.ts | 88 ++++++++++ .../components/expand-menu.component.html | 2 +- .../components/expand-menu.component.ts | 5 +- .../directives/action.directive.spec.ts | 40 +++-- .../sidenav/directives/action.directive.ts | 14 +- .../directives/active-link.directive.ts | 4 +- .../lib/store/effects/search.effects.spec.ts | 25 +-- .../src/lib/store/effects/search.effects.ts | 20 +-- .../src/lib/utils/aca-search-utils.spec.ts | 155 ++++++++++++++++++ .../src/lib/utils/aca-search-utils.ts | 143 ++++++++++++++++ projects/aca-content/src/public-api.ts | 1 + .../search/search-overlay.components.ts | 2 +- .../src/page-objects/pages/search.page.ts | 2 +- 37 files changed, 1161 insertions(+), 323 deletions(-) create mode 100644 projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.html create mode 100644 projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.scss create mode 100644 projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.spec.ts create mode 100644 projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.ts create mode 100644 projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.spec.ts create mode 100644 projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.ts create mode 100644 projects/aca-content/src/lib/components/search/search-save/sidenav/save-search-sidenav.component.html create mode 100644 projects/aca-content/src/lib/components/search/search-save/sidenav/save-search-sidenav.component.spec.ts create mode 100644 projects/aca-content/src/lib/components/search/search-save/sidenav/save-search-sidenav.component.ts create mode 100644 projects/aca-content/src/lib/utils/aca-search-utils.spec.ts create mode 100644 projects/aca-content/src/lib/utils/aca-search-utils.ts diff --git a/e2e/playwright/library-actions/src/tests/library-actions.e2e.ts b/e2e/playwright/library-actions/src/tests/library-actions.e2e.ts index 5f10af0fd..80e39a1f6 100644 --- a/e2e/playwright/library-actions/src/tests/library-actions.e2e.ts +++ b/e2e/playwright/library-actions/src/tests/library-actions.e2e.ts @@ -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(); diff --git a/e2e/playwright/list-views/src/tests/permissions.e2e.ts b/e2e/playwright/list-views/src/tests/permissions.e2e.ts index e3ab5f9e8..7182545af 100755 --- a/e2e/playwright/list-views/src/tests/permissions.e2e.ts +++ b/e2e/playwright/list-views/src/tests/permissions.e2e.ts @@ -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 }) => { diff --git a/e2e/playwright/list-views/src/tests/sort-list.e2e.ts b/e2e/playwright/list-views/src/tests/sort-list.e2e.ts index 2f1db328e..7bd8ad074 100644 --- a/e2e/playwright/list-views/src/tests/sort-list.e2e.ts +++ b/e2e/playwright/list-views/src/tests/sort-list.e2e.ts @@ -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(); diff --git a/e2e/playwright/special-permissions-actions-available/src/tests/other-permissions.ts b/e2e/playwright/special-permissions-actions-available/src/tests/other-permissions.ts index 4254373fc..842a23008 100644 --- a/e2e/playwright/special-permissions-actions-available/src/tests/other-permissions.ts +++ b/e2e/playwright/special-permissions-actions-available/src/tests/other-permissions.ts @@ -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, diff --git a/e2e/playwright/special-permissions-actions-available/src/tests/viewer.ts b/e2e/playwright/special-permissions-actions-available/src/tests/viewer.ts index c16d6e75a..6abc07489 100644 --- a/e2e/playwright/special-permissions-actions-available/src/tests/viewer.ts +++ b/e2e/playwright/special-permissions-actions-available/src/tests/viewer.ts @@ -330,7 +330,7 @@ export function viewerTests(userConsumer: string, siteName: string) { ): Promise { 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); diff --git a/projects/aca-content/assets/app.extensions.json b/projects/aca-content/assets/app.extensions.json index 258fe3a9a..288a45303 100644 --- a/projects/aca-content/assets/app.extensions.json +++ b/projects/aca-content/assets/app.extensions.json @@ -260,6 +260,15 @@ ] } ] + }, + { + "id": "app.navbar.secondary", + "items": [ + { + "id": "app.search.navbar", + "component": "app.search.navbar" + } + ] } ], "toolbar": [ diff --git a/projects/aca-content/assets/i18n/en.json b/projects/aca-content/assets/i18n/en.json index cc0293981..f5f9bca1d 100644 --- a/projects/aca-content/assets/i18n/en.json +++ b/projects/aca-content/assets/i18n/en.json @@ -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": { diff --git a/projects/aca-content/src/lib/aca-content.module.ts b/projects/aca-content/src/lib/aca-content.module.ts index aa3a121ef..de80f54d7 100644 --- a/projects/aca-content/src/lib/aca-content.module.ts +++ b/projects/aca-content/src/lib/aca-content.module.ts @@ -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({ diff --git a/projects/aca-content/src/lib/components/search/search-input/search-input.component.spec.ts b/projects/aca-content/src/lib/components/search/search-input/search-input.component.spec.ts index 74b37cd23..dc5582e36 100644 --- a/projects/aca-content/src/lib/components/search/search-input/search-input.component.spec.ts +++ b/projects/aca-content/src/lib/components/search/search-input/search-input.component.spec.ts @@ -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; 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(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(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(); + }); }); diff --git a/projects/aca-content/src/lib/components/search/search-input/search-input.component.ts b/projects/aca-content/src/lib/components/search/search-input/search-input.component.ts index 48ac7da33..8c8bb143a 100644 --- a/projects/aca-content/src/lib/components/search/search-input/search-input.component.ts +++ b/projects/aca-content/src/lib/components/search/search-input/search-input.component.ts @@ -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 = new Subject(); - 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, - 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, + private readonly appHookService: AppHookService, + private readonly appService: AppService, + public readonly searchInputService: SearchNavigationService ) { this.searchOnChange = this.config.get('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']); } } diff --git a/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.spec.ts b/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.spec.ts index 0716806ff..1c9fd14ba 100644 --- a/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.spec.ts +++ b/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.spec.ts @@ -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; + 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(); + }); }); diff --git a/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.ts b/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.ts index f38b4aa28..81bfacf17 100644 --- a/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.ts +++ b/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.ts @@ -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(); diff --git a/projects/aca-content/src/lib/components/search/search-results/search-results.component.html b/projects/aca-content/src/lib/components/search/search-results/search-results.component.html index 2c71186ab..8f76e4dd8 100644 --- a/projects/aca-content/src/lib/components/search/search-results/search-results.component.html +++ b/projects/aca-content/src/lib/components/search/search-results/search-results.component.html @@ -26,14 +26,26 @@

{{ 'APP.BROWSE.SEARCH.ADVANCED_FILTERS' | translate }}

- +
+ + +
diff --git a/projects/aca-content/src/lib/components/search/search-results/search-results.component.scss b/projects/aca-content/src/lib/components/search/search-results/search-results.component.scss index 112adcd66..bca1510e1 100644 --- a/projects/aca-content/src/lib/components/search/search-results/search-results.component.scss +++ b/projects/aca-content/src/lib/components/search/search-results/search-results.component.scss @@ -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; diff --git a/projects/aca-content/src/lib/components/search/search-results/search-results.component.spec.ts b/projects/aca-content/src/lib/components/search/search-results/search-results.component.spec.ts index 841025d0b..5c5383f7c 100644 --- a/projects/aca-content/src/lib/components/search/search-results/search-results.component.spec.ts +++ b/projects/aca-content/src/lib/components/search/search-results/search-results.component.spec.ts @@ -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; 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); diff --git a/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts b/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts index 0598ca69a..d2ee75869 100644 --- a/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts +++ b/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts @@ -22,13 +22,14 @@ * from Hyland Software. If not, see . */ -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(); 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; + } } diff --git a/projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.html b/projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.html new file mode 100644 index 000000000..6c718b0b1 --- /dev/null +++ b/projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.html @@ -0,0 +1,53 @@ +

{{"APP.BROWSE.SEARCH.SAVE_SEARCH.MODAL_HEADER" | translate}}

+ + +
+ + {{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAME_LABEL' | translate }} + + + + + {{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAME_REQUIRED_ERROR' | translate }} + + + {{ form.controls['name'].errors?.message | translate }} + + + + + + {{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.DESCRIPTION_LABEL' | translate }} + + +
+
+ + + + + + diff --git a/projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.scss b/projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.scss new file mode 100644 index 000000000..bdfd960de --- /dev/null +++ b/projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.scss @@ -0,0 +1,5 @@ +.aca-save-search-dialog { + .aca-save-search-dialog__form-field { + width: 100%; + } +} diff --git a/projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.spec.ts b/projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.spec.ts new file mode 100644 index 000000000..fc572c589 --- /dev/null +++ b/projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.spec.ts @@ -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 . + */ + +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; + 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' + }); + } +}); diff --git a/projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.ts b/projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.ts new file mode 100644 index 000000000..195c01281 --- /dev/null +++ b/projects/aca-content/src/lib/components/search/search-save/dialog/save-search-dialog.component.ts @@ -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 . + */ + +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, + private readonly store: Store, + 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; + } + }); + } +} diff --git a/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.spec.ts b/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.spec.ts new file mode 100644 index 000000000..b4aaba058 --- /dev/null +++ b/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.spec.ts @@ -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 . + */ + +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: '
', + standalone: true, + imports: [SaveSearchDirective] +}) +class TestComponent { + searchQuery = 'encodedQuery'; +} + +describe('SaveSearchDirective', () => { + let fixture: ComponentFixture; + 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); + }); +}); diff --git a/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.ts b/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.ts new file mode 100644 index 000000000..5b662b0c0 --- /dev/null +++ b/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.ts @@ -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 . + */ + +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 } + }; + } +} diff --git a/projects/aca-content/src/lib/components/search/search-save/sidenav/save-search-sidenav.component.html b/projects/aca-content/src/lib/components/search/search-save/sidenav/save-search-sidenav.component.html new file mode 100644 index 000000000..43f6f10a5 --- /dev/null +++ b/projects/aca-content/src/lib/components/search/search-save/sidenav/save-search-sidenav.component.html @@ -0,0 +1 @@ + diff --git a/projects/aca-content/src/lib/components/search/search-save/sidenav/save-search-sidenav.component.spec.ts b/projects/aca-content/src/lib/components/search/search-save/sidenav/save-search-sidenav.component.spec.ts new file mode 100644 index 000000000..ae46b9738 --- /dev/null +++ b/projects/aca-content/src/lib/components/search/search-save/sidenav/save-search-sidenav.component.spec.ts @@ -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 . + */ + +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; + 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' + }); + })); +}); diff --git a/projects/aca-content/src/lib/components/search/search-save/sidenav/save-search-sidenav.component.ts b/projects/aca-content/src/lib/components/search/search-save/sidenav/save-search-sidenav.component.ts new file mode 100644 index 000000000..6561480ad --- /dev/null +++ b/projects/aca-content/src/lib/components/search/search-save/sidenav/save-search-sidenav.component.ts @@ -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 . + */ + +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(); + + 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' + }; + } +} diff --git a/projects/aca-content/src/lib/components/sidenav/components/expand-menu.component.html b/projects/aca-content/src/lib/components/sidenav/components/expand-menu.component.html index e174f8b06..0feb93613 100644 --- a/projects/aca-content/src/lib/components/sidenav/components/expand-menu.component.html +++ b/projects/aca-content/src/lib/components/sidenav/components/expand-menu.component.html @@ -20,7 +20,6 @@ @@ -44,6 +43,7 @@