diff --git a/eslint.config.mjs b/eslint.config.mjs index da6b6d418..74d397510 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -62,7 +62,8 @@ export default tseslint.config( "^@components/", "^@services/", "^@angular/", - "^@pipes/" + "^@pipes/", + "^@shared/", ] } ], diff --git a/jest.config.ts b/jest.config.ts index f0c6d4279..65d11fa92 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -97,7 +97,8 @@ const config: Config = { '@components/(.*)': '/src/app/components/$1', '@services/(.*)': '/src/app/services/$1', '@pipes/(.*)': '/src/app/pipes/$1', - '@app/(.*)': '/src/app/$1' + '@app/(.*)': '/src/app/$1', + '@shared/(.*)': '/src/app/shared/$1', }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader diff --git a/src/app/applications/components/table.component.html b/src/app/applications/components/table.component.html index f0db90e2d..25765d810 100644 --- a/src/app/applications/components/table.component.html +++ b/src/app/applications/components/table.component.html @@ -1,3 +1,7 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/app/applications/components/table.component.spec.ts b/src/app/applications/components/table.component.spec.ts index f03f3a63c..ec10b3515 100644 --- a/src/app/applications/components/table.component.spec.ts +++ b/src/app/applications/components/table.component.spec.ts @@ -1,17 +1,20 @@ import { TaskStatus } from '@aneoconsultingfr/armonik.api.angular'; import { Clipboard } from '@angular/cdk/clipboard'; +import { ViewContainerRef, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; import { ManageGroupsDialogResult, TasksStatusesGroup } from '@app/dashboard/types'; import { TableColumn } from '@app/types/column.type'; import { ApplicationData, ColumnKey } from '@app/types/data'; +import { Group } from '@app/types/groups'; import { IconsService } from '@services/icons.service'; import { NotificationService } from '@services/notification.service'; import { TasksByStatusService } from '@services/tasks-by-status.service'; -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { ApplicationsTableComponent } from './table.component'; import ApplicationsDataService from '../services/applications-data.service'; +import { ApplicationsFiltersService } from '../services/applications-filters.service'; import { ApplicationRaw } from '../types'; describe('TasksTableComponent', () => { @@ -82,10 +85,11 @@ describe('TasksTableComponent', () => { ] }; + const afterClosedMocked = jest.fn((): Observable => of(mockDialogReturn)); const mockMatDialog = { open: jest.fn(() => { return { - afterClosed: jest.fn(() => of(mockDialogReturn)) + afterClosed: afterClosedMocked, }; }) }; @@ -108,6 +112,13 @@ describe('TasksTableComponent', () => { refresh$: { next: jest.fn() }, + refreshGroup: jest.fn(), + groupsConditions: [], + manageGroupDialogResult: jest.fn(), + }; + + const mockApplicationsFilterService = { + saveGroups: jest.fn(), }; beforeEach(() => { @@ -120,7 +131,9 @@ describe('TasksTableComponent', () => { { provide: MatDialog, useValue: mockMatDialog }, { provide: TasksByStatusService, useValue: mockTasksByStatusService }, IconsService, - { provide: Router, useValue: mockRouter } + { provide: Router, useValue: mockRouter }, + { provide: ViewContainerRef, useValue: {} }, + { provide: ApplicationsFiltersService, useValue: mockApplicationsFilterService }, ] }).inject(ApplicationsTableComponent); @@ -207,9 +220,16 @@ describe('TasksTableComponent', () => { }); }); - it('should track an application by its name and version', () => { - const application = {raw: { name: 'application', version: '0.1.2'}} as ApplicationData; - expect(component.trackBy(0, application)).toEqual(`${application.raw.name}-${application.raw.version}`); + describe('track By', () => { + it('should track an application by its name and version', () => { + const application = {raw: { name: 'application', version: '0.1.2'}} as ApplicationData; + expect(component.trackBy(0, application)).toEqual(`${application.raw.name}-${application.raw.version}`); + }); + + it('should track group by its name', () => { + const group = { name: signal('some-name') } as unknown as Group; + expect(component.trackBy(0, group)).toEqual(group.name()); + }); }); it('should get data', () => { @@ -235,4 +255,34 @@ describe('TasksTableComponent', () => { it('should get displayedColumns', () => { expect(component.columns).toEqual(displayedColumns); }); + + describe('UpdateGroupPage', () => { + const groupName = 'group 1'; + + beforeEach(() => { + component.updateGroupPage(groupName); + }); + + it('should refresh the selected group', () => { + expect(mockApplicationsDataService.refreshGroup).toHaveBeenCalledWith(groupName); + }); + }); + + describe('openGroupSettings', () => { + const groupName = 'Group 1'; + const result = [{name: 'Renamed Group', conditions: []}]; + + beforeEach(() => { + afterClosedMocked.mockReturnValueOnce(of(result)); + component.openGroupSettings(groupName); + }); + + it('should manage the group dialog result', () => { + expect(mockApplicationsDataService.manageGroupDialogResult).toHaveBeenCalledWith(result); + }); + + it('should update the groups in the local storage', () => { + expect(mockApplicationsFilterService.saveGroups).toHaveBeenCalledWith(mockApplicationsDataService.groupsConditions); + }); + }); }); \ No newline at end of file diff --git a/src/app/applications/components/table.component.ts b/src/app/applications/components/table.component.ts index 10b36cb9b..e558de8f4 100644 --- a/src/app/applications/components/table.component.ts +++ b/src/app/applications/components/table.component.ts @@ -4,6 +4,7 @@ import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; import { AbstractTaskByStatusTableComponent } from '@app/types/components/table'; import { ArmonikData } from '@app/types/data'; +import { Group } from '@app/types/groups'; import { ActionTable } from '@app/types/table'; import { TableComponent } from '@components/table/table.component'; import { FiltersService } from '@services/filters.service'; @@ -11,6 +12,7 @@ import { IconsService } from '@services/icons.service'; import { TableTasksByStatus, TasksByStatusService } from '@services/tasks-by-status.service'; import { Subject } from 'rxjs'; import ApplicationsDataService from '../services/applications-data.service'; +import { ApplicationsFiltersService } from '../services/applications-filters.service'; import { ApplicationRaw } from '../types'; @Component({ @@ -31,6 +33,7 @@ export class ApplicationsTableComponent extends AbstractTaskByStatusTableCompone table: TableTasksByStatus = 'applications'; readonly tableDataService = inject(ApplicationsDataService); + readonly filtersService = inject(ApplicationsFiltersService); readonly iconsService = inject(IconsService); readonly router = inject(Router); @@ -65,7 +68,11 @@ export class ApplicationsTableComponent extends AbstractTaskByStatusTableCompone }; } - trackBy(index: number, item: ArmonikData) { - return `${item.raw.name}-${item.raw.version}`; + trackBy(index: number, item: ArmonikData | Group) { + if ((item as ArmonikData).raw !== undefined) { + return `${(item as ArmonikData).raw.name}-${(item as ArmonikData).raw.version}`; + } else { + return (item as Group).name(); + } } } \ No newline at end of file diff --git a/src/app/applications/index.component.html b/src/app/applications/index.component.html index 33d861f3b..17091f8c1 100644 --- a/src/app/applications/index.component.html +++ b/src/app/applications/index.component.html @@ -13,6 +13,7 @@ [displayedColumns]="displayedColumnsKeys" [availableColumns]="availableColumns" [lockColumns]="lockColumns" + [groupsLength]="tableDataService.groupsConditions.length" (refresh)="refresh()" (intervalValueChange)="onIntervalValueChange($event)" (displayedColumnsChange)="onColumnsChange($event)" @@ -20,6 +21,7 @@ (resetFilters)="onFiltersReset()" (lockColumnsChange)="onLockColumnsChange()" (addToDashboard)="onAddToDashboard()" + (groupSettings)="openGroupsSettings()" > diff --git a/src/app/applications/index.component.spec.ts b/src/app/applications/index.component.spec.ts index 7b9ca97e9..c4a684e0f 100644 --- a/src/app/applications/index.component.spec.ts +++ b/src/app/applications/index.component.spec.ts @@ -1,4 +1,5 @@ import { ApplicationRawEnumField, FilterStringOperator } from '@aneoconsultingfr/armonik.api.angular'; +import { ViewContainerRef } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { DashboardIndexService } from '@app/dashboard/services/dashboard-index.service'; @@ -129,7 +130,10 @@ describe('Application component', () => { saveFilters: jest.fn(), resetFilters: jest.fn(() => defaultFilters), saveShowFilters: jest.fn(), - restoreShowFilters: jest.fn(() => defaultShowFilters) + restoreShowFilters: jest.fn(() => defaultShowFilters), + restoreGroups: jest.fn(() => []), + saveGroups: jest.fn(), + resetGroups: jest.fn() }; @@ -152,6 +156,21 @@ describe('Application component', () => { refresh$: { next: jest.fn() }, + groups: [], + groupsConditions: [], + initGroups: jest.fn(), + manageGroupDialogResult: jest.fn(), + }; + + const dialogResult: unknown[] = []; + const mockmatDialog = { + open: () => { + return { + afterClosed: () => { + return of(dialogResult); + } + }; + } }; beforeEach(() => { @@ -164,19 +183,10 @@ describe('Application component', () => { { provide: ShareUrlService, useValue: mockShareUrlService }, { provide: ApplicationsIndexService, useValue: mockApplicationIndexService }, { provide: AutoRefreshService, useValue: mockAutoRefreshService }, - { provide: MatDialog, useValue: - { - open: () => { - return { - afterClosed: () => { - return of([]); - } - }; - } - } - }, + { provide: MatDialog, useValue: mockmatDialog }, { provide: DashboardIndexService, useValue: mockDashboardIndexService }, DefaultConfigService, + { provide: ViewContainerRef, useValue: {} }, ] }).inject(IndexComponent); @@ -201,6 +211,11 @@ describe('Application component', () => { expect(component.availableColumns).toEqual(displayedColumns.map(col => col.key)); }); + it('should init groups on init', () => { + expect(mockApplicationsDataService.groupsConditions).toEqual(mockApplicationsFilterService.restoreGroups()); + expect(mockApplicationsDataService.initGroups).toHaveBeenCalled(); + }); + it('should get page icon', () => { expect(component.getIcon('applications')).toEqual('apps'); }); @@ -409,6 +424,7 @@ describe('Application component', () => { displayedColumns: component.displayedColumnsKeys, options: defaultOptions, filters: component.filters, + groups: mockApplicationsDataService.groupsConditions, }); }); }); @@ -426,4 +442,18 @@ describe('Application component', () => { expect(mockApplicationsFilterService.saveShowFilters).toHaveBeenCalledWith(newShowFilters); }); }); + + describe('openGroupsSettings', () => { + beforeEach(() => { + component.openGroupsSettings(); + }); + + it('should manage the group dialogResult', () => { + expect(mockApplicationsDataService.manageGroupDialogResult).toHaveBeenCalledWith(dialogResult); + }); + + it('should save the groups', () => { + expect(mockApplicationsFilterService.saveGroups).toHaveBeenCalledWith(mockApplicationsDataService.groupsConditions); + }); + }); }); \ No newline at end of file diff --git a/src/app/applications/index.component.ts b/src/app/applications/index.component.ts index ca33c66a1..c86556e1d 100644 --- a/src/app/applications/index.component.ts +++ b/src/app/applications/index.component.ts @@ -19,6 +19,7 @@ import { CacheService } from '@services/cache.service'; import { FiltersService } from '@services/filters.service'; import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; import { IconsService } from '@services/icons.service'; +import { InvertFilterService } from '@services/invert-filter.service'; import { NotificationService } from '@services/notification.service'; import { QueryParamsService } from '@services/query-params.service'; import { ShareUrlService } from '@services/share-url.service'; @@ -63,6 +64,7 @@ import { ApplicationRaw } from './types'; NotificationService, FiltersService, GrpcSortFieldService, + InvertFilterService, ], imports: [ PageHeaderComponent, diff --git a/src/app/applications/services/applications-data.service.spec.ts b/src/app/applications/services/applications-data.service.spec.ts index c066f6010..591f324ee 100644 --- a/src/app/applications/services/applications-data.service.spec.ts +++ b/src/app/applications/services/applications-data.service.spec.ts @@ -1,10 +1,13 @@ import { ApplicationRawEnumField, FilterStringOperator, ListApplicationsResponse, TaskOptionEnumField } from '@aneoconsultingfr/armonik.api.angular'; import { TestBed } from '@angular/core/testing'; import { FiltersOr } from '@app/types/filters'; +import { GroupConditions } from '@app/types/groups'; import { ListOptions } from '@app/types/options'; +import { ManageGroupsTableDialogResult } from '@components/table/group/manage-groups-dialog/manage-groups-dialog.component'; import { GrpcStatusEvent } from '@ngx-grpc/common'; import { CacheService } from '@services/cache.service'; import { FiltersService } from '@services/filters.service'; +import { InvertFilterService } from '@services/invert-filter.service'; import { NotificationService } from '@services/notification.service'; import { of, throwError } from 'rxjs'; import ApplicationsDataService from './applications-data.service'; @@ -40,7 +43,58 @@ describe('ApplicationDataService', () => { } }; - const initialFilters: FiltersOr = []; + const initialFilters: FiltersOr = [ + [ + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + value: 'name', + }, + ], + [ + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_SERVICE, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_NOT_EQUAL, + value: 'service' + } + ] + ]; + + const mockInvertFilterService = { + invert: jest.fn((e) => e), + }; + + const groupConditions: GroupConditions[] = [ + { + name: 'Group 1', + conditions: [ + [ + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_STARTS_WITH, + value: 't' + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_ENDS_WITH, + value: 'p', + }, + ], + [ + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_STARTS_WITH, + value: 'test' + }, + ], + ] + } + ]; beforeEach(() => { service = TestBed.configureTestingModule({ @@ -50,10 +104,13 @@ describe('ApplicationDataService', () => { { provide: ApplicationsGrpcService, useValue: mockApplicationsGrpcService }, { provide: NotificationService, useValue: mockNotificationService }, { provide: CacheService, useValue: mockCacheService }, + { provide: InvertFilterService, useValue: mockInvertFilterService }, ] }).inject(ApplicationsDataService); service.options = initialOptions; service.filters = initialFilters; + service.groupsConditions.push(...groupConditions); + service.initGroups(); }); it('should create', () => { @@ -97,12 +154,16 @@ describe('ApplicationDataService', () => { it('should set the total cached data', () => { expect(service.total()).toEqual(cachedApplications.total); }); + + it('should add groups according to group conditions', () => { + expect(service.groups.length).toEqual(groupConditions.length); + }); }); describe('Fetching data', () => { it('should list the data', () => { service.refresh$.next(); - expect(mockApplicationsGrpcService.list$).toHaveBeenCalledWith(service.options, service.filters); + expect(mockApplicationsGrpcService.list$).toHaveBeenCalledWith(service.prepareOptions(), service.prepareFilters()); }); it('should update the total', () => { @@ -120,7 +181,10 @@ describe('ApplicationDataService', () => { } as ApplicationRaw, queryTasksParams: { '0-options-5-0': 'application1', - '0-options-6-0': 'version1' + '0-options-6-0': 'version1', + '1-options-5-0': 'application1', + '1-options-6-0': 'version1', + '1-options-8-1': 'service', }, filters: [[ { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application1' }, @@ -134,7 +198,10 @@ describe('ApplicationDataService', () => { } as ApplicationRaw, queryTasksParams: { '0-options-5-0': 'application2', - '0-options-6-0': 'version2' + '0-options-6-0': 'version2', + '1-options-5-0': 'application2', + '1-options-6-0': 'version2', + '1-options-8-1': 'service', }, filters: [[ { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application2' }, @@ -148,7 +215,10 @@ describe('ApplicationDataService', () => { } as ApplicationRaw, queryTasksParams: { '0-options-5-0': 'application3', - '0-options-6-0': 'version3' + '0-options-6-0': 'version3', + '1-options-5-0': 'application3', + '1-options-6-0': 'version3', + '1-options-8-1': 'service', }, filters: [[ { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application3' }, @@ -185,6 +255,200 @@ describe('ApplicationDataService', () => { }); }); + describe('PrepareOptions', () => { + it('should clone the options', () => { + expect(service.prepareOptions()).toEqual(initialOptions); + }); + }); + + describe('PrepareFilters', () => { + it('should merge filters and group conditions', () => { + (expect(service.prepareFilters())).toEqual([ + [ + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_STARTS_WITH, + value: 't' + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_ENDS_WITH, + value: 'p', + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + value: 'name', + }, + ], + [ + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_STARTS_WITH, + value: 'test' + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + value: 'name', + }, + ], + [ + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_STARTS_WITH, + value: 't' + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_ENDS_WITH, + value: 'p', + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_SERVICE, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_NOT_EQUAL, + value: 'service' + }, + ], + [ + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_STARTS_WITH, + value: 'test' + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_SERVICE, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_NOT_EQUAL, + value: 'service' + }, + ], + ]); + }); + + it('should return group conditions if there is no filters', () => { + service.filters = []; + expect(service.prepareFilters()).toEqual(groupConditions[0].conditions); + }); + }); + + describe('groups fetching data', () => { + describe('defined conditions', () => { + it('should list data if there is a group condition', () => { + const group = service.groups[0]; + group.data.subscribe(() => { + expect(mockApplicationsGrpcService.list$).toHaveBeenCalledWith( + { + pageSize: 100, + pageIndex: group.page, + sort: initialOptions.sort + }, + groupConditions[0].conditions + ); + }); + group.refresh$.next(); + }); + + it('should update the group total', () => { + const group = service.groups[0]; + group.data.subscribe(() => { + expect(group.total).toEqual(applications.total); + }); + group.refresh$.next(); + }); + }); + + describe('empty conditions', () => { + it('should set emptyCondition to true', () => { + service.groupsConditions[0].conditions = []; + const group = service.groups[0]; + group.data.subscribe(() => { + expect(group.emptyCondition).toBeTruthy(); + }); + group.refresh$.next(); + }); + + it('should set total to 0', () => { + const group = service.groups[0]; + group.data.subscribe(() => { + expect(group.total).toEqual(0); + }); + group.refresh$.next(); + }); + }); + }); + + describe('manageGroupDialogResult', () => { + const toDeleteCondition: GroupConditions = { + name: 'ToBeDeleted', + conditions: [] + }; + + const dialogResult: ManageGroupsTableDialogResult = { + editedGroups: { + 'Group 1': { + name: 'Renamed Group', + conditions: [], + }, + }, + addedGroups: [ + { + name: 'New Group', + conditions: [], + }, + ], + deletedGroups: [ + 'ToBeDeleted', + ], + }; + + beforeEach(() => { + service['removeGroup']('New Group'); + service['addGroup'](toDeleteCondition); + service.manageGroupDialogResult(dialogResult); + }); + + it('should edit the correct group condition', () => { + expect(service.groupsConditions[0]).toEqual(dialogResult.editedGroups['Group 1']); + }); + + it('should edit the correct group', () => { + expect(service.groups[0].name()).toEqual(dialogResult.editedGroups['Group 1'].name); + }); + + it('should add a group condition', () => { + expect(service.groupsConditions[1]).toEqual(dialogResult.addedGroups[0]); + }); + + it('should add a group', () => { + expect(service.groups[1].name()).toEqual(dialogResult.addedGroups[0].name); + }); + + it('should remove a group condition', () => { + expect(service.groupsConditions.findIndex((group) => group.name === 'ToBeDeleted')).toEqual(-1); + }); + + it('should remove a group', () => { + expect(service.groups.findIndex((group) => group.name() === 'ToBeDeleted')).toEqual(-1); + }); + }); + + it('should refresh the correct group', () => { + const group = service.groups[0]; + const spy = jest.spyOn(group.refresh$, 'next'); + service.refreshGroup(group.name()); + expect(spy).toHaveBeenCalled(); + }); + it('should display a success message', () => { const message = 'A success message !'; service.success(message); @@ -345,4 +609,14 @@ describe('ApplicationDataService', () => { ]); }); }); + + describe('on destroy', () => { + beforeEach(() => { + service.ngOnDestroy(); + }); + + it('should unsubscribe', () => { + expect(service['dataSubscription'].closed).toBeTruthy(); + }); + }); }); \ No newline at end of file diff --git a/src/app/applications/services/applications-data.service.ts b/src/app/applications/services/applications-data.service.ts index c6526a69f..9a0e00698 100644 --- a/src/app/applications/services/applications-data.service.ts +++ b/src/app/applications/services/applications-data.service.ts @@ -1,5 +1,5 @@ import { ApplicationRawEnumField, FilterStringOperator, ListApplicationsResponse, TaskOptionEnumField } from '@aneoconsultingfr/armonik.api.angular'; -import { Injectable, inject } from '@angular/core'; +import { Injectable, OnDestroy, inject } from '@angular/core'; import { TaskSummaryFilters } from '@app/tasks/types'; import { Scope } from '@app/types/config'; import { ApplicationData } from '@app/types/data'; @@ -9,11 +9,15 @@ import { ApplicationRaw } from '../types'; import { ApplicationsGrpcService } from './applications-grpc.service'; @Injectable() -export default class ApplicationsDataService extends AbstractTableDataService { +export default class ApplicationsDataService extends AbstractTableDataService implements OnDestroy { readonly grpcService = inject(ApplicationsGrpcService); scope: Scope = 'applications'; + ngOnDestroy(): void { + this.onDestroy(); + } + computeGrpcData(entries: ListApplicationsResponse): ApplicationRaw[] | undefined { return entries.applications; } diff --git a/src/app/components/columns-button.component.ts b/src/app/components/columns-button.component.ts index 62f1c13ed..8c17165f4 100644 --- a/src/app/components/columns-button.component.ts +++ b/src/app/components/columns-button.component.ts @@ -20,7 +20,6 @@ import { ColumnsModifyDialogComponent } from './columns-modify-dialog.component' `], standalone: true, imports: [ - ColumnsModifyDialogComponent, MatDialogModule, MatButtonModule, MatIconModule diff --git a/src/app/components/table-actions-toolbar.component.html b/src/app/components/table-actions-toolbar.component.html index fbe6deb0a..0c5eed372 100644 --- a/src/app/components/table-actions-toolbar.component.html +++ b/src/app/components/table-actions-toolbar.component.html @@ -9,6 +9,11 @@ + + { component.onLockColumnsChange(); expect(spyOnEventEmitter).toHaveBeenCalled(); }); + + it('should emit on "Open groups settings"', () => { + const spy = jest.spyOn(component.groupSettings, 'emit'); + component.openGroupsSettings(); + expect(spy).toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/src/app/components/table-actions-toolbar.component.ts b/src/app/components/table-actions-toolbar.component.ts index b88883472..82147868a 100644 --- a/src/app/components/table-actions-toolbar.component.ts +++ b/src/app/components/table-actions-toolbar.component.ts @@ -1,4 +1,5 @@ import { Component, EventEmitter, Input, Output, inject } from '@angular/core'; +import { MatBadgeModule } from '@angular/material/badge'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; @@ -13,6 +14,7 @@ import { AutoRefreshButtonComponent } from './auto-refresh-button.component'; import { ColumnsButtonComponent } from './columns-button.component'; import { SpinnerComponent } from './spinner.component'; + @Component({ selector: 'app-table-actions-toolbar', templateUrl: './table-actions-toolbar.component.html', @@ -31,6 +33,7 @@ import { SpinnerComponent } from './spinner.component'; MatMenuModule, MatIconModule, MatTooltipModule, + MatBadgeModule ] }) export class TableActionsToolbarComponent { @@ -43,6 +46,7 @@ export class TableActionsToolbarComponent[] = []; @Input({ required: true }) availableColumns: ColumnKey[] = []; @Input({ required: true }) lockColumns = false; + @Input({ required: false }) groupsLength: number = 0; @Output() refresh: EventEmitter = new EventEmitter(); @Output() intervalValueChange: EventEmitter = new EventEmitter(); @@ -50,6 +54,7 @@ export class TableActionsToolbarComponent = new EventEmitter(); @Output() resetFilters: EventEmitter = new EventEmitter(); @Output() lockColumnsChange = new EventEmitter(); + @Output() groupSettings = new EventEmitter(); getIcon(name: string): string { return this.iconsService.getIcon(name); @@ -78,4 +83,8 @@ export class TableActionsToolbarComponent diff --git a/src/app/components/table-dashboard-actions-toolbar.component.spec.ts b/src/app/components/table-dashboard-actions-toolbar.component.spec.ts index 316736488..51feeef26 100644 --- a/src/app/components/table-dashboard-actions-toolbar.component.spec.ts +++ b/src/app/components/table-dashboard-actions-toolbar.component.spec.ts @@ -93,4 +93,10 @@ describe('TableDashboardActionsToolbarComponent', () => { component.onDeleteLine(); expect(spy).toHaveBeenCalled(); }); + + it('should emit on "Open groups settings"', () => { + const spy = jest.spyOn(component.groupSettings, 'emit'); + component.openGroupsSettings(); + expect(spy).toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/src/app/components/table-dashboard-actions-toolbar.component.ts b/src/app/components/table-dashboard-actions-toolbar.component.ts index 2004f76f0..703d0b94d 100644 --- a/src/app/components/table-dashboard-actions-toolbar.component.ts +++ b/src/app/components/table-dashboard-actions-toolbar.component.ts @@ -26,6 +26,7 @@ export class TableDashboardActionsToolbarComponent[]; @Input({ required: true }) availableColumns: ColumnKey[]; @Input({ required: true }) lockColumns = false; + @Input({ required: false }) groupsLength: number; @Output() refresh: EventEmitter = new EventEmitter(); @Output() intervalValueChange: EventEmitter = new EventEmitter(); @@ -35,6 +36,7 @@ export class TableDashboardActionsToolbarComponent(); @Output() editNameLine = new EventEmitter(); @Output() deleteLine = new EventEmitter(); + @Output() groupSettings = new EventEmitter(); getIcon(name: string): string { return this.iconsService.getIcon(name); @@ -71,4 +73,8 @@ export class TableDashboardActionsToolbarComponent diff --git a/src/app/components/table-index-actions-toolbar.component.spec.ts b/src/app/components/table-index-actions-toolbar.component.spec.ts index 08266d022..42ad7f1c5 100644 --- a/src/app/components/table-index-actions-toolbar.component.spec.ts +++ b/src/app/components/table-index-actions-toolbar.component.spec.ts @@ -87,4 +87,10 @@ describe('TableDashboardActionsToolbarComponent', () => { component.onAddToDashboard(); expect(spy).toHaveBeenCalled(); }); + + it('should emit on "Open groups settings"', () => { + const spy = jest.spyOn(component.groupSettings, 'emit'); + component.openGroupsSettings(); + expect(spy).toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/src/app/components/table-index-actions-toolbar.component.ts b/src/app/components/table-index-actions-toolbar.component.ts index 37a7a1b5c..ca0ca12aa 100644 --- a/src/app/components/table-index-actions-toolbar.component.ts +++ b/src/app/components/table-index-actions-toolbar.component.ts @@ -27,6 +27,7 @@ export class TableIndexActionsToolbarComponent[]; @Input({ required: true }) availableColumns: ColumnKey[]; @Input({ required: true }) lockColumns = false; + @Input({ required: false }) groupsLength: number; @Output() refresh: EventEmitter = new EventEmitter(); @Output() intervalValueChange: EventEmitter = new EventEmitter(); @@ -35,6 +36,7 @@ export class TableIndexActionsToolbarComponent = new EventEmitter(); @Output() lockColumnsChange = new EventEmitter(); @Output() addToDashboard = new EventEmitter(); + @Output() groupSettings = new EventEmitter(); getIcon(value: string): string { return this.iconsService.getIcon(value); @@ -67,4 +69,8 @@ export class TableIndexActionsToolbarComponent +
+
+ +

{{ group.name() }}

+
+

+ Total: + {{ group.total }} +

+
+ @if (statusesGroups) { + + } +
+ @if (group.emptyCondition) { + + +

No conditions

+
+ } + + +
+ +
+ +
+ +
+ + @for (column of displayedColumns; track $index) { + + + + + } + +
+ @if (column.type !== 'actions') { + + } @else { + + } +
+
+
\ No newline at end of file diff --git a/src/app/components/table/group/group-row/group.component.spec.ts b/src/app/components/table/group/group-row/group.component.spec.ts new file mode 100644 index 000000000..cd0ceeb8e --- /dev/null +++ b/src/app/components/table/group/group-row/group.component.spec.ts @@ -0,0 +1,151 @@ +import { FilterStringOperator, SessionStatus, TaskSummaryEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { SessionRaw } from '@app/sessions/types'; +import { TaskOptions } from '@app/tasks/types'; +import { TableColumn } from '@app/types/column.type'; +import { SessionData } from '@app/types/data'; +import { Group } from '@app/types/groups'; +import { IconsService } from '@services/icons.service'; +import { Subject, of } from 'rxjs'; +import { TableGroupComponent } from './group.component'; + +describe('TableGroupComponent', () => { + let component: TableGroupComponent; + + const data: SessionData[] = [ + { + raw: {} as SessionRaw, + filters: [[ + { + field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + value: 'session-1' + } + ]], + queryTasksParams: { + '0-root-1-0': 'session-1' + }, + resultsQueryParams: {}, + }, + { + raw: {} as SessionRaw, + filters: [[ + { + field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + value: 'session-2' + } + ]], + queryTasksParams: { + '0-root-1-0': 'session-2' + }, + resultsQueryParams: {}, + }, + ]; + + const group: Group = { + name: signal('Goup 1'), + page: 0, + opened: false, + total: 2, + refresh$: new Subject(), + emptyCondition: false, + data: of(data), + }; + + const columns: TableColumn[] = [ + { + key: 'sessionId', + name: 'Session Id', + sortable: true, + link: '/sessions', + type: 'link' + }, + { + key: 'actions', + name: 'Actions', + sortable: false, + }, + ]; + + beforeEach(() => { + component = TestBed.configureTestingModule({ + providers: [ + TableGroupComponent, + IconsService, + ] + }).inject(TableGroupComponent); + component.group = group; + component.columns = columns; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('initialisation', () => { + it('should set the group', () => { + expect(component.group).toBe(group); + }); + + it('should set columnsKeys', () => { + expect(component.columnsKeys).toEqual(columns.map((col) => col.key)); + }); + + it('should set displayedColumns', () => { + expect(component.displayedColumns).toEqual(columns); + }); + }); + + it('should get icons', () => { + expect(component.getIcon('heart')).toEqual('favorite'); + }); + + it('should switch the view of the group', () => { + component.switchView(); + expect(component.group.opened).toBeTruthy(); + }); + + describe('pageChange', () => { + let pageSpy: jest.SpyInstance; + const newPage = 1; + + beforeEach(() => { + pageSpy = jest.spyOn(component.page, 'emit'); + component.pageChange({ + length: 100, + pageIndex: newPage, + pageSize: 100, + previousPageIndex: 0 + }); + }); + + it('should emit on page change', () => { + expect(pageSpy).toHaveBeenCalled(); + }); + + it('should update group page', () => { + expect(group.page).toEqual(newPage); + }); + }); + + describe('groupSettingsEmit', () => { + let groupSettingsSpy: jest.SpyInstance; + + beforeEach(() => { + groupSettingsSpy = jest.spyOn(component.groupSettings, 'emit'); + component.groupSettingsEmit(); + }); + + it('should emit the group name', () => { + expect(groupSettingsSpy).toHaveBeenCalledWith(group.name()); + }); + }); + + it('should track the index of data by default', () => { + expect(component.trackBy(1, data[0])).toEqual(1); + }); +}); \ No newline at end of file diff --git a/src/app/components/table/group/group-row/group.component.ts b/src/app/components/table/group/group-row/group.component.ts new file mode 100644 index 000000000..a01f4c9f3 --- /dev/null +++ b/src/app/components/table/group/group-row/group.component.ts @@ -0,0 +1,105 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, EventEmitter, Input, Output, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatIconModule } from '@angular/material/icon'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatTableModule } from '@angular/material/table'; +import { TasksStatusesGroup } from '@app/dashboard/types'; +import { TaskOptions } from '@app/tasks/types'; +import { TableColumn } from '@app/types/column.type'; +import { ArmonikData, ColumnKey, DataRaw } from '@app/types/data'; +import { Group } from '@app/types/groups'; +import { Status, StatusService } from '@app/types/status'; +import { ActionTable } from '@app/types/table'; +import { TableActionsComponent } from '@components/table/table-actions.component'; +import { TableCellComponent } from '@components/table/table-cell.component'; +import { IconsService } from '@services/icons.service'; +import { rotateFull, expand } from '@shared/animations'; +import { GroupTasksByStatusComponent } from '../grouped-tasks-by-status/group-tasks-by-status.component'; + +/** + * Display groups row in tables. + */ +@Component({ + selector: 'app-table-group', + templateUrl: 'group.component.html', + styleUrl: 'group.component.css', + standalone: true, + imports: [ + MatButtonModule, + MatIconModule, + MatTableModule, + MatPaginatorModule, + MatCardModule, + TableCellComponent, + TableActionsComponent, + GroupTasksByStatusComponent, + AsyncPipe, + MatChipsModule, + ], + providers: [ + IconsService + ], + animations: [ + rotateFull, + expand, + ] +}) +export class TableGroupComponent { + @Input({ required: true }) group: Group; + @Input({ required: true }) set columns(entry: TableColumn[]) { + this.displayedColumns = entry; + this.columnsKeys = entry.map(column => column.key); + } + + @Input({ required: false }) actions: ActionTable[]; + @Input({ required: false }) statusesService: StatusService; + @Input({ required: false }) statusesGroups: TasksStatusesGroup[]; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + @Input({ required: false }) trackBy(index: number, item: ArmonikData | Group): number | string { + return index; + } + + columnsKeys: ColumnKey[]; + displayedColumns: TableColumn[]; + settingsRotate = false; + + @Output() page = new EventEmitter(); + @Output() groupSettings = new EventEmitter(); + + private readonly iconsService = inject(IconsService); + + /** + * Retrieves an icon. + * @param name icon name + * @returns Material icon name + */ + getIcon(name: string) { + return this.iconsService.getIcon(name); + } + + /** + * Expand or close the group row. + */ + switchView() { + this.group.opened = !this.group.opened; + } + + /** + * Emits the page change to the parent component. + * @param event PageEvent + */ + pageChange(event: PageEvent) { + this.group.page = event.pageIndex; + this.page.emit(); + } + + /** + * Emits the name of the group to open its settings. + */ + groupSettingsEmit() { + this.groupSettings.emit(this.group.name()); + } +} \ No newline at end of file diff --git a/src/app/components/table/group/grouped-tasks-by-status/group-tasks-by-status.component.css b/src/app/components/table/group/grouped-tasks-by-status/group-tasks-by-status.component.css new file mode 100644 index 000000000..9c0e174cb --- /dev/null +++ b/src/app/components/table/group/grouped-tasks-by-status/group-tasks-by-status.component.css @@ -0,0 +1,8 @@ +article { + display: flex; + align-items: center; +} + +p { + margin: 0; +} \ No newline at end of file diff --git a/src/app/components/table/group/grouped-tasks-by-status/group-tasks-by-status.component.html b/src/app/components/table/group/grouped-tasks-by-status/group-tasks-by-status.component.html new file mode 100644 index 000000000..4d0a13cc7 --- /dev/null +++ b/src/app/components/table/group/grouped-tasks-by-status/group-tasks-by-status.component.html @@ -0,0 +1,6 @@ +@if (queryParamsLength !== 0 && filters.length !== 0) { +
+

Tasks statuses:

+ +
+} \ No newline at end of file diff --git a/src/app/components/table/group/grouped-tasks-by-status/group-tasks-by-status.component.spec.ts b/src/app/components/table/group/grouped-tasks-by-status/group-tasks-by-status.component.spec.ts new file mode 100644 index 000000000..2adf46207 --- /dev/null +++ b/src/app/components/table/group/grouped-tasks-by-status/group-tasks-by-status.component.spec.ts @@ -0,0 +1,82 @@ +import { FilterStringOperator, TaskStatus, TaskSummaryEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { TasksStatusesGroup } from '@app/dashboard/types'; +import { SessionRaw } from '@app/sessions/types'; +import { TaskOptions, TaskSummaryFilters } from '@app/tasks/types'; +import { SessionData } from '@app/types/data'; +import { GroupTasksByStatusComponent } from './group-tasks-by-status.component'; + +describe('GroupTasksByStatusComponent', () => { + const component = new GroupTasksByStatusComponent(); + + const data: SessionData[] = [ + { + raw: {} as SessionRaw, + filters: [[ + { + field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + value: 'session-1' + } + ]], + queryTasksParams: { + '0-root-1-0': 'session-1' + }, + resultsQueryParams: {}, + }, + { + raw: {} as SessionRaw, + filters: [[ + { + field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + value: 'session-2' + } + ]], + queryTasksParams: { + '0-root-1-0': 'session-2' + }, + resultsQueryParams: {}, + }, + ]; + + const statusesGroups: TasksStatusesGroup[] = [ + { + name: 'Validated', + statuses: [TaskStatus.TASK_STATUS_COMPLETED, TaskStatus.TASK_STATUS_PROCESSED], + color: 'green', + }, + { + name: 'Error', + statuses: [TaskStatus.TASK_STATUS_ERROR, TaskStatus.TASK_STATUS_RETRIED], + color: 'red', + } + ]; + + beforeEach(() => { + component.groupData = data; + component.statusesGroups = statusesGroups; + }); + + describe('initialisation', () => { + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set filters', () => { + expect(component.filters).toEqual(data.reduce((acc: TaskSummaryFilters, current) => [...acc, ...current.filters], [])); + }); + + it('should set queryParams', () => { + expect(component.queryParams).toEqual({ + '0-root-1-0': 'session-1', + '1-root-1-0': 'session-2' + }); + }); + + it('should set queryParamsLength', () => { + expect(component.queryParamsLength).toEqual(2); + }); + }); +}); \ No newline at end of file diff --git a/src/app/components/table/group/grouped-tasks-by-status/group-tasks-by-status.component.ts b/src/app/components/table/group/grouped-tasks-by-status/group-tasks-by-status.component.ts new file mode 100644 index 000000000..dd33f8ae8 --- /dev/null +++ b/src/app/components/table/group/grouped-tasks-by-status/group-tasks-by-status.component.ts @@ -0,0 +1,50 @@ +import { Component, Input } from '@angular/core'; +import { TasksStatusesGroup } from '@app/dashboard/types'; +import { TaskOptions, TaskSummaryFilters } from '@app/tasks/types'; +import { ApplicationData, ArmonikData, DataRaw, PartitionData, SessionData } from '@app/types/data'; +import { CountTasksByStatusComponent } from '@components/count-tasks-by-status.component'; +import { Subject } from 'rxjs'; + +/** + * For a group of (Applications, Partitions or Sessions) rows, display the additionned tasks by status of all loaded data. + */ +@Component({ + selector: 'app-group-tasks-by-status', + templateUrl: 'group-tasks-by-status.component.html', + styleUrl: 'group-tasks-by-status.component.css', + standalone: true, + imports: [ + CountTasksByStatusComponent, + ] +}) +export class GroupTasksByStatusComponent { + /** + * Takes data from a group, and for each filter and queryTasksParams of this data, + * will prepare every needed objects for the taskByStatus component. + */ + @Input({ required: true }) set groupData(entry: ArmonikData[] | null) { + if (entry !== null) { + this.filters = []; + this.queryParams = {}; + const groupData = entry as unknown as (SessionData | ApplicationData | PartitionData)[]; + groupData.forEach((data) => { + this.filters.push(...data.filters); + }); + + groupData.forEach((data, index) => { + const keys = Object.keys(data.queryTasksParams); + keys.forEach((key) => (this.queryParams[`${index}${key.slice(1)}`] = data.queryTasksParams[key])); + }); + this.queryParamsLength = Object.keys(this.queryParams).length; + + this.refresh.next(); + } + } + + @Input({ required: true }) statusesGroups: TasksStatusesGroup[]; + + filters: TaskSummaryFilters; + queryParams: Record; + queryParamsLength = 0; + refresh = new Subject(); +} \ No newline at end of file diff --git a/src/app/components/table/group/manage-groups-dialog/manage-groups-dialog.component.css b/src/app/components/table/group/manage-groups-dialog/manage-groups-dialog.component.css new file mode 100644 index 000000000..0c78c41d6 --- /dev/null +++ b/src/app/components/table/group/manage-groups-dialog/manage-groups-dialog.component.css @@ -0,0 +1,66 @@ +mat-dialog-content { + display: flex; + justify-content: space-between; + min-height: 40vh; + max-height: 65vh; + width: 80vw; +} + +mat-divider { + margin-top: 1rem; +} + +#groups-names { + display: flex; + flex-direction: column; + padding-right: 1rem; + margin-right: 1rem; + border-right: 1px solid white; + width: 25%; + max-height: 55vh; +} + +#group-conditions { + display: flex; + flex-direction: column; + width: 100%; + align-items: center; +} + +#no-groups { + height: 100%; + display: flex; + align-items: center; +} + +#conditions-area { + max-height: 55vh; + overflow-y: auto; +} + +mat-form-field { + width: 50vw; +} + +span { + width: fit-content; +} + +ul { + padding: 0; + overflow: auto; + max-height: 55vh; +} + +li { + display: flex; + align-items: center; + width: 100%; + justify-content: space-between; +} + +#or-button { + width: fit-content; + margin-left: 5rem; + margin-top: 1rem; +} \ No newline at end of file diff --git a/src/app/components/table/group/manage-groups-dialog/manage-groups-dialog.component.html b/src/app/components/table/group/manage-groups-dialog/manage-groups-dialog.component.html new file mode 100644 index 000000000..74d09a9e3 --- /dev/null +++ b/src/app/components/table/group/manage-groups-dialog/manage-groups-dialog.component.html @@ -0,0 +1,46 @@ + +
+ + +
    + @for (group of groups; track $index) { +
  • + + +
  • + } +
+
+
+ @if (selectedGroup) { + + + +
+ @for (filters of selectedGroup.conditions; track $index) { + + } +
+ + } @else { +
+

Please select or create a group.

+
+ } +
+
+ + + + + \ No newline at end of file diff --git a/src/app/components/table/group/manage-groups-dialog/manage-groups-dialog.component.spec.ts b/src/app/components/table/group/manage-groups-dialog/manage-groups-dialog.component.spec.ts new file mode 100644 index 000000000..0550c48c8 --- /dev/null +++ b/src/app/components/table/group/manage-groups-dialog/manage-groups-dialog.component.spec.ts @@ -0,0 +1,248 @@ +import { FilterDateOperator, FilterStringOperator, SessionRawEnumField, TaskOptionEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { GroupConditions } from '@app/types/groups'; +import { IconsService } from '@services/icons.service'; +import { ManageGroupsTableDialogInput, ManageTableGroupsDialogComponent } from './manage-groups-dialog.component'; + +describe('ManageTableGroupsDialogComponent', () => { + let component: ManageTableGroupsDialogComponent; + + const groupConditions: GroupConditions[] = [ + { + name: 'Group 1', + conditions: [[ + { + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, + value: 'some' + } + ]] + }, + { + name: 'ToBeDeleted', + conditions: [[ + { + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_DELETED_AT, + for: 'root', + operator: FilterDateOperator.FILTER_DATE_OPERATOR_BEFORE, + value: '17345800', + } + ]] + } + ]; + + const dialogData: ManageGroupsTableDialogInput = { + selected: 'Group 1', + groups: groupConditions + }; + + const dialogRef = { + close: jest.fn() + }; + + beforeEach(() => { + component = TestBed.configureTestingModule({ + providers: [ + ManageTableGroupsDialogComponent, + IconsService, + { provide: MAT_DIALOG_DATA, useValue: dialogData }, + { provide: MatDialogRef, useValue: dialogRef }, + ] + }).inject(ManageTableGroupsDialogComponent); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Initialisation', () => { + it('should create groups', () => { + expect(component.groups).toEqual(groupConditions); + }); + + it('should select a group', () => { + expect(component.selectedGroup).toEqual(groupConditions[0]); + }); + }); + + describe('selectGroup', () => { + const fakeSelectedGroup: GroupConditions = { + name: 'Fake selected', + conditions: [], + }; + + beforeEach(() => { + component.groups.push(groupConditions[0]); + component.selectGroup(fakeSelectedGroup, true); + }); + + it('should select the fakeSelectedGroup', () => { + expect(component.selectedGroup).toBe(fakeSelectedGroup); + }); + + it('should set the newly selected group as "edited"', () => { + expect(component['editedGroups'][fakeSelectedGroup.name]).toEqual(fakeSelectedGroup); + }); + + it('should edit the name of the last selected group', () => { + expect(component.groups[0].name).toEqual(`Group 1 ${1}`); + }); + }); + + describe('addGroup', () => { + beforeEach(() => { + component.addGroup(); + }); + + it('should add a group', () => { + expect(component.groups.at(-1)).toEqual({ + name: 'New Group', + conditions: [[{ + field: null, + for: null, + operator: null, + value: null, + }]], + }); + }); + + it('should select the newly added group', () => { + expect(component.selectedGroup).toEqual(component.groups.at(-1)); + }); + + it('should add the group to the "addedGroups" array', () => { + expect(component['addedGroups']).toEqual([{ + name: 'New Group', + conditions: [[{ + field: null, + for: null, + operator: null, + value: null, + }]], + }]); + }); + + it('should avoid duplicate names', () => { + component.addGroup(); + expect(component.groups.at(-1)!.name).toEqual('New Group 1'); + }); + }); + + describe('removeGroup', () => { + const index = groupConditions.findIndex((group) => group.name === 'ToBeDeleted'); + const deletedGroup = groupConditions.find((group) => group.name === 'ToBeDeleted') as GroupConditions; + + describe('if not included in the "addedGroup" array', () => { + beforeEach(() => { + component.selectedGroup = deletedGroup; + component['editedGroups'][deletedGroup.name] = deletedGroup; + component.removeGroup(index); + }); + + it('should remove the group at the correct index', () => { + expect(component.groups).not.toContain(deletedGroup); + }); + + it('should add the group to the "deletedGroup" array', () => { + expect(component['deletedGroups']).toEqual([deletedGroup.name]); + }); + + it('should unselect the selected group if it is the one deleted', () => { + expect(component.selectedGroup).toBeUndefined(); + }); + + it('should remove the group from the "editedGroups" record', () => { + expect(component['editedGroups'][deletedGroup.name]).toBeUndefined(); + }); + }); + + describe('if included in the "addedGroup" array', () => { + beforeEach(() => { + component['addedGroups'].push(deletedGroup); + component.removeGroup(index); + }); + + it('should remove the group from the "addedGroups" array', () => { + expect(component['addedGroups']).toEqual([]); + }); + + it('should not add the group to the "deletedGroup" array if included in the "addedGroups" first', () => { + expect(component['deletedGroups']).not.toContain(deletedGroup); + }); + }); + + describe('updateName', () => { + const event = {target: {value: 'New Name'}} as unknown as Event; + + beforeEach(() => { + component.groups.push({name: 'New Name', conditions: []}); + component.updateName(event); + }); + + it('should update the name of the selectedGroup', () => { + expect(component.selectedGroup?.name).toEqual((event.target as HTMLInputElement).value); + }); + }); + }); + + describe('addOrFilter', () => { + beforeEach(() => { + component.addOrFilter(); + }); + + it('should add an empty filter', () => { + expect(component.selectedGroup?.conditions.at(-1)).toEqual([{ + field: null, + for: null, + operator: null, + value: null, + }]); + }); + }); + + describe('removeOrFilter', () => { + beforeEach(() => { + component.selectedGroup?.conditions.push([{ + field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, + for: 'options', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, + value: 'application' + }]); + component.removeOrFilter(1); + }); + + it('should remove the filter from the selected group', () => { + expect(component.selectedGroup?.conditions[1]).toBeUndefined(); + }); + + it('should add an empty filter if the conditions array is empty', () => { + component.removeOrFilter(0); + expect(component.selectedGroup?.conditions).toEqual([[{ + field: null, + for: null, + operator: null, + value: null, + }]]); + }); + }); + + it('should get icons', () => { + expect(component.getIcon('heart')).toEqual('favorite'); + }); + + it('should close with all required data on confirm', () => { + component.confirmClose(); + expect(dialogRef.close).toHaveBeenCalledWith({ + addedGroups: component['addedGroups'], + editedGroups: component['editedGroups'], + deletedGroups: component['deletedGroups'], + }); + }); + + it('should close with nothing', () => { + component.close(); + expect(dialogRef.close).toHaveBeenCalledWith(); + }); +}); \ No newline at end of file diff --git a/src/app/components/table/group/manage-groups-dialog/manage-groups-dialog.component.ts b/src/app/components/table/group/manage-groups-dialog/manage-groups-dialog.component.ts new file mode 100644 index 000000000..c72659c81 --- /dev/null +++ b/src/app/components/table/group/manage-groups-dialog/manage-groups-dialog.component.ts @@ -0,0 +1,203 @@ +import { Component, inject, Inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { FiltersEnums, FiltersOptionsEnums } from '@app/types/filters'; +import { GroupConditions } from '@app/types/groups'; +import { FiltersDialogOrComponent } from '@components/filters/filters-dialog-or.component'; +import { IconsService } from '@services/icons.service'; + +/** + * Data required by the ManageTableGroupsDialog component in order to manage groups correctly. + * - groups: Array of GroupConditions, all currently created groups. + * - selected: string, represent a name of a group to focus when the dialog opens. + */ +export type ManageGroupsTableDialogInput = { + groups: GroupConditions[]; + selected?: string; +} + +/** + * Data returned by the ManageTableGroupsDialog component in order to modify groups in the simpliest way possible. + * - addedGroups: Array of GroupConditions, Groups to add once the dialog is closed. + * - editedGroups: Record according a string to GroupCondition. + * - deletedGroups: Array of string, names of groups to delete once the dialog is closed. + */ +export type ManageGroupsTableDialogResult = { + addedGroups: GroupConditions[]; + editedGroups: Record>; + deletedGroups: string[]; +} + +/** + * Dialog made to manage (create, edit and delete) group conditions for a specific table. + */ +@Component({ + selector: 'app-manage-groups-dialog', + templateUrl: 'manage-groups-dialog.component.html', + styleUrl: 'manage-groups-dialog.component.css', + standalone: true, + imports: [ + MatDialogModule, + MatButtonModule, + MatIconModule, + FiltersDialogOrComponent, + MatDividerModule, + MatFormFieldModule, + MatInputModule, + ], +}) +export class ManageTableGroupsDialogComponent { + private readonly dialogRef: MatDialogRef, ManageGroupsTableDialogResult> = inject(MatDialogRef); + private readonly iconsService = inject(IconsService); + + groups: GroupConditions[]; + + private readonly addedGroups: GroupConditions[] = []; + private readonly editedGroups: Record> = {}; + private readonly deletedGroups: string[] = []; + + selectedGroup: GroupConditions | undefined; + + constructor(@Inject(MAT_DIALOG_DATA) dialogData: ManageGroupsTableDialogInput) { + this.groups = structuredClone(dialogData.groups); + if (dialogData.selected !== undefined) { + const group = this.groups.find(group => group.name === dialogData.selected); + if (group) { + this.selectGroup(group, true); + } + } + } + + /** + * Set a group as selected. + * - If the previously selected group has a name that is already existing in the **groups** array, + * updates its with the number of duplicates. + * + * @param group GroupCondition. group to select. + * @param setAsEdited boolean. If set to true, will add the selectedGroup in the editedArray. + */ + selectGroup(group: GroupConditions, setAsEdited: boolean = false) { + if (this.selectedGroup) { + const duplicates = this.groups.filter((group) => group.name.includes(this.selectedGroup!.name)).length; + if (duplicates > 1) { + this.selectedGroup.name += ` ${duplicates-1}`; + } + } + this.selectedGroup = group; + if (setAsEdited) { + const groupName = `${group.name}`; + this.editedGroups[groupName] = group; + } + } + + /** + * Add a group with the name "New Group" and an empty filter. + * If a group with the same name already exists, will add a number at the end of the name. + */ + addGroup() { + const name = $localize`New Group`; + const duplicates = this.groups.filter((group) => group.name.includes(name)).length; + const group: GroupConditions = { + name: name + (duplicates !== 0 ? ` ${duplicates}` : ''), + conditions: [[{ + field: null, + for: null, + operator: null, + value: null, + }]] + }; + this.groups.push(group); + this.selectGroup(group); + this.addedGroups.push(group); + } + + /** + * Remove a group at the specified index. + * @param index number. + */ + removeGroup(index: number) { + const deletedGroup = this.groups.splice(index, 1)[0]; + if (deletedGroup) { + if (this.selectedGroup?.name === deletedGroup?.name) { + this.selectedGroup = undefined; + } + const addedIndex = this.addedGroups.findIndex((group) => group.name === deletedGroup.name); + if (addedIndex !== -1) { + this.addedGroups.splice(addedIndex, 1); + } else { + this.deletedGroups.push(deletedGroup.name); + delete this.editedGroups[deletedGroup.name]; + } + } + } + + /** + * Update the group name on user input. + * Will compute the number of duplicates of this name. + * @param event Event + */ + updateName(event: Event) { + if (this.selectedGroup) { + const name = (event.target as HTMLInputElement).value; + this.selectedGroup.name = name; + } + } + + /** + * Add an FiltersAnd to the selectedGroup condition. + */ + addOrFilter() { + if (this.selectedGroup) { + this.selectedGroup.conditions.push([{ + field: null, + for: null, + operator: null, + value: null, + }]); + } + } + + /** + * Remove the FiltersAnd from the selectedGroup condition at the specified index. + * @param index number. + */ + removeOrFilter(index: number) { + if (this.selectedGroup) { + this.selectedGroup.conditions.splice(index, 1); + if (this.selectedGroup.conditions.length === 0) { + this.addOrFilter(); + } + } + } + + /** + * Retrieves an icon. + * @param name icon name + * @returns Material icon name + */ + getIcon(name: string) { + return this.iconsService.getIcon(name); + } + + /** + * Close the dialog on confirm. + */ + confirmClose() { + this.dialogRef.close({ + addedGroups: this.addedGroups, + deletedGroups: this.deletedGroups, + editedGroups: this.editedGroups + }); + } + + /** + * Close the dialog on cancel. + */ + close() { + this.dialogRef.close(); + } +} \ No newline at end of file diff --git a/src/app/components/table/table.component.html b/src/app/components/table/table.component.html index 825bf0d5f..510f00530 100644 --- a/src/app/components/table/table.component.html +++ b/src/app/components/table/table.component.html @@ -4,6 +4,26 @@ [cdkDropListDisabled]="lockColumns" (cdkDropListDropped)="onDrop($event)" [dataSource]="data" i18n-aria-label aria-label="Data Table"> + + + + @if (!isData(element)) { + + + + } + + + @for (column of columns; track column.key) { @@ -13,17 +33,21 @@ (statusesChange)="onPersonnalizeTasksByStatus()" /> - @if (column.type !== 'actions') { - - - - } @else { - - - - } + + @if (isData(element)) { + @if (column.type !== 'actions') { + + + + } @else { + + + + } + } + } diff --git a/src/app/components/table/table.component.spec.ts b/src/app/components/table/table.component.spec.ts index e0428d7f6..cc57c1f6e 100644 --- a/src/app/components/table/table.component.spec.ts +++ b/src/app/components/table/table.component.spec.ts @@ -7,6 +7,7 @@ import { SessionRaw } from '@app/sessions/types'; import { TaskOptions } from '@app/tasks/types'; import { TableColumn } from '@app/types/column.type'; import { SessionData } from '@app/types/data'; +import { Group } from '@app/types/groups'; import { ListOptions } from '@app/types/options'; import { TableComponent } from './table.component'; @@ -94,7 +95,7 @@ describe('TableComponent', () => { }); it('should set columnsKeys', () => { - expect(component.columnsKeys).toEqual(columns.map((entry) => entry.key)); + expect(component.columnsKeys).toEqual([...columns.map((entry) => entry.key), 'group']); }); describe('sortChange', () => { @@ -241,6 +242,30 @@ describe('TableComponent', () => { expect(component.trackBy(index, data[0])).toEqual(index); }); + describe('isData', () => { + it('should return true if the element is an ArmonikData', () => { + expect(component.isData({raw: {sessionId: ''}} as SessionData)); + }); + + it('should return false if the element is a group', () => { + expect(component.isData({name: 'group'} as unknown as Group)); + }); + }); + + it('should emit on group page update', () => { + const name = 'group'; + const spy = jest.spyOn(component.groupPageChange, 'emit'); + component.groupPageUpdate(name); + expect(spy).toHaveBeenCalledWith(name); + }); + + it('should emit on group settings open', () => { + const name = 'group'; + const spy = jest.spyOn(component.groupSettings, 'emit'); + component.onGroupSettingsEmit(name); + expect(spy).toHaveBeenCalledWith(name); + }); + it('should unsubscribe on destroy', () => { const sortSpy = jest.spyOn(component.sort.sortChange, 'unsubscribe'); const paginatorSpy = jest.spyOn(component.paginator.page, 'unsubscribe'); diff --git a/src/app/components/table/table.component.ts b/src/app/components/table/table.component.ts index aa930eb71..b95128a93 100644 --- a/src/app/components/table/table.component.ts +++ b/src/app/components/table/table.component.ts @@ -8,10 +8,12 @@ import { TasksStatusesGroup } from '@app/dashboard/types'; import { TaskOptions } from '@app/tasks/types'; import { TableColumn } from '@app/types/column.type'; import { ArmonikData, ColumnKey, DataRaw } from '@app/types/data'; +import { Group } from '@app/types/groups'; import { ListOptions } from '@app/types/options'; import { Status, StatusService } from '@app/types/status'; import { ActionTable } from '@app/types/table'; import { TableContainerComponent } from '@components/table-container.component'; +import { TableGroupComponent } from './group/group-row/group.component'; import { TableActionsComponent } from './table-actions.component'; import { TableCellComponent } from './table-cell.component'; import { TableColumnHeaderComponent } from './table-column-header.component'; @@ -31,6 +33,7 @@ import { TableEmptyDataComponent } from './table-empty-data.component'; MatSortModule, TableActionsComponent, TableContainerComponent, + TableGroupComponent, ], changeDetection: ChangeDetectionStrategy.OnPush }) @@ -39,6 +42,7 @@ export class TableComponent[]) { this._columns = entries; this._columnsKeys = entries.map((entry) => entry.key); + this._columnsKeys.push('group'); } @Input({ required: true }) set data(entries: ArmonikData[]) { @@ -51,6 +55,8 @@ export class TableComponent[]; + @Input({ required: true }) total: number; @Input({ required: true }) options: ListOptions; @@ -63,7 +69,7 @@ export class TableComponent boolean) | undefined; // eslint-disable-next-line @typescript-eslint/no-unused-vars - @Input({ required: false }) trackBy(index: number, item: ArmonikData): number | string { + @Input({ required: false }) trackBy(index: number, item: ArmonikData | Group): number | string { return index; } @@ -71,6 +77,8 @@ export class TableComponent(); @Output() selectionChange = new EventEmitter(); @Output() personnalizeTasksByStatus = new EventEmitter(); + @Output() groupPageChange = new EventEmitter(); + @Output() groupSettings = new EventEmitter(); @ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatSort) sort: MatSort; @@ -80,8 +88,8 @@ export class TableComponent[]; private _isAllSelected: boolean = false; - get data(): ArmonikData[] { - return this._data; + get data(): (ArmonikData | Group)[] { + return [...(this.groups ?? []), ...this._data]; } get columns(): TableColumn[] { @@ -109,7 +117,7 @@ export class TableComponent { - if (this.options.pageSize > this.paginator.pageSize) this.data = this.data.slice(0, this.paginator.pageSize); + if (this.options.pageSize > this.paginator.pageSize) this._data = this._data.slice(0, this.paginator.pageSize); this.options.pageIndex = this.paginator.pageIndex; this.options.pageSize = this.paginator.pageSize; this.optionsChange.emit(); @@ -147,7 +155,7 @@ export class TableComponent d.raw))); + this.selection.select(...(this._data.map(d => d.raw))); this._isAllSelected = true; } this.emitSelectionChange(); @@ -165,4 +173,16 @@ export class TableComponent | Group) { + return (element as ArmonikData).raw !== undefined && (element as Group).name === undefined; + } + + groupPageUpdate(groupName: string) { + this.groupPageChange.emit(groupName); + } + + onGroupSettingsEmit(groupName: string) { + this.groupSettings.emit(groupName); + } } \ No newline at end of file diff --git a/src/app/components/view-tasks-by-status.component.spec.ts b/src/app/components/view-tasks-by-status.component.spec.ts index 46e0faa28..ed1a9766a 100644 --- a/src/app/components/view-tasks-by-status.component.spec.ts +++ b/src/app/components/view-tasks-by-status.component.spec.ts @@ -77,7 +77,7 @@ describe('ViewTasksByStatusComponent', () => { }); it('should handle statuses on init', () => { - expect(component.statusesGroups).toEqual([ + expect(component.groups).toEqual([ { ...initialStatusesGroups[0], queryParams: { @@ -111,6 +111,6 @@ describe('ViewTasksByStatusComponent', () => { { status: TaskStatus.TASK_STATUS_ERROR, count: errorStatusCount }, { status: TaskStatus.TASK_STATUS_TIMEOUT, count: timeoutStatusCount }, ]; - expect(component.statusesGroups.map(group => group.statusCount)).toEqual([completedStatusCount, errorStatusCount + timeoutStatusCount]); + expect(component.groups.map(group => group.statusCount)).toEqual([completedStatusCount, errorStatusCount + timeoutStatusCount]); }); }); \ No newline at end of file diff --git a/src/app/components/view-tasks-by-status.component.ts b/src/app/components/view-tasks-by-status.component.ts index 2bc872d41..1ea963476 100644 --- a/src/app/components/view-tasks-by-status.component.ts +++ b/src/app/components/view-tasks-by-status.component.ts @@ -14,7 +14,7 @@ import { SpinnerComponent } from './spinner.component'; @if (loading) { } @else { - @for (group of statusesGroups; track group.name) { + @for (group of groups; track group.name) { = {}; - - @Input() set statusesGroups(entries: TasksStatusesGroup[]) { - this._statusesGroups = entries.map(group => this.completeGroup(group)); + @Input({ required: true }) set defaultQueryParams(entry: Record) { + this.queryParams = entry; + this.groups.forEach((group) => group.queryParams = this.createQueryParams(group)); } - private _statusesGroups: TasksStatusesGroup[] = []; + private queryParams: Record = {}; - get statusesGroups(): TasksStatusesGroup[] { - return this._statusesGroups; + @Input() set statusesGroups(entries: TasksStatusesGroup[]) { + this.groups = entries.map(group => this.completeGroup(group)); } + groups: TasksStatusesGroup[] = []; + @Input() set statusesCount(entries: StatusCount[] | null) { - this.statusesGroups.forEach(group => group.statusCount = 0); + this.groups.forEach(group => group.statusCount = 0); entries?.forEach((entry) => { - this.statusesGroups.forEach(group => { + this.groups.forEach(group => { if (group.statuses.includes(entry.status) && group.statusCount !== undefined) { group.statusCount += entry.count; } @@ -69,9 +70,9 @@ export class ViewTasksByStatusComponent { } createQueryParams(group: TasksStatusesGroup) { - const queryOrs = Object.keys(this.defaultQueryParams).map(key => key[0]).filter((key, index, self) => self.indexOf(key) === index); - const queryParamsKeys = Object.keys(this.defaultQueryParams); - const queryParamsValues = Object.values(this.defaultQueryParams); + const queryOrs = Object.keys(this.queryParams).map(key => key[0]).filter((key, index, self) => self.indexOf(key) === index); + const queryParamsKeys = Object.keys(this.queryParams); + const queryParamsValues = Object.values(this.queryParams); const taskStatusQueryParams: Record = {}; let orGroups = 0; diff --git a/src/app/dashboard/components/lines/applications-line.component.html b/src/app/dashboard/components/lines/applications-line.component.html index a4b26d040..822e99dca 100644 --- a/src/app/dashboard/components/lines/applications-line.component.html +++ b/src/app/dashboard/components/lines/applications-line.component.html @@ -16,6 +16,7 @@ (lockColumnsChange)="onLockColumnsChange()" (deleteLine)="onDeleteLine()" (editNameLine)="onEditNameLine()" + (groupSettings)="openGroupsSettings()" > diff --git a/src/app/dashboard/components/lines/applications-line.component.spec.ts b/src/app/dashboard/components/lines/applications-line.component.spec.ts index f7ebabbf1..194ecc029 100644 --- a/src/app/dashboard/components/lines/applications-line.component.spec.ts +++ b/src/app/dashboard/components/lines/applications-line.component.spec.ts @@ -1,4 +1,5 @@ -import { ApplicationRawEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { ApplicationRawEnumField, FilterStringOperator } from '@aneoconsultingfr/armonik.api.angular'; +import { ViewContainerRef } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import ApplicationsDataService from '@app/applications/services/applications-data.service'; @@ -6,7 +7,8 @@ import { ApplicationsIndexService } from '@app/applications/services/application import { ApplicationRaw, ApplicationRawColumnKey, ApplicationRawFieldKey, ApplicationRawListOptions } from '@app/applications/types'; import { TableColumn } from '@app/types/column.type'; import { ColumnKey } from '@app/types/data'; -import { FiltersOr } from '@app/types/filters'; +import { FiltersEnums, FiltersOptionsEnums, FiltersOr } from '@app/types/filters'; +import { GroupConditions } from '@app/types/groups'; import { AutoRefreshService } from '@services/auto-refresh.service'; import { DefaultConfigService } from '@services/default-config.service'; import { IconsService } from '@services/icons.service'; @@ -72,6 +74,16 @@ describe('ApplicationsLineComponent', () => { }, }; + const lineGroup: GroupConditions = { + name: 'Group 1', + conditions: [[{ + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, + value: 'name' + }]], + }; + const line: TableLine = { name: 'Tasks', type: 'Applications', @@ -79,7 +91,8 @@ describe('ApplicationsLineComponent', () => { filters: [], interval: 20, options: options, - showFilters: false + showFilters: false, + groups: lineGroup as unknown as GroupConditions[], }; const nameLine = { @@ -88,7 +101,7 @@ describe('ApplicationsLineComponent', () => { const mockMatDialog = { open: jest.fn(() => { return { - afterClosed() { + afterClosed(): unknown { return of(nameLine); } }; @@ -104,6 +117,10 @@ describe('ApplicationsLineComponent', () => { refresh$: { next: jest.fn() }, + initGroups: jest.fn(), + manageGroupDialogResult: jest.fn(), + groupsConditions: [], + groups: [], }; const mockApplicationsIndexService = { @@ -129,7 +146,8 @@ describe('ApplicationsLineComponent', () => { IconsService, { provide: ApplicationsIndexService, useValue: mockApplicationsIndexService }, DefaultConfigService, - { provide: NotificationService, useValue: mockNotificationService } + { provide: NotificationService, useValue: mockNotificationService }, + { provide: ViewContainerRef, usevalue: {} }, ] }).inject(ApplicationsLineComponent); component.line = line; @@ -166,6 +184,11 @@ describe('ApplicationsLineComponent', () => { expect(component.intervalValue).toEqual(10); expect(component.options).toEqual(defaultConfigService.defaultApplications.options); }); + + it('should init groups', () => { + expect(mockApplicationsDataService.groupsConditions).toBe(lineGroup); + expect(mockApplicationsDataService.initGroups).toHaveBeenCalled(); + }); }); it('should unsubscribe on destroy', () => { @@ -390,4 +413,22 @@ describe('ApplicationsLineComponent', () => { expect(component.line.showFilters).toEqual(newShowFilters); }); }); + + describe('openGroupsSettings', () => { + const dialogResult = [{fake: 'return'}]; + beforeEach(() => { + mockMatDialog.open.mockReturnValueOnce({ + afterClosed: () => of(dialogResult) + }); + component.openGroupsSettings(); + }); + + it('should manage the group dialogResult', () => { + expect(mockApplicationsDataService.manageGroupDialogResult).toHaveBeenCalledWith(dialogResult); + }); + + it('should save the groups', () => { + expect(component.line.groups).toEqual(mockApplicationsDataService.groupsConditions); + }); + }); }); \ No newline at end of file diff --git a/src/app/dashboard/components/lines/applications-line.component.ts b/src/app/dashboard/components/lines/applications-line.component.ts index e4de80d3a..755dcd211 100644 --- a/src/app/dashboard/components/lines/applications-line.component.ts +++ b/src/app/dashboard/components/lines/applications-line.component.ts @@ -18,6 +18,7 @@ import { AutoRefreshService } from '@services/auto-refresh.service'; import { DefaultConfigService } from '@services/default-config.service'; import { FiltersService } from '@services/filters.service'; import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; +import { InvertFilterService } from '@services/invert-filter.service'; import { NotificationService } from '@services/notification.service'; import { ShareUrlService } from '@services/share-url.service'; @@ -41,6 +42,7 @@ import { ShareUrlService } from '@services/share-url.service'; FiltersService, ApplicationsDataService, GrpcSortFieldService, + InvertFilterService, ], imports: [ FiltersToolbarComponent, diff --git a/src/app/dashboard/components/lines/partitions-line.component.html b/src/app/dashboard/components/lines/partitions-line.component.html index 402b611be..d5f15d323 100644 --- a/src/app/dashboard/components/lines/partitions-line.component.html +++ b/src/app/dashboard/components/lines/partitions-line.component.html @@ -16,6 +16,7 @@ (lockColumnsChange)="onLockColumnsChange()" (deleteLine)="onDeleteLine()" (editNameLine)="onEditNameLine()" + (groupSettings)="openGroupsSettings()" > diff --git a/src/app/dashboard/components/lines/partitions-line.component.spec.ts b/src/app/dashboard/components/lines/partitions-line.component.spec.ts index d4f615982..789bfb453 100644 --- a/src/app/dashboard/components/lines/partitions-line.component.spec.ts +++ b/src/app/dashboard/components/lines/partitions-line.component.spec.ts @@ -1,4 +1,5 @@ -import { PartitionRawEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { FilterStringOperator, PartitionRawEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { ViewContainerRef } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import PartitionsDataService from '@app/partitions/services/partitions-data.service'; @@ -6,7 +7,8 @@ import { PartitionsIndexService } from '@app/partitions/services/partitions-inde import { PartitionRaw, PartitionRawColumnKey, PartitionRawFieldKey, PartitionRawListOptions } from '@app/partitions/types'; import { TableColumn } from '@app/types/column.type'; import { ColumnKey } from '@app/types/data'; -import { FiltersOr } from '@app/types/filters'; +import { FiltersEnums, FiltersOptionsEnums, FiltersOr } from '@app/types/filters'; +import { GroupConditions } from '@app/types/groups'; import { AutoRefreshService } from '@services/auto-refresh.service'; import { DefaultConfigService } from '@services/default-config.service'; import { IconsService } from '@services/icons.service'; @@ -65,6 +67,16 @@ describe('PartitionsLineComponent', () => { }, }; + const lineGroup: GroupConditions = { + name: 'Group 1', + conditions: [[{ + field: PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, + value: 'name' + }]], + }; + const line: TableLine = { name: 'Tasks', type: 'Partitions', @@ -73,6 +85,7 @@ describe('PartitionsLineComponent', () => { interval: 20, options: options, showFilters: false, + groups: lineGroup as unknown as GroupConditions[], }; const nameLine = { @@ -81,7 +94,7 @@ describe('PartitionsLineComponent', () => { const mockMatDialog = { open: jest.fn(() => { return { - afterClosed() { + afterClosed(): unknown { return of(nameLine); } }; @@ -97,6 +110,10 @@ describe('PartitionsLineComponent', () => { refresh$: { next: jest.fn() }, + initGroups: jest.fn(), + manageGroupDialogResult: jest.fn(), + groupsConditions: [], + groups: [], }; const mockPartitionsIndexService = { @@ -122,6 +139,7 @@ describe('PartitionsLineComponent', () => { { provide: PartitionsIndexService, useValue: mockPartitionsIndexService }, DefaultConfigService, { provide: NotificationService, useValue: mockNotificationService }, + { provide: ViewContainerRef, usevalue: {} }, ] }).inject(PartitionsLineComponent); component.line = line; @@ -158,6 +176,11 @@ describe('PartitionsLineComponent', () => { expect(component.options).toEqual(defaultConfigService.defaultPartitions.options); expect(component.showFilters).toEqual(line.showFilters); }); + + it('should init groups', () => { + expect(mockPartitionsDataService.groupsConditions).toBe(lineGroup); + expect(mockPartitionsDataService.initGroups).toHaveBeenCalled(); + }); }); it('should unsubscribe on destroy', () => { @@ -383,4 +406,22 @@ describe('PartitionsLineComponent', () => { expect(component.line.showFilters).toEqual(newShowFilters); }); }); + + describe('openGroupsSettings', () => { + const dialogResult = [{fake: 'return'}]; + beforeEach(() => { + mockMatDialog.open.mockReturnValueOnce({ + afterClosed: () => of(dialogResult) + }); + component.openGroupsSettings(); + }); + + it('should manage the group dialogResult', () => { + expect(mockPartitionsDataService.manageGroupDialogResult).toHaveBeenCalledWith(dialogResult); + }); + + it('should save the groups', () => { + expect(component.line.groups).toEqual(mockPartitionsDataService.groupsConditions); + }); + }); }); \ No newline at end of file diff --git a/src/app/dashboard/components/lines/partitions-line.component.ts b/src/app/dashboard/components/lines/partitions-line.component.ts index 8d4f8cc35..bcd355a17 100644 --- a/src/app/dashboard/components/lines/partitions-line.component.ts +++ b/src/app/dashboard/components/lines/partitions-line.component.ts @@ -15,6 +15,7 @@ import { DataFilterService } from '@app/types/services/data-filter.service'; import { FiltersToolbarComponent } from '@components/filters/filters-toolbar.component'; import { TableDashboardActionsToolbarComponent } from '@components/table-dashboard-actions-toolbar.component'; import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; +import { InvertFilterService } from '@services/invert-filter.service'; import { NotificationService } from '@services/notification.service'; @Component({ @@ -33,6 +34,7 @@ import { NotificationService } from '@services/notification.service'; PartitionsGrpcService, PartitionsDataService, GrpcSortFieldService, + InvertFilterService, ], imports: [ MatToolbarModule, diff --git a/src/app/dashboard/components/lines/results-line.component.html b/src/app/dashboard/components/lines/results-line.component.html index 3754cc45d..35740959d 100644 --- a/src/app/dashboard/components/lines/results-line.component.html +++ b/src/app/dashboard/components/lines/results-line.component.html @@ -16,6 +16,7 @@ (lockColumnsChange)="onLockColumnsChange()" (deleteLine)="onDeleteLine()" (editNameLine)="onEditNameLine()" + (groupSettings)="openGroupsSettings()" > diff --git a/src/app/dashboard/components/lines/results-line.component.spec.ts b/src/app/dashboard/components/lines/results-line.component.spec.ts index 051b59366..4fce5455c 100644 --- a/src/app/dashboard/components/lines/results-line.component.spec.ts +++ b/src/app/dashboard/components/lines/results-line.component.spec.ts @@ -1,11 +1,13 @@ -import { ResultRawEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { FilterStringOperator, ResultRawEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { ViewContainerRef } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import ResultsDataService from '@app/results/services/results-data.service'; import { ResultsIndexService } from '@app/results/services/results-index.service'; import { ResultRaw, ResultRawColumnKey, ResultRawFieldKey, ResultRawListOptions } from '@app/results/types'; import { TableColumn } from '@app/types/column.type'; -import { FiltersOr } from '@app/types/filters'; +import { FiltersEnums, FiltersOptionsEnums, FiltersOr } from '@app/types/filters'; +import { GroupConditions } from '@app/types/groups'; import { AutoRefreshService } from '@services/auto-refresh.service'; import { DefaultConfigService } from '@services/default-config.service'; import { IconsService } from '@services/icons.service'; @@ -64,6 +66,16 @@ describe('ResultsLineComponent', () => { }, }; + const lineGroup: GroupConditions = { + name: 'Group 1', + conditions: [[{ + field: ResultRawEnumField.RESULT_RAW_ENUM_FIELD_NAME, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, + value: 'name' + }]], + }; + const line: TableLine = { name: 'Tasks', type: 'Results', @@ -72,6 +84,7 @@ describe('ResultsLineComponent', () => { interval: 20, options: options, showFilters: false, + groups: lineGroup as unknown as GroupConditions[], }; const nameLine = { @@ -80,7 +93,7 @@ describe('ResultsLineComponent', () => { const mockMatDialog = { open: jest.fn(() => { return { - afterClosed() { + afterClosed(): unknown { return of(nameLine); } }; @@ -96,6 +109,10 @@ describe('ResultsLineComponent', () => { refresh$: { next: jest.fn() }, + initGroups: jest.fn(), + manageGroupDialogResult: jest.fn(), + groupsConditions: [], + groups: [], }; const mockResultsIndexService = { @@ -120,7 +137,8 @@ describe('ResultsLineComponent', () => { { provide: ResultsDataService, useValue: mockResultsDataService }, { provide: ResultsIndexService, useValue: mockResultsIndexService }, DefaultConfigService, - { provide: NotificationService, useValue: mockNotificationService } + { provide: NotificationService, useValue: mockNotificationService }, + { provide: ViewContainerRef, useValue: {} }, ] }).inject(ResultsLineComponent); component.line = line; @@ -157,6 +175,11 @@ describe('ResultsLineComponent', () => { expect(component.options).toEqual(defaultConfigService.defaultResults.options); expect(component.showFilters).toEqual(line.showFilters); }); + + it('should init groups', () => { + expect(mockResultsDataService.groupsConditions).toBe(lineGroup); + expect(mockResultsDataService.initGroups).toHaveBeenCalled(); + }); }); it('should unsubscribe on destroy', () => { @@ -382,4 +405,22 @@ describe('ResultsLineComponent', () => { expect(component.line.showFilters).toEqual(newShowFilters); }); }); + + describe('openGroupsSettings', () => { + const dialogResult = [{fake: 'return'}]; + beforeEach(() => { + mockMatDialog.open.mockReturnValueOnce({ + afterClosed: () => of(dialogResult) + }); + component.openGroupsSettings(); + }); + + it('should manage the group dialogResult', () => { + expect(mockResultsDataService.manageGroupDialogResult).toHaveBeenCalledWith(dialogResult); + }); + + it('should save the groups', () => { + expect(component.line.groups).toEqual(mockResultsDataService.groupsConditions); + }); + }); }); \ No newline at end of file diff --git a/src/app/dashboard/components/lines/results-line.component.ts b/src/app/dashboard/components/lines/results-line.component.ts index e0d793d28..8b1bd0c19 100644 --- a/src/app/dashboard/components/lines/results-line.component.ts +++ b/src/app/dashboard/components/lines/results-line.component.ts @@ -17,6 +17,7 @@ import { FiltersToolbarComponent } from '@components/filters/filters-toolbar.com import { TableDashboardActionsToolbarComponent } from '@components/table-dashboard-actions-toolbar.component'; import { FiltersService } from '@services/filters.service'; import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; +import { InvertFilterService } from '@services/invert-filter.service'; import { NotificationService } from '@services/notification.service'; @Component({ @@ -37,6 +38,7 @@ import { NotificationService } from '@services/notification.service'; GrpcSortFieldService, FiltersService, NotificationService, + InvertFilterService, ], imports: [ MatIconModule, diff --git a/src/app/dashboard/components/lines/sessions-line.component.html b/src/app/dashboard/components/lines/sessions-line.component.html index 0f628f8b5..7c2fef4c6 100644 --- a/src/app/dashboard/components/lines/sessions-line.component.html +++ b/src/app/dashboard/components/lines/sessions-line.component.html @@ -16,6 +16,7 @@ (lockColumnsChange)="onLockColumnsChange()" (deleteLine)="onDeleteLine()" (editNameLine)="onEditNameLine()" + (groupSettings)="openGroupsSettings()" >