From a7911338c3400f09359ff6cfde7372cd84b21b5b Mon Sep 17 00:00:00 2001 From: dominikiwanekhyland <141320833+dominikiwanekhyland@users.noreply.github.com> Date: Fri, 25 Oct 2024 16:14:11 +0200 Subject: [PATCH] [ACS-8634] "Manage Searches" - a full page list of saved searches (#10307) * [ACS-8634] "Manage Searches" - a full page list of saved searches * Changes after CR * [ACS-8634] Removed extra selector, drag&drop on hover * [ACS-8634] Fix discovered bugs * [ACS-8634] Unit test fixes * [ACS-8634] Cleanup failing test case * [ACS-8634] Unit test fixes * [ACS-8634] Remove unused import * [ACS-8634] Final cleanup * [ACS-8634] Remove unused imports --------- Co-authored-by: MichalKinas --- docs/core/components/datatable.component.md | 74 +++++------ .../services/saved-searches.service.spec.ts | 60 +++++++-- .../common/services/saved-searches.service.ts | 118 +++++++++++++++++- .../services/base-query-builder.service.ts | 1 + .../datatable/datatable.component.html | 26 +++- .../datatable/datatable.component.scss | 23 ++++ .../datatable/datatable.component.spec.ts | 20 +++ .../datatable/datatable.component.ts | 28 ++++- lib/core/src/lib/i18n/en.json | 1 + .../sidenav-layout.component.spec.ts | 2 + .../sidenav-layout.component.ts | 10 +- 11 files changed, 308 insertions(+), 55 deletions(-) diff --git a/docs/core/components/datatable.component.md b/docs/core/components/datatable.component.md index ff26458ff44..c799727857f 100644 --- a/docs/core/components/datatable.component.md +++ b/docs/core/components/datatable.component.md @@ -419,46 +419,48 @@ Learn more about styling your datatable: [Customizing the component's styles](#c ### Properties -| Name | Type | Default value | Description | -|--------------------------|-------------------------------------------------------------------------------------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| actions | `boolean` | false | Toggles the data actions column. | -| actionsPosition | `string` | "right" | Position of the actions dropdown menu. Can be "left" or "right". | -| actionsVisibleOnHover | `boolean` | false | Toggles whether the actions dropdown should only be visible if the row is hovered over or the dropdown menu is open. | -| allowFiltering | `boolean` | false | Flag that indicate if the datatable allow the use [facet widget](../../../lib/content-services/src/lib/search/models/facet-widget.interface.ts) search for filtering. | -| blurOnResize | `boolean` | true | Toggles blur when columns of the datatable are being resized. | -| columns | `any[]` | \[] | The columns that the datatable will show. | -| contextMenu | `boolean` | false | Toggles custom context menu for the component. | -| data | [`DataTableAdapter`](../../../lib/core/src/lib/datatable/data/datatable-adapter.ts) | | Data source for the table | -| displayCheckboxesOnHover | `boolean` | false | Enables checkboxes in datatable rows being displayed on hover only. | -| fallbackThumbnail | `string` | | Fallback image for rows where the thumbnail is missing. | -| isResizingEnabled | `boolean` | false | Flag that indicates if the datatable allows column resizing. | -| loading | `boolean` | false | Flag that indicates if the datatable is in loading state and needs to show the loading template (see the docs to learn how to configure a loading template). | -| mainTableAction | `boolean` | true | Toggles main data table action column. | -| multiselect | `boolean` | false | Toggles multiple row selection, which renders checkboxes at the beginning of each row. | -| noPermission | `boolean` | false | Flag that indicates if the datatable should show the "no permission" template. | -| resolverFn | `Function` | null | Custom resolver function which is used to parse dynamic column objects see the docs to learn how to configure a resolverFn. | -| rowMenuCacheEnabled | `boolean` | true | Should the items for the row actions menu be cached for reuse after they are loaded the first time? | -| rowStyle | `Function` | | The inline style to apply to every row. See [NgStyle](https://angular.io/docs/ts/latest/api/common/index/NgStyle-directive.html) docs for more details and usage examples. | -| rowStyleClass | `string` | "" | The CSS class to apply to every row. | -| rows | `any[]` | \[] | The rows that the datatable will show. | -| selectionMode | `string` | "single" | Row selection mode. Can be none, `single` or `multiple`. For `multiple` mode, you can use Cmd (macOS) or Ctrl (Win) modifier key to toggle selection for multiple rows. | -| showHeader | `ShowHeaderMode` | | Toggles the header. | -| showMainDatatableActions | `boolean` | false | Toggles the main datatable action. | -| sorting | `any[]` | \[] | Define the sort order of the datatable. Possible values are : [`created`, `desc`], [`created`, `asc`], [`due`, `desc`], [`due`, `asc`] | -| stickyHeader | `boolean` | false | Toggles the sticky header mode. | +| Name | Type | Default value | Description | +|----------------------------|-------------------------------------------------------------------------------------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| actions | `boolean` | false | Toggles the data actions column. | +| actionsPosition | `string` | "right" | Position of the actions dropdown menu. Can be "left" or "right". | +| actionsVisibleOnHover | `boolean` | false | Toggles whether the actions dropdown should only be visible if the row is hovered over or the dropdown menu is open. | +| allowFiltering | `boolean` | false | Flag that indicate if the datatable allow the use [facet widget](../../../lib/content-services/src/lib/search/models/facet-widget.interface.ts) search for filtering. | +| blurOnResize | `boolean` | true | Toggles blur when columns of the datatable are being resized. | +| columns | `any[]` | \[] | The columns that the datatable will show. | +| contextMenu | `boolean` | false | Toggles custom context menu for the component. | +| data | [`DataTableAdapter`](../../../lib/core/src/lib/datatable/data/datatable-adapter.ts) | | Data source for the table | +| displayCheckboxesOnHover | `boolean` | false | Enables checkboxes in datatable rows being displayed on hover only. | +| fallbackThumbnail | `string` | | Fallback image for rows where the thumbnail is missing. | +| isResizingEnabled | `boolean` | false | Flag that indicates if the datatable allows column resizing. | +| loading | `boolean` | false | Flag that indicates if the datatable is in loading state and needs to show the loading template (see the docs to learn how to configure a loading template). | +| mainTableAction | `boolean` | true | Toggles main data table action column. | +| multiselect | `boolean` | false | Toggles multiple row selection, which renders checkboxes at the beginning of each row. | +| noPermission | `boolean` | false | Flag that indicates if the datatable should show the "no permission" template. | +| resolverFn | `Function` | null | Custom resolver function which is used to parse dynamic column objects see the docs to learn how to configure a resolverFn. | +| rowMenuCacheEnabled | `boolean` | true | Should the items for the row actions menu be cached for reuse after they are loaded the first time? | +| rowStyle | `Function` | | The inline style to apply to every row. See [NgStyle](https://angular.io/docs/ts/latest/api/common/index/NgStyle-directive.html) docs for more details and usage examples. | +| rowStyleClass | `string` | "" | The CSS class to apply to every row. | +| rows | `any[]` | \[] | The rows that the datatable will show. | +| selectionMode | `string` | "single" | Row selection mode. Can be none, `single` or `multiple`. For `multiple` mode, you can use Cmd (macOS) or Ctrl (Win) modifier key to toggle selection for multiple rows. | +| showHeader | `ShowHeaderMode` | | Toggles the header. | +| showMainDatatableActions | `boolean` | false | Toggles the main datatable action. | +| sorting | `any[]` | \[] | Define the sort order of the datatable. Possible values are : [`created`, `desc`], [`created`, `asc`], [`due`, `desc`], [`due`, `asc`] | +| stickyHeader | `boolean` | false | Toggles the sticky header mode. | +| enableDragRows | `boolean` | false | Flag that enables dragging rows. | ### Events -| Name | Type | Description | -|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------| -| columnOrderChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`DataColumn`](../../../lib/core/src/lib/datatable/data/data-column.model.ts)`<>[]>` | Emitted when the column order is changed. | -| columnsWidthChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`DataColumn`](../../../lib/core/src/lib/datatable/data/data-column.model.ts)`<>[]>` | Emitted when the column width is changed. | -| selectedItemsCountChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`` | Emitted when the item row count is changed. | +| Name | Type | Description | +|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------| +| columnOrderChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`DataColumn`](../../../lib/core/src/lib/datatable/data/data-column.model.ts)`<>[]>` | Emitted when the column order is changed. | +| columnsWidthChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`DataColumn`](../../../lib/core/src/lib/datatable/data/data-column.model.ts)`<>[]>` | Emitted when the column width is changed. | +| selectedItemsCountChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`` | Emitted when the item row count is changed. | | executeRowAction | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`DataRowActionEvent`](../../../lib/core/src/lib/datatable/components/data-row-action.event.ts)`>` | Emitted when the user executes a row action. | -| rowClick | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`DataRowEvent`](../../../lib/core/src/lib/datatable/data/data-row-event.model.ts)`>` | Emitted when the user clicks a row. | -| rowDblClick | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`DataRowEvent`](../../../lib/core/src/lib/datatable/data/data-row-event.model.ts)`>` | Emitted when the user double-clicks a row. | -| showRowActionsMenu | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`DataCellEvent`](../../../lib/core/src/lib/datatable/components/data-cell.event.ts)`>` | Emitted before the actions menu is displayed for a row. | -| showRowContextMenu | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`DataCellEvent`](../../../lib/core/src/lib/datatable/components/data-cell.event.ts)`>` | Emitted before the context menu is displayed for a row. | +| rowClick | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`DataRowEvent`](../../../lib/core/src/lib/datatable/data/data-row-event.model.ts)`>` | Emitted when the user clicks a row. | +| rowDblClick | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`DataRowEvent`](../../../lib/core/src/lib/datatable/data/data-row-event.model.ts)`>` | Emitted when the user double-clicks a row. | +| showRowActionsMenu | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`DataCellEvent`](../../../lib/core/src/lib/datatable/components/data-cell.event.ts)`>` | Emitted before the actions menu is displayed for a row. | +| showRowContextMenu | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`DataCellEvent`](../../../lib/core/src/lib/datatable/components/data-cell.event.ts)`>` | Emitted before the context menu is displayed for a row. | +| dragDropped | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`{ previousIndex: number; currentIndex: number }]`>` | Emitted when dragged row is dropped. | ## Details diff --git a/lib/content-services/src/lib/common/services/saved-searches.service.spec.ts b/lib/content-services/src/lib/common/services/saved-searches.service.spec.ts index b38d0ead959..854bbe99e80 100644 --- a/lib/content-services/src/lib/common/services/saved-searches.service.spec.ts +++ b/lib/content-services/src/lib/common/services/saved-searches.service.spec.ts @@ -28,6 +28,7 @@ describe('SavedSearchesService', () => { let service: SavedSearchesService; let authService: AuthenticationService; let testUserName: string; + let getNodeContentSpy: jasmine.Spy; const testNodeId = 'test-node-id'; const SAVED_SEARCHES_NODE_ID = 'saved-searches-node-id__'; @@ -59,6 +60,9 @@ describe('SavedSearchesService', () => { authService = TestBed.inject(AuthenticationService); spyOn(service.nodesApi, 'getNode').and.callFake(() => Promise.resolve({ entry: { id: testNodeId } } as NodeEntry)); spyOn(service.searchApi, 'search').and.callFake(() => Promise.resolve({ list: { entries: [] } })); + spyOn(service.nodesApi, 'createNode').and.callFake(() => Promise.resolve({ entry: { id: 'new-node-id' } })); + spyOn(service.nodesApi, 'updateNodeContent').and.callFake(() => Promise.resolve({ entry: {} } as NodeEntry)); + getNodeContentSpy = spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => createBlob()); }); afterEach(() => { @@ -68,12 +72,11 @@ describe('SavedSearchesService', () => { it('should retrieve saved searches from the saved-searches.json file', (done) => { spyOn(authService, 'getUsername').and.callFake(() => testUserName); spyOn(localStorage, 'getItem').and.callFake(() => testNodeId); - spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => createBlob()); service.innit(); service.getSavedSearches().subscribe((searches) => { expect(localStorage.getItem).toHaveBeenCalledWith(SAVED_SEARCHES_NODE_ID + testUserName); - expect(service.nodesApi.getNodeContent).toHaveBeenCalledWith(testNodeId); + expect(getNodeContentSpy).toHaveBeenCalledWith(testNodeId); expect(searches.length).toBe(2); expect(searches[0].name).toBe('Search 1'); expect(searches[1].name).toBe('Search 2'); @@ -83,8 +86,7 @@ describe('SavedSearchesService', () => { it('should create saved-searches.json file if it does not exist', (done) => { spyOn(authService, 'getUsername').and.callFake(() => testUserName); - spyOn(service.nodesApi, 'createNode').and.callFake(() => Promise.resolve({ entry: { id: 'new-node-id' } })); - spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => Promise.resolve(new Blob(['']))); + getNodeContentSpy.and.callFake(() => Promise.resolve(new Blob(['']))); service.innit(); service.getSavedSearches().subscribe((searches) => { @@ -100,9 +102,7 @@ describe('SavedSearchesService', () => { spyOn(authService, 'getUsername').and.callFake(() => testUserName); const nodeId = 'saved-searches-node-id'; spyOn(localStorage, 'getItem').and.callFake(() => nodeId); - spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => createBlob()); const newSearch = { name: 'Search 3', description: 'Description 3', encodedUrl: 'url3' }; - spyOn(service.nodesApi, 'updateNodeContent').and.callFake(() => Promise.resolve({ entry: {} } as NodeEntry)); service.innit(); service.saveSearch(newSearch).subscribe(() => { @@ -110,7 +110,7 @@ describe('SavedSearchesService', () => { expect(service.savedSearches$).toBeDefined(); service.savedSearches$.subscribe((searches) => { expect(searches.length).toBe(3); - expect(searches[2].name).toBe('Search 3'); + expect(searches[2].name).toBe('Search 2'); expect(searches[2].order).toBe(2); done(); }); @@ -120,7 +120,6 @@ describe('SavedSearchesService', () => { it('should emit initial saved searches on subscription', (done) => { const nodeId = 'saved-searches-node-id'; spyOn(localStorage, 'getItem').and.returnValue(nodeId); - spyOn(service.nodesApi, 'getNodeContent').and.returnValue(createBlob()); service.innit(); service.savedSearches$.pipe().subscribe((searches) => { @@ -135,9 +134,7 @@ describe('SavedSearchesService', () => { it('should emit updated saved searches after saving a new search', (done) => { spyOn(authService, 'getUsername').and.callFake(() => testUserName); spyOn(localStorage, 'getItem').and.callFake(() => testNodeId); - spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => createBlob()); const newSearch = { name: 'Search 3', description: 'Description 3', encodedUrl: 'url3' }; - spyOn(service.nodesApi, 'updateNodeContent').and.callFake(() => Promise.resolve({ entry: {} } as NodeEntry)); service.innit(); let emissionCount = 0; @@ -149,11 +146,52 @@ describe('SavedSearchesService', () => { } if (emissionCount === 2) { expect(searches.length).toBe(3); - expect(searches[2].name).toBe('Search 3'); + expect(searches[2].name).toBe('Search 2'); done(); } }); service.saveSearch(newSearch).subscribe(); }); + + it('should edit a search', (done) => { + const updatedSearch = { name: 'Search 3', description: 'Description 3', encodedUrl: 'url3', order: 0 }; + prepareDefaultMock(); + + service.editSavedSearch(updatedSearch).subscribe(() => { + service.savedSearches$.subscribe((searches) => { + expect(searches.length).toBe(2); + expect(searches[0].name).toBe('Search 3'); + expect(searches[0].order).toBe(0); + + expect(searches[1].name).toBe('Search 2'); + expect(searches[1].order).toBe(1); + done(); + }); + }); + }); + + it('should delete a search', (done) => { + const searchToDelete = { name: 'Search 1', description: 'Description 1', encodedUrl: 'url1', order: 0 }; + prepareDefaultMock(); + + service.deleteSavedSearch(searchToDelete).subscribe(() => { + service.savedSearches$.subscribe((searches) => { + expect(searches.length).toBe(1); + expect(searches[0].name).toBe('Search 2'); + expect(searches[0].order).toBe(0); + done(); + }); + }); + }); + + /** + * Prepares default mocks for service + */ + function prepareDefaultMock(): void { + spyOn(authService, 'getUsername').and.callFake(() => testUserName); + const nodeId = 'saved-searches-node-id'; + spyOn(localStorage, 'getItem').and.callFake(() => nodeId); + service.innit(); + } }); diff --git a/lib/content-services/src/lib/common/services/saved-searches.service.ts b/lib/content-services/src/lib/common/services/saved-searches.service.ts index 71ae6fe721e..8327fb31e8b 100644 --- a/lib/content-services/src/lib/common/services/saved-searches.service.ts +++ b/lib/content-services/src/lib/common/services/saved-searches.service.ts @@ -76,23 +76,133 @@ export class SavedSearchesService { } /** - * Gets a list of saved searches by user. + * Saves a new search into state and updates state. If there are less than 5 searches, + * it will be pushed on first place, if more it will be pushed to 6th place. * * @param newSaveSearch object { name: string, description: string, encodedUrl: string } - * @returns Adds and saves search also updating current saved search state + * @returns NodeEntry */ saveSearch(newSaveSearch: Pick): Observable { return this.getSavedSearches().pipe( take(1), - switchMap((savedSearches: Array) => { - const updatedSavedSearches = [...savedSearches, { ...newSaveSearch, order: savedSearches.length }]; + switchMap((savedSearches: SavedSearch[]) => { + let updatedSavedSearches: SavedSearch[] = []; + + if (savedSearches.length < 5) { + updatedSavedSearches = [{ ...newSaveSearch, order: 0 }, ...savedSearches]; + } else { + const firstFiveSearches = savedSearches.slice(0, 5); + const restOfSearches = savedSearches.slice(5); + + updatedSavedSearches = [...firstFiveSearches, { ...newSaveSearch, order: 5 }, ...restOfSearches]; + } + + updatedSavedSearches = updatedSavedSearches.map((search, index) => ({ + ...search, + order: index + })); + return from(this.nodesApi.updateNodeContent(this.savedSearchFileNodeId, JSON.stringify(updatedSavedSearches))).pipe( tap(() => this.savedSearches$.next(updatedSavedSearches)) ); + }), + catchError((error) => { + console.error('Error saving new search:', error); + return throwError(() => error); + }) + ); + } + + /** + * Replace Save Search with new one and also updates the state. + * + * @param updatedSavedSearch - updated Save Search + * @returns NodeEntry + */ + editSavedSearch(updatedSavedSearch: SavedSearch): Observable { + let previousSavedSearches: SavedSearch[]; + return this.savedSearches$.pipe( + take(1), + map((savedSearches: SavedSearch[]) => { + previousSavedSearches = [...savedSearches]; + return savedSearches.map((search) => (search.order === updatedSavedSearch.order ? updatedSavedSearch : search)); + }), + tap((updatedSearches: SavedSearch[]) => { + this.savedSearches$.next(updatedSearches); + }), + switchMap((updatedSearches: SavedSearch[]) => { + return from(this.nodesApi.updateNodeContent(this.savedSearchFileNodeId, JSON.stringify(updatedSearches))); + }), + catchError((error) => { + this.savedSearches$.next(previousSavedSearches); + return throwError(() => error); }) ); } + /** + * Deletes Save Search and update state. + * + * @param deletedSavedSearch - Save Search to delete + * @returns NodeEntry + */ + deleteSavedSearch(deletedSavedSearch: SavedSearch): Observable { + let previousSavedSearchesOrder: SavedSearch[]; + return this.savedSearches$.pipe( + take(1), + map((savedSearches: SavedSearch[]) => { + previousSavedSearchesOrder = [...savedSearches]; + const updatedSearches = savedSearches.filter((search) => search.order !== deletedSavedSearch.order); + return updatedSearches.map((search, index) => ({ + ...search, + order: index + })); + }), + tap((updatedSearches: SavedSearch[]) => { + this.savedSearches$.next(updatedSearches); + }), + switchMap((updatedSearches: SavedSearch[]) => { + return from(this.nodesApi.updateNodeContent(this.savedSearchFileNodeId, JSON.stringify(updatedSearches))); + }), + catchError((error) => { + this.savedSearches$.next(previousSavedSearchesOrder); + return throwError(() => error); + }) + ); + } + + /** + * Reorders saved search place + * + * @param previousIndex - previous index of saved search + * @param currentIndex - new index of saved search + */ + changeOrder(previousIndex: number, currentIndex: number): void { + let previousSavedSearchesOrder: SavedSearch[]; + this.savedSearches$ + .pipe( + take(1), + map((savedSearches: SavedSearch[]) => { + previousSavedSearchesOrder = [...savedSearches]; + const [movedSearch] = savedSearches.splice(previousIndex, 1); + savedSearches.splice(currentIndex, 0, movedSearch); + return savedSearches.map((search, index) => ({ + ...search, + order: index + })); + }), + tap((savedSearches: SavedSearch[]) => this.savedSearches$.next(savedSearches)), + switchMap((updatedSearches: SavedSearch[]) => { + return from(this.nodesApi.updateNodeContent(this.savedSearchFileNodeId, JSON.stringify(updatedSearches))); + }), + catchError((error) => { + this.savedSearches$.next(previousSavedSearchesOrder); + return throwError(() => error); + }) + ) + .subscribe(); + } + private getSavedSearchesNodeId(): Observable { const localStorageKey = this.getLocalStorageKey(); if (this.currentUserLocalStorageKey && this.currentUserLocalStorageKey !== localStorageKey) { diff --git a/lib/content-services/src/lib/search/services/base-query-builder.service.ts b/lib/content-services/src/lib/search/services/base-query-builder.service.ts index 744f46782c2..42ca177c295 100644 --- a/lib/content-services/src/lib/search/services/base-query-builder.service.ts +++ b/lib/content-services/src/lib/search/services/base-query-builder.service.ts @@ -596,6 +596,7 @@ export abstract class BaseQueryBuilderService { * @param searchUrl search url to navigate to */ async navigateToSearch(query: string, searchUrl: string) { + this.update(); this.userQuery = query; await this.execute(); await this.router.navigate([searchUrl], { diff --git a/lib/core/src/lib/datatable/components/datatable/datatable.component.html b/lib/core/src/lib/datatable/components/datatable/datatable.component.html index 64087c4055e..a1d675279e9 100644 --- a/lib/core/src/lib/datatable/components/datatable/datatable.component.html +++ b/lib/core/src/lib/datatable/components/datatable/datatable.component.html @@ -15,6 +15,12 @@ class="adf-datatable-row" role="row"> + + +
+ {{ 'ADF-DATATABLE.ACCESSIBILITY.DRAG' | translate }} +
+
{{ 'ADF-DATATABLE.ACCESSIBILITY.ACTIONS' | translate }} @@ -148,10 +154,17 @@
+ +
+ +
+