[ACS-8634] Add new option to edit changes in saved search or save as new (#4229)

* [ACS-8634] Add uniqueness validation for saved searches

* [ACS-8634] Saved searches edit or save as new

* [ACS-8634] CR fixes

* [ACS-8634] Sonar fixes

* [ACS-8634] Accept last saved searches when navigated from

* [ACS-8634] Display empty list of saved searches

* [ACS-8634] Proper url check

* [ACS-8634] Add new option to execute saved search

* [ACS-8634] CR fixes

* [ACS-8634] Sonar fix
This commit is contained in:
MichalKinas 2024-11-08 12:45:24 +01:00 committed by GitHub
parent 71764b09e2
commit a74d189167
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 379 additions and 37 deletions

View File

@ -211,12 +211,15 @@
"RESET_ACTION": "Reset search filters", "RESET_ACTION": "Reset search filters",
"SAVE_SEARCH": { "SAVE_SEARCH": {
"ACTION_BUTTON": "Save Search", "ACTION_BUTTON": "Save Search",
"SAVE_CHANGES": "Save changes",
"SAVE_AS_NEW": "Save as new",
"MODAL_HEADER": "Save this search", "MODAL_HEADER": "Save this search",
"NAME_LABEL": "Name", "NAME_LABEL": "Name",
"NAME_REQUIRED_ERROR": "This field is required", "NAME_REQUIRED_ERROR": "This field is required",
"DESCRIPTION_LABEL": "Description", "DESCRIPTION_LABEL": "Description",
"SAVE_SUCCESS": "Search Saved", "SAVE_SUCCESS": "Search Saved",
"SAVE_ERROR": "Error occurred. Search could not be saved.", "SAVE_ERROR": "Error occurred. Search could not be saved.",
"SEARCH_NAME_NOT_UNIQUE_ERROR": "Saved Search with '{{ name }}' name already exists.",
"NAVBAR": { "NAVBAR": {
"TITLE": "Saved Searches ({{ number }})", "TITLE": "Saved Searches ({{ number }})",
"MANAGE_BUTTON": "Manage searches" "MANAGE_BUTTON": "Manage searches"
@ -240,7 +243,8 @@
"DESCRIPTION": "Description", "DESCRIPTION": "Description",
"EMPTY_LIST": "No saved searches", "EMPTY_LIST": "No saved searches",
"COPY_TO_CLIPBOARD": "Copy to clipboard", "COPY_TO_CLIPBOARD": "Copy to clipboard",
"COPY_TO_CLIPBOARD_SUCCESS": "Search copied to clipboard" "COPY_TO_CLIPBOARD_SUCCESS": "Search copied to clipboard",
"EXECUTE_SEARCH": "Execute Search"
} }
}, },
"FOUND_RESULTS": "{{ number }} results found", "FOUND_RESULTS": "{{ number }} results found",

View File

@ -27,15 +27,45 @@
<p>{{ 'APP.BROWSE.SEARCH.ADVANCED_FILTERS' | translate }}</p> <p>{{ 'APP.BROWSE.SEARCH.ADVANCED_FILTERS' | translate }}</p>
<div class="aca-content__advanced-filters--header--action-buttons"> <div class="aca-content__advanced-filters--header--action-buttons">
<button <button
*ngIf="initialSavedSearch !== undefined else saveSearchButton"
mat-button mat-button
acaSaveSearch
[acaSaveSearchQuery]="encodedQuery"
[disabled]="!encodedQuery" [disabled]="!encodedQuery"
class="aca-content__save-search-action" class="aca-content__save-search-action"
title="{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate }}" title="{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate }}"
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate "> [attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate "
[matMenuTriggerFor]="saveSearchOptionsMenu">
{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate }} {{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate }}
<mat-icon iconPositionEnd>keyboard_arrow_down</mat-icon>
</button> </button>
<mat-menu #saveSearchOptionsMenu="matMenu">
<button
mat-menu-item
(click)="editSavedSearch(initialSavedSearch)"
title="{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_CHANGES' | translate }}"
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_CHANGES' | translate ">
{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_CHANGES' | translate }}
</button>
<button
mat-menu-item
acaSaveSearch
[acaSaveSearchQuery]="encodedQuery"
title="{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_AS_NEW' | translate }}"
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_AS_NEW' | translate ">
{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_AS_NEW' | translate }}
</button>
</mat-menu>
<ng-template #saveSearchButton>
<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>
</ng-template>
<button <button
mat-button mat-button
adf-reset-search adf-reset-search

View File

@ -26,16 +26,21 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testin
import { SearchResultsComponent } from './search-results.component'; import { SearchResultsComponent } from './search-results.component';
import { AppConfigService, NotificationService, TranslationService } from '@alfresco/adf-core'; import { AppConfigService, NotificationService, TranslationService } from '@alfresco/adf-core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NavigateToFolder } from '@alfresco/aca-shared/store'; import { NavigateToFolder, SnackbarErrorAction, SnackbarInfoAction } from '@alfresco/aca-shared/store';
import { Pagination, SearchRequest } from '@alfresco/js-api'; import { Pagination, SearchRequest } from '@alfresco/js-api';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services'; import { SavedSearchesService, SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, of, Subject } from 'rxjs'; import { BehaviorSubject, of, Subject, throwError } from 'rxjs';
import { AppTestingModule } from '../../../testing/app-testing.module'; import { AppTestingModule } from '../../../testing/app-testing.module';
import { AppService } from '@alfresco/aca-shared'; import { AppService } from '@alfresco/aca-shared';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import { testHeader } from '../../../testing/document-base-page-utils'; import { testHeader } from '../../../testing/document-base-page-utils';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatMenuModule } from '@angular/material/menu';
import { MatMenuHarness } from '@angular/material/menu/testing';
describe('SearchComponent', () => { describe('SearchComponent', () => {
let component: SearchResultsComponent; let component: SearchResultsComponent;
@ -49,6 +54,10 @@ describe('SearchComponent', () => {
const searchRequest = {} as SearchRequest; const searchRequest = {} as SearchRequest;
let params: BehaviorSubject<any>; let params: BehaviorSubject<any>;
let showErrorSpy: jasmine.Spy; let showErrorSpy: jasmine.Spy;
let loader: HarnessLoader;
const editSavedSearchesSpy = jasmine.createSpy('editSavedSearch');
const getSavedSearchButton = (): HTMLButtonElement => fixture.nativeElement.querySelector('.aca-content__save-search-action');
const encodeQuery = (query: any): string => { const encodeQuery = (query: any): string => {
return Buffer.from(JSON.stringify(query)).toString('base64'); return Buffer.from(JSON.stringify(query)).toString('base64');
@ -57,7 +66,7 @@ describe('SearchComponent', () => {
beforeEach(() => { beforeEach(() => {
params = new BehaviorSubject({ q: 'TYPE: "cm:folder" AND %28=cm: name: email OR cm: name: budget%29' }); params = new BehaviorSubject({ q: 'TYPE: "cm:folder" AND %28=cm: name: email OR cm: name: budget%29' });
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [AppTestingModule, SearchResultsComponent, MatSnackBarModule], imports: [AppTestingModule, SearchResultsComponent, MatSnackBarModule, MatMenuModule, NoopAnimationsModule],
providers: [ providers: [
{ {
provide: AppService, provide: AppService,
@ -67,6 +76,15 @@ describe('SearchComponent', () => {
setAppNavbarMode: jasmine.createSpy('setAppNavbarMode') setAppNavbarMode: jasmine.createSpy('setAppNavbarMode')
} }
}, },
{
provide: SavedSearchesService,
useValue: {
getSavedSearches: jasmine
.createSpy('getSavedSearches')
.and.returnValue(of([{ name: 'test', encodedUrl: encodeQuery({ name: 'test' }), order: 0 }])),
editSavedSearch: editSavedSearchesSpy
}
},
{ {
provide: ActivatedRoute, provide: ActivatedRoute,
useValue: { useValue: {
@ -102,6 +120,7 @@ describe('SearchComponent', () => {
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'update').and.stub();
fixture.detectChanges(); fixture.detectChanges();
loader = TestbedHarnessEnvironment.loader(fixture);
}); });
afterEach(() => { afterEach(() => {
@ -225,5 +244,66 @@ describe('SearchComponent', () => {
expect(queryBuilder.userQuery).toBe(`((cm:tag:"orange*"))`); expect(queryBuilder.userQuery).toBe(`((cm:tag:"orange*"))`);
}); });
it('should get initial saved search when url matches', fakeAsync(() => {
route.queryParams = of({ q: encodeQuery({ name: 'test' }) });
component.ngOnInit();
tick();
expect(component.initialSavedSearch).toEqual({ name: 'test', encodedUrl: encodeQuery({ name: 'test' }), order: 0 });
}));
it('should render a menu with 2 options when initial saved search is found', async () => {
route.queryParams = of({ q: encodeQuery({ name: 'test' }) });
component.ngOnInit();
fixture.detectChanges();
const saveSearchButton = getSavedSearchButton();
expect(saveSearchButton).toBeDefined();
expect(saveSearchButton.textContent.trim()).toBe('APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON keyboard_arrow_down');
const menu = await loader.getHarness(MatMenuHarness.with({ selector: '.aca-content__save-search-action' }));
expect(await menu.isDisabled()).toBeFalse();
await menu.open();
expect(await menu.isOpen()).toBeTrue();
const menuItems = await menu.getItems();
expect(menuItems.length).toBe(2);
expect(await menuItems[0].getText()).toBe('APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_CHANGES');
expect(await menuItems[1].getText()).toBe('APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_AS_NEW');
});
it('should not get initial saved search when url does not match', fakeAsync(() => {
route.snapshot.queryParams = { q: 'test2' };
tick();
component.ngOnInit();
tick();
expect(component.initialSavedSearch).toBeUndefined();
}));
it('should render regular save search button when there is no initial saved search', fakeAsync(() => {
route.snapshot.queryParams = { q: 'test2' };
tick();
component.ngOnInit();
tick();
fixture.detectChanges();
const saveSearchButton = getSavedSearchButton();
expect(saveSearchButton).toBeDefined();
expect(saveSearchButton.textContent.trim()).toBe('APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON');
expect(saveSearchButton.getAttribute('aria-label')).toBe('APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON');
}));
it('should dispatch success snackbar action when editing saved search is successful', fakeAsync(() => {
spyOn(store, 'dispatch').and.stub();
editSavedSearchesSpy.and.returnValue(of({}));
component.editSavedSearch({ name: 'test', encodedUrl: 'test', order: 0 });
tick();
expect(store.dispatch).toHaveBeenCalledWith(new SnackbarInfoAction('APP.BROWSE.SEARCH.SAVE_SEARCH.EDIT_DIALOG.SUCCESS_MESSAGE'));
}));
it('should dispatch error snackbar action when editing saved search failed', fakeAsync(() => {
spyOn(store, 'dispatch').and.stub();
editSavedSearchesSpy.and.returnValue(throwError(() => new Error('')));
component.editSavedSearch({ name: 'test', encodedUrl: 'test', order: 0 });
tick();
expect(store.dispatch).toHaveBeenCalledWith(new SnackbarErrorAction('APP.BROWSE.SEARCH.SAVE_SEARCH.EDIT_DIALOG.ERROR_MESSAGE'));
}));
testHeader(SearchResultsComponent, false); testHeader(SearchResultsComponent, false);
}); });

View File

@ -22,13 +22,15 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>. * from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { ChangeDetectorRef, Component, inject, OnInit, ViewEncapsulation } from '@angular/core'; import { ChangeDetectorRef, Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { NodeEntry, Pagination, ResultSetPaging } from '@alfresco/js-api'; import { NodeEntry, Pagination, ResultSetPaging } from '@alfresco/js-api';
import { ActivatedRoute, Params } from '@angular/router'; import { ActivatedRoute, Params } from '@angular/router';
import { import {
AlfrescoViewerComponent, AlfrescoViewerComponent,
DocumentListComponent, DocumentListComponent,
ResetSearchDirective, ResetSearchDirective,
SavedSearch,
SavedSearchesService,
SearchConfiguration, SearchConfiguration,
SearchFilterChipsComponent, SearchFilterChipsComponent,
SearchFormComponent, SearchFormComponent,
@ -41,7 +43,9 @@ import {
SetInfoDrawerPreviewStateAction, SetInfoDrawerPreviewStateAction,
SetInfoDrawerStateAction, SetInfoDrawerStateAction,
SetSearchItemsTotalCountAction, SetSearchItemsTotalCountAction,
ShowInfoDrawerPreviewAction ShowInfoDrawerPreviewAction,
SnackbarErrorAction,
SnackbarInfoAction
} from '@alfresco/aca-shared/store'; } from '@alfresco/aca-shared/store';
import { import {
CustomEmptyContentTemplateDirective, CustomEmptyContentTemplateDirective,
@ -62,7 +66,7 @@ import {
ToolbarComponent ToolbarComponent
} from '@alfresco/aca-shared'; } from '@alfresco/aca-shared';
import { SearchSortingDefinition } from '@alfresco/adf-content-services/lib/search/models/search-sorting-definition.interface'; import { SearchSortingDefinition } from '@alfresco/adf-content-services/lib/search/models/search-sorting-definition.interface';
import { takeUntil } from 'rxjs/operators'; import { take, takeUntil } from 'rxjs/operators';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { SearchInputComponent } from '../search-input/search-input.component'; import { SearchInputComponent } from '../search-input/search-input.component';
@ -86,6 +90,8 @@ import {
} from '../../../utils/aca-search-utils'; } from '../../../utils/aca-search-utils';
import { SaveSearchDirective } from '../search-save/directive/save-search.directive'; import { SaveSearchDirective } from '../search-save/directive/save-search.directive';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatMenuModule } from '@angular/material/menu';
@Component({ @Component({
standalone: true, standalone: true,
@ -96,6 +102,7 @@ import { Subject } from 'rxjs';
MatProgressBarModule, MatProgressBarModule,
MatDividerModule, MatDividerModule,
MatButtonModule, MatButtonModule,
MatMenuModule,
DocumentListDirective, DocumentListDirective,
ContextActionsDirective, ContextActionsDirective,
ThumbnailColumnComponent, ThumbnailColumnComponent,
@ -140,18 +147,21 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
isLoading = false; isLoading = false;
totalResults: number; totalResults: number;
isTagsEnabled = false; isTagsEnabled = false;
initialSavedSearch: SavedSearch = undefined;
columns: DocumentListPresetRef[] = []; columns: DocumentListPresetRef[] = [];
encodedQuery: string; encodedQuery: string;
searchConfig: SearchConfiguration; searchConfig: SearchConfiguration;
private readonly loadedFilters$ = new Subject<void>(); private readonly loadedFilters$ = new Subject<void>();
private readonly destroyRef = inject(DestroyRef);
constructor( constructor(
tagsService: TagService, tagsService: TagService,
private readonly queryBuilder: SearchQueryBuilderService, private readonly queryBuilder: SearchQueryBuilderService,
private readonly changeDetectorRef: ChangeDetectorRef, private readonly changeDetectorRef: ChangeDetectorRef,
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly translationService: TranslationService private readonly translationService: TranslationService,
private readonly savedSearchesService: SavedSearchesService
) { ) {
super(); super();
@ -164,7 +174,7 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
this.queryBuilder.configUpdated this.queryBuilder.configUpdated
.asObservable() .asObservable()
.pipe(takeUntil(this.onDestroy$)) .pipe(takeUntilDestroyed())
.subscribe((searchConfig) => { .subscribe((searchConfig) => {
this.searchConfig = searchConfig; this.searchConfig = searchConfig;
this.updateUserQuery(); this.updateUserQuery();
@ -202,7 +212,14 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
this.columns = this.extensions.documentListPresets.searchResults || []; this.columns = this.extensions.documentListPresets.searchResults || [];
if (this.route) { if (this.route) {
this.route.queryParams.pipe(takeUntil(this.onDestroy$)).subscribe((params: Params) => { this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params: Params) => {
this.savedSearchesService
.getSavedSearches()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((savedSearches) => {
const savedSearchFound = savedSearches.find((savedSearch) => savedSearch.encodedUrl === encodeURIComponent(params[this.queryParamName]));
this.initialSavedSearch = savedSearchFound !== undefined ? savedSearchFound : this.initialSavedSearch;
});
if (params[this.queryParamName]) { if (params[this.queryParamName]) {
this.isLoading = true; this.isLoading = true;
} }
@ -216,7 +233,7 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
let loadedFilters = this.searchedWord === '' ? 0 : 1; let loadedFilters = this.searchedWord === '' ? 0 : 1;
this.queryBuilder.filterLoaded this.queryBuilder.filterLoaded
.asObservable() .asObservable()
.pipe(takeUntil(this.onDestroy$), takeUntil(this.loadedFilters$)) .pipe(takeUntilDestroyed(this.destroyRef), takeUntil(this.loadedFilters$))
.subscribe(() => { .subscribe(() => {
loadedFilters++; loadedFilters++;
if (filtersToLoad === loadedFilters) { if (filtersToLoad === loadedFilters) {
@ -307,6 +324,21 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
this.queryBuilder.update(); this.queryBuilder.update();
} }
editSavedSearch(searchToSave: SavedSearch) {
searchToSave.encodedUrl = this.encodedQuery;
this.savedSearchesService
.editSavedSearch(searchToSave)
.pipe(take(1))
.subscribe({
next: () => {
this.store.dispatch(new SnackbarInfoAction('APP.BROWSE.SEARCH.SAVE_SEARCH.EDIT_DIALOG.SUCCESS_MESSAGE'));
},
error: () => {
this.store.dispatch(new SnackbarErrorAction('APP.BROWSE.SEARCH.SAVE_SEARCH.EDIT_DIALOG.ERROR_MESSAGE'));
}
});
}
private updateUserQuery(): void { private updateUserQuery(): void {
const updatedUserQuery = formatSearchTerm(this.searchedWord, this.searchConfig['app:fields']); const updatedUserQuery = formatSearchTerm(this.searchedWord, this.searchConfig['app:fields']);
this.queryBuilder.userQuery = updatedUserQuery; this.queryBuilder.userQuery = updatedUserQuery;

View File

@ -25,7 +25,7 @@
{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAME_REQUIRED_ERROR' | translate }} {{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAME_REQUIRED_ERROR' | translate }}
</span> </span>
<span *ngIf="!form.controls['name'].errors?.required && form.controls['name'].errors?.message"> <span *ngIf="!form.controls['name'].errors?.required && form.controls['name'].errors?.message">
{{ form.controls['name'].errors?.message | translate }} {{ form.controls['name'].errors?.message | translate : { name: form.controls.name.value } }}
</span> </span>
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>

View File

@ -55,7 +55,7 @@ describe('SaveSearchEditDialogComponent', () => {
providers: [ providers: [
{ provide: MatDialogRef, useValue: dialogRef }, { provide: MatDialogRef, useValue: dialogRef },
provideMockStore(), provideMockStore(),
{ provide: SavedSearchesService, useValue: { editSavedSearch: () => of() } }, { provide: SavedSearchesService, useValue: { editSavedSearch: () => of(), getSavedSearches: () => of([]) } },
{ provide: MAT_DIALOG_DATA, useValue: savedSearchToDelete } { provide: MAT_DIALOG_DATA, useValue: savedSearchToDelete }
] ]
}); });

View File

@ -30,6 +30,7 @@ import { AppStore, SnackbarErrorAction, SnackbarInfoAction } from '@alfresco/aca
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { CoreModule } from '@alfresco/adf-core'; import { CoreModule } from '@alfresco/adf-core';
import { FormControl, FormGroup, Validators } from '@angular/forms'; import { FormControl, FormGroup, Validators } from '@angular/forms';
import { UniqueSearchNameValidator } from '../unique-search-name-validator';
@Component({ @Component({
standalone: true, standalone: true,
@ -42,7 +43,11 @@ import { FormControl, FormGroup, Validators } from '@angular/forms';
}) })
export class SavedSearchEditDialogComponent { export class SavedSearchEditDialogComponent {
form = new FormGroup({ form = new FormGroup({
name: new FormControl('', [Validators.required, forbidOnlySpaces]), name: new FormControl('', {
validators: [Validators.required, forbidOnlySpaces],
asyncValidators: [this.uniqueSearchNameValidator.validate.bind(this.uniqueSearchNameValidator)],
updateOn: 'blur'
}),
description: new FormControl('') description: new FormControl('')
}); });
@ -52,6 +57,7 @@ export class SavedSearchEditDialogComponent {
private readonly dialog: MatDialogRef<SavedSearchEditDialogComponent>, private readonly dialog: MatDialogRef<SavedSearchEditDialogComponent>,
private readonly store: Store<AppStore>, private readonly store: Store<AppStore>,
private readonly savedSearchesService: SavedSearchesService, private readonly savedSearchesService: SavedSearchesService,
private readonly uniqueSearchNameValidator: UniqueSearchNameValidator,
@Inject(MAT_DIALOG_DATA) private readonly data: SavedSearch @Inject(MAT_DIALOG_DATA) private readonly data: SavedSearch
) { ) {
this.form.patchValue({ this.form.patchValue({

View File

@ -22,7 +22,7 @@
{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAME_REQUIRED_ERROR' | translate }} {{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAME_REQUIRED_ERROR' | translate }}
</span> </span>
<span *ngIf="!form.controls['name'].errors?.required && form.controls['name'].errors?.message"> <span *ngIf="!form.controls['name'].errors?.required && form.controls['name'].errors?.message">
{{ form.controls['name'].errors?.message | translate }} {{ form.controls['name'].errors?.message | translate : { name: form.controls.name.value } }}
</span> </span>
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>

View File

@ -49,7 +49,7 @@ describe('SaveSearchDialogComponent', () => {
providers: [ providers: [
{ provide: MatDialogRef, useValue: dialogRef }, { provide: MatDialogRef, useValue: dialogRef },
provideMockStore(), provideMockStore(),
{ provide: SavedSearchesService, useValue: { saveSearch: () => of() } }, { provide: SavedSearchesService, useValue: { saveSearch: () => of(), getSavedSearches: () => of([]) } },
{ provide: MAT_DIALOG_DATA, useValue: { searchUrl: 'abcdef' } } { provide: MAT_DIALOG_DATA, useValue: { searchUrl: 'abcdef' } }
] ]
}); });

View File

@ -40,6 +40,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppStore, SnackbarErrorAction, SnackbarInfoAction } from '@alfresco/aca-shared/store'; import { AppStore, SnackbarErrorAction, SnackbarInfoAction } from '@alfresco/aca-shared/store';
import { UniqueSearchNameValidator } from './unique-search-name-validator';
@Component({ @Component({
standalone: true, standalone: true,
@ -66,7 +67,11 @@ import { AppStore, SnackbarErrorAction, SnackbarInfoAction } from '@alfresco/aca
}) })
export class SaveSearchDialogComponent { export class SaveSearchDialogComponent {
form = new FormGroup({ form = new FormGroup({
name: new FormControl('', [Validators.required, forbidOnlySpaces]), name: new FormControl('', {
validators: [Validators.required, forbidOnlySpaces],
asyncValidators: [this.uniqueSearchNameValidator.validate.bind(this.uniqueSearchNameValidator)],
updateOn: 'blur'
}),
description: new FormControl('') description: new FormControl('')
}); });
@ -76,6 +81,7 @@ export class SaveSearchDialogComponent {
private readonly dialog: MatDialogRef<SaveSearchDialogComponent>, private readonly dialog: MatDialogRef<SaveSearchDialogComponent>,
private readonly store: Store<AppStore>, private readonly store: Store<AppStore>,
private readonly savedSearchesService: SavedSearchesService, private readonly savedSearchesService: SavedSearchesService,
private readonly uniqueSearchNameValidator: UniqueSearchNameValidator,
@Inject(MAT_DIALOG_DATA) private readonly data: { searchUrl: string } @Inject(MAT_DIALOG_DATA) private readonly data: { searchUrl: string }
) {} ) {}

View File

@ -0,0 +1,74 @@
/*!
* 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 { TestBed } from '@angular/core/testing';
import { UniqueSearchNameValidator } from './unique-search-name-validator';
import { ContentTestingModule, SavedSearchesService } from '@alfresco/adf-content-services';
import { of } from 'rxjs';
import { FormControl } from '@angular/forms';
describe('UniqueSearchNameValidator', () => {
let validator: UniqueSearchNameValidator;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ContentTestingModule]
});
});
describe('Save searches returns results', () => {
beforeEach(() => {
TestBed.overrideProvider(SavedSearchesService, { useValue: { getSavedSearches: () => of([{ name: 'test' }]) } });
validator = TestBed.inject(UniqueSearchNameValidator);
});
it('should return null when name is unique', (done) => {
validator.validate(new FormControl({ value: 'unique', disabled: false })).subscribe((result) => {
expect(result).toBe(null);
done();
});
});
it('should return error when name is not unique', (done) => {
validator.validate(new FormControl({ value: 'test', disabled: false })).subscribe((result) => {
expect(result).toEqual({ message: 'APP.BROWSE.SEARCH.SAVE_SEARCH.SEARCH_NAME_NOT_UNIQUE_ERROR' });
done();
});
});
});
describe('Save searches returns error', () => {
beforeEach(() => {
TestBed.overrideProvider(SavedSearchesService, { useValue: { getSavedSearches: () => of(null) } });
validator = TestBed.inject(UniqueSearchNameValidator);
});
it('should return null when error occurs', (done) => {
validator.validate(new FormControl({ value: 'test', disabled: false })).subscribe((result) => {
expect(result).toBe(null);
done();
});
});
});
});

View File

@ -0,0 +1,42 @@
/*!
* 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 { SavedSearchesService } from '@alfresco/adf-content-services';
import { Injectable } from '@angular/core';
import { AbstractControl, AsyncValidator, ValidationErrors } from '@angular/forms';
import { catchError, map, Observable, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UniqueSearchNameValidator implements AsyncValidator {
constructor(private readonly savedSearchesService: SavedSearchesService) {}
validate(control: AbstractControl): Observable<ValidationErrors | null> {
return this.savedSearchesService.getSavedSearches().pipe(
map((searches) =>
searches.some((search) => search.name === control.value) ? { message: 'APP.BROWSE.SEARCH.SAVE_SEARCH.SEARCH_NAME_NOT_UNIQUE_ERROR' } : null
),
catchError(() => of(null))
);
}
}

View File

@ -1,4 +1,5 @@
.aca-saved-searches-ui-list { .aca-saved-searches-ui-list {
height: 100%;
overflow-y: auto; overflow-y: auto;
.adf-datatable-list { .adf-datatable-list {

View File

@ -31,12 +31,14 @@ import { SavedSearch } from '@alfresco/adf-content-services';
import { provideMockStore } from '@ngrx/store/testing'; import { provideMockStore } from '@ngrx/store/testing';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { Clipboard } from '@angular/cdk/clipboard'; import { Clipboard } from '@angular/cdk/clipboard';
import { Router } from '@angular/router';
describe('SavedSearchesListUiComponent ', () => { describe('SavedSearchesListUiComponent ', () => {
let fixture: ComponentFixture<SavedSearchesListUiComponent>; let fixture: ComponentFixture<SavedSearchesListUiComponent>;
let component: SavedSearchesListUiComponent; let component: SavedSearchesListUiComponent;
let savedSearchesListUiService: SavedSearchesListUiService; let savedSearchesListUiService: SavedSearchesListUiService;
let clipboard: Clipboard; let clipboard: Clipboard;
let router: Router;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@ -46,6 +48,7 @@ describe('SavedSearchesListUiComponent ', () => {
savedSearchesListUiService = TestBed.inject(SavedSearchesListUiService); savedSearchesListUiService = TestBed.inject(SavedSearchesListUiService);
clipboard = TestBed.inject(Clipboard); clipboard = TestBed.inject(Clipboard);
router = TestBed.inject(Router);
fixture = TestBed.createComponent(SavedSearchesListUiComponent); fixture = TestBed.createComponent(SavedSearchesListUiComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
@ -76,6 +79,8 @@ describe('SavedSearchesListUiComponent ', () => {
hasValue: () => true, hasValue: () => true,
getValue: () => 'Some value', getValue: () => 'Some value',
obj: { obj: {
name: 'test',
encodedUrl: 'test',
field: 'some value', field: 'some value',
id: 'some id' id: 'some id'
} }
@ -120,6 +125,16 @@ describe('SavedSearchesListUiComponent ', () => {
}, },
data: dataCellEvent.value.row.obj data: dataCellEvent.value.row.obj
}, },
{
title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.LIST.EXECUTE_SEARCH',
key: 'execute',
subject: jasmine.any(Subject),
model: {
visible: true,
icon: 'exit_to_app'
},
data: dataCellEvent.value.row.obj
},
{ {
title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.EDIT_DIALOG.CONTEXT_OPTION', title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.EDIT_DIALOG.CONTEXT_OPTION',
key: 'edit', key: 'edit',
@ -155,7 +170,7 @@ describe('SavedSearchesListUiComponent ', () => {
let editAction: any; let editAction: any;
beforeEach(() => { beforeEach(() => {
editAction = dataCellEvent.value.actions[1]; editAction = dataCellEvent.value.actions[2];
editAction.subject.next(editAction); editAction.subject.next(editAction);
}); });
@ -168,7 +183,7 @@ describe('SavedSearchesListUiComponent ', () => {
let deleteAction: any; let deleteAction: any;
beforeEach(() => { beforeEach(() => {
deleteAction = dataCellEvent.value.actions[2]; deleteAction = dataCellEvent.value.actions[3];
deleteAction.subject.next(deleteAction); deleteAction.subject.next(deleteAction);
}); });
@ -189,6 +204,20 @@ describe('SavedSearchesListUiComponent ', () => {
expect(clipboard.copy).toHaveBeenCalled(); expect(clipboard.copy).toHaveBeenCalled();
}); });
}); });
describe('Execute search', () => {
let actionData: any;
beforeEach(() => {
spyOn(router, 'navigate').and.stub();
actionData = dataCellEvent.value.actions[1];
actionData.subject.next(actionData);
});
it('should navigate to search when selected execute search option', () => {
expect(router.navigate).toHaveBeenCalledWith(['/search'], { queryParams: { q: 'test' } });
});
});
}); });
}); });
}); });

View File

@ -41,6 +41,7 @@ import { SavedSearch } from '@alfresco/adf-content-services';
import { Clipboard } from '@angular/cdk/clipboard'; import { Clipboard } from '@angular/cdk/clipboard';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { SnackbarInfoAction } from '@alfresco/aca-shared/store'; import { SnackbarInfoAction } from '@alfresco/aca-shared/store';
import { Router } from '@angular/router';
@Component({ @Component({
selector: 'aca-saved-searches-ui-list', selector: 'aca-saved-searches-ui-list',
@ -66,12 +67,18 @@ export class SavedSearchesListUiComponent extends DataTableSchema implements Aft
private readonly editSavedSearchOptionKey = 'edit'; private readonly editSavedSearchOptionKey = 'edit';
private readonly deleteSavedSearchOptionKey = 'delete'; private readonly deleteSavedSearchOptionKey = 'delete';
private readonly copyToClipboardUrlOptionKey = 'copy'; private readonly copyToClipboardUrlOptionKey = 'copy';
private readonly executeSearchOptionKey = 'execute';
private readonly menuOptions = [ private readonly menuOptions = [
{ {
icon: 'copy', icon: 'copy',
title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.LIST.COPY_TO_CLIPBOARD', title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.LIST.COPY_TO_CLIPBOARD',
key: this.copyToClipboardUrlOptionKey key: this.copyToClipboardUrlOptionKey
}, },
{
icon: 'exit_to_app',
title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.LIST.EXECUTE_SEARCH',
key: this.executeSearchOptionKey
},
{ {
icon: 'edit', icon: 'edit',
title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.EDIT_DIALOG.CONTEXT_OPTION', title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.EDIT_DIALOG.CONTEXT_OPTION',
@ -84,7 +91,12 @@ export class SavedSearchesListUiComponent extends DataTableSchema implements Aft
} }
]; ];
constructor(protected appConfig: AppConfigService, private readonly clipboard: Clipboard, private readonly store: Store) { constructor(
protected appConfig: AppConfigService,
private readonly clipboard: Clipboard,
private readonly store: Store,
private readonly router: Router
) {
super(appConfig, '', savedSearchesListSchema); super(appConfig, '', savedSearchesListSchema);
} }
@ -112,6 +124,9 @@ export class SavedSearchesListUiComponent extends DataTableSchema implements Aft
case this.copyToClipboardUrlOptionKey: case this.copyToClipboardUrlOptionKey:
this.copyToClipboard(savedSearchData); this.copyToClipboard(savedSearchData);
break; break;
case this.executeSearchOptionKey:
this.executeSearch(savedSearchData);
break;
} }
} }
@ -128,6 +143,12 @@ export class SavedSearchesListUiComponent extends DataTableSchema implements Aft
this.store.dispatch(new SnackbarInfoAction('APP.BROWSE.SEARCH.SAVE_SEARCH.LIST.COPY_TO_CLIPBOARD_SUCCESS')); this.store.dispatch(new SnackbarInfoAction('APP.BROWSE.SEARCH.SAVE_SEARCH.LIST.COPY_TO_CLIPBOARD_SUCCESS'));
} }
executeSearch(savedSearch: SavedSearch): void {
this.router.navigate(['/search'], {
queryParams: { q: decodeURIComponent(savedSearch.encodedUrl) }
});
}
fillContextMenu(event: DataCellEvent) { fillContextMenu(event: DataCellEvent) {
event.value.actions = this.menuOptions.map((option) => ({ event.value.actions = this.menuOptions.map((option) => ({
title: option.title, title: option.title,

View File

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

View File

@ -62,7 +62,15 @@ describe('SaveSearchSidenavComponent', () => {
expect(component.item).toEqual({ expect(component.item).toEqual({
icon: '', icon: '',
title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAVBAR.TITLE', title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAVBAR.TITLE',
children: [], children: [
{
id: 'manage-saved-searches',
icon: '',
title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAVBAR.MANAGE_BUTTON',
route: 'saved-searches',
url: 'saved-searches'
}
],
route: '/', route: '/',
id: 'search-navbar' id: 'search-navbar'
}); });

View File

@ -63,6 +63,10 @@ export class SaveSearchSidenavComponent implements OnInit, OnDestroy {
this.destroy$.complete(); this.destroy$.complete();
} }
onActionClick(): void {
this.appService.appNavNarMode$.next('collapsed');
}
private createNavBarLinkRef(children: SavedSearch[]): NavBarLinkRef { private createNavBarLinkRef(children: SavedSearch[]): NavBarLinkRef {
const mappedChildren = children const mappedChildren = children
.map((child) => ({ .map((child) => ({
@ -74,15 +78,13 @@ export class SaveSearchSidenavComponent implements OnInit, OnDestroy {
})) }))
.slice(0, 5); .slice(0, 5);
const title = this.translationService.instant('APP.BROWSE.SEARCH.SAVE_SEARCH.NAVBAR.TITLE', { number: children.length }); const title = this.translationService.instant('APP.BROWSE.SEARCH.SAVE_SEARCH.NAVBAR.TITLE', { number: children.length });
if (children.length) { mappedChildren.push({
mappedChildren.push({ id: this.manageSearchesId,
id: this.manageSearchesId, icon: '',
icon: '', title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAVBAR.MANAGE_BUTTON',
title: 'APP.BROWSE.SEARCH.SAVE_SEARCH.NAVBAR.MANAGE_BUTTON', route: 'saved-searches',
route: 'saved-searches', url: 'saved-searches'
url: 'saved-searches' });
});
}
return { return {
icon: '', icon: '',
title, title,

View File

@ -43,6 +43,7 @@
<button <button
acaActiveLink="aca-action-button--active" acaActiveLink="aca-action-button--active"
[action]="child" [action]="child"
(actionClicked)="actionClicked.emit()"
[attr.aria-label]="child.title | translate" [attr.aria-label]="child.title | translate"
[id]="child.id" [id]="child.id"
[attr.data-automation-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/>. * from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { ChangeDetectorRef, Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { NavBarLinkRef } from '@alfresco/adf-extensions'; import { NavBarLinkRef } from '@alfresco/adf-extensions';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
@ -54,6 +54,9 @@ export class ExpandMenuComponent implements OnInit {
@Input() @Input()
item: NavBarLinkRef; item: NavBarLinkRef;
@Output()
actionClicked = new EventEmitter<void>();
constructor(private cd: ChangeDetectorRef) {} constructor(private cd: ChangeDetectorRef) {}
ngOnInit() { ngOnInit() {

View File

@ -22,7 +22,7 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>. * from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { Directive, HostListener, Input } from '@angular/core'; import { Directive, EventEmitter, HostListener, Input, Output } from '@angular/core';
import { Params, PRIMARY_OUTLET, Router } from '@angular/router'; import { Params, PRIMARY_OUTLET, Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppStore } from '@alfresco/aca-shared/store'; import { AppStore } from '@alfresco/aca-shared/store';
@ -36,6 +36,8 @@ import { AppStore } from '@alfresco/aca-shared/store';
export class ActionDirective { export class ActionDirective {
@Input() action; @Input() action;
@Output() actionClicked = new EventEmitter<void>();
@HostListener('click') @HostListener('click')
onClick() { onClick() {
if (this.action.url) { if (this.action.url) {
@ -46,6 +48,7 @@ export class ActionDirective {
payload: this.getNavigationCommands(this.action.click.payload) payload: this.getNavigationCommands(this.action.click.payload)
}); });
} }
this.actionClicked.next();
} }
constructor(private router: Router, private store: Store<AppStore>) {} constructor(private router: Router, private store: Store<AppStore>) {}