[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 <michal.kinas@hyland.com>
This commit is contained in:
dominikiwanekhyland 2024-10-25 16:14:11 +02:00 committed by GitHub
parent ba52074bb5
commit a7911338c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 308 additions and 55 deletions

View File

@ -419,46 +419,48 @@ Learn more about styling your datatable: [Customizing the component's styles](#c
### Properties ### Properties
| Name | Type | Default value | Description | | Name | Type | Default value | Description |
|--------------------------|-------------------------------------------------------------------------------------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |----------------------------|-------------------------------------------------------------------------------------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| actions | `boolean` | false | Toggles the data actions column. | | actions | `boolean` | false | Toggles the data actions column. |
| actionsPosition | `string` | "right" | Position of the actions dropdown menu. Can be "left" or "right". | | 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. | | 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. | | 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. | | blurOnResize | `boolean` | true | Toggles blur when columns of the datatable are being resized. |
| columns | `any[]` | \[] | The columns that the datatable will show. | | columns | `any[]` | \[] | The columns that the datatable will show. |
| contextMenu | `boolean` | false | Toggles custom context menu for the component. | | 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 | | 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. | | displayCheckboxesOnHover | `boolean` | false | Enables checkboxes in datatable rows being displayed on hover only. |
| fallbackThumbnail | `string` | | Fallback image for rows where the thumbnail is missing. | | fallbackThumbnail | `string` | | Fallback image for rows where the thumbnail is missing. |
| isResizingEnabled | `boolean` | false | Flag that indicates if the datatable allows column resizing. | | 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). | | 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. | | 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. | | 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. | | 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. | | 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? | | 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. | | 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. | | rowStyleClass | `string` | "" | The CSS class to apply to every row. |
| rows | `any[]` | \[] | The rows that the datatable will show. | | 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. | | 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. | | showHeader | `ShowHeaderMode` | | Toggles the header. |
| showMainDatatableActions | `boolean` | false | Toggles the main datatable action. | | 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`] | | 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. | | stickyHeader | `boolean` | false | Toggles the sticky header mode. |
| enableDragRows | `boolean` | false | Flag that enables dragging rows. |
### Events ### Events
| Name | Type | Description | | 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. | | 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. | | 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)`<number>` | Emitted when the item row count is changed. | | selectedItemsCountChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<number>` | 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. | | 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. | | 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. | | 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. | | 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. | | 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 ## Details

View File

@ -28,6 +28,7 @@ describe('SavedSearchesService', () => {
let service: SavedSearchesService; let service: SavedSearchesService;
let authService: AuthenticationService; let authService: AuthenticationService;
let testUserName: string; let testUserName: string;
let getNodeContentSpy: jasmine.Spy;
const testNodeId = 'test-node-id'; const testNodeId = 'test-node-id';
const SAVED_SEARCHES_NODE_ID = 'saved-searches-node-id__'; const SAVED_SEARCHES_NODE_ID = 'saved-searches-node-id__';
@ -59,6 +60,9 @@ describe('SavedSearchesService', () => {
authService = TestBed.inject(AuthenticationService); authService = TestBed.inject(AuthenticationService);
spyOn(service.nodesApi, 'getNode').and.callFake(() => Promise.resolve({ entry: { id: testNodeId } } as NodeEntry)); 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.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(() => { afterEach(() => {
@ -68,12 +72,11 @@ describe('SavedSearchesService', () => {
it('should retrieve saved searches from the saved-searches.json file', (done) => { it('should retrieve saved searches from the saved-searches.json file', (done) => {
spyOn(authService, 'getUsername').and.callFake(() => testUserName); spyOn(authService, 'getUsername').and.callFake(() => testUserName);
spyOn(localStorage, 'getItem').and.callFake(() => testNodeId); spyOn(localStorage, 'getItem').and.callFake(() => testNodeId);
spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => createBlob());
service.innit(); service.innit();
service.getSavedSearches().subscribe((searches) => { service.getSavedSearches().subscribe((searches) => {
expect(localStorage.getItem).toHaveBeenCalledWith(SAVED_SEARCHES_NODE_ID + testUserName); 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.length).toBe(2);
expect(searches[0].name).toBe('Search 1'); expect(searches[0].name).toBe('Search 1');
expect(searches[1].name).toBe('Search 2'); 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) => { it('should create saved-searches.json file if it does not exist', (done) => {
spyOn(authService, 'getUsername').and.callFake(() => testUserName); spyOn(authService, 'getUsername').and.callFake(() => testUserName);
spyOn(service.nodesApi, 'createNode').and.callFake(() => Promise.resolve({ entry: { id: 'new-node-id' } })); getNodeContentSpy.and.callFake(() => Promise.resolve(new Blob([''])));
spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => Promise.resolve(new Blob([''])));
service.innit(); service.innit();
service.getSavedSearches().subscribe((searches) => { service.getSavedSearches().subscribe((searches) => {
@ -100,9 +102,7 @@ describe('SavedSearchesService', () => {
spyOn(authService, 'getUsername').and.callFake(() => testUserName); spyOn(authService, 'getUsername').and.callFake(() => testUserName);
const nodeId = 'saved-searches-node-id'; const nodeId = 'saved-searches-node-id';
spyOn(localStorage, 'getItem').and.callFake(() => nodeId); spyOn(localStorage, 'getItem').and.callFake(() => nodeId);
spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => createBlob());
const newSearch = { name: 'Search 3', description: 'Description 3', encodedUrl: 'url3' }; const newSearch = { name: 'Search 3', description: 'Description 3', encodedUrl: 'url3' };
spyOn(service.nodesApi, 'updateNodeContent').and.callFake(() => Promise.resolve({ entry: {} } as NodeEntry));
service.innit(); service.innit();
service.saveSearch(newSearch).subscribe(() => { service.saveSearch(newSearch).subscribe(() => {
@ -110,7 +110,7 @@ describe('SavedSearchesService', () => {
expect(service.savedSearches$).toBeDefined(); expect(service.savedSearches$).toBeDefined();
service.savedSearches$.subscribe((searches) => { service.savedSearches$.subscribe((searches) => {
expect(searches.length).toBe(3); 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); expect(searches[2].order).toBe(2);
done(); done();
}); });
@ -120,7 +120,6 @@ describe('SavedSearchesService', () => {
it('should emit initial saved searches on subscription', (done) => { it('should emit initial saved searches on subscription', (done) => {
const nodeId = 'saved-searches-node-id'; const nodeId = 'saved-searches-node-id';
spyOn(localStorage, 'getItem').and.returnValue(nodeId); spyOn(localStorage, 'getItem').and.returnValue(nodeId);
spyOn(service.nodesApi, 'getNodeContent').and.returnValue(createBlob());
service.innit(); service.innit();
service.savedSearches$.pipe().subscribe((searches) => { service.savedSearches$.pipe().subscribe((searches) => {
@ -135,9 +134,7 @@ describe('SavedSearchesService', () => {
it('should emit updated saved searches after saving a new search', (done) => { it('should emit updated saved searches after saving a new search', (done) => {
spyOn(authService, 'getUsername').and.callFake(() => testUserName); spyOn(authService, 'getUsername').and.callFake(() => testUserName);
spyOn(localStorage, 'getItem').and.callFake(() => testNodeId); spyOn(localStorage, 'getItem').and.callFake(() => testNodeId);
spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => createBlob());
const newSearch = { name: 'Search 3', description: 'Description 3', encodedUrl: 'url3' }; const newSearch = { name: 'Search 3', description: 'Description 3', encodedUrl: 'url3' };
spyOn(service.nodesApi, 'updateNodeContent').and.callFake(() => Promise.resolve({ entry: {} } as NodeEntry));
service.innit(); service.innit();
let emissionCount = 0; let emissionCount = 0;
@ -149,11 +146,52 @@ describe('SavedSearchesService', () => {
} }
if (emissionCount === 2) { if (emissionCount === 2) {
expect(searches.length).toBe(3); expect(searches.length).toBe(3);
expect(searches[2].name).toBe('Search 3'); expect(searches[2].name).toBe('Search 2');
done(); done();
} }
}); });
service.saveSearch(newSearch).subscribe(); 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();
}
}); });

View File

@ -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 } * @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<SavedSearch, 'name' | 'description' | 'encodedUrl'>): Observable<NodeEntry> { saveSearch(newSaveSearch: Pick<SavedSearch, 'name' | 'description' | 'encodedUrl'>): Observable<NodeEntry> {
return this.getSavedSearches().pipe( return this.getSavedSearches().pipe(
take(1), take(1),
switchMap((savedSearches: Array<SavedSearch>) => { switchMap((savedSearches: SavedSearch[]) => {
const updatedSavedSearches = [...savedSearches, { ...newSaveSearch, order: savedSearches.length }]; 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( return from(this.nodesApi.updateNodeContent(this.savedSearchFileNodeId, JSON.stringify(updatedSavedSearches))).pipe(
tap(() => this.savedSearches$.next(updatedSavedSearches)) 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<NodeEntry> {
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<NodeEntry> {
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<string> { private getSavedSearchesNodeId(): Observable<string> {
const localStorageKey = this.getLocalStorageKey(); const localStorageKey = this.getLocalStorageKey();
if (this.currentUserLocalStorageKey && this.currentUserLocalStorageKey !== localStorageKey) { if (this.currentUserLocalStorageKey && this.currentUserLocalStorageKey !== localStorageKey) {

View File

@ -596,6 +596,7 @@ export abstract class BaseQueryBuilderService {
* @param searchUrl search url to navigate to * @param searchUrl search url to navigate to
*/ */
async navigateToSearch(query: string, searchUrl: string) { async navigateToSearch(query: string, searchUrl: string) {
this.update();
this.userQuery = query; this.userQuery = query;
await this.execute(); await this.execute();
await this.router.navigate([searchUrl], { await this.router.navigate([searchUrl], {

View File

@ -15,6 +15,12 @@
class="adf-datatable-row" class="adf-datatable-row"
role="row"> role="row">
<!-- Drag -->
<div *ngIf="enableDragRows" class="adf-datatable-cell-header adf-drag-column">
<span class="adf-sr-only">{{ 'ADF-DATATABLE.ACCESSIBILITY.DRAG' | translate }}</span>
</div>
<!-- Actions (left) --> <!-- Actions (left) -->
<div *ngIf="actions && actionsPosition === 'left'" class="adf-actions-column adf-datatable-cell-header"> <div *ngIf="actions && actionsPosition === 'left'" class="adf-actions-column adf-datatable-cell-header">
<span class="adf-sr-only">{{ 'ADF-DATATABLE.ACCESSIBILITY.ACTIONS' | translate }}</span> <span class="adf-sr-only">{{ 'ADF-DATATABLE.ACCESSIBILITY.ACTIONS' | translate }}</span>
@ -148,10 +154,17 @@
<div <div
class="adf-datatable-body" class="adf-datatable-body"
[ngClass]="{ 'adf-blur-datatable-body': blurOnResize && (isDraggingHeaderColumn || isResizing) }" [ngClass]="{ 'adf-blur-datatable-body': blurOnResize && (isDraggingHeaderColumn || isResizing), 'adf-datatable-body__draggable': enableDragRows && !isDraggingRow, 'adf-datatable-body__dragging': isDraggingRow }"
cdkDropList
[cdkDropListDisabled]="!enableDragRows"
role="rowgroup"> role="rowgroup">
<ng-container *ngIf="!loading && !noPermission"> <ng-container *ngIf="!loading && !noPermission">
<adf-datatable-row *ngFor="let row of data.getRows(); let idx = index" <adf-datatable-row *ngFor="let row of data.getRows(); let idx = index"
cdkDrag
[cdkDragDisabled]="!enableDragRows"
(cdkDragDropped)="onDragDrop($event)"
(cdkDragStarted)="onDragStart()"
(cdkDragEnded)="onDragEnd()"
[row]="row" [row]="row"
(select)="onEnterKeyPressed(row, $event)" (select)="onEnterKeyPressed(row, $event)"
(keyup)="onRowKeyUp(row, $event)" (keyup)="onRowKeyUp(row, $event)"
@ -160,8 +173,19 @@
[adf-upload-data]="row" [adf-upload-data]="row"
[ngStyle]="rowStyle" [ngStyle]="rowStyle"
[ngClass]="getRowStyle(row)" [ngClass]="getRowStyle(row)"
[class.adf-datatable-row__dragging]="isDraggingRow"
[attr.data-automation-id]="'datatable-row-' + idx" [attr.data-automation-id]="'datatable-row-' + idx"
(contextmenu)="markRowAsContextMenuSource(row)"> (contextmenu)="markRowAsContextMenuSource(row)">
<!-- Drag button -->
<div *ngIf="enableDragRows"
role="gridcell"
class="adf-datatable-cell adf-datatable__actions-cell adf-datatable-hover-only">
<button mat-icon-button
[attr.aria-label]="'ADF-DATATABLE.ACCESSIBILITY.DRAG' | translate">
<mat-icon>drag_indicator</mat-icon>
</button>
</div>
<!-- Actions (left) --> <!-- Actions (left) -->
<div *ngIf="actions && actionsPosition === 'left'" role="gridcell" class="adf-datatable-cell"> <div *ngIf="actions && actionsPosition === 'left'" role="gridcell" class="adf-datatable-cell">
<button mat-icon-button [matMenuTriggerFor]="menu" #actionsMenuTrigger="matMenuTrigger" <button mat-icon-button [matMenuTriggerFor]="menu" #actionsMenuTrigger="matMenuTrigger"

View File

@ -133,6 +133,14 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width-1 !default;
width: 100%; width: 100%;
min-width: 100%; min-width: 100%;
&.adf-datatable-body__draggable {
cursor: grab;
}
&.adf-datatable-body__dragging {
cursor: grabbing;
}
.adf-datatable-row { .adf-datatable-row {
@include material-animation-default(0.28s); @include material-animation-default(0.28s);
@ -148,6 +156,10 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width-1 !default;
background-color: var(--adf-theme-background-selected-button-color); background-color: var(--adf-theme-background-selected-button-color);
} }
&.adf-drag-row {
cursor: grab;
}
&:last-child { &:last-child {
border-bottom: 1px solid var(--adf-theme-foreground-text-color-007); border-bottom: 1px solid var(--adf-theme-foreground-text-color-007);
} }
@ -284,6 +296,10 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width-1 !default;
box-sizing: content-box; box-sizing: content-box;
} }
&.adf-drag-column {
flex: 0;
}
.adf-datatable-cell-container { .adf-datatable-cell-container {
overflow: hidden; overflow: hidden;
min-height: inherit; min-height: inherit;
@ -585,6 +601,13 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width-1 !default;
} }
#{$cdk-drag-preview} { #{$cdk-drag-preview} {
min-height: $data-table-row-height;
display: flex;
align-items: center;
background-color: var(--theme-background-color);
border-top: 2px solid var(--theme-selected-background-color);
opacity: 1;
&.adf-datatable-cell-header { &.adf-datatable-cell-header {
border-radius: 6px; border-radius: 6px;
background-color: var(--theme-background-color); background-color: var(--theme-background-color);

View File

@ -2126,4 +2126,24 @@ describe('Column Resizing', () => {
expect(dataTable.isResizing).toBeFalse(); expect(dataTable.isResizing).toBeFalse();
expect(dataTable.columnsWidthChanged.emit).toHaveBeenCalled(); expect(dataTable.columnsWidthChanged.emit).toHaveBeenCalled();
}); });
it('should not have drag and drop directive enabled and not emit event when drag rows is disabled', () => {
spyOn(dataTable.dragDropped, 'emit');
dataTable.enableDragRows = false;
dataTable.showHeader = ShowHeaderMode.Never;
fixture.detectChanges();
const dragAndDrop = fixture.debugElement.query(By.directive(CdkDropList)).injector.get(CdkDropList);
dataTable.onDragDrop({} as CdkDragDrop<any>);
expect(dataTable.dragDropped.emit).not.toHaveBeenCalled();
expect(dragAndDrop.disabled).toBeTrue();
});
it('should emit event when drag rows is enabled', () => {
spyOn(dataTable.dragDropped, 'emit');
dataTable.enableDragRows = true;
fixture.detectChanges();
const data = { previousIndex: 1, currentIndex: 0 };
dataTable.onDragDrop(data as CdkDragDrop<any>);
expect(dataTable.dragDropped.emit).toHaveBeenCalledWith(data);
});
}); });

View File

@ -292,6 +292,16 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
@Input() @Input()
displayCheckboxesOnHover = false; displayCheckboxesOnHover = false;
/**
* Flag that enables dragging rows
*/
@Input()
enableDragRows = false;
/** Emitted when dragged row is dropped. */
@Output()
dragDropped = new EventEmitter<{ previousIndex: number; currentIndex: number }>();
headerFilterTemplate: TemplateRef<any>; headerFilterTemplate: TemplateRef<any>;
noContentTemplate: TemplateRef<any>; noContentTemplate: TemplateRef<any>;
noPermissionTemplate: TemplateRef<any>; noPermissionTemplate: TemplateRef<any>;
@ -306,6 +316,7 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
isDraggingHeaderColumn = false; isDraggingHeaderColumn = false;
hoveredHeaderColumnIndex = -1; hoveredHeaderColumnIndex = -1;
resizingColumnIndex = -1; resizingColumnIndex = -1;
isDraggingRow = false;
private keyManager: FocusKeyManager<DataTableRowComponent>; private keyManager: FocusKeyManager<DataTableRowComponent>;
private clickObserver: Observer<DataRowEvent>; private clickObserver: Observer<DataRowEvent>;
@ -844,7 +855,8 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
row.cssClass = row.cssClass ? row.cssClass : ''; row.cssClass = row.cssClass ? row.cssClass : '';
this.rowStyleClass = this.rowStyleClass ? this.rowStyleClass : ''; this.rowStyleClass = this.rowStyleClass ? this.rowStyleClass : '';
const contextMenuSourceClass = row.isContextMenuSource ? 'adf-context-menu-source' : ''; const contextMenuSourceClass = row.isContextMenuSource ? 'adf-context-menu-source' : '';
return `${row.cssClass} ${this.rowStyleClass} ${contextMenuSourceClass}`; const isDragEnabled = this.enableDragRows ? 'adf-drag-row' : '';
return `${row.cssClass} ${this.rowStyleClass} ${contextMenuSourceClass} ${isDragEnabled}`;
} }
markRowAsContextMenuSource(selectedRow: DataRow): void { markRowAsContextMenuSource(selectedRow: DataRow): void {
@ -1010,6 +1022,20 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
return !drop.getSortedItems()[index].disabled; return !drop.getSortedItems()[index].disabled;
} }
onDragDrop(droppedEvent: CdkDragDrop<any>): void {
if (this.enableDragRows) {
this.dragDropped.emit({ previousIndex: droppedEvent.previousIndex, currentIndex: droppedEvent.currentIndex });
}
}
onDragStart(): void {
this.isDraggingRow = true;
}
onDragEnd(): void {
this.isDraggingRow = false;
}
private updateColumnsWidths(): void { private updateColumnsWidths(): void {
const allColumns = this.data.getColumns(); const allColumns = this.data.getColumns();

View File

@ -381,6 +381,7 @@
"ICON_TEXT": "Item type {{ type }}", "ICON_TEXT": "Item type {{ type }}",
"ICON_DISABLED": "Disabled", "ICON_DISABLED": "Disabled",
"ROW_OPTION_BUTTON": "Actions", "ROW_OPTION_BUTTON": "Actions",
"DRAG": "Drag button",
"EMPTY_HEADER": "Empty header" "EMPTY_HEADER": "Empty header"
}, },
"FILE_TYPE": { "FILE_TYPE": {

View File

@ -79,6 +79,8 @@ describe('SidenavLayoutComponent', () => {
fixture = TestBed.createComponent(SidenavLayoutComponent); fixture = TestBed.createComponent(SidenavLayoutComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
component.sidenavMin = 70;
component.sidenavMax = 320;
}); });
afterEach(() => { afterEach(() => {

View File

@ -26,7 +26,8 @@ import {
OnDestroy, OnDestroy,
TemplateRef, TemplateRef,
EventEmitter, EventEmitter,
ViewEncapsulation ViewEncapsulation,
ChangeDetectorRef
} from '@angular/core'; } from '@angular/core';
import { MediaMatcher } from '@angular/cdk/layout'; import { MediaMatcher } from '@angular/cdk/layout';
import { UserPreferencesService } from '../../../common/services/user-preferences.service'; import { UserPreferencesService } from '../../../common/services/user-preferences.service';
@ -100,7 +101,11 @@ export class SidenavLayoutComponent implements OnInit, AfterViewInit, OnDestroy
private onDestroy$ = new Subject<boolean>(); private onDestroy$ = new Subject<boolean>();
constructor(private mediaMatcher: MediaMatcher, private userPreferencesService: UserPreferencesService) { constructor(
private readonly mediaMatcher: MediaMatcher,
private readonly userPreferencesService: UserPreferencesService,
private readonly changeDetectorRef: ChangeDetectorRef
) {
this.onMediaQueryChange = this.onMediaQueryChange.bind(this); this.onMediaQueryChange = this.onMediaQueryChange.bind(this);
} }
@ -137,6 +142,7 @@ export class SidenavLayoutComponent implements OnInit, AfterViewInit, OnDestroy
} else { } else {
this.isMenuMinimized = false; this.isMenuMinimized = false;
} }
this.changeDetectorRef.detectChanges();
this.container.toggleMenu(); this.container.toggleMenu();
this.expanded.emit(!this.isMenuMinimized); this.expanded.emit(!this.isMenuMinimized);