From e95c3316aedc54be02ce12e0440cad061a4858a8 Mon Sep 17 00:00:00 2001 From: Faustin Date: Tue, 29 Oct 2024 14:30:32 +0100 Subject: [PATCH 1/4] feat: table data services TableData services are used by index and tables components to retrieve and provide every data-related field (such as options, filter, loading, data). This modification allows a better defragmentation of the code, making it overall simplier. --- .../components/table.component.html | 4 +- .../components/table.component.spec.ts | 322 ++------ .../components/table.component.ts | 111 +-- src/app/applications/index.component.html | 9 +- src/app/applications/index.component.spec.ts | 314 +++++--- src/app/applications/index.component.ts | 14 +- .../applications-data.service.spec.ts | 348 +++++++++ .../services/applications-data.service.ts | 113 +++ .../lines/applications-line.component.html | 9 +- .../lines/applications-line.component.spec.ts | 62 +- .../lines/applications-line.component.ts | 9 +- .../lines/partitions-line.component.html | 9 +- .../lines/partitions-line.component.spec.ts | 61 +- .../lines/partitions-line.component.ts | 7 + .../lines/results-line.component.html | 9 +- .../lines/results-line.component.spec.ts | 61 +- .../lines/results-line.component.ts | 13 + .../lines/sessions-line.component.html | 9 +- .../lines/sessions-line.component.spec.ts | 95 ++- .../lines/sessions-line.component.ts | 11 + .../lines/tasks-line.component.html | 9 +- .../lines/tasks-line.component.spec.ts | 103 +-- .../components/lines/tasks-line.component.ts | 19 +- .../components/table.component.html | 2 +- .../components/table.component.spec.ts | 306 ++------ .../partitions/components/table.component.ts | 85 +-- src/app/partitions/index.component.html | 9 +- src/app/partitions/index.component.spec.ts | 87 ++- src/app/partitions/index.component.ts | 9 +- .../services/partitions-data.service.spec.ts | 283 +++++++ .../services/partitions-data.service.ts | 83 ++ .../results/components/table.component.html | 5 +- .../components/table.component.spec.ts | 186 +---- src/app/results/components/table.component.ts | 55 +- src/app/results/index.component.html | 9 +- src/app/results/index.component.spec.ts | 87 ++- src/app/results/index.component.ts | 11 +- .../services/results-data.service.spec.ts | 167 ++++ .../results/services/results-data.service.ts | 24 + .../sessions/components/table.component.html | 5 +- .../components/table.component.spec.ts | 714 ++---------------- .../sessions/components/table.component.ts | 308 +------- src/app/sessions/index.component.html | 9 +- src/app/sessions/index.component.spec.ts | 80 +- src/app/sessions/index.component.ts | 20 + .../services/sessions-data.service.spec.ts | 464 ++++++++++++ .../services/sessions-data.service.ts | 351 +++++++++ src/app/tasks/components/table.component.html | 7 +- .../tasks/components/table.component.spec.ts | 258 ++----- src/app/tasks/components/table.component.ts | 77 +- src/app/tasks/index.component.html | 10 +- src/app/tasks/index.component.spec.ts | 103 ++- src/app/tasks/index.component.ts | 15 +- .../tasks/services/tasks-data.service.spec.ts | 299 ++++++++ src/app/tasks/services/tasks-data.service.ts | 88 +++ .../types/components/dashboard-line-table.ts | 86 ++- src/app/types/components/index.ts | 98 ++- src/app/types/components/table.ts | 149 +--- src/app/types/services/table-data.service.ts | 174 +++++ 59 files changed, 3657 insertions(+), 2787 deletions(-) create mode 100644 src/app/applications/services/applications-data.service.spec.ts create mode 100644 src/app/applications/services/applications-data.service.ts create mode 100644 src/app/partitions/services/partitions-data.service.spec.ts create mode 100644 src/app/partitions/services/partitions-data.service.ts create mode 100644 src/app/results/services/results-data.service.spec.ts create mode 100644 src/app/results/services/results-data.service.ts create mode 100644 src/app/sessions/services/sessions-data.service.spec.ts create mode 100644 src/app/sessions/services/sessions-data.service.ts create mode 100644 src/app/tasks/services/tasks-data.service.spec.ts create mode 100644 src/app/tasks/services/tasks-data.service.ts create mode 100644 src/app/types/services/table-data.service.ts diff --git a/src/app/applications/components/table.component.html b/src/app/applications/components/table.component.html index 45e65ad54..21d0fd4bf 100644 --- a/src/app/applications/components/table.component.html +++ b/src/app/applications/components/table.component.html @@ -1,3 +1,3 @@ - \ 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 9acd71229..738edf54a 100644 --- a/src/app/applications/components/table.component.spec.ts +++ b/src/app/applications/components/table.component.spec.ts @@ -1,22 +1,17 @@ -import { ApplicationRawEnumField, FilterStringOperator, TaskOptionEnumField, TaskStatus } from '@aneoconsultingfr/armonik.api.angular'; +import { TaskStatus } from '@aneoconsultingfr/armonik.api.angular'; import { Clipboard } from '@angular/cdk/clipboard'; -import { 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 { FiltersOr } from '@app/types/filters'; -import { CacheService } from '@services/cache.service'; -import { FiltersService } from '@services/filters.service'; import { IconsService } from '@services/icons.service'; import { NotificationService } from '@services/notification.service'; import { TasksByStatusService } from '@services/tasks-by-status.service'; -import { BehaviorSubject, Subject, of, throwError } from 'rxjs'; +import { of } from 'rxjs'; import { ApplicationsTableComponent } from './table.component'; -import { ApplicationsGrpcService } from '../services/applications-grpc.service'; -import { ApplicationsIndexService } from '../services/applications-index.service'; +import ApplicationsDataService from '../services/applications-data.service'; import { ApplicationRaw } from '../types'; describe('TasksTableComponent', () => { @@ -50,21 +45,6 @@ describe('TasksTableComponent', () => { } ]; - const mockApplicationsIndexService = { - isActionsColumn: jest.fn(), - isTaskIdColumn: jest.fn(), - isStatusColumn: jest.fn(), - isDateColumn: jest.fn(), - isDurationColumn: jest.fn(), - isObjectColumn: jest.fn(), - isSelectColumn: jest.fn(), - isSimpleColumn: jest.fn(), - isNotSortableColumn: jest.fn(), - columnToLabel: jest.fn(), - saveColumns: jest.fn(), - saveOptions: jest.fn(), - }; - const mockNotificationService = { success: jest.fn(), error: jest.fn(), @@ -74,12 +54,6 @@ describe('TasksTableComponent', () => { copy: jest.fn() }; - const applications = { applications: [{ name: 'application1', version: 'version1' }, { name: 'application2', version: 'version2' }, { name: 'application3', version: 'version3' }], total: 3 }; - const mockApplicationsGrpcService = { - list$: jest.fn(() => of(applications)), - cancel$: jest.fn(() => of({})), - }; - const defaultStatusesGroups: TasksStatusesGroup[] = [ { name: 'Completed', @@ -125,20 +99,22 @@ describe('TasksTableComponent', () => { navigate: jest.fn() }; - const cachedApplications = { applications: [{ name: 'application1', version: 'version1' }, { name: 'application2', version: 'version2' }], total: 2 }; - const mockCacheService = { - get: jest.fn(() => cachedApplications), - save: jest.fn() + const mockApplicationsDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [], + refresh$: { + next: jest.fn() + }, }; beforeEach(() => { component = TestBed.configureTestingModule({ providers: [ ApplicationsTableComponent, - { provide: ApplicationsIndexService, useValue: mockApplicationsIndexService }, - { provide: ApplicationsGrpcService, useValue: mockApplicationsGrpcService }, - FiltersService, - { provide: CacheService, useValue: mockCacheService }, + { provide: ApplicationsDataService, useValue: mockApplicationsDataService }, { provide: NotificationService, useValue: mockNotificationService }, { provide: Clipboard, useValue: mockClipBoard }, { provide: MatDialog, useValue: mockMatDialog }, @@ -149,19 +125,7 @@ describe('TasksTableComponent', () => { }).inject(ApplicationsTableComponent); component.displayedColumns = displayedColumns; - component.filters$ = new BehaviorSubject>([]); - component.options = { - pageIndex: 0, - pageSize: 10, - sort: { - active: 'name', - direction: 'desc' - } - }; - component.refresh$ = new Subject(); - component.loading = signal(false); component.ngOnInit(); - component.ngAfterViewInit(); }); it('should run', () => { @@ -169,150 +133,24 @@ describe('TasksTableComponent', () => { }); describe('initialisation', () => { - it('should get cached data', () => { - expect(mockCacheService.get).toHaveBeenCalled(); - }); - }); - - describe('loadFromCache', () => { - beforeEach(() => { - component.loadFromCache(); - }); - - it('should update total data with cached one', () => { - expect(component.total).toEqual(cachedApplications.total); - }); - - it('should update data with cached one', () => { - expect(component.data()).toEqual([ - { - raw: { - name: 'application1', - version: 'version1' - } as ApplicationRaw, - queryTasksParams: { - '0-options-5-0': 'application1', - '0-options-6-0': 'version1' - }, - filters: [[ - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application1' }, - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'version1' } - ]] - }, - { - raw: { - name: 'application2', - version: 'version2' - } as ApplicationRaw, - queryTasksParams: { - '0-options-5-0': 'application2', - '0-options-6-0': 'version2' - }, - filters: [[ - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application2' }, - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'version2' } - ]] - } - ]); - }); - }); - - it('should update data on refresh', () => { - component.refresh$.next(); - expect(component.data()).toEqual([ - { - raw: { - name: 'application1', - version: 'version1' - } as ApplicationRaw, - queryTasksParams: { - '0-options-5-0': 'application1', - '0-options-6-0': 'version1' - }, - filters: [[ - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application1' }, - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'version1' } - ]] - }, - { - raw: { - name: 'application2', - version: 'version2' - } as ApplicationRaw, - queryTasksParams: { - '0-options-5-0': 'application2', - '0-options-6-0': 'version2' - }, - filters: [[ - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application2' }, - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'version2' } - ]] - }, - { - raw: { - name: 'application3', - version: 'version3' - } as ApplicationRaw, - queryTasksParams: { - '0-options-5-0': 'application3', - '0-options-6-0': 'version3' - }, - filters: [[ - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application3' }, - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'version3' } - ]] - } - ]); - }); - - it('should cache the received data', () => { - component.refresh$.next(); - expect(mockCacheService.save).toHaveBeenCalled(); - }); - - it('should return columns keys', () => { - expect(component.columnKeys).toEqual(displayedColumns.map(column => column.key)); - }); - - describe('on list error', () => { - beforeEach(() => { - mockApplicationsGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); - }); - - it('should log error', () => { - const spy = jest.spyOn(console, 'error').mockImplementation(() => { }); - component.refresh$.next(); - expect(spy).toHaveBeenCalled(); - }); - - it('should send a notification', () => { - component.refresh$.next(); - expect(mockNotificationService.error).toHaveBeenCalled(); - }); - - it('should send empty data', () => { - component.refresh$.next(); - expect(component.data()).toEqual([]); + it('init statuses', () => { + expect(component.statusesGroups).toEqual(defaultStatusesGroups); }); }); describe('options changes', () => { - it('should refresh data', () => { - const spy = jest.spyOn(component.refresh$, 'next'); + it('should emit', () => { + const spy = jest.spyOn(component.optionsUpdate, 'emit'); component.onOptionsChange(); expect(spy).toHaveBeenCalled(); }); - - it('should save options', () => { - component.onOptionsChange(); - expect(mockApplicationsIndexService.saveOptions).toHaveBeenCalled(); - }); }); - test('onDrop should call ApplicationsIndexService', () => { + test('onDrop should emit', () => { + const spy = jest.spyOn(component.columnUpdate, 'emit'); const newColumns: ColumnKey[] = ['actions', 'name', 'namespace', 'version']; component.onDrop(newColumns); - expect(mockApplicationsIndexService.saveColumns).toHaveBeenCalledWith(newColumns); + expect(spy).toHaveBeenCalledWith(newColumns); }); describe('personnalizeTasksByStatus', () => { @@ -340,102 +178,6 @@ describe('TasksTableComponent', () => { }); }); - describe('createTasksByStatysQueryParams', () => { - it('should create params for a task by status redirection', () => { - const data = { - name: 'name', - version: 'version' - }; - expect(component.createTasksByStatusQueryParams(data.name, data.version)).toEqual({ - '0-options-5-0': data.name, - '0-options-6-0': data.version - }); - }); - - it('should create params for each filter', () => { - component.filters = [ - [ - { - field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_NOT_EQUAL, - for: 'root', - value: 'name' - }, - { - field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_SERVICE, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, - for: 'root', - value: 'service' - }, - { - field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAMESPACE, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, - for: 'root', - value: 'shouldNotAppearNamespace', - }, - { - field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_UNSPECIFIED, - operator: null, - for: 'root', - value: null - } - ], - [ - { - field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_UNSPECIFIED, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_ENDS_WITH, - for: 'root', - value: 'shouldNotAppear' - }, - { - field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_VERSION, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, - for: 'root', - value: 'version' - }, - { - field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAMESPACE, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_STARTS_WITH, - for: 'root', - value: 'namespace', - }, - { - field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, - for: 'root', - value: 'shouldNotAppearName' - }, - { - field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_SERVICE, - operator: null, - for: 'root', - value: 'shouldNotAppearService' - }, - { - field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_VERSION, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, - for: 'root', - value: null, - } - ] - ]; - const data = { - name: 'nameData', - version: 'versionData' - }; - expect(component.createTasksByStatusQueryParams(data.name, data.version)).toEqual({ - '0-options-5-1': 'name', - '0-options-8-0': 'service', - '0-options-5-0': data.name, - '0-options-6-0': data.version, - '1-options-6-2': 'version', - '1-options-7-4': 'namespace', - '1-options-5-0': data.name, - '1-options-6-0': data.version, - }); - }); - }); - it('should get page icon', () => { expect(component.getIcon('applications')).toEqual('apps'); }); @@ -469,4 +211,28 @@ describe('TasksTableComponent', () => { 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 get data', () => { + expect(component.data).toEqual(mockApplicationsDataService.data); + }); + + it('should get total', () => { + expect(component.total).toEqual(mockApplicationsDataService.total); + }); + + it('should get options', () => { + expect(component.options).toEqual(mockApplicationsDataService.options); + }); + + it('should get filters', () => { + expect(component.filters).toEqual(mockApplicationsDataService.filters); + }); + + it('should get column keys', () => { + expect(component.columnKeys).toEqual(displayedColumns.map(c => c.key)); + }); + + it('should get displayedColumns', () => { + expect(component.displayedColumns).toEqual(displayedColumns); + }); }); \ 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 ae06945ee..0be1d8777 100644 --- a/src/app/applications/components/table.component.ts +++ b/src/app/applications/components/table.component.ts @@ -1,22 +1,16 @@ -import { ApplicationRawEnumField, FilterStringOperator, ListApplicationsResponse, SessionTaskOptionEnumField, TaskOptionEnumField } from '@aneoconsultingfr/armonik.api.angular'; -import { AfterViewInit, Component, OnInit, inject } from '@angular/core'; +import { ApplicationRawEnumField, FilterStringOperator, SessionTaskOptionEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { Component, OnInit, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; -import { TaskSummaryFilters } from '@app/tasks/types'; import { AbstractTaskByStatusTableComponent } from '@app/types/components/table'; -import { Scope } from '@app/types/config'; -import { ApplicationData, ArmonikData } from '@app/types/data'; -import { Filter } from '@app/types/filters'; +import { ArmonikData } from '@app/types/data'; import { ActionTable } from '@app/types/table'; import { TableComponent } from '@components/table/table.component'; import { FiltersService } from '@services/filters.service'; -import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; import { IconsService } from '@services/icons.service'; -import { NotificationService } from '@services/notification.service'; import { TableTasksByStatus, TasksByStatusService } from '@services/tasks-by-status.service'; import { Subject } from 'rxjs'; -import { ApplicationsGrpcService } from '../services/applications-grpc.service'; -import { ApplicationsIndexService } from '../services/applications-index.service'; +import ApplicationsDataService from '../services/applications-data.service'; import { ApplicationRaw } from '../types'; @Component({ @@ -24,25 +18,19 @@ import { ApplicationRaw } from '../types'; standalone: true, templateUrl: './table.component.html', providers: [ - ApplicationsGrpcService, - ApplicationsIndexService, - NotificationService, TasksByStatusService, MatDialog, FiltersService, - GrpcSortFieldService, ], imports: [ TableComponent, ] }) export class ApplicationsTableComponent extends AbstractTaskByStatusTableComponent - implements OnInit, AfterViewInit { - scope: Scope = 'applications'; + implements OnInit { table: TableTasksByStatus = 'applications'; - readonly grpcService = inject(ApplicationsGrpcService); - readonly indexService = inject(ApplicationsIndexService); + readonly tableDataService = inject(ApplicationsDataService); readonly iconsService = inject(IconsService); readonly router = inject(Router); @@ -58,29 +46,13 @@ export class ApplicationsTableComponent extends AbstractTaskByStatusTableCompone ]; ngOnInit(): void { - this.initTable(); - } - - ngAfterViewInit(): void { - this.subscribeToData(); - } - - computeGrpcData(entries: ListApplicationsResponse): ApplicationRaw[] | undefined { - return entries.applications; + this.initStatuses(); } isDataRawEqual(value: ApplicationRaw, entry: ApplicationRaw): boolean { return value.name === entry.name && value.version === entry.version; } - createNewLine(entry: ApplicationRaw): ApplicationData { - return { - raw: entry, - queryTasksParams: this.createTasksByStatusQueryParams(entry.name, entry.version), - filters: this.countTasksByStatusFilters(entry.name, entry.version), - }; - } - getIcon(name: string): string { return this.iconsService.getIcon(name); } @@ -92,75 +64,6 @@ export class ApplicationsTableComponent extends AbstractTaskByStatusTableCompone }; } - createTasksByStatusQueryParams(name: string, version: string) { - if(this.filters.length === 0) { - return { - [`0-options-${TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`]: name, - [`0-options-${TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`]: version - }; - } else { - const params: Record = {}; - this.filters.forEach((filterAnd, index) => { - params[`${index}-options-${TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`] = name; - params[`${index}-options-${TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`] = version; - filterAnd.forEach(filter => { - if ((filter.field !== ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME || filter.operator !== FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL) && - (filter.field !== ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAMESPACE || filter.operator !== FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL)) { - const filterLabel = this.#createQueryParamFilterKey(filter, index); - if (filterLabel && filter.value) { - params[filterLabel] = filter.value.toString(); - } - } - }); - - }); - return params; - } - } - - #createQueryParamFilterKey(filter: Filter, orGroup: number): string | null { - if (filter.field !== null && filter.operator !== null && filter.value !== null) { - const taskField = this.#applicationsToTaskField(filter.field as ApplicationRawEnumField); // We transform it into an options filter for a task - if (!taskField) return null; - return this.filtersService.createQueryParamsKey(orGroup, 'options', filter.operator, taskField); - } - return null; - } - - #applicationsToTaskField(applicationField: ApplicationRawEnumField) { - switch (applicationField) { - case ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME: - return TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME; - case ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAMESPACE: - return TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAMESPACE; - case ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_SERVICE: - return TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_SERVICE; - case ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_VERSION: - return TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION; - default: - return null; - } - } - - countTasksByStatusFilters(applicationName: string, applicationVersion: string): TaskSummaryFilters { - return [ - [ - { - for: 'options', - field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, - value: applicationName, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL - }, - { - for: 'options', - field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, - value: applicationVersion, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL - } - ] - ]; - } - trackBy(index: number, item: ArmonikData) { return `${item.raw.name}-${item.raw.version}`; } diff --git a/src/app/applications/index.component.html b/src/app/applications/index.component.html index 909af0bb4..63c0a6ae0 100644 --- a/src/app/applications/index.component.html +++ b/src/app/applications/index.component.html @@ -6,14 +6,14 @@ \ No newline at end of file diff --git a/src/app/applications/index.component.spec.ts b/src/app/applications/index.component.spec.ts index 97c6456a3..7b9ca97e9 100644 --- a/src/app/applications/index.component.spec.ts +++ b/src/app/applications/index.component.spec.ts @@ -1,23 +1,26 @@ +import { ApplicationRawEnumField, FilterStringOperator } from '@aneoconsultingfr/armonik.api.angular'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { DashboardIndexService } from '@app/dashboard/services/dashboard-index.service'; import { TableColumn } from '@app/types/column.type'; import { ColumnKey } from '@app/types/data'; import { FiltersOr } from '@app/types/filters'; +import { ListOptions } from '@app/types/options'; import { AutoRefreshService } from '@services/auto-refresh.service'; import { DefaultConfigService } from '@services/default-config.service'; import { IconsService } from '@services/icons.service'; import { ShareUrlService } from '@services/share-url.service'; import { Subject, of } from 'rxjs'; import { IndexComponent } from './index.component'; +import ApplicationsDataService from './services/applications-data.service'; import { ApplicationsFiltersService } from './services/applications-filters.service'; import { ApplicationsIndexService } from './services/applications-index.service'; -import { ApplicationRaw, ApplicationRawFilters, ApplicationRawListOptions } from './types'; +import { ApplicationRaw } from './types'; describe('Application component', () => { - let component: IndexComponent; + const defaultColumns: ColumnKey[] = ['name', 'version', 'actions']; const displayedColumns: TableColumn[] = [ { name: 'Name', @@ -50,7 +53,13 @@ describe('Application component', () => { key: 'count', type: 'count', sortable: true - } + }, + { + name: $localize`Select`, + key: 'select', + type: 'select', + sortable: false, + }, ]; const sampleFiltersOr: FiltersOr = [ @@ -70,19 +79,20 @@ describe('Application component', () => { ] ]; - const options: ApplicationRawListOptions = { - pageIndex: 1, - pageSize: 25, - sort: { - active: 'name', - direction: 'asc' - } - }; - const intervalRefreshSubject = new Subject(); const defaultShowFilters = false; + const defaultLockColumns = false; + const defaultIntervalValue = 10; + const defaultOptions: ListOptions = { + pageIndex: 0, + pageSize: 10, + sort: { + active: 'name', + direction: 'desc' + } + }; const mockApplicationIndexService = { availableTableColumns: displayedColumns, columnsLabels: { @@ -95,47 +105,53 @@ describe('Application component', () => { }, restoreColumns: jest.fn(() => displayedColumns.map(col => col.key)), saveColumns: jest.fn(), - resetColumns: jest.fn(), + resetColumns: jest.fn(() => defaultColumns), columnToLabel: jest.fn(), isActionsColumn: jest.fn(), isCountColumn: jest.fn(), isSimpleColumn: jest.fn(), isNotSortableColumn: jest.fn(), - restoreOptions: jest.fn(() => options), - restoreIntervalValue: jest.fn(), + restoreOptions: jest.fn(() => defaultOptions), + restoreIntervalValue: jest.fn(() => defaultIntervalValue), saveIntervalValue: jest.fn(), saveOptions: jest.fn(), saveLockColumns: jest.fn(), - restoreLockColumns: jest.fn() + restoreLockColumns: jest.fn(() => defaultLockColumns), }; const mockShareUrlService = { generateSharableURL: jest.fn() }; + const defaultFilters : FiltersOr = []; const mockApplicationsFilterService = { restoreFilters: jest.fn(() => sampleFiltersOr), saveFilters: jest.fn(), - resetFilters: jest.fn(), + resetFilters: jest.fn(() => defaultFilters), saveShowFilters: jest.fn(), restoreShowFilters: jest.fn(() => defaultShowFilters) }; + + const intervalMessage = 'interval message'; const mockAutoRefreshService = { - autoRefreshTooltip: jest.fn(), + autoRefreshTooltip: jest.fn((value: string) => intervalMessage + value), createInterval: jest.fn(() => intervalRefreshSubject), }; const mockDashboardIndexService = { - restoreIntervalValue: jest.fn(), - restoreColumns: jest.fn(), - restoreLockColumns: jest.fn(), - restoreOptions: jest.fn(), - saveIntervalValue: jest.fn(), - saveColumns: jest.fn(), - saveOptions: jest.fn(), - saveLockColumns: jest.fn(), - availableTableColumns: displayedColumns, + addLine: jest.fn(), + }; + + const mockApplicationsDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [], + refresh$: { + next: jest.fn() + }, }; beforeEach(() => { @@ -144,6 +160,7 @@ describe('Application component', () => { IndexComponent, IconsService, { provide: ApplicationsFiltersService, useValue: mockApplicationsFilterService }, + { provide: ApplicationsDataService, useValue: mockApplicationsDataService }, { provide: ShareUrlService, useValue: mockShareUrlService }, { provide: ApplicationsIndexService, useValue: mockApplicationIndexService }, { provide: AutoRefreshService, useValue: mockAutoRefreshService }, @@ -171,6 +188,10 @@ describe('Application component', () => { expect(component).toBeTruthy(); }); + it('should load properly', () => { + expect(component.loading).toEqual(mockApplicationsDataService.loading); + }); + it('should restore on init', () => { expect(mockApplicationIndexService.restoreColumns).toHaveBeenCalled(); expect(mockApplicationIndexService.restoreOptions).toHaveBeenCalled(); @@ -185,91 +206,210 @@ describe('Application component', () => { }); it('should refresh', () => { - const spyRefresh = jest.spyOn(component.refresh$, 'next'); - component.onRefresh(); - expect(spyRefresh).toHaveBeenCalled(); + component.refresh(); + expect(mockApplicationsDataService.refresh$.next).toHaveBeenCalled(); }); - describe('onIntervalValueChange', () => { - it('should call the application index service', () => { - component.onIntervalValueChange(1); - expect(mockApplicationIndexService.saveIntervalValue).toHaveBeenCalled(); + describe('On interval value change', () => { + it('should update intervalValue', () => { + component.onIntervalValueChange(5); + expect(component.intervalValue).toEqual(5); }); - it('should change the interval value', () => { - const spyRefresh = jest.spyOn(component.interval, 'next'); - component.onIntervalValueChange(10); - expect(spyRefresh).toHaveBeenCalledWith(10); + it('should update interval observer', () => { + const spy = jest.spyOn(component.interval, 'next'); + component.onIntervalValueChange(5); + expect(spy).toHaveBeenCalledWith(5); }); - it('should stop the interval value', () => { - const spyStopRefresh = jest.spyOn(component.stopInterval, 'next'); + it('should refresh if the value is not null', () => { + component.onIntervalValueChange(5); + expect(mockApplicationsDataService.refresh$.next).toHaveBeenCalled(); + }); + + it('should stop the interval if the value is 0', () => { + const spy = jest.spyOn(component.stopInterval, 'next'); component.onIntervalValueChange(0); - expect(spyStopRefresh).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled(); }); - }); - it('should change columns', () => { - const newColumns: ColumnKey[] = ['name', 'count', 'service']; - component.onColumnsChange(newColumns); - expect(component.displayedColumnsKeys).toEqual(newColumns); - expect(mockApplicationIndexService.saveColumns).toHaveBeenCalledWith(newColumns); + it('should save the interval value', () => { + component.onIntervalValueChange(5); + expect(mockApplicationIndexService.saveIntervalValue).toHaveBeenCalledWith(5); + }); }); - it('should reset columns', () => { - mockApplicationIndexService.resetColumns.mockImplementationOnce(() => mockApplicationIndexService.availableTableColumns.map(col => col.key)); - component.onColumnsReset(); - expect(component.displayedColumnsKeys).toEqual(mockApplicationIndexService.availableTableColumns.map(col => col.key)); - expect(mockApplicationIndexService.resetColumns).toHaveBeenCalled(); + describe('On columns change', () => { + const newColumns: ColumnKey[] = ['name', 'count', 'select']; + beforeEach(() => { + component.onColumnsChange(newColumns); + }); + + it('should update displayed column keys', () => { + expect(component.displayedColumnsKeys).toEqual(['select', 'name', 'count']); + }); + + it('should update displayed columns', () => { + expect(component.displayedColumns()).toEqual([ + { + name: $localize`Select`, + key: 'select', + type: 'select', + sortable: false, + }, + { + name: $localize`Name`, + key: 'name', + sortable: true, + }, + { + name: $localize`Tasks by Status`, + key: 'count', + type: 'count', + sortable: true, + }, + ]); + }); + + it('should save columns', () => { + expect(mockApplicationIndexService.saveColumns).toHaveBeenCalledWith(['select', 'name', 'count']); + }); }); - it('should update filters', () => { - const newFilterOr: ApplicationRawFilters = [[{ - field: 3, - for: 'root', - operator: 4, - value: 'new value' - }]]; - const spyFilters = jest.spyOn(component.filters$, 'next'); - component.onFiltersChange(newFilterOr); - - expect(mockApplicationsFilterService.saveFilters).toHaveBeenCalledWith(newFilterOr); - expect(component.options.pageIndex).toEqual(0); - expect(spyFilters).toHaveBeenCalled(); - expect(component.filters).toEqual(newFilterOr); + describe('On Columns Reset', () => { + beforeEach(() => { + component.onColumnsReset(); + }); + + it('should reset columns', () => { + expect(component.displayedColumnsKeys).toEqual(defaultColumns); + }); + + it('should update displayed columns', () => { + expect(component.displayedColumns()).toEqual([ + { + name: 'Name', + key: 'name', + sortable: true + }, + { + name: 'Version', + key: 'version', + sortable: true + }, + { + name: 'Actions', + key: 'actions', + type: 'actions', + sortable: false + }, + ]); + }); }); - it('should reset filters', () => { - mockApplicationsFilterService.resetFilters.mockImplementationOnce(() => []); - component.onFiltersReset(); - expect(mockApplicationsFilterService.resetFilters).toHaveBeenCalled(); - expect(component.options.pageIndex).toEqual(0); - expect(component.filters).toEqual([]); + describe('On Options Change', () => { + beforeEach(() => { + component.onOptionsChange(); + }); + + it('should save options', () => { + expect(mockApplicationIndexService.saveOptions).toHaveBeenCalledWith(mockApplicationsDataService.options); + }); + + it('should refresh', () => { + expect(mockApplicationsDataService.refresh$.next).toHaveBeenCalled(); + }); }); - it('should give the tooltip', () => { - component.intervalValue = 13; - component.autoRefreshTooltip(); - expect(mockAutoRefreshService.autoRefreshTooltip).toHaveBeenCalledWith(13); + describe('On Filters Change', () => { + const newFilters: FiltersOr = [ + [ + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + value: 'name', + for: 'root', + } + ] + ]; + beforeEach(() => { + component.onFiltersChange(newFilters); + }); + + it('should update filters', () => { + expect(component.filters).toEqual(newFilters); + }); + + it('should save filters', () => { + expect(mockApplicationsFilterService.saveFilters).toHaveBeenCalledWith(newFilters); + }); + + it('should update page index', () => { + expect(component.options.pageIndex).toEqual(0); + }); }); - it('should stop the auto-refresh', () => { - const spyStopRefresh = jest.spyOn(component.stopInterval, 'next'); - component.intervalValue = 0; - component.handleAutoRefreshStart(); - expect(spyStopRefresh).toHaveBeenCalled(); + describe('On Filter Reset', () => { + beforeEach(() => { + component.onFiltersReset(); + }); + + it('should reset filters', () => { + expect(component.filters).toEqual(defaultFilters); + }); + + it('should reset page index', () => { + expect(component.options.pageIndex).toEqual(0); + }); }); - describe('onLockColumnsChange', () => { - it('should switch the value of lockColumns', () => { - component.lockColumns = false; + describe('On lockColumns Change', () => { + beforeEach(() => { component.onLockColumnsChange(); + }); + + it('should update lockColumn value', () => { expect(component.lockColumns).toBeTruthy(); }); - it('should call applications index service saveLockColumns', () => { - component.onLockColumnsChange(); - expect(mockApplicationIndexService.saveLockColumns).toHaveBeenCalledWith(component.lockColumns); + it('should save lockColumns', () => { + expect(mockApplicationIndexService.saveLockColumns).toHaveBeenCalledWith(true); + }); + }); + + it('should get auto refresh tooltip', () => { + const tooltip = component.autoRefreshTooltip(); + expect(tooltip).toEqual(intervalMessage + defaultIntervalValue); + }); + + describe('handleAutoRefreshStart', () => { + it('should start interval if interval value is not 0', () => { + const spy = jest.spyOn(component.interval, 'next'); + component.handleAutoRefreshStart(); + expect(spy).toHaveBeenCalledWith(component.intervalValue); + }); + + it('should stop interval if interval value is 0', () => { + const spy = jest.spyOn(component.stopInterval, 'next'); + component.intervalValue = 0; + component.handleAutoRefreshStart(); + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('Adding table as a line to dashboard', () => { + it('should add a line', () => { + component.onAddToDashboard(); + expect(mockDashboardIndexService.addLine).toHaveBeenCalledWith({ + name: 'Applications', + type: 'Applications', + interval: defaultIntervalValue, + showFilters: defaultShowFilters, + lockColumns: false, + displayedColumns: component.displayedColumnsKeys, + options: defaultOptions, + filters: component.filters, + }); }); }); diff --git a/src/app/applications/index.component.ts b/src/app/applications/index.component.ts index 066503aef..b1e279a9c 100644 --- a/src/app/applications/index.component.ts +++ b/src/app/applications/index.component.ts @@ -15,7 +15,11 @@ import { FiltersToolbarComponent } from '@components/filters/filters-toolbar.com import { PageHeaderComponent } from '@components/page-header.component'; import { TableIndexActionsToolbarComponent } from '@components/table-index-actions-toolbar.component'; import { AutoRefreshService } from '@services/auto-refresh.service'; +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 { NotificationService } from '@services/notification.service'; import { QueryParamsService } from '@services/query-params.service'; import { ShareUrlService } from '@services/share-url.service'; import { StorageService } from '@services/storage.service'; @@ -24,7 +28,9 @@ import { TableURLService } from '@services/table-url.service'; import { TableService } from '@services/table.service'; import { UtilsService } from '@services/utils.service'; import { ApplicationsTableComponent } from './components/table.component'; +import ApplicationsDataService from './services/applications-data.service'; import { ApplicationsFiltersService } from './services/applications-filters.service'; +import { ApplicationsGrpcService } from './services/applications-grpc.service'; import { ApplicationsIndexService } from './services/applications-index.service'; import { ApplicationRaw } from './types'; @@ -51,6 +57,12 @@ import { ApplicationRaw } from './types'; }, DashboardIndexService, DashboardStorageService, + ApplicationsDataService, + ApplicationsGrpcService, + CacheService, + NotificationService, + FiltersService, + GrpcSortFieldService, ], imports: [ PageHeaderComponent, @@ -65,7 +77,7 @@ import { ApplicationRaw } from './types'; ] }) export class IndexComponent extends TableHandler implements OnInit, AfterViewInit, OnDestroy { - + readonly tableDataService = inject(ApplicationsDataService); readonly filtersService = inject(ApplicationsFiltersService); readonly indexService = inject(ApplicationsIndexService); diff --git a/src/app/applications/services/applications-data.service.spec.ts b/src/app/applications/services/applications-data.service.spec.ts new file mode 100644 index 000000000..f9eb785ac --- /dev/null +++ b/src/app/applications/services/applications-data.service.spec.ts @@ -0,0 +1,348 @@ +import { ApplicationRawEnumField, FilterStringOperator, ListApplicationsResponse, TaskOptionEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { TestBed } from '@angular/core/testing'; +import { FiltersOr } from '@app/types/filters'; +import { ListOptions } from '@app/types/options'; +import { GrpcStatusEvent } from '@ngx-grpc/common'; +import { CacheService } from '@services/cache.service'; +import { FiltersService } from '@services/filters.service'; +import { NotificationService } from '@services/notification.service'; +import { of, throwError } from 'rxjs'; +import ApplicationsDataService from './applications-data.service'; +import { ApplicationRaw } from '../types'; +import { ApplicationsGrpcService } from './applications-grpc.service'; + +describe('ApplicationDataService', () => { + let service: ApplicationsDataService; + + const cachedApplications = { applications: [{ name: 'application1', version: 'version1' }, { name: 'application2', version: 'version2' }], total: 2 } as unknown as ListApplicationsResponse; + const mockCacheService = { + get: jest.fn(() => cachedApplications), + save: jest.fn(), + }; + + const applications = { applications: [{ name: 'application1', version: 'version1' }, { name: 'application2', version: 'version2' }, { name: 'application3', version: 'version3' }], total: 3 } as unknown as ListApplicationsResponse; + const mockApplicationsGrpcService = { + list$: jest.fn(() => of(applications)), + }; + + const mockNotificationService = { + success: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }; + + const initialOptions: ListOptions = { + pageIndex: 0, + pageSize: 10, + sort: { + active: 'name', + direction: 'desc' + } + }; + + const initialFilters: FiltersOr = []; + + beforeEach(() => { + service = TestBed.configureTestingModule({ + providers: [ + ApplicationsDataService, + FiltersService, + { provide: ApplicationsGrpcService, useValue: mockApplicationsGrpcService }, + { provide: NotificationService, useValue: mockNotificationService }, + { provide: CacheService, useValue: mockCacheService }, + ] + }).inject(ApplicationsDataService); + service.options = initialOptions; + service.filters = initialFilters; + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('initialisation', () => { + it('should load data from the cache', () => { + expect(service.data).toEqual([ + { + raw: { + name: 'application1', + version: 'version1' + } as ApplicationRaw, + queryTasksParams: { + '0-options-5-0': 'application1', + '0-options-6-0': 'version1' + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application1' }, + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'version1' } + ]] + }, + { + raw: { + name: 'application2', + version: 'version2' + } as ApplicationRaw, + queryTasksParams: { + '0-options-5-0': 'application2', + '0-options-6-0': 'version2' + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application2' }, + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'version2' } + ]] + } + ]); + }); + + it('should set the total cached data', () => { + expect(service.total).toEqual(cachedApplications.total); + }); + }); + + describe('Fetching data', () => { + it('should list the data', () => { + service.refresh$.next(); + expect(mockApplicationsGrpcService.list$).toHaveBeenCalledWith(service.options, service.filters); + }); + + it('should update the total', () => { + service.refresh$.next(); + expect(service.total).toEqual(applications.total); + }); + + it('should update the data', () => { + service.refresh$.next(); + expect(service.data).toEqual([ + { + raw: { + name: 'application1', + version: 'version1' + } as ApplicationRaw, + queryTasksParams: { + '0-options-5-0': 'application1', + '0-options-6-0': 'version1' + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application1' }, + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'version1' } + ]] + }, + { + raw: { + name: 'application2', + version: 'version2' + } as ApplicationRaw, + queryTasksParams: { + '0-options-5-0': 'application2', + '0-options-6-0': 'version2' + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application2' }, + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'version2' } + ]] + }, + { + raw: { + name: 'application3', + version: 'version3' + } as ApplicationRaw, + queryTasksParams: { + '0-options-5-0': 'application3', + '0-options-6-0': 'version3' + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application3' }, + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'version3' } + ]] + } + ]); + }); + + it('should handle an empty DataRaw', () => { + const emptyApplications = { applications: undefined, total: 0 } as unknown as ListApplicationsResponse; + mockApplicationsGrpcService.list$.mockReturnValueOnce(of(emptyApplications)); + service.refresh$.next(); + expect(service.data).toEqual([]); + }); + + it('should catch errors', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockApplicationsGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); + service.refresh$.next(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('should notify errors', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + mockApplicationsGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); + service.refresh$.next(); + expect(mockNotificationService.error).toHaveBeenCalled(); + }); + + it('should cache the raw data', () => { + service.refresh$.next(); + expect(mockCacheService.save).toHaveBeenCalledWith(service.scope, applications); + }); + }); + + it('should display a success message', () => { + const message = 'A success message !'; + service.success(message); + expect(mockNotificationService.success).toHaveBeenCalledWith(message); + }); + + it('should display a warning message', () => { + const message = 'A warning message !'; + service.warning(message); + expect(mockNotificationService.warning).toHaveBeenCalledWith(message); + }); + + it('should display an error message', () => { + const error: GrpcStatusEvent = { + statusMessage: 'A error status message' + } as GrpcStatusEvent; + jest.spyOn(console, 'error').mockImplementation(() => {}); + service.error(error); + expect(mockNotificationService.error).toHaveBeenCalledWith(error.statusMessage); + }); + + it('should load correctly', () => { + expect(service.loading).toBeFalsy(); + }); + + describe('Applying filters', () => { + const filters: FiltersOr = [ + [ + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_NOT_EQUAL, + for: 'root', + value: 'name' + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_SERVICE, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + for: 'root', + value: 'service' + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAMESPACE, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + for: 'root', + value: 'shouldNotAppearNamespace', + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_UNSPECIFIED, + operator: null, + for: 'root', + value: null + } + ], + [ + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_UNSPECIFIED, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_ENDS_WITH, + for: 'root', + value: 'shouldNotAppear' + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_VERSION, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, + for: 'root', + value: 'version' + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAMESPACE, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_STARTS_WITH, + for: 'root', + value: 'namespace', + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + for: 'root', + value: 'shouldNotAppearName' + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_SERVICE, + operator: null, + for: 'root', + value: 'shouldNotAppearService' + }, + { + field: ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_VERSION, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + for: 'root', + value: null, + } + ] + ]; + + it('should apply the filters correctly when transforming the data', () => { + service.filters = filters; + service.refresh$.next(); + expect(service.data).toEqual([ + { + raw: { + name: 'application1', + version: 'version1' + } as ApplicationRaw, + queryTasksParams: { + '0-options-5-1': 'name', + '0-options-8-0': 'service', + '0-options-5-0': 'application1', + '0-options-6-0': 'version1', + '1-options-6-2': 'version', + '1-options-7-4': 'namespace', + '1-options-5-0': 'application1', + '1-options-6-0': 'version1', + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application1' }, + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'version1' } + ]] + }, + { + raw: { + name: 'application2', + version: 'version2' + } as ApplicationRaw, + queryTasksParams: { + '0-options-5-1': 'name', + '0-options-8-0': 'service', + '0-options-5-0': 'application2', + '0-options-6-0': 'version2', + '1-options-6-2': 'version', + '1-options-7-4': 'namespace', + '1-options-5-0': 'application2', + '1-options-6-0': 'version2', + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application2' }, + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'version2' } + ]] + }, + { + raw: { + name: 'application3', + version: 'version3' + } as ApplicationRaw, + queryTasksParams: { + '0-options-5-1': 'name', + '0-options-8-0': 'service', + '0-options-5-0': 'application3', + '0-options-6-0': 'version3', + '1-options-6-2': 'version', + '1-options-7-4': 'namespace', + '1-options-5-0': 'application3', + '1-options-6-0': 'version3', + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'application3' }, + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'version3' } + ]] + } + ]); + }); + }); +}); \ 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 new file mode 100644 index 000000000..c6526a69f --- /dev/null +++ b/src/app/applications/services/applications-data.service.ts @@ -0,0 +1,113 @@ +import { ApplicationRawEnumField, FilterStringOperator, ListApplicationsResponse, TaskOptionEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { Injectable, inject } from '@angular/core'; +import { TaskSummaryFilters } from '@app/tasks/types'; +import { Scope } from '@app/types/config'; +import { ApplicationData } from '@app/types/data'; +import { Filter } from '@app/types/filters'; +import { AbstractTableDataService } from '@app/types/services/table-data.service'; +import { ApplicationRaw } from '../types'; +import { ApplicationsGrpcService } from './applications-grpc.service'; + +@Injectable() +export default class ApplicationsDataService extends AbstractTableDataService { + readonly grpcService = inject(ApplicationsGrpcService); + + scope: Scope = 'applications'; + + computeGrpcData(entries: ListApplicationsResponse): ApplicationRaw[] | undefined { + return entries.applications; + } + + createNewLine(entry: ApplicationRaw): ApplicationData { + return { + raw: entry, + queryTasksParams: this.createTasksByStatusQueryParams(entry.name, entry.version), + filters: this.countTasksByStatusFilters(entry.name, entry.version), + }; + } + + /** + * Create the queryParams used by the taskByStatus component to redirect to the task table. + * The applicationName and applicationVersion filter are applied on top of every filter of the table. + */ + private createTasksByStatusQueryParams(name: string, version: string) { + if(this.filters.length === 0) { + return { + [`0-options-${TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`]: name, + [`0-options-${TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`]: version + }; + } else { + const params: Record = {}; + this.filters.forEach((filterAnd, index) => { + params[`${index}-options-${TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`] = name; + params[`${index}-options-${TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`] = version; + filterAnd.forEach(filter => { + if ((filter.field !== ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME || filter.operator !== FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL) && + (filter.field !== ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAMESPACE || filter.operator !== FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL)) { + const filterLabel = this.createQueryParamFilterKey(filter, index); + if (filterLabel && filter.value) { + params[filterLabel] = filter.value.toString(); + } + } + }); + + }); + return params; + } + } + + /** + * Create the filter key used by the query params. + */ + private createQueryParamFilterKey(filter: Filter, orGroup: number): string | null { + if (filter.field !== null && filter.operator !== null && filter.value !== null) { + const taskField = this.applicationsToTaskField(filter.field as ApplicationRawEnumField); // We transform it into an options filter for a task + if (taskField) { + return this.filtersService.createQueryParamsKey(orGroup, 'options', filter.operator, taskField); + } else { + return null; + } + } + return null; + } + + /** + * Transforms a application field into a TaskOptionField. + */ + private applicationsToTaskField(applicationField: ApplicationRawEnumField) { + switch (applicationField) { + case ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAME: + return TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME; + case ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_NAMESPACE: + return TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAMESPACE; + case ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_SERVICE: + return TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_SERVICE; + case ApplicationRawEnumField.APPLICATION_RAW_ENUM_FIELD_VERSION: + return TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION; + default: + return null; + } + } + + /** + * Create the filter used by the **TaskByStatus** component. + */ + private countTasksByStatusFilters(applicationName: string, applicationVersion: string): TaskSummaryFilters { + return [ + [ + { + for: 'options', + field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_NAME, + value: applicationName, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL + }, + { + for: 'options', + field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_APPLICATION_VERSION, + value: applicationVersion, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL + } + ] + ]; + } +} \ No newline at end of file diff --git a/src/app/dashboard/components/lines/applications-line.component.html b/src/app/dashboard/components/lines/applications-line.component.html index d3199a368..b5d0f79db 100644 --- a/src/app/dashboard/components/lines/applications-line.component.html +++ b/src/app/dashboard/components/lines/applications-line.component.html @@ -1,14 +1,14 @@ \ No newline at end of file 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 ea6641593..d4683dd7a 100644 --- a/src/app/dashboard/components/lines/applications-line.component.spec.ts +++ b/src/app/dashboard/components/lines/applications-line.component.spec.ts @@ -1,6 +1,7 @@ import { ApplicationRawEnumField } from '@aneoconsultingfr/armonik.api.angular'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; +import ApplicationsDataService from '@app/applications/services/applications-data.service'; import { ApplicationsIndexService } from '@app/applications/services/applications-index.service'; import { ApplicationRaw, ApplicationRawColumnKey, ApplicationRawFieldKey, ApplicationRawListOptions } from '@app/applications/types'; import { TableColumn } from '@app/types/column.type'; @@ -53,7 +54,13 @@ describe('ApplicationsLineComponent', () => { key: 'count', type: 'count', sortable: true - } + }, + { + name: $localize`Select`, + key: 'select', + type: 'select', + sortable: false, + }, ]; const options: ApplicationRawListOptions = { @@ -88,6 +95,17 @@ describe('ApplicationsLineComponent', () => { }), }; + const mockApplicationsDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [] as FiltersOr, + refresh$: { + next: jest.fn() + }, + }; + const mockApplicationsIndexService = { availableTableColumns: displayedColumns, defaultColumns: defaultColumns, @@ -106,6 +124,7 @@ describe('ApplicationsLineComponent', () => { providers: [ ApplicationsLineComponent, { provide: MatDialog, useValue: mockMatDialog }, + { provide: ApplicationsDataService, useValue: mockApplicationsDataService }, AutoRefreshService, IconsService, { provide: ApplicationsIndexService, useValue: mockApplicationsIndexService }, @@ -122,11 +141,14 @@ describe('ApplicationsLineComponent', () => { expect(component).toBeTruthy(); }); + it('should load properly', () => { + expect(component.loading).toEqual(mockApplicationsDataService.loading); + }); + describe('on init', () => { it('should init with line values', () => { const intervalSpy = jest.spyOn(component.interval, 'next'); component.ngOnInit(); - expect(component.loading).toBeTruthy(); expect(component.filters).toBe(line.filters); expect(intervalSpy).toHaveBeenCalledWith(line.interval); expect(component.showFilters).toEqual(line.showFilters); @@ -161,9 +183,22 @@ describe('ApplicationsLineComponent', () => { }); it('should refresh', () => { - const refreshSpy = jest.spyOn(component.refresh, 'next'); - component.onRefresh(); - expect(refreshSpy).toHaveBeenCalled(); + component.refresh(); + expect(mockApplicationsDataService.refresh$.next).toHaveBeenCalled(); + }); + + describe('On Options Change', () => { + beforeEach(() => { + component.onOptionsChange(); + }); + + it('should save options', () => { + expect(component.line.options).toEqual(mockApplicationsDataService.options); + }); + + it('should refresh', () => { + expect(mockApplicationsDataService.refresh$.next).toHaveBeenCalled(); + }); }); describe('onIntervalValueChange', () => { @@ -223,14 +258,13 @@ describe('ApplicationsLineComponent', () => { }); it('should refresh', () => { - const spyFilters = jest.spyOn(component.filters$, 'next'); component.onFiltersChange(newFilters); - expect(spyFilters).toHaveBeenCalled(); + expect(mockApplicationsDataService.refresh$.next).toHaveBeenCalled(); }); }); describe('OnColumnsChange', () => { - const newColumns: ApplicationRawColumnKey[] = ['name', 'count', 'service']; + const newColumns: ApplicationRawColumnKey[] = ['name', 'count', 'service', 'select']; beforeEach(() => { component.displayedColumnsKeys = ['namespace', 'service']; @@ -239,17 +273,17 @@ describe('ApplicationsLineComponent', () => { it('should change displayedColumns', () => { component.onColumnsChange(newColumns); - expect(component.displayedColumnsKeys).toEqual(newColumns); + expect(component.displayedColumnsKeys).toEqual(['select', 'name', 'count', 'service']); }); it('should change line displayedColumns', () => { component.onColumnsChange(newColumns); - expect(component.line.displayedColumns).toEqual(newColumns); + expect(component.line.displayedColumns).toEqual(['select', 'name', 'count', 'service']); }); it('should emit', () => { const spy = jest.spyOn(component.lineChange, 'emit'); - component.onColumnsChange(newColumns); + component.onColumnsChange(['select', 'name', 'count', 'service']); expect(spy).toHaveBeenCalled(); }); }); @@ -278,9 +312,8 @@ describe('ApplicationsLineComponent', () => { }); describe('onFiltersReset', () => { - beforeEach(() => { - component.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; + mockApplicationsDataService.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]] as FiltersOr; component.line.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; }); @@ -301,9 +334,8 @@ describe('ApplicationsLineComponent', () => { }); it('should refresh', () => { - const spyFilters = jest.spyOn(component.filters$, 'next'); component.onFiltersReset(); - expect(spyFilters).toHaveBeenCalled(); + expect(mockApplicationsDataService.refresh$.next).toHaveBeenCalled(); }); }); diff --git a/src/app/dashboard/components/lines/applications-line.component.ts b/src/app/dashboard/components/lines/applications-line.component.ts index 15b87535c..379a92233 100644 --- a/src/app/dashboard/components/lines/applications-line.component.ts +++ b/src/app/dashboard/components/lines/applications-line.component.ts @@ -5,6 +5,7 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatToolbarModule } from '@angular/material/toolbar'; import { ApplicationsTableComponent } from '@app/applications/components/table.component'; +import ApplicationsDataService from '@app/applications/services/applications-data.service'; import { ApplicationsFiltersService } from '@app/applications/services/applications-filters.service'; import { ApplicationsGrpcService } from '@app/applications/services/applications-grpc.service'; import { ApplicationsIndexService } from '@app/applications/services/applications-index.service'; @@ -15,6 +16,8 @@ import { FiltersToolbarComponent } from '@components/filters/filters-toolbar.com import { TableDashboardActionsToolbarComponent } from '@components/table-dashboard-actions-toolbar.component'; 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 { NotificationService } from '@services/notification.service'; import { ShareUrlService } from '@services/share-url.service'; @@ -34,7 +37,10 @@ import { ShareUrlService } from '@services/share-url.service'; provide: DATA_FILTERS_SERVICE, useClass: ApplicationsFiltersService }, - ApplicationsFiltersService + ApplicationsFiltersService, + FiltersService, + ApplicationsDataService, + GrpcSortFieldService, ], imports: [ FiltersToolbarComponent, @@ -48,6 +54,7 @@ import { ShareUrlService } from '@services/share-url.service'; export class ApplicationsLineComponent extends DashboardLineTableComponent implements OnInit, AfterViewInit,OnDestroy { readonly indexService = inject(ApplicationsIndexService); readonly defaultConfig = this.defaultConfigService.defaultApplications; + readonly tableDataService = inject(ApplicationsDataService); ngOnInit(): void { this.initLineEnvironment(); diff --git a/src/app/dashboard/components/lines/partitions-line.component.html b/src/app/dashboard/components/lines/partitions-line.component.html index e5ad6d2f4..9ea666124 100644 --- a/src/app/dashboard/components/lines/partitions-line.component.html +++ b/src/app/dashboard/components/lines/partitions-line.component.html @@ -1,14 +1,14 @@ \ No newline at end of file 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 b234a923a..8217d6057 100644 --- a/src/app/dashboard/components/lines/partitions-line.component.spec.ts +++ b/src/app/dashboard/components/lines/partitions-line.component.spec.ts @@ -1,6 +1,7 @@ import { PartitionRawEnumField } from '@aneoconsultingfr/armonik.api.angular'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; +import PartitionsDataService from '@app/partitions/services/partitions-data.service'; import { PartitionsIndexService } from '@app/partitions/services/partitions-index.service'; import { PartitionRaw, PartitionRawColumnKey, PartitionRawFieldKey, PartitionRawListOptions } from '@app/partitions/types'; import { TableColumn } from '@app/types/column.type'; @@ -46,7 +47,13 @@ describe('PartitionsLineComponent', () => { key: 'count', type: 'count', sortable: true - } + }, + { + name: $localize`Select`, + key: 'select', + type: 'select', + sortable: false, + }, ]; const options: PartitionRawListOptions = { @@ -81,6 +88,17 @@ describe('PartitionsLineComponent', () => { }), }; + const mockPartitionsDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [] as FiltersOr, + refresh$: { + next: jest.fn() + }, + }; + const mockPartitionsIndexService = { availableTableColumns: displayedColumns, defaultColumns: defaultColumns, @@ -100,6 +118,7 @@ describe('PartitionsLineComponent', () => { { provide: MatDialog, useValue: mockMatDialog }, AutoRefreshService, IconsService, + { provide: PartitionsDataService, useValue: mockPartitionsDataService }, { provide: PartitionsIndexService, useValue: mockPartitionsIndexService }, DefaultConfigService, { provide: NotificationService, useValue: mockNotificationService }, @@ -114,11 +133,14 @@ describe('PartitionsLineComponent', () => { expect(component).toBeTruthy(); }); + it('should load properly', () => { + expect(component.loading).toEqual(mockPartitionsDataService.loading); + }); + describe('on init', () => { it('should init with line values', () => { const intervalSpy = jest.spyOn(component.interval, 'next'); component.ngOnInit(); - expect(component.loading).toBeTruthy(); expect(component.filters).toBe(line.filters); expect(intervalSpy).toHaveBeenCalledWith(line.interval); }); @@ -153,9 +175,22 @@ describe('PartitionsLineComponent', () => { }); it('should refresh', () => { - const refreshSpy = jest.spyOn(component.refresh, 'next'); - component.onRefresh(); - expect(refreshSpy).toHaveBeenCalled(); + component.refresh(); + expect(mockPartitionsDataService.refresh$.next).toHaveBeenCalled(); + }); + + describe('On Options Change', () => { + beforeEach(() => { + component.onOptionsChange(); + }); + + it('should save options', () => { + expect(component.line.options).toEqual(mockPartitionsDataService.options); + }); + + it('should refresh', () => { + expect(mockPartitionsDataService.refresh$.next).toHaveBeenCalled(); + }); }); describe('onIntervalValueChange', () => { @@ -215,14 +250,13 @@ describe('PartitionsLineComponent', () => { }); it('should refresh', () => { - const spyFilters = jest.spyOn(component.filters$, 'next'); component.onFiltersChange(newFilters); - expect(spyFilters).toHaveBeenCalled(); + expect(mockPartitionsDataService.refresh$.next).toHaveBeenCalled(); }); }); describe('OnColumnsChange', () => { - const newColumns: PartitionRawColumnKey[] = ['id', 'count', 'podMax']; + const newColumns: PartitionRawColumnKey[] = ['id', 'count', 'podMax', 'select']; beforeEach(() => { component.displayedColumnsKeys = ['count', 'id'] as PartitionRawColumnKey[]; @@ -231,17 +265,17 @@ describe('PartitionsLineComponent', () => { it('should change displayedColumns', () => { component.onColumnsChange(newColumns); - expect(component.displayedColumnsKeys).toEqual(newColumns); + expect(component.displayedColumnsKeys).toEqual(['select', 'id', 'count', 'podMax']); }); it('should change line displayedColumns', () => { component.onColumnsChange(newColumns); - expect(component.line.displayedColumns).toEqual(newColumns); + expect(component.line.displayedColumns).toEqual(['select', 'id', 'count', 'podMax']); }); it('should emit', () => { const spy = jest.spyOn(component.lineChange, 'emit'); - component.onColumnsChange(newColumns); + component.onColumnsChange(['select', 'id', 'count', 'podMax']); expect(spy).toHaveBeenCalled(); }); }); @@ -272,7 +306,7 @@ describe('PartitionsLineComponent', () => { describe('onFiltersReset', () => { beforeEach(() => { - component.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; + mockPartitionsDataService.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; component.line.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; }); @@ -293,9 +327,8 @@ describe('PartitionsLineComponent', () => { }); it('should refresh', () => { - const spyFilters = jest.spyOn(component.filters$, 'next'); component.onFiltersReset(); - expect(spyFilters).toHaveBeenCalled(); + expect(mockPartitionsDataService.refresh$.next).toHaveBeenCalled(); }); }); diff --git a/src/app/dashboard/components/lines/partitions-line.component.ts b/src/app/dashboard/components/lines/partitions-line.component.ts index b0d97e9c0..314d4007a 100644 --- a/src/app/dashboard/components/lines/partitions-line.component.ts +++ b/src/app/dashboard/components/lines/partitions-line.component.ts @@ -5,13 +5,16 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatToolbarModule } from '@angular/material/toolbar'; import { PartitionsTableComponent } from '@app/partitions/components/table.component'; +import PartitionsDataService from '@app/partitions/services/partitions-data.service'; import { PartitionsFiltersService } from '@app/partitions/services/partitions-filters.service'; +import { PartitionsGrpcService } from '@app/partitions/services/partitions-grpc.service'; import { PartitionsIndexService } from '@app/partitions/services/partitions-index.service'; import { PartitionRaw } from '@app/partitions/types'; import { DATA_FILTERS_SERVICE } from '@app/tokens/filters.token'; import { DashboardLineTableComponent } from '@app/types/components/dashboard-line-table'; 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 { NotificationService } from '@services/notification.service'; @Component({ @@ -27,6 +30,9 @@ import { NotificationService } from '@services/notification.service'; provide: DATA_FILTERS_SERVICE, useExisting: PartitionsFiltersService }, + PartitionsGrpcService, + PartitionsDataService, + GrpcSortFieldService, ], imports: [ MatToolbarModule, @@ -40,6 +46,7 @@ import { NotificationService } from '@services/notification.service'; export class PartitionsLineComponent extends DashboardLineTableComponent implements OnInit, AfterViewInit, OnDestroy { readonly indexService = inject(PartitionsIndexService); readonly defaultConfig = this.defaultConfigService.defaultPartitions; + readonly tableDataService = inject(PartitionsDataService); ngOnInit() { this.initLineEnvironment(); diff --git a/src/app/dashboard/components/lines/results-line.component.html b/src/app/dashboard/components/lines/results-line.component.html index b86edc6c7..78fe576a5 100644 --- a/src/app/dashboard/components/lines/results-line.component.html +++ b/src/app/dashboard/components/lines/results-line.component.html @@ -1,14 +1,14 @@ \ No newline at end of file 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 4956d9eac..ae8f6b5bf 100644 --- a/src/app/dashboard/components/lines/results-line.component.spec.ts +++ b/src/app/dashboard/components/lines/results-line.component.spec.ts @@ -1,6 +1,7 @@ import { ResultRawEnumField } from '@aneoconsultingfr/armonik.api.angular'; 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'; @@ -45,7 +46,13 @@ describe('ResultsLineComponent', () => { key: 'size', type: 'count', sortable: true - } + }, + { + name: $localize`Select`, + key: 'select', + type: 'select', + sortable: false, + }, ]; const options: ResultRawListOptions = { @@ -80,6 +87,17 @@ describe('ResultsLineComponent', () => { }), }; + const mockResultsDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [] as FiltersOr, + refresh$: { + next: jest.fn() + }, + }; + const mockResultsIndexService = { availableTableColumns: displayedColumns, defaultColumns: defaultColumns, @@ -99,6 +117,7 @@ describe('ResultsLineComponent', () => { { provide: MatDialog, useValue: mockMatDialog }, AutoRefreshService, IconsService, + { provide: ResultsDataService, useValue: mockResultsDataService }, { provide: ResultsIndexService, useValue: mockResultsIndexService }, DefaultConfigService, { provide: NotificationService, useValue: mockNotificationService } @@ -113,11 +132,14 @@ describe('ResultsLineComponent', () => { expect(component).toBeTruthy(); }); + it('should load properly', () => { + expect(component.loading).toEqual(mockResultsDataService.loading); + }); + describe('on init', () => { it('should init with line values', () => { const intervalSpy = jest.spyOn(component.interval, 'next'); component.ngOnInit(); - expect(component.loading).toBeTruthy(); expect(component.filters).toBe(line.filters); expect(intervalSpy).toHaveBeenCalledWith(line.interval); }); @@ -152,9 +174,22 @@ describe('ResultsLineComponent', () => { }); it('should refresh', () => { - const refreshSpy = jest.spyOn(component.refresh, 'next'); - component.onRefresh(); - expect(refreshSpy).toHaveBeenCalled(); + component.refresh(); + expect(mockResultsDataService.refresh$.next).toHaveBeenCalled(); + }); + + describe('On Options Change', () => { + beforeEach(() => { + component.onOptionsChange(); + }); + + it('should save options', () => { + expect(component.line.options).toEqual(mockResultsDataService.options); + }); + + it('should refresh', () => { + expect(mockResultsDataService.refresh$.next).toHaveBeenCalled(); + }); }); describe('onIntervalValueChange', () => { @@ -214,14 +249,13 @@ describe('ResultsLineComponent', () => { }); it('should refresh', () => { - const spyFilters = jest.spyOn(component.filters$, 'next'); component.onFiltersChange(newFilters); - expect(spyFilters).toHaveBeenCalled(); + expect(mockResultsDataService.refresh$.next).toHaveBeenCalled(); }); }); describe('OnColumnsChange', () => { - const newColumns: ResultRawColumnKey[] = ['resultId', 'name', 'sessionId']; + const newColumns: ResultRawColumnKey[] = ['resultId', 'name', 'sessionId', 'select']; beforeEach(() => { component.displayedColumnsKeys = ['actions', 'resultId'] as ResultRawColumnKey[]; @@ -230,17 +264,17 @@ describe('ResultsLineComponent', () => { it('should change displayedColumns', () => { component.onColumnsChange(newColumns); - expect(component.displayedColumnsKeys).toEqual(newColumns); + expect(component.displayedColumnsKeys).toEqual(['select', 'resultId', 'name', 'sessionId']); }); it('should change line displayedColumns', () => { component.onColumnsChange(newColumns); - expect(component.line.displayedColumns).toEqual(newColumns); + expect(component.line.displayedColumns).toEqual(['select', 'resultId', 'name', 'sessionId']); }); it('should emit', () => { const spy = jest.spyOn(component.lineChange, 'emit'); - component.onColumnsChange(newColumns); + component.onColumnsChange(['select', 'resultId', 'name', 'sessionId']); expect(spy).toHaveBeenCalled(); }); }); @@ -271,7 +305,7 @@ describe('ResultsLineComponent', () => { describe('onFiltersReset', () => { beforeEach(() => { - component.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; + mockResultsDataService.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; component.line.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; }); @@ -292,9 +326,8 @@ describe('ResultsLineComponent', () => { }); it('should refresh', () => { - const spyFilters = jest.spyOn(component.filters$, 'next'); component.onFiltersReset(); - expect(spyFilters).toHaveBeenCalled(); + expect(mockResultsDataService.refresh$.next).toHaveBeenCalled(); }); }); diff --git a/src/app/dashboard/components/lines/results-line.component.ts b/src/app/dashboard/components/lines/results-line.component.ts index 98a1794db..d243707ea 100644 --- a/src/app/dashboard/components/lines/results-line.component.ts +++ b/src/app/dashboard/components/lines/results-line.component.ts @@ -5,13 +5,19 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatToolbarModule } from '@angular/material/toolbar'; import { ResultsTableComponent } from '@app/results/components/table.component'; +import ResultsDataService from '@app/results/services/results-data.service'; import { ResultsFiltersService } from '@app/results/services/results-filters.service'; +import { ResultsGrpcService } from '@app/results/services/results-grpc.service'; import { ResultsIndexService } from '@app/results/services/results-index.service'; +import { ResultsStatusesService } from '@app/results/services/results-statuses.service'; import { ResultRaw } from '@app/results/types'; import { DATA_FILTERS_SERVICE } from '@app/tokens/filters.token'; import { DashboardLineTableComponent } from '@app/types/components/dashboard-line-table'; import { FiltersToolbarComponent } from '@components/filters/filters-toolbar.component'; import { TableDashboardActionsToolbarComponent } from '@components/table-dashboard-actions-toolbar.component'; +import { FiltersService } from '@services/filters.service'; +import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; +import { NotificationService } from '@services/notification.service'; @Component({ selector: 'app-dashboard-results-line', @@ -25,6 +31,12 @@ import { TableDashboardActionsToolbarComponent } from '@components/table-dashboa provide: DATA_FILTERS_SERVICE, useExisting: ResultsFiltersService }, + ResultsDataService, + ResultsGrpcService, + ResultsStatusesService, + GrpcSortFieldService, + FiltersService, + NotificationService, ], imports: [ MatIconModule, @@ -38,6 +50,7 @@ import { TableDashboardActionsToolbarComponent } from '@components/table-dashboa export class ResultsLineComponent extends DashboardLineTableComponent implements OnInit, OnDestroy, AfterViewInit { readonly indexService = inject(ResultsIndexService); readonly defaultConfig = this.defaultConfigService.defaultResults; + readonly tableDataService = inject(ResultsDataService); ngOnInit(): void { this.initLineEnvironment(); diff --git a/src/app/dashboard/components/lines/sessions-line.component.html b/src/app/dashboard/components/lines/sessions-line.component.html index 790dd90f0..c0328248e 100644 --- a/src/app/dashboard/components/lines/sessions-line.component.html +++ b/src/app/dashboard/components/lines/sessions-line.component.html @@ -1,14 +1,14 @@ \ No newline at end of file diff --git a/src/app/dashboard/components/lines/sessions-line.component.spec.ts b/src/app/dashboard/components/lines/sessions-line.component.spec.ts index f50034807..38ef7cb77 100644 --- a/src/app/dashboard/components/lines/sessions-line.component.spec.ts +++ b/src/app/dashboard/components/lines/sessions-line.component.spec.ts @@ -1,6 +1,7 @@ import { SessionRawEnumField, TaskOptionEnumField } from '@aneoconsultingfr/armonik.api.angular'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; +import { SessionsDataService } from '@app/sessions/services/sessions-data.service'; import { SessionsIndexService } from '@app/sessions/services/sessions-index.service'; import { SessionRaw } from '@app/sessions/types'; import { TaskOptions } from '@app/tasks/types'; @@ -49,7 +50,13 @@ describe('SessionsLineComponent', () => { key: 'count', type: 'count', sortable: true - } + }, + { + name: $localize`Select`, + key: 'select', + type: 'select', + sortable: false, + }, ]; const options: ListOptions = { @@ -84,6 +91,17 @@ describe('SessionsLineComponent', () => { }), }; + const mockSessionsDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [] as FiltersOr, + refresh$: { + next: jest.fn() + }, + }; + const mockSessionsIndexService = { availableTableColumns: displayedColumns, defaultColumns: defaultColumns, @@ -103,6 +121,7 @@ describe('SessionsLineComponent', () => { { provide: MatDialog, useValue: mockMatDialog }, AutoRefreshService, IconsService, + { provide: SessionsDataService, useValue: mockSessionsDataService }, { provide: SessionsIndexService, useValue: mockSessionsIndexService }, DefaultConfigService, { provide: NotificationService, useValue: mockNotificationService }, @@ -117,11 +136,14 @@ describe('SessionsLineComponent', () => { expect(component).toBeTruthy(); }); + it('should load properly', () => { + expect(component.loading).toEqual(mockSessionsDataService.loading); + }); + describe('on init', () => { it('should init with line values', () => { const intervalSpy = jest.spyOn(component.interval, 'next'); component.ngOnInit(); - expect(component.loading).toBeTruthy(); expect(component.filters).toBe(line.filters); expect(intervalSpy).toHaveBeenCalledWith(line.interval); }); @@ -156,9 +178,22 @@ describe('SessionsLineComponent', () => { }); it('should refresh', () => { - const refreshSpy = jest.spyOn(component.refresh, 'next'); - component.onRefresh(); - expect(refreshSpy).toHaveBeenCalled(); + component.refresh(); + expect(mockSessionsDataService.refresh$.next).toHaveBeenCalled(); + }); + + describe('On Options Change', () => { + beforeEach(() => { + component.onOptionsChange(); + }); + + it('should save options', () => { + expect(component.line.options).toEqual(mockSessionsDataService.options); + }); + + it('should refresh', () => { + expect(mockSessionsDataService.refresh$.next).toHaveBeenCalled(); + }); }); describe('onIntervalValueChange', () => { @@ -218,14 +253,28 @@ describe('SessionsLineComponent', () => { }); it('should refresh', () => { - const spyFilters = jest.spyOn(component.filters$, 'next'); - component.onFiltersChange(newFilters); - expect(spyFilters).toHaveBeenCalled(); + component.refresh(); + expect(mockSessionsDataService.refresh$.next).toHaveBeenCalled(); + }); + + describe('On Options Change', () => { + beforeEach(() => { + component.onOptionsChange(); + }); + + it('should save options', () => { + expect(component.line.options).toEqual(mockSessionsDataService.options); + }); + + it('should refresh', () => { + expect(mockSessionsDataService.refresh$.next).toHaveBeenCalled(); + }); }); + }); describe('OnColumnsChange', () => { - const newColumns: ColumnKey[] = ['sessionId', 'count', 'duration']; + const newColumns: ColumnKey[] = ['sessionId', 'count', 'duration', 'select']; beforeEach(() => { component.displayedColumnsKeys = ['count', 'id'] as ColumnKey[]; @@ -234,17 +283,17 @@ describe('SessionsLineComponent', () => { it('should change displayedColumns', () => { component.onColumnsChange(newColumns); - expect(component.displayedColumnsKeys).toEqual(newColumns); + expect(component.displayedColumnsKeys).toEqual(['select', 'sessionId', 'count', 'duration']); }); it('should change line displayedColumns', () => { component.onColumnsChange(newColumns); - expect(component.line.displayedColumns).toEqual(newColumns); + expect(component.line.displayedColumns).toEqual(['select', 'sessionId', 'count', 'duration']); }); it('should emit', () => { const spy = jest.spyOn(component.lineChange, 'emit'); - component.onColumnsChange(newColumns); + component.onColumnsChange(['select', 'sessionId', 'count', 'duration']); expect(spy).toHaveBeenCalled(); }); }); @@ -275,7 +324,7 @@ describe('SessionsLineComponent', () => { describe('onFiltersReset', () => { beforeEach(() => { - component.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; + mockSessionsDataService.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; component.line.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; }); @@ -296,10 +345,24 @@ describe('SessionsLineComponent', () => { }); it('should refresh', () => { - const spyFilters = jest.spyOn(component.filters$, 'next'); - component.onFiltersReset(); - expect(spyFilters).toHaveBeenCalled(); + component.refresh(); + expect(mockSessionsDataService.refresh$.next).toHaveBeenCalled(); + }); + + describe('On Options Change', () => { + beforeEach(() => { + component.onOptionsChange(); + }); + + it('should save options', () => { + expect(component.line.options).toEqual(mockSessionsDataService.options); + }); + + it('should refresh', () => { + expect(mockSessionsDataService.refresh$.next).toHaveBeenCalled(); + }); }); + }); describe('onLockColumnChange', () => { diff --git a/src/app/dashboard/components/lines/sessions-line.component.ts b/src/app/dashboard/components/lines/sessions-line.component.ts index bded70296..d91351d47 100644 --- a/src/app/dashboard/components/lines/sessions-line.component.ts +++ b/src/app/dashboard/components/lines/sessions-line.component.ts @@ -5,17 +5,22 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatToolbarModule } from '@angular/material/toolbar'; import { SessionsTableComponent } from '@app/sessions/components/table.component'; +import { SessionsDataService } from '@app/sessions/services/sessions-data.service'; import { SessionsFiltersService } from '@app/sessions/services/sessions-filters.service'; +import { SessionsGrpcService } from '@app/sessions/services/sessions-grpc.service'; import { SessionsIndexService } from '@app/sessions/services/sessions-index.service'; import { SessionsStatusesService } from '@app/sessions/services/sessions-statuses.service'; import { SessionRaw } from '@app/sessions/types'; import { TasksFiltersService } from '@app/tasks/services/tasks-filters.service'; +import { TasksGrpcService } from '@app/tasks/services/tasks-grpc.service'; import { TasksStatusesService } from '@app/tasks/services/tasks-statuses.service'; import { TaskOptions } from '@app/tasks/types'; import { DATA_FILTERS_SERVICE } from '@app/tokens/filters.token'; import { DashboardLineCustomColumnsComponent } from '@app/types/components/dashboard-line-table'; import { FiltersToolbarComponent } from '@components/filters/filters-toolbar.component'; import { TableDashboardActionsToolbarComponent } from '@components/table-dashboard-actions-toolbar.component'; +import { FiltersService } from '@services/filters.service'; +import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; import { NotificationService } from '@services/notification.service'; @Component({ @@ -34,6 +39,11 @@ import { NotificationService } from '@services/notification.service'; SessionsStatusesService, TasksStatusesService, TasksFiltersService, + SessionsGrpcService, + SessionsDataService, + GrpcSortFieldService, + FiltersService, + TasksGrpcService, ], imports: [ MatToolbarModule, @@ -47,6 +57,7 @@ import { NotificationService } from '@services/notification.service'; export class SessionsLineComponent extends DashboardLineCustomColumnsComponent implements OnInit, AfterViewInit, OnDestroy { readonly indexService = inject(SessionsIndexService); readonly defaultConfig = this.defaultConfigService.defaultSessions; + readonly tableDataService = inject(SessionsDataService); ngOnInit(): void { this.initLineEnvironment(); diff --git a/src/app/dashboard/components/lines/tasks-line.component.html b/src/app/dashboard/components/lines/tasks-line.component.html index c47ab9f3b..0d414aad9 100644 --- a/src/app/dashboard/components/lines/tasks-line.component.html +++ b/src/app/dashboard/components/lines/tasks-line.component.html @@ -1,14 +1,14 @@ \ No newline at end of file diff --git a/src/app/dashboard/components/lines/tasks-line.component.spec.ts b/src/app/dashboard/components/lines/tasks-line.component.spec.ts index 6d18b54d0..25d1fcda5 100644 --- a/src/app/dashboard/components/lines/tasks-line.component.spec.ts +++ b/src/app/dashboard/components/lines/tasks-line.component.spec.ts @@ -1,7 +1,7 @@ import { TaskOptionEnumField, TaskSummaryEnumField } from '@aneoconsultingfr/armonik.api.angular'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; -import { TasksGrpcService } from '@app/tasks/services/tasks-grpc.service'; +import TasksDataService from '@app/tasks/services/tasks-data.service'; import { TasksIndexService } from '@app/tasks/services/tasks-index.service'; import { TaskOptions, TaskSummary } from '@app/tasks/types'; import { TableColumn } from '@app/types/column.type'; @@ -12,7 +12,7 @@ import { AutoRefreshService } from '@services/auto-refresh.service'; import { DefaultConfigService } from '@services/default-config.service'; import { IconsService } from '@services/icons.service'; import { NotificationService } from '@services/notification.service'; -import { Observable, of, throwError } from 'rxjs'; +import { of } from 'rxjs'; import { TasksLineComponent } from './tasks-line.component'; import { TableLine } from '../../types'; @@ -21,7 +21,7 @@ describe('TasksLineComponent', () => { const defaultConfigService = new DefaultConfigService(); - const defaultColumns: ColumnKey[] = ['id', 'options.applicationName', 'actions', 'status']; + const defaultColumns: ColumnKey[] = ['id', 'options.applicationName', 'actions', 'status', 'select']; const customColumns: CustomColumn[] = ['options.options.FastCompute']; const displayedColumns: TableColumn[] = [ @@ -34,7 +34,7 @@ describe('TasksLineComponent', () => { }, { key: 'options.applicationName', - name: 'Application Name', + name: 'Task Name', type: 'object', sortable: true }, @@ -49,7 +49,13 @@ describe('TasksLineComponent', () => { key: 'status', type: 'status', sortable: true - } + }, + { + name: $localize`Select`, + key: 'select', + type: 'select', + sortable: false, + }, ]; const options: ListOptions = { @@ -90,6 +96,18 @@ describe('TasksLineComponent', () => { urlTemplate: 'url', }; + const mockTasksDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [] as FiltersOr, + refresh$: { + next: jest.fn() + }, + cancelTasks: jest.fn(), + }; + const mockTasksIndexService = { availableTableColumns: displayedColumns, defaultColumns: defaultColumns, @@ -103,10 +121,6 @@ describe('TasksLineComponent', () => { error: jest.fn(), }; - const mockTasksGrpcService = { - cancel$: jest.fn((): Observable => of({})), - }; - beforeEach(() => { component = TestBed.configureTestingModule({ providers: [ @@ -114,10 +128,10 @@ describe('TasksLineComponent', () => { { provide: MatDialog, useValue: mockMatDialog }, AutoRefreshService, IconsService, + { provide: TasksDataService, useValue: mockTasksDataService }, { provide: TasksIndexService, useValue: mockTasksIndexService }, DefaultConfigService, { provide: NotificationService, useValue: mockNotificationService }, - { provide: TasksGrpcService, useValue: mockTasksGrpcService }, ] }).inject(TasksLineComponent); component.line = line; @@ -130,11 +144,14 @@ describe('TasksLineComponent', () => { expect(component).toBeTruthy(); }); + it('should load properly', () => { + expect(component.loading).toEqual(mockTasksDataService.loading); + }); + describe('on init', () => { it('should init with line values', () => { const intervalSpy = jest.spyOn(component.interval, 'next'); component.ngOnInit(); - expect(component.loading).toBeTruthy(); expect(component.filters).toBe(line.filters); expect(intervalSpy).toHaveBeenCalledWith(line.interval); }); @@ -175,9 +192,22 @@ describe('TasksLineComponent', () => { }); it('should refresh', () => { - const refreshSpy = jest.spyOn(component.refresh, 'next'); - component.onRefresh(); - expect(refreshSpy).toHaveBeenCalled(); + component.refresh(); + expect(mockTasksDataService.refresh$.next).toHaveBeenCalled(); + }); + + describe('On Options Change', () => { + beforeEach(() => { + component.onOptionsChange(); + }); + + it('should save options', () => { + expect(component.line.options).toEqual(mockTasksDataService.options); + }); + + it('should refresh', () => { + expect(mockTasksDataService.refresh$.next).toHaveBeenCalled(); + }); }); describe('onIntervalValueChange', () => { @@ -237,14 +267,13 @@ describe('TasksLineComponent', () => { }); it('should refresh', () => { - const spyFilters = jest.spyOn(component.filters$, 'next'); - component.onFiltersChange(newFilters); - expect(spyFilters).toHaveBeenCalled(); + component.onFiltersReset(); + expect(mockTasksDataService.refresh$.next).toHaveBeenCalled(); }); }); describe('OnColumnsChange', () => { - const newColumns: ColumnKey[] = ['id', 'acquiredAt', 'creationToEndDuration']; + const newColumns: ColumnKey[] = ['id', 'acquiredAt', 'creationToEndDuration', 'select']; beforeEach(() => { component.displayedColumnsKeys = ['count', 'id'] as ColumnKey[]; @@ -253,17 +282,17 @@ describe('TasksLineComponent', () => { it('should change displayedColumns', () => { component.onColumnsChange(newColumns); - expect(component.displayedColumnsKeys).toEqual(newColumns); + expect(component.displayedColumnsKeys).toEqual(['select', 'id', 'acquiredAt', 'creationToEndDuration']); }); it('should change line displayedColumns', () => { component.onColumnsChange(newColumns); - expect(component.line.displayedColumns).toEqual(newColumns); + expect(component.line.displayedColumns).toEqual(['select', 'id', 'acquiredAt', 'creationToEndDuration']); }); it('should emit', () => { const spy = jest.spyOn(component.lineChange, 'emit'); - component.onColumnsChange(newColumns); + component.onColumnsChange(['select', 'id', 'acquiredAt', 'creationToEndDuration']); expect(spy).toHaveBeenCalled(); }); }); @@ -292,9 +321,8 @@ describe('TasksLineComponent', () => { }); describe('onFiltersReset', () => { - beforeEach(() => { - component.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; + mockTasksDataService.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; component.line.filters = [[{ field: 1, for: 'root', operator: 1, value: 2 }]]; }); @@ -315,9 +343,8 @@ describe('TasksLineComponent', () => { }); it('should refresh', () => { - const spyFilters = jest.spyOn(component.filters$, 'next'); component.onFiltersReset(); - expect(spyFilters).toHaveBeenCalled(); + expect(mockTasksDataService.refresh$.next).toHaveBeenCalled(); }); }); @@ -350,31 +377,17 @@ describe('TasksLineComponent', () => { expect(component.selection).toEqual(selection); }); - describe('cancelTasks', () => { - it('should cancel task', () => { - const tasksIds = ['1', '2']; - component.cancelTasks(tasksIds); - expect(mockTasksGrpcService.cancel$).toHaveBeenCalledWith(tasksIds); - }); - - it('should notify on success', () => { - component.cancelTasks(['1', '2']); - expect(mockNotificationService.success).toHaveBeenCalled(); - }); - - it('should notify on error', () => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - mockTasksGrpcService.cancel$.mockReturnValueOnce(throwError(() => new Error('error'))); - component.cancelTasks(['1', '2']); - expect(mockNotificationService.error).toHaveBeenCalled(); - }); + it('should cancel task', () => { + const tasksIds = ['1', '2']; + component.cancelTasks(tasksIds); + expect(mockTasksDataService.cancelTasks).toHaveBeenCalledWith(tasksIds); }); - it('should cancel tasks selection', () => { + it('should cancel selected tasks', () => { const selection = ['1', '2']; component.selection = selection; component.onCancelTasksSelection(); - expect(mockTasksGrpcService.cancel$).toHaveBeenCalledWith(selection); + expect(mockTasksDataService.cancelTasks).toHaveBeenCalledWith(selection); }); it('should update view in logs', () => { diff --git a/src/app/dashboard/components/lines/tasks-line.component.ts b/src/app/dashboard/components/lines/tasks-line.component.ts index 4f16440ee..fb71abcf6 100644 --- a/src/app/dashboard/components/lines/tasks-line.component.ts +++ b/src/app/dashboard/components/lines/tasks-line.component.ts @@ -7,6 +7,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { MatToolbarModule } from '@angular/material/toolbar'; import { ManageViewInLogsDialogComponent } from '@app/tasks/components/manage-view-in-logs-dialog.component'; import { TasksTableComponent } from '@app/tasks/components/table.component'; +import TasksDataService from '@app/tasks/services/tasks-data.service'; import { TasksFiltersService } from '@app/tasks/services/tasks-filters.service'; import { TasksGrpcService } from '@app/tasks/services/tasks-grpc.service'; import { TasksIndexService } from '@app/tasks/services/tasks-index.service'; @@ -16,6 +17,7 @@ import { DashboardLineCustomColumnsComponent } from '@app/types/components/dashb import { ManageViewInLogsDialogData, ManageViewInLogsDialogResult } from '@app/types/dialog'; import { FiltersToolbarComponent } from '@components/filters/filters-toolbar.component'; import { TableDashboardActionsToolbarComponent } from '@components/table-dashboard-actions-toolbar.component'; +import { FiltersService } from '@services/filters.service'; import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; import { NotificationService } from '@services/notification.service'; @@ -33,7 +35,9 @@ import { NotificationService } from '@services/notification.service'; useExisting: TasksFiltersService }, TasksGrpcService, - GrpcSortFieldService + GrpcSortFieldService, + TasksDataService, + FiltersService, ], imports: [ MatToolbarModule, @@ -47,7 +51,7 @@ import { NotificationService } from '@services/notification.service'; }) export class TasksLineComponent extends DashboardLineCustomColumnsComponent implements OnInit, AfterViewInit, OnDestroy { readonly indexService = inject(TasksIndexService); - readonly tasksGrpcService = inject(TasksGrpcService); + readonly tableDataService = inject(TasksDataService); serviceIcon: string | null = null; serviceName: string | null = null; @@ -82,16 +86,7 @@ export class TasksLineComponent extends DashboardLineCustomColumnsComponent { - this.notificationService.success('Tasks canceled'); - this.refresh.next(); - }, - error: (error) => { - console.error(error); - this.notificationService.error('Unable to cancel tasks'); - }, - }); + this.tableDataService.cancelTasks(tasksIds); } manageViewInLogs(): void { diff --git a/src/app/partitions/components/table.component.html b/src/app/partitions/components/table.component.html index 95de1d46f..dfe18bd95 100644 --- a/src/app/partitions/components/table.component.html +++ b/src/app/partitions/components/table.component.html @@ -1,3 +1,3 @@ - diff --git a/src/app/partitions/components/table.component.spec.ts b/src/app/partitions/components/table.component.spec.ts index 965b65bf0..63cc9b9a1 100644 --- a/src/app/partitions/components/table.component.spec.ts +++ b/src/app/partitions/components/table.component.spec.ts @@ -1,20 +1,15 @@ -import { FilterNumberOperator, FilterStringOperator, PartitionRawEnumField, TaskOptionEnumField, TaskStatus } from '@aneoconsultingfr/armonik.api.angular'; +import { TaskStatus } from '@aneoconsultingfr/armonik.api.angular'; import { Clipboard } from '@angular/cdk/clipboard'; -import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { ManageGroupsDialogResult, TasksStatusesGroup } from '@app/dashboard/types'; import { TableColumn } from '@app/types/column.type'; import { ColumnKey, PartitionData } from '@app/types/data'; -import { FiltersOr } from '@app/types/filters'; -import { CacheService } from '@services/cache.service'; -import { FiltersService } from '@services/filters.service'; import { NotificationService } from '@services/notification.service'; import { TasksByStatusService } from '@services/tasks-by-status.service'; -import { BehaviorSubject, Subject, of, throwError } from 'rxjs'; +import { of } from 'rxjs'; import { PartitionsTableComponent } from './table.component'; -import { PartitionsGrpcService } from '../services/partitions-grpc.service'; -import { PartitionsIndexService } from '../services/partitions-index.service'; +import PartitionsDataService from '../services/partitions-data.service'; import { PartitionRaw } from '../types'; describe('TasksTableComponent', () => { @@ -42,36 +37,10 @@ describe('TasksTableComponent', () => { } ]; - const mockPartitionsIndexService = { - isActionsColumn: jest.fn(), - isTaskIdColumn: jest.fn(), - isStatusColumn: jest.fn(), - isDateColumn: jest.fn(), - isDurationColumn: jest.fn(), - isObjectColumn: jest.fn(), - isSelectColumn: jest.fn(), - isSimpleColumn: jest.fn(), - isNotSortableColumn: jest.fn(), - columnToLabel: jest.fn(), - saveColumns: jest.fn(), - saveOptions: jest.fn(), - }; - - const mockNotificationService = { - success: jest.fn(), - error: jest.fn(), - }; - const mockClipBoard = { copy: jest.fn() }; - const partitions = { partitions: [{ id: 'partition1' }, { id: 'partition2', }, { id: 'partition3', }], total: 3 }; - const mockPartitionsGrpcService = { - list$: jest.fn(() => of(partitions)), - cancel$: jest.fn(() => of({})), - }; - const defaultStatusesGroups: TasksStatusesGroup[] = [ { name: 'Completed', @@ -113,177 +82,58 @@ describe('TasksTableComponent', () => { saveStatuses: jest.fn() }; - const cachedPartitions = { partitions: [{ id: 'partition1' }, { id: 'partition2', }], total: 2 }; - const mockCacheService = { - get: jest.fn(() => cachedPartitions), - save: jest.fn(), + const mockPartitionsDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [], + refresh$: { + next: jest.fn() + }, + }; + + const mockNotificationService = { + success: jest.fn(), + warning: jest.fn(), + error: jest.fn(), }; beforeEach(() => { component = TestBed.configureTestingModule({ providers: [ PartitionsTableComponent, - { provide: PartitionsIndexService, useValue: mockPartitionsIndexService }, - { provide: PartitionsGrpcService, useValue: mockPartitionsGrpcService }, - FiltersService, - { provide: CacheService, useValue: mockCacheService }, - { provide: NotificationService, useValue: mockNotificationService }, + { provide: PartitionsDataService, useValue: mockPartitionsDataService }, { provide: Clipboard, useValue: mockClipBoard }, { provide: MatDialog, useValue: mockMatDialog }, { provide: TasksByStatusService, useValue: mockTasksByStatusService }, + { provide: NotificationService, useValue: mockNotificationService }, ] }).inject(PartitionsTableComponent); component.displayedColumns = displayedColumns; - component.filters$ = new BehaviorSubject>([]); - component.options = { - pageIndex: 0, - pageSize: 10, - sort: { - active: 'id', - direction: 'desc' - } - }; - component.refresh$ = new Subject(); - component.loading = signal(false); component.ngOnInit(); - component.ngAfterViewInit(); }); it('should run', () => { expect(component).toBeTruthy(); }); - describe('initialisation', () => { - it('should load cached data from cachedService', () => { - expect(mockCacheService.get).toHaveBeenCalled(); - }); - }); - - describe('loadFromCache', () => { - beforeEach(() => { - component.loadFromCache(); - }); - - it('should update total data with cached one', () => { - expect(component.total).toEqual(cachedPartitions.total); - }); - - it('should update data with cached one', () => { - expect(component.data()).toEqual([ - { - raw: { - id: 'partition1', - } as PartitionRaw, - queryTasksParams: { - '0-options-4-0': 'partition1', - }, - filters: [[ - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'partition1' }, - ]] - }, - { - raw: { - id: 'partition2', - } as PartitionRaw, - queryTasksParams: { - '0-options-4-0': 'partition2', - }, - filters: [[ - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'partition2' }, - ]] - }, - ]); - }); - }); - - it('should update data on refresh', () => { - component.refresh$.next(); - expect(component.data()).toEqual([ - { - raw: { - id: 'partition1', - } as PartitionRaw, - queryTasksParams: { - '0-options-4-0': 'partition1', - }, - filters: [[ - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'partition1' }, - ]] - }, - { - raw: { - id: 'partition2', - } as PartitionRaw, - queryTasksParams: { - '0-options-4-0': 'partition2', - }, - filters: [[ - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'partition2' }, - ]] - }, - { - raw: { - id: 'partition3', - } as PartitionRaw, - queryTasksParams: { - '0-options-4-0': 'partition3', - }, - filters: [[ - { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'partition3' }, - ]] - } - ]); - }); - - it('should cache received data', () => { - component.refresh$.next(); - expect(mockCacheService.get).toHaveBeenCalled(); - }); - it('should return columns keys', () => { expect(component.columnKeys).toEqual(displayedColumns.map(column => column.key)); }); - describe('on list error', () => { - beforeEach(() => { - mockPartitionsGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); - }); - - it('should log error', () => { - const spy = jest.spyOn(console, 'error').mockImplementation(() => { }); - component.refresh$.next(); - expect(spy).toHaveBeenCalled(); - }); - - it('should send a notification', () => { - component.refresh$.next(); - expect(mockNotificationService.error).toHaveBeenCalled(); - }); - - it('should send empty data', () => { - component.refresh$.next(); - expect(component.data()).toEqual([]); - }); - }); - - describe('options changes', () => { - it('should refresh data', () => { - const spy = jest.spyOn(component.refresh$, 'next'); - component.onOptionsChange(); - expect(spy).toHaveBeenCalled(); - }); - - it('should save options', () => { - component.onOptionsChange(); - expect(mockPartitionsIndexService.saveOptions).toHaveBeenCalled(); - }); - }); - - test('onDrop should call PartitionsIndexService', () => { + test('onDrop should emit', () => { + const spy = jest.spyOn(component.columnUpdate, 'emit'); const newColumns: ColumnKey[] = ['actions', 'id', 'parentPartitionIds', 'preemptionPercentage']; component.onDrop(newColumns); - expect(mockPartitionsIndexService.saveColumns).toHaveBeenCalledWith(newColumns); + expect(spy).toHaveBeenCalledWith(newColumns); + }); + + test('onOptionsChange should emit', () => { + const spy = jest.spyOn(component.optionsUpdate, 'emit'); + component.onOptionsChange(); + expect(spy).toHaveBeenCalled(); }); describe('personnalizeTasksByStatus', () => { @@ -300,80 +150,6 @@ describe('TasksTableComponent', () => { }); }); - describe('createTasksByStatysQueryParams', () => { - it('should create params for a task by status redirection', () => { - const partitionId = 'partitionId'; - expect(component.createTasksByStatusQueryParams(partitionId)).toEqual({ - '0-options-4-0': partitionId, - }); - }); - - it('should create params for each filter', () => { - component.filters = [ - [ - { - field: PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_ID, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_NOT_EQUAL, - value: 'partitionId', - for: 'root' - }, - { - field: PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_PRIORITY, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, - value: '1', - for: 'root' - }, - { - field: PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_ID, - operator: null, - value: null, - for: 'root' - }, - { - field: PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_PRIORITY, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, - value: null, - for: 'root' - } - ], - [ - { - field: PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_ID, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, - value: 'ShouldNotAppearId', - for: 'root' - }, - { - field: PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_PRIORITY, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, - value: '2', - for: 'root' - }, - { - field: PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_ID, - operator: null, - value: 'nullOperatorPartionId', - for: 'root' - }, - { - field: PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_POD_MAX, - operator: FilterNumberOperator.FILTER_NUMBER_OPERATOR_GREATER_THAN, - value: '3', - for: 'root' - } - ] - ]; - const partitionId = 'partitionId'; - expect(component.createTasksByStatusQueryParams(partitionId)).toEqual({ - '0-options-4-1': 'partitionId', - '0-options-3-0': '1', - '0-options-4-0': partitionId, - '1-options-3-2': '2', - '1-options-4-0': partitionId, - }); - }); - }); - describe('isDataRawEqual', () => { it('should return true if two partitionRaws are the same', () => { const partition1 = { id: 'partition' } as PartitionRaw; @@ -392,4 +168,28 @@ describe('TasksTableComponent', () => { const partition = {raw: { id: 'partition' }} as PartitionData; expect(component.trackBy(0, partition)).toEqual(partition.raw.id); }); + + it('should get data', () => { + expect(component.data).toEqual(mockPartitionsDataService.data); + }); + + it('should get total', () => { + expect(component.total).toEqual(mockPartitionsDataService.total); + }); + + it('should get options', () => { + expect(component.options).toEqual(mockPartitionsDataService.options); + }); + + it('should get filters', () => { + expect(component.filters).toEqual(mockPartitionsDataService.filters); + }); + + it('should get column keys', () => { + expect(component.columnKeys).toEqual(displayedColumns.map(c => c.key)); + }); + + it('should get displayedColumns', () => { + expect(component.displayedColumns).toEqual(displayedColumns); + }); }); \ No newline at end of file diff --git a/src/app/partitions/components/table.component.ts b/src/app/partitions/components/table.component.ts index f0fb7ba34..005621e59 100644 --- a/src/app/partitions/components/table.component.ts +++ b/src/app/partitions/components/table.component.ts @@ -1,15 +1,11 @@ -import { FilterStringOperator, ListPartitionsResponse, PartitionRawEnumField, TaskOptionEnumField } from '@aneoconsultingfr/armonik.api.angular'; -import { AfterViewInit, Component, OnInit, inject } from '@angular/core'; -import { TaskSummaryFilters } from '@app/tasks/types'; +import { PartitionRawEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { Component, OnInit, inject } from '@angular/core'; import { AbstractTaskByStatusTableComponent } from '@app/types/components/table'; -import { Scope } from '@app/types/config'; -import { ArmonikData, PartitionData } from '@app/types/data'; +import { ArmonikData } from '@app/types/data'; import { TableComponent } from '@components/table/table.component'; import { FiltersService } from '@services/filters.service'; -import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; import { TableTasksByStatus, TasksByStatusService } from '@services/tasks-by-status.service'; -import { PartitionsGrpcService } from '../services/partitions-grpc.service'; -import { PartitionsIndexService } from '../services/partitions-index.service'; +import PartitionsDataService from '../services/partitions-data.service'; import { PartitionRaw } from '../types'; @Component({ @@ -17,94 +13,27 @@ import { PartitionRaw } from '../types'; standalone: true, templateUrl: './table.component.html', providers: [ - PartitionsGrpcService, - PartitionsIndexService, TasksByStatusService, FiltersService, - GrpcSortFieldService, ], imports: [ TableComponent, ] }) export class PartitionsTableComponent extends AbstractTaskByStatusTableComponent - implements OnInit, AfterViewInit { + implements OnInit { - readonly grpcService = inject(PartitionsGrpcService); - readonly indexService = inject(PartitionsIndexService); + readonly tableDataService = inject(PartitionsDataService); - scope: Scope = 'partitions'; table: TableTasksByStatus = 'partitions'; ngOnInit(): void { - this.initTable(); - } - - ngAfterViewInit(): void { - this.subscribeToData(); - } - - computeGrpcData(entries: ListPartitionsResponse): PartitionRaw[] | undefined { - return entries.partitions; + this.initStatuses(); } isDataRawEqual(value: PartitionRaw, entry: PartitionRaw): boolean { return value.id === entry.id; } - - createNewLine(entry: PartitionRaw): PartitionData { - return { - raw: entry, - queryTasksParams: this.createTasksByStatusQueryParams(entry.id), - filters: this.countTasksByStatusFilters(entry.id), - }; - } - - createTasksByStatusQueryParams(partition: string) { - if (this.filters.length === 0) { - return { - [`0-options-${TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`]: partition, - }; - } - const params: Record = {}; - this.filters.forEach((filtersAnd, index) => { - params[`${index}-options-${TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`] = partition; - filtersAnd.forEach((filter) => { - if (filter.field !== PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_ID || filter.operator !== FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL) { - const taskField = this.#partitionToTaskFilter(filter.field as PartitionRawEnumField | null); - if (taskField && filter.operator !== null && filter.value !== null) { - const key = this.filtersService.createQueryParamsKey(index, 'options', filter.operator, taskField); - params[key] = filter.value?.toString(); - } - } - }); - }); - return params; - } - - #partitionToTaskFilter(field: PartitionRawEnumField | null) { - switch (field) { - case PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_ID: - return TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID; - case PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_PRIORITY: - return TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PRIORITY; - default: - return null; - } - } - - countTasksByStatusFilters(partitionId: string): TaskSummaryFilters { - return [ - [ - { - for: 'options', - field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, - value: partitionId, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, - } - ] - ]; - } trackBy(index: number, items: ArmonikData) { return items.raw.id; diff --git a/src/app/partitions/index.component.html b/src/app/partitions/index.component.html index 5a65fd50a..c2ace5d0f 100644 --- a/src/app/partitions/index.component.html +++ b/src/app/partitions/index.component.html @@ -6,14 +6,14 @@ \ No newline at end of file diff --git a/src/app/partitions/index.component.spec.ts b/src/app/partitions/index.component.spec.ts index 63b8baea8..ecf0b1cd3 100644 --- a/src/app/partitions/index.component.spec.ts +++ b/src/app/partitions/index.component.spec.ts @@ -13,18 +13,14 @@ import { NotificationService } from '@services/notification.service'; import { ShareUrlService } from '@services/share-url.service'; import { of } from 'rxjs'; import { IndexComponent } from './index.component'; +import PartitionsDataService from './services/partitions-data.service'; import { PartitionsFiltersService } from './services/partitions-filters.service'; -import { PartitionsGrpcService } from './services/partitions-grpc.service'; import { PartitionsIndexService } from './services/partitions-index.service'; import { PartitionRaw } from './types'; describe('Partitions Index Component', () => { let component: IndexComponent; - const mockPartitionsGrpcService = { - cancel$: jest.fn(() => of()), - }; - const newCustomColumns: CustomColumn[] = ['options.options.FastCompute', 'options.options.NewCustom']; const mockMatDialog = { @@ -87,6 +83,12 @@ describe('Partitions Index Component', () => { key: 'podMax', sortable: true, }, + { + name: $localize`Select`, + key: 'select', + type: 'select', + sortable: false, + }, ]; const defaultIntervalValue = 10; @@ -99,6 +101,17 @@ describe('Partitions Index Component', () => { const defaultShowFilters = false; + const mockPartitionsDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [], + refresh$: { + next: jest.fn() + }, + }; + const mockPartitionsIndexService = { restoreViewInLogs: jest.fn(() => defaultViewInLogs), saveViewInLogs: jest.fn(), @@ -114,7 +127,7 @@ describe('Partitions Index Component', () => { resetColumns: jest.fn(() => defaultColumns), }; - const mockTaskFiltersService = { + const mockPartititionsFiltersService = { restoreFilters: jest.fn(() => []), saveFilters: jest.fn(), resetFilters: jest.fn(() => []), @@ -139,11 +152,11 @@ describe('Partitions Index Component', () => { IconsService, AutoRefreshService, { provide: PartitionsIndexService, useValue: mockPartitionsIndexService }, - { provide: PartitionsGrpcService, useValue: mockPartitionsGrpcService }, + { provide: PartitionsDataService, useValue: mockPartitionsDataService }, { provide: MatDialog, useValue: mockMatDialog }, { provide: DashboardIndexService, useValue: mockDashboardIndexService }, { provide: Router, useValue: mockRouter }, - { provide: PartitionsFiltersService, useValue: mockTaskFiltersService }, + { provide: PartitionsFiltersService, useValue: mockPartititionsFiltersService }, { provide: ShareUrlService, useValue: mockShareUrlService }, { provide: NotificationService, useValue: mockNotificationService }, ] @@ -156,6 +169,10 @@ describe('Partitions Index Component', () => { expect(component).toBeTruthy(); }); + it('should load properly', () => { + expect(component.loading).toEqual(mockPartitionsDataService.loading); + }); + it('should update columns keys', () => { component.updateDisplayedColumns(); expect(component.displayedColumnsKeys).toEqual(defaultColumns); @@ -196,7 +213,6 @@ describe('Partitions Index Component', () => { it('should initialise filters', () => { expect(component.filters).toEqual([]); - expect(component.filters$).toBeDefined(); }); it('should init options', () => { @@ -220,9 +236,8 @@ describe('Partitions Index Component', () => { }); it('should refresh', () => { - const spy = jest.spyOn(component.refresh$, 'next'); - component.onRefresh(); - expect(spy).toHaveBeenCalled(); + component.refresh(); + expect(mockPartitionsDataService.refresh$.next).toHaveBeenCalled(); }); describe('On interval value change', () => { @@ -238,9 +253,8 @@ describe('Partitions Index Component', () => { }); it('should refresh if the value is not null', () => { - const spy = jest.spyOn(component.refresh$, 'next'); component.onIntervalValueChange(5); - expect(spy).toHaveBeenCalled(); + expect(mockPartitionsDataService.refresh$.next).toHaveBeenCalled(); }); it('should stop the interval if the value is 0', () => { @@ -256,17 +270,23 @@ describe('Partitions Index Component', () => { }); describe('On columns change', () => { - const newColumns: ColumnKey[] = ['id', 'count']; + const newColumns: ColumnKey[] = ['id', 'count', 'select']; beforeEach(() => { component.onColumnsChange(newColumns); }); it('should update displayed column keys', () => { - expect(component.displayedColumnsKeys).toEqual(newColumns); + expect(component.displayedColumnsKeys).toEqual(['select', 'id', 'count']); }); it('should update displayed columns', () => { expect(component.displayedColumns()).toEqual([ + { + name: $localize`Select`, + key: 'select', + type: 'select', + sortable: false, + }, { name: $localize`ID`, key: 'id', @@ -284,7 +304,7 @@ describe('Partitions Index Component', () => { }); it('should save columns', () => { - expect(mockPartitionsIndexService.saveColumns).toHaveBeenCalledWith(['id', 'count']); + expect(mockPartitionsIndexService.saveColumns).toHaveBeenCalledWith(['select', 'id', 'count']); }); }); @@ -326,6 +346,20 @@ describe('Partitions Index Component', () => { }); }); + describe('On Options Change', () => { + beforeEach(() => { + component.onOptionsChange(); + }); + + it('should save options', () => { + expect(mockPartitionsIndexService.saveOptions).toHaveBeenCalledWith(mockPartitionsDataService.options); + }); + + it('should refresh', () => { + expect(mockPartitionsDataService.refresh$.next).toHaveBeenCalled(); + }); + }); + describe('On Filters Change', () => { const newFilters: FiltersOr = [ [ @@ -337,11 +371,7 @@ describe('Partitions Index Component', () => { } ] ]; - - let filterSpy: jest.SpyInstance; - beforeEach(() => { - filterSpy = jest.spyOn(component.filters$, 'next'); component.onFiltersChange(newFilters); }); @@ -350,23 +380,16 @@ describe('Partitions Index Component', () => { }); it('should save filters', () => { - expect(mockTaskFiltersService.saveFilters).toHaveBeenCalledWith(newFilters); + expect(mockPartititionsFiltersService.saveFilters).toHaveBeenCalledWith(newFilters); }); it('should update page index', () => { expect(component.options.pageIndex).toEqual(0); }); - - it('should emit filters', () => { - expect(filterSpy).toHaveBeenCalledWith(newFilters); - }); }); describe('On Filter Reset', () => { - let filterSpy: jest.SpyInstance; - beforeEach(() => { - filterSpy = jest.spyOn(component.filters$, 'next'); component.onFiltersReset(); }); @@ -377,10 +400,6 @@ describe('Partitions Index Component', () => { it('should reset page index', () => { expect(component.options.pageIndex).toEqual(0); }); - - it('should emit empty filters', () => { - expect(filterSpy).toHaveBeenCalledWith([]); - }); }); describe('On lockColumns Change', () => { @@ -443,7 +462,7 @@ describe('Partitions Index Component', () => { it('should save show filters', () => { const newShowFilters = true; component.onShowFiltersChange(newShowFilters); - expect(mockTaskFiltersService.saveShowFilters).toHaveBeenCalledWith(newShowFilters); + expect(mockPartititionsFiltersService.saveShowFilters).toHaveBeenCalledWith(newShowFilters); }); }); }); \ No newline at end of file diff --git a/src/app/partitions/index.component.ts b/src/app/partitions/index.component.ts index 62f855106..435527c54 100644 --- a/src/app/partitions/index.component.ts +++ b/src/app/partitions/index.component.ts @@ -17,6 +17,7 @@ import { PageHeaderComponent } from '@components/page-header.component'; import { TableIndexActionsToolbarComponent } from '@components/table-index-actions-toolbar.component'; import { AutoRefreshService } from '@services/auto-refresh.service'; import { FiltersService } from '@services/filters.service'; +import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; import { NotificationService } from '@services/notification.service'; import { QueryParamsService } from '@services/query-params.service'; import { ShareUrlService } from '@services/share-url.service'; @@ -26,6 +27,7 @@ import { TableService } from '@services/table.service'; import { TasksByStatusService } from '@services/tasks-by-status.service'; import { UtilsService } from '@services/utils.service'; import { PartitionsTableComponent } from './components/table.component'; +import PartitionsDataService from './services/partitions-data.service'; import { PartitionsFiltersService } from './services/partitions-filters.service'; import { PartitionsGrpcService } from './services/partitions-grpc.service'; import { PartitionsIndexService } from './services/partitions-index.service'; @@ -57,6 +59,9 @@ import { PartitionRaw } from './types'; }, DashboardIndexService, DashboardStorageService, + PartitionsDataService, + GrpcSortFieldService, + PartitionsGrpcService, ], imports: [ PageHeaderComponent, @@ -68,13 +73,13 @@ import { PartitionRaw } from './types'; MatButtonModule, MatSnackBarModule, MatMenuModule, - PartitionsTableComponent + PartitionsTableComponent, ] }) export class IndexComponent extends TableHandler implements OnInit, AfterViewInit, OnDestroy { - readonly filtersService = inject(PartitionsFiltersService); readonly indexService = inject(PartitionsIndexService); + readonly tableDataService = inject(PartitionsDataService); tableType: TableType = 'Partitions'; diff --git a/src/app/partitions/services/partitions-data.service.spec.ts b/src/app/partitions/services/partitions-data.service.spec.ts new file mode 100644 index 000000000..50343b0a7 --- /dev/null +++ b/src/app/partitions/services/partitions-data.service.spec.ts @@ -0,0 +1,283 @@ +import { FilterArrayOperator, FilterNumberOperator, FilterStringOperator, ListPartitionsResponse, PartitionRawEnumField, TaskOptionEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { TestBed } from '@angular/core/testing'; +import { FiltersOr } from '@app/types/filters'; +import { ListOptions } from '@app/types/options'; +import { GrpcStatusEvent } from '@ngx-grpc/common'; +import { CacheService } from '@services/cache.service'; +import { FiltersService } from '@services/filters.service'; +import { NotificationService } from '@services/notification.service'; +import { of, throwError } from 'rxjs'; +import PartitionsDataService from './partitions-data.service'; +import { PartitionsGrpcService } from './partitions-grpc.service'; +import { PartitionRaw } from '../types'; + +describe('PartitionsDataService', () => { + let service: PartitionsDataService; + + const cachedPartitions = { partitions: [{ id: 'partition1' }, { id: 'partition2', }], total: 2 } as unknown as ListPartitionsResponse; + const mockCacheService = { + get: jest.fn(() => cachedPartitions), + save: jest.fn(), + }; + + const partitions = { partitions: [{ id: 'partition1' }, { id: 'partition2', }, { id: 'partition3', }], total: 3 } as unknown as ListPartitionsResponse; + const mockPartitionsGrpcService = { + list$: jest.fn(() => of(partitions)), + }; + + const mockNotificationService = { + success: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }; + + const initialOptions: ListOptions = { + pageIndex: 0, + pageSize: 10, + sort: { + active: 'id', + direction: 'desc' + } + }; + + const initialFilters: FiltersOr = []; + + beforeEach(() => { + service = TestBed.configureTestingModule({ + providers: [ + PartitionsDataService, + FiltersService, + { provide: PartitionsGrpcService, useValue: mockPartitionsGrpcService }, + { provide: NotificationService, useValue: mockNotificationService }, + { provide: CacheService, useValue: mockCacheService }, + ] + }).inject(PartitionsDataService); + service.options = initialOptions; + service.filters = initialFilters; + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('initialisation', () => { + it('should load data from the cache', () => { + expect(service.data).toEqual([ + { + raw: { + id: 'partition1', + } as PartitionRaw, + queryTasksParams: { + '0-options-4-0': 'partition1', + }, + filters: [[ + { + for: 'options', + field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + value: 'partition1' + }, + ]] + }, + { + raw: { + id: 'partition2', + } as PartitionRaw, + queryTasksParams: { + '0-options-4-0': 'partition2', + }, + filters: [[ + { + for: 'options', + field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + value: 'partition2' + }, + ]] + }, + ]); + }); + + it('should set the total cached data', () => { + expect(service.total).toEqual(cachedPartitions.total); + }); + }); + + describe('Fetching data', () => { + it('should list the data', () => { + service.refresh$.next(); + expect(mockPartitionsGrpcService.list$).toHaveBeenCalledWith(service.options, service.filters); + }); + + it('should update the total', () => { + service.refresh$.next(); + expect(service.total).toEqual(partitions.total); + }); + + it('should update the data', () => { + service.refresh$.next(); + expect(service.data).toEqual([ + { + raw: { + id: 'partition1', + } as PartitionRaw, + queryTasksParams: { + '0-options-4-0': 'partition1', + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'partition1' }, + ]] + }, + { + raw: { + id: 'partition2', + } as PartitionRaw, + queryTasksParams: { + '0-options-4-0': 'partition2', + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'partition2' }, + ]] + }, + { + raw: { + id: 'partition3', + } as PartitionRaw, + queryTasksParams: { + '0-options-4-0': 'partition3', + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'partition3' }, + ]] + } + ]); + }); + + it('should handle an empty DataRaw', () => { + const partitions = { partitions: undefined, total: 0} as unknown as ListPartitionsResponse; + mockPartitionsGrpcService.list$.mockReturnValueOnce(of(partitions)); + service.refresh$.next(); + expect(service.data).toEqual([]); + }); + + it('should catch errors', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockPartitionsGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); + service.refresh$.next(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('should notify errors', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + mockPartitionsGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); + service.refresh$.next(); + expect(mockNotificationService.error).toHaveBeenCalled(); + }); + + it('should cache the raw data', () => { + service.refresh$.next(); + expect(mockCacheService.save).toHaveBeenCalledWith(service.scope, partitions); + }); + }); + + it('should display a success message', () => { + const message = 'A success message !'; + service.success(message); + expect(mockNotificationService.success).toHaveBeenCalledWith(message); + }); + + it('should display a warning message', () => { + const message = 'A warning message !'; + service.warning(message); + expect(mockNotificationService.warning).toHaveBeenCalledWith(message); + }); + + it('should display an error message', () => { + const error: GrpcStatusEvent = { + statusMessage: 'A error status message' + } as GrpcStatusEvent; + jest.spyOn(console, 'error').mockImplementation(() => {}); + service.error(error); + expect(mockNotificationService.error).toHaveBeenCalledWith(error.statusMessage); + }); + + it('should load correctly', () => { + expect(service.loading).toBeFalsy(); + }); + + describe('Applying filters', () => { + const filters: FiltersOr = [ + [ + { + field: PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, + value: 'a', + }, + { + field: PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_PARENT_PARTITION_IDS, + for: 'root', + operator: FilterArrayOperator.FILTER_ARRAY_OPERATOR_NOT_CONTAINS, + value: 'partition1', + }, + ], + [ + { + field: PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_PRIORITY, + for: 'root', + operator: FilterNumberOperator.FILTER_NUMBER_OPERATOR_EQUAL, + value: 1, + } + ] + ]; + + it('should apply the filters correctly when transforming the data', () => { + service.filters = filters; + service.refresh$.next(); + expect(service.data).toEqual([ + { + raw: { + id: 'partition1', + } as PartitionRaw, + queryTasksParams: { + '0-options-4-0': 'partition1', + '0-options-4-2': 'a', + '1-options-4-0': 'partition1', + '1-options-3-0': '1' + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'partition1' }, + ]] + }, + { + raw: { + id: 'partition2', + } as PartitionRaw, + queryTasksParams: { + '0-options-4-0': 'partition2', + '0-options-4-2': 'a', + '1-options-4-0': 'partition2', + '1-options-3-0': '1' + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'partition2' }, + ]] + }, + { + raw: { + id: 'partition3', + } as PartitionRaw, + queryTasksParams: { + '0-options-4-0': 'partition3', + '0-options-4-2': 'a', + '1-options-4-0': 'partition3', + '1-options-3-0': '1' + }, + filters: [[ + { for: 'options', field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'partition3' }, + ]] + } + ]); + }); + }); +}); \ No newline at end of file diff --git a/src/app/partitions/services/partitions-data.service.ts b/src/app/partitions/services/partitions-data.service.ts new file mode 100644 index 000000000..ba80373dd --- /dev/null +++ b/src/app/partitions/services/partitions-data.service.ts @@ -0,0 +1,83 @@ +import { FilterStringOperator, ListPartitionsResponse, PartitionRawEnumField, TaskOptionEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { Injectable, inject } from '@angular/core'; +import { TaskSummaryFilters } from '@app/tasks/types'; +import { Scope } from '@app/types/config'; +import { PartitionData } from '@app/types/data'; +import { AbstractTableDataService } from '@app/types/services/table-data.service'; +import { PartitionRaw } from '../types'; +import { PartitionsGrpcService } from './partitions-grpc.service'; + +@Injectable() +export default class PartitionsDataService extends AbstractTableDataService { + readonly grpcService = inject(PartitionsGrpcService); + + scope: Scope = 'partitions'; + + computeGrpcData(entries: ListPartitionsResponse): PartitionRaw[] | undefined { + return entries.partitions; + } + + createNewLine(entry: PartitionRaw): PartitionData { + return { + raw: entry, + queryTasksParams: this.createTasksByStatusQueryParams(entry.id), + filters: this.countTasksByStatusFilters(entry.id), + }; + } + + /** + * Create the queryParams used by the taskByStatus component to redirect to the task table. + * The partitionId filter is applied on top of every filter of the table. + */ + private createTasksByStatusQueryParams(partition: string) { + if (this.filters.length === 0) { + return { + [`0-options-${TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`]: partition, + }; + } + const params: Record = {}; + this.filters.forEach((filtersAnd, index) => { + params[`${index}-options-${TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`] = partition; + filtersAnd.forEach((filter) => { + if (filter.field !== PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_ID || filter.operator !== FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL) { + const taskField = this.partitionToTaskFilter(filter.field as PartitionRawEnumField | null); + if (taskField && filter.operator !== null && filter.value !== null) { + const key = this.filtersService.createQueryParamsKey(index, 'options', filter.operator, taskField); + params[key] = filter.value?.toString(); + } + } + }); + }); + return params; + } + + /** + * Transforms a partition field into a TaskOptionField. + */ + private partitionToTaskFilter(field: PartitionRawEnumField | null): TaskOptionEnumField | null { + switch (field) { + case PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_ID: + return TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID; + case PartitionRawEnumField.PARTITION_RAW_ENUM_FIELD_PRIORITY: + return TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PRIORITY; + default: + return null; + } + } + + /** + * Create the filter used by the **TaskByStatus** component. + */ + private countTasksByStatusFilters(partitionId: string): TaskSummaryFilters { + return [ + [ + { + for: 'options', + field: TaskOptionEnumField.TASK_OPTION_ENUM_FIELD_PARTITION_ID, + value: partitionId, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + } + ] + ]; + } +} \ No newline at end of file diff --git a/src/app/results/components/table.component.html b/src/app/results/components/table.component.html index a2a5e041a..05f197934 100644 --- a/src/app/results/components/table.component.html +++ b/src/app/results/components/table.component.html @@ -1,2 +1,3 @@ - + diff --git a/src/app/results/components/table.component.spec.ts b/src/app/results/components/table.component.spec.ts index c0a0dd059..17495389f 100644 --- a/src/app/results/components/table.component.spec.ts +++ b/src/app/results/components/table.component.spec.ts @@ -1,17 +1,12 @@ import { Clipboard } from '@angular/cdk/clipboard'; -import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { TableColumn } from '@app/types/column.type'; import { ColumnKey, ResultData } from '@app/types/data'; -import { CacheService } from '@services/cache.service'; -import { FiltersService } from '@services/filters.service'; import { NotificationService } from '@services/notification.service'; -import { BehaviorSubject, Subject, of, throwError } from 'rxjs'; import { ResultsTableComponent } from './table.component'; -import { ResultsGrpcService } from '../services/results-grpc.service'; -import { ResultsIndexService } from '../services/results-index.service'; +import ResultsDataService from '../services/results-data.service'; import { ResultsStatusesService } from '../services/results-statuses.service'; -import { ResultRaw, ResultRawFilters } from '../types'; +import { ResultRaw } from '../types'; describe('TasksTableComponent', () => { let component: ResultsTableComponent; @@ -44,21 +39,6 @@ describe('TasksTableComponent', () => { } ]; - const mockResultsIndexService = { - isActionsColumn: jest.fn(), - isTaskIdColumn: jest.fn(), - isStatusColumn: jest.fn(), - isDateColumn: jest.fn(), - isDurationColumn: jest.fn(), - isObjectColumn: jest.fn(), - isSelectColumn: jest.fn(), - isSimpleColumn: jest.fn(), - isNotSortableColumn: jest.fn(), - columnToLabel: jest.fn(), - saveColumns: jest.fn(), - saveOptions: jest.fn(), - }; - const mockNotificationService = { success: jest.fn(), error: jest.fn(), @@ -68,160 +48,48 @@ describe('TasksTableComponent', () => { copy: jest.fn() }; - const results = { results: [{ resultId: 'result1' }, { resultId: 'result2' }, { resultId: 'result3' }], total: 3 }; - const mockResultsGrpcService = { - list$: jest.fn(() => of(results)), - cancel$: jest.fn(() => of({})), - }; - - const cachedResults = { results: [{ resultId: 'result1' }, { resultId: 'result2' }], total: 2 }; - const mockCacheService = { - get: jest.fn(() => cachedResults), - save: jest.fn() + const mockResultsDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [], + refresh$: { + next: jest.fn() + }, }; beforeEach(() => { component = TestBed.configureTestingModule({ providers: [ ResultsTableComponent, - { provide: ResultsIndexService, useValue: mockResultsIndexService }, - { provide: ResultsGrpcService, useValue: mockResultsGrpcService }, ResultsStatusesService, - FiltersService, - { provide: CacheService, useValue: mockCacheService }, { provide: NotificationService, useValue: mockNotificationService }, { provide: Clipboard, useValue: mockClipBoard }, + { provide: ResultsDataService, useValue: mockResultsDataService } ] }).inject(ResultsTableComponent); component.displayedColumns = displayedColumns; - component.filters$ = new BehaviorSubject([]); - component.options = { - pageIndex: 0, - pageSize: 10, - sort: { - active: 'resultId', - direction: 'desc' - } - }; - component.refresh$ = new Subject(); - component.loading = signal(false); - component.ngOnInit(); - component.ngAfterViewInit(); }); it('should run', () => { expect(component).toBeTruthy(); }); - describe('initialisation', () => { - it('should load cached data', () => { - expect(mockCacheService.get).toHaveBeenCalled(); - }); - }); - - describe('loadFromCache', () => { - beforeEach(() => { - component.loadFromCache(); - }); - - it('should update total data with cached one', () => { - expect(component.total).toEqual(cachedResults.total); - }); - - it('should update data with cached one', () => { - expect(component.data()).toEqual([ - { - raw: { - resultId: 'result1' - } - }, - { - raw: { - resultId: 'result2' - } - }, - ]); - }); - }); - - it('should update data on refresh', () => { - component.refresh$.next(); - expect(component.data()).toEqual([ - { - raw: { - resultId: 'result1' - } - }, - { - raw: { - resultId: 'result2' - } - }, - { - raw: { - resultId: 'result3' - } - } - ]); - }); - - it('should cache received data', () => { - component.refresh$.next(); - expect(mockCacheService.get).toHaveBeenCalled(); - }); - - it('should return columns keys', () => { - expect(component.columnKeys).toEqual(displayedColumns.map(column => column.key)); - }); - - describe('on list error', () => { - beforeEach(() => { - mockResultsGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); - }); - - it('should log error', () => { - const spy = jest.spyOn(console, 'error').mockImplementation(() => { }); - component.refresh$.next(); - expect(spy).toHaveBeenCalled(); - }); - - it('should send a notification', () => { - component.refresh$.next(); - expect(mockNotificationService.error).toHaveBeenCalled(); - }); - - it('should send empty data', () => { - component.refresh$.next(); - expect(component.data()).toEqual([]); - }); - }); - describe('options changes', () => { it('should refresh data', () => { - const spy = jest.spyOn(component.refresh$, 'next'); + const spy = jest.spyOn(component.optionsUpdate, 'emit'); component.onOptionsChange(); expect(spy).toHaveBeenCalled(); }); - - it('should save options', () => { - component.onOptionsChange(); - expect(mockResultsIndexService.saveOptions).toHaveBeenCalled(); - }); }); test('onDrop should call ResultsIndexService', () => { const newColumns: ColumnKey[] = ['actions', 'resultId', 'status']; + const spy = jest.spyOn(component.columnUpdate, 'emit'); component.onDrop(newColumns); - expect(mockResultsIndexService.saveColumns).toHaveBeenCalledWith(newColumns); - }); - - test('createSessionIdQueryParams should return query params with correct session', () => { - const sessionId = 'session1'; - const result = component.createSessionIdQueryParams(sessionId); - expect(result).toEqual({ - '1-root-1-0': sessionId - }); + expect(spy).toHaveBeenCalledWith(newColumns); }); describe('isDataRawEqual', () => { @@ -242,4 +110,28 @@ describe('TasksTableComponent', () => { const result = {raw: { resultId: 'result' }} as ResultData; expect(component.trackBy(0, result)).toEqual(result.raw.resultId); }); + + it('should get data', () => { + expect(component.data).toEqual(mockResultsDataService.data); + }); + + it('should get total', () => { + expect(component.total).toEqual(mockResultsDataService.total); + }); + + it('should get options', () => { + expect(component.options).toEqual(mockResultsDataService.options); + }); + + it('should get filters', () => { + expect(component.filters).toEqual(mockResultsDataService.filters); + }); + + it('should get column keys', () => { + expect(component.columnKeys).toEqual(displayedColumns.map(c => c.key)); + }); + + it('should get displayedColumns', () => { + expect(component.displayedColumns).toEqual(displayedColumns); + }); }); \ No newline at end of file diff --git a/src/app/results/components/table.component.ts b/src/app/results/components/table.component.ts index 1cb865bb6..cc9fb1520 100644 --- a/src/app/results/components/table.component.ts +++ b/src/app/results/components/table.component.ts @@ -1,16 +1,10 @@ -import { FilterStringOperator, ListResultsResponse, ResultRawEnumField, SessionRawEnumField } from '@aneoconsultingfr/armonik.api.angular'; -import { AfterViewInit, Component, OnInit, inject } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; +import { ResultRawEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { Component, inject } from '@angular/core'; import { AbstractTableComponent } from '@app/types/components/table'; -import { Scope } from '@app/types/config'; -import { ArmonikData, ResultData } from '@app/types/data'; +import { ArmonikData } from '@app/types/data'; import { TableComponent } from '@components/table/table.component'; -import { FiltersService } from '@services/filters.service'; -import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; import { NotificationService } from '@services/notification.service'; -import { ResultsFiltersService } from '../services/results-filters.service'; -import { ResultsGrpcService } from '../services/results-grpc.service'; -import { ResultsIndexService } from '../services/results-index.service'; +import ResultsDataService from '../services/results-data.service'; import { ResultsStatusesService } from '../services/results-statuses.service'; import { ResultRaw } from '../types'; @@ -19,55 +13,20 @@ import { ResultRaw } from '../types'; standalone: true, templateUrl: './table.component.html', providers: [ - ResultsGrpcService, - ResultsIndexService, - MatDialog, - FiltersService, - NotificationService, ResultsStatusesService, - ResultsFiltersService, - GrpcSortFieldService, + NotificationService, ], imports: [ TableComponent, ] }) -export class ResultsTableComponent extends AbstractTableComponent - implements OnInit, AfterViewInit { - scope: Scope = 'results'; - readonly grpcService = inject(ResultsGrpcService); - readonly indexService = inject(ResultsIndexService); +export class ResultsTableComponent extends AbstractTableComponent { + readonly tableDataService = inject(ResultsDataService); readonly statusesService = inject(ResultsStatusesService); - ngOnInit(): void { - this.initTable(); - } - - ngAfterViewInit(): void { - this.subscribeToData(); - } - - createSessionIdQueryParams(sessionId: string) { - const keySession = this.filtersService.createQueryParamsKey(1, 'root', FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID); - - return { - [keySession]: sessionId, - }; - } - - computeGrpcData(entries: ListResultsResponse): ResultRaw[] | undefined { - return entries.results; - } - isDataRawEqual(value: ResultRaw, entry: ResultRaw): boolean { return value.resultId === entry.resultId; } - - createNewLine(entry: ResultRaw): ResultData { - return { - raw: entry, - }; - } trackBy(index: number, item: ArmonikData): string | number { return item.raw.resultId; diff --git a/src/app/results/index.component.html b/src/app/results/index.component.html index 13c385d12..658c38b5d 100644 --- a/src/app/results/index.component.html +++ b/src/app/results/index.component.html @@ -6,14 +6,14 @@ \ No newline at end of file diff --git a/src/app/results/index.component.spec.ts b/src/app/results/index.component.spec.ts index cd661f37e..274e542d3 100644 --- a/src/app/results/index.component.spec.ts +++ b/src/app/results/index.component.spec.ts @@ -13,18 +13,14 @@ import { NotificationService } from '@services/notification.service'; import { ShareUrlService } from '@services/share-url.service'; import { of } from 'rxjs'; import { IndexComponent } from './index.component'; +import ResultsDataService from './services/results-data.service'; import { ResultsFiltersService } from './services/results-filters.service'; -import { ResultsGrpcService } from './services/results-grpc.service'; import { ResultsIndexService } from './services/results-index.service'; import { ResultRaw } from './types'; describe('Results Index Component', () => { let component: IndexComponent; - const mockResultsGrpcService = { - cancel$: jest.fn(() => of()), - }; - const newCustomColumns: CustomColumn[] = ['options.options.FastCompute', 'options.options.NewCustom']; const mockMatDialog = { @@ -87,6 +83,12 @@ describe('Results Index Component', () => { key: 'ownerTaskId', sortable: true, }, + { + name: $localize`Select`, + key: 'select', + type: 'select', + sortable: false, + }, ]; const defaultIntervalValue = 10; @@ -114,7 +116,7 @@ describe('Results Index Component', () => { resetColumns: jest.fn(() => defaultColumns), }; - const mockTaskFiltersService = { + const mockResultsFiltersService = { restoreFilters: jest.fn(() => []), saveFilters: jest.fn(), resetFilters: jest.fn(() => []), @@ -132,6 +134,17 @@ describe('Results Index Component', () => { warning: jest.fn(), }; + const mockResultsDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [], + refresh$: { + next: jest.fn() + }, + }; + beforeEach(() => { component = TestBed.configureTestingModule({ providers: [ @@ -139,11 +152,11 @@ describe('Results Index Component', () => { IconsService, AutoRefreshService, { provide: ResultsIndexService, useValue: mockResultsIndexService }, - { provide: ResultsGrpcService, useValue: mockResultsGrpcService }, + { provide: ResultsDataService, useValue: mockResultsDataService }, { provide: MatDialog, useValue: mockMatDialog }, { provide: DashboardIndexService, useValue: mockDashboardIndexService }, { provide: Router, useValue: mockRouter }, - { provide: ResultsFiltersService, useValue: mockTaskFiltersService }, + { provide: ResultsFiltersService, useValue: mockResultsFiltersService }, { provide: ShareUrlService, useValue: mockShareUrlService }, { provide: NotificationService, useValue: mockNotificationService }, ] @@ -156,6 +169,10 @@ describe('Results Index Component', () => { expect(component).toBeTruthy(); }); + it('should load properly', () => { + expect(component.loading).toEqual(mockResultsDataService.loading); + }); + it('should update columns keys', () => { component.updateDisplayedColumns(); expect(component.displayedColumnsKeys).toEqual(defaultColumns); @@ -197,7 +214,6 @@ describe('Results Index Component', () => { it('should initialise filters', () => { expect(component.filters).toEqual([]); - expect(component.filters$).toBeDefined(); }); it('should init options', () => { @@ -221,9 +237,8 @@ describe('Results Index Component', () => { }); it('should refresh', () => { - const spy = jest.spyOn(component.refresh$, 'next'); - component.onRefresh(); - expect(spy).toHaveBeenCalled(); + component.refresh(); + expect(mockResultsDataService.refresh$.next).toHaveBeenCalled(); }); describe('On interval value change', () => { @@ -239,9 +254,8 @@ describe('Results Index Component', () => { }); it('should refresh if the value is not null', () => { - const spy = jest.spyOn(component.refresh$, 'next'); component.onIntervalValueChange(5); - expect(spy).toHaveBeenCalled(); + expect(mockResultsDataService.refresh$.next).toHaveBeenCalled(); }); it('should stop the interval if the value is 0', () => { @@ -256,18 +270,38 @@ describe('Results Index Component', () => { }); }); + describe('On Options Change', () => { + beforeEach(() => { + component.onOptionsChange(); + }); + + it('should save options', () => { + expect(mockResultsIndexService.saveOptions).toHaveBeenCalledWith(mockResultsDataService.options); + }); + + it('should refresh', () => { + expect(mockResultsDataService.refresh$.next).toHaveBeenCalled(); + }); + }); + describe('On columns change', () => { - const newColumns: ColumnKey[] = ['resultId', 'createdAt']; + const newColumns: ColumnKey[] = ['resultId', 'createdAt', 'select']; beforeEach(() => { component.onColumnsChange(newColumns); }); it('should update displayed column keys', () => { - expect(component.displayedColumnsKeys).toEqual(newColumns); + expect(component.displayedColumnsKeys).toEqual(['select', 'resultId', 'createdAt']); }); it('should update displayed columns', () => { expect(component.displayedColumns()).toEqual([ + { + name: $localize`Select`, + key: 'select', + type: 'select', + sortable: false, + }, { name: $localize`Result ID`, key: 'resultId', @@ -285,7 +319,7 @@ describe('Results Index Component', () => { }); it('should save columns', () => { - expect(mockResultsIndexService.saveColumns).toHaveBeenCalledWith(['resultId', 'createdAt']); + expect(mockResultsIndexService.saveColumns).toHaveBeenCalledWith(['select', 'resultId', 'createdAt']); }); }); @@ -329,7 +363,6 @@ describe('Results Index Component', () => { }); describe('On Filters Change', () => { - const newFilters: FiltersOr = [ [ { @@ -341,10 +374,7 @@ describe('Results Index Component', () => { ] ]; - let filterSpy: jest.SpyInstance; - beforeEach(() => { - filterSpy = jest.spyOn(component.filters$, 'next'); component.onFiltersChange(newFilters); }); @@ -353,23 +383,16 @@ describe('Results Index Component', () => { }); it('should save filters', () => { - expect(mockTaskFiltersService.saveFilters).toHaveBeenCalledWith(newFilters); + expect(mockResultsFiltersService.saveFilters).toHaveBeenCalledWith(newFilters); }); it('should update page index', () => { expect(component.options.pageIndex).toEqual(0); }); - - it('should emit filters', () => { - expect(filterSpy).toHaveBeenCalledWith(newFilters); - }); }); describe('On Filter Reset', () => { - let filterSpy: jest.SpyInstance; - beforeEach(() => { - filterSpy = jest.spyOn(component.filters$, 'next'); component.onFiltersReset(); }); @@ -380,10 +403,6 @@ describe('Results Index Component', () => { it('should reset page index', () => { expect(component.options.pageIndex).toEqual(0); }); - - it('should emit empty filters', () => { - expect(filterSpy).toHaveBeenCalledWith([]); - }); }); describe('On lockColumns Change', () => { @@ -446,7 +465,7 @@ describe('Results Index Component', () => { it('should save show filters', () => { const newShowFilters = true; component.onShowFiltersChange(newShowFilters); - expect(mockTaskFiltersService.saveShowFilters).toHaveBeenCalledWith(newShowFilters); + expect(mockResultsFiltersService.saveShowFilters).toHaveBeenCalledWith(newShowFilters); }); }); }); \ No newline at end of file diff --git a/src/app/results/index.component.ts b/src/app/results/index.component.ts index d897f0aaa..44bf383de 100644 --- a/src/app/results/index.component.ts +++ b/src/app/results/index.component.ts @@ -15,6 +15,8 @@ import { FiltersToolbarComponent } from '@components/filters/filters-toolbar.com import { PageHeaderComponent } from '@components/page-header.component'; import { TableIndexActionsToolbarComponent } from '@components/table-index-actions-toolbar.component'; import { AutoRefreshService } from '@services/auto-refresh.service'; +import { FiltersService } from '@services/filters.service'; +import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; import { NotificationService } from '@services/notification.service'; import { QueryParamsService } from '@services/query-params.service'; import { ShareUrlService } from '@services/share-url.service'; @@ -24,12 +26,13 @@ import { TableURLService } from '@services/table-url.service'; import { TableService } from '@services/table.service'; import { UtilsService } from '@services/utils.service'; import { ResultsTableComponent } from './components/table.component'; +import ResultsDataService from './services/results-data.service'; import { ResultsFiltersService } from './services/results-filters.service'; +import { ResultsGrpcService } from './services/results-grpc.service'; import { ResultsIndexService } from './services/results-index.service'; import { ResultsStatusesService } from './services/results-statuses.service'; import { ResultRaw } from './types'; - @Component({ selector: 'app-results-index', templateUrl: './index.component.html', @@ -54,6 +57,10 @@ import { ResultRaw } from './types'; DashboardIndexService, DashboardStorageService, TasksStatusesService, + ResultsDataService, + ResultsGrpcService, + GrpcSortFieldService, + FiltersService, ], imports: [ PageHeaderComponent, @@ -68,7 +75,7 @@ import { ResultRaw } from './types'; ] }) export class IndexComponent extends TableHandler implements OnInit, AfterViewInit, OnDestroy { - + readonly tableDataService = inject(ResultsDataService); readonly filtersService = inject(ResultsFiltersService); readonly indexService = inject(ResultsIndexService); diff --git a/src/app/results/services/results-data.service.spec.ts b/src/app/results/services/results-data.service.spec.ts new file mode 100644 index 000000000..89051735f --- /dev/null +++ b/src/app/results/services/results-data.service.spec.ts @@ -0,0 +1,167 @@ +import { ResultRawEnumField, ListResultsResponse } from '@aneoconsultingfr/armonik.api.angular'; +import { TestBed } from '@angular/core/testing'; +import { FiltersOr } from '@app/types/filters'; +import { ListOptions } from '@app/types/options'; +import { GrpcStatusEvent } from '@ngx-grpc/common'; +import { CacheService } from '@services/cache.service'; +import { FiltersService } from '@services/filters.service'; +import { NotificationService } from '@services/notification.service'; +import { of, throwError } from 'rxjs'; +import ResultsDataService from './results-data.service'; +import { ResultRaw } from '../types'; +import { ResultsGrpcService } from './results-grpc.service'; + +describe('ResultsDataService', () => { + let service: ResultsDataService; + + const cachedResults = { results: [{ resultId: 'result1' }, { resultId: 'result2' }], total: 2 } as unknown as ListResultsResponse; + const mockCacheService = { + get: jest.fn(() => cachedResults), + save: jest.fn(), + }; + + const results = { results: [{ resultId: 'result1' }, { resultId: 'result2' }, { resultId: 'result3' }], total: 3 } as unknown as ListResultsResponse; + const mockResultsGrpcService = { + list$: jest.fn(() => of(results)), + }; + + const mockNotificationService = { + success: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }; + + const initialOptions: ListOptions = { + pageIndex: 0, + pageSize: 10, + sort: { + active: 'name', + direction: 'desc' + } + }; + + const initialFilters: FiltersOr = []; + + beforeEach(() => { + service = TestBed.configureTestingModule({ + providers: [ + ResultsDataService, + FiltersService, + { provide: ResultsGrpcService, useValue: mockResultsGrpcService }, + { provide: NotificationService, useValue: mockNotificationService }, + { provide: CacheService, useValue: mockCacheService }, + ] + }).inject(ResultsDataService); + service.options = initialOptions; + service.filters = initialFilters; + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('initialisation', () => { + it('should load data from the cache', () => { + expect(service.data).toEqual([ + { + raw: { + resultId: 'result1' + } + }, + { + raw: { + resultId: 'result2' + } + }, + ]); + }); + + it('should set the total cached data', () => { + expect(service.total).toEqual(cachedResults.total); + }); + }); + + describe('Fetching data', () => { + it('should list the data', () => { + service.refresh$.next(); + expect(mockResultsGrpcService.list$).toHaveBeenCalledWith(service.options, service.filters); + }); + + it('should update the total', () => { + service.refresh$.next(); + expect(service.total).toEqual(results.total); + }); + + it('should update the data', () => { + service.refresh$.next(); + expect(service.data).toEqual([ + { + raw: { + resultId: 'result1' + } + }, + { + raw: { + resultId: 'result2' + } + }, + { + raw: { + resultId: 'result3' + } + } + ]); + }); + + it('should handle an empty DataRaw', () => { + const emptyResults = { results: undefined, total: 0 } as unknown as ListResultsResponse; + mockResultsGrpcService.list$.mockReturnValueOnce(of(emptyResults)); + service.refresh$.next(); + expect(service.data).toEqual([]); + }); + + it('should catch errors', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockResultsGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); + service.refresh$.next(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('should notify errors', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + mockResultsGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); + service.refresh$.next(); + expect(mockNotificationService.error).toHaveBeenCalled(); + }); + + it('should cache the raw data', () => { + service.refresh$.next(); + expect(mockCacheService.save).toHaveBeenCalledWith(service.scope, results); + }); + }); + + it('should display a success message', () => { + const message = 'A success message !'; + service.success(message); + expect(mockNotificationService.success).toHaveBeenCalledWith(message); + }); + + it('should display a warning message', () => { + const message = 'A warning message !'; + service.warning(message); + expect(mockNotificationService.warning).toHaveBeenCalledWith(message); + }); + + it('should display an error message', () => { + const error: GrpcStatusEvent = { + statusMessage: 'A error status message' + } as GrpcStatusEvent; + jest.spyOn(console, 'error').mockImplementation(() => {}); + service.error(error); + expect(mockNotificationService.error).toHaveBeenCalledWith(error.statusMessage); + }); + + it('should load correctly', () => { + expect(service.loading).toBeFalsy(); + }); +}); \ No newline at end of file diff --git a/src/app/results/services/results-data.service.ts b/src/app/results/services/results-data.service.ts new file mode 100644 index 000000000..94036ec65 --- /dev/null +++ b/src/app/results/services/results-data.service.ts @@ -0,0 +1,24 @@ +import { ListResultsResponse, ResultRawEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { Injectable, inject } from '@angular/core'; +import { Scope } from '@app/types/config'; +import { ResultData } from '@app/types/data'; +import { AbstractTableDataService } from '@app/types/services/table-data.service'; +import { ResultRaw } from '../types'; +import { ResultsGrpcService } from './results-grpc.service'; + +@Injectable() +export default class ResultsDataService extends AbstractTableDataService { + readonly grpcService = inject(ResultsGrpcService); + + scope: Scope = 'results'; + + computeGrpcData(entries: ListResultsResponse): ResultRaw[] | undefined { + return entries.results; + } + + createNewLine(entry: ResultRaw): ResultData { + return { + raw: entry, + }; + } +} \ No newline at end of file diff --git a/src/app/sessions/components/table.component.html b/src/app/sessions/components/table.component.html index 75f90427a..6784db80d 100644 --- a/src/app/sessions/components/table.component.html +++ b/src/app/sessions/components/table.component.html @@ -1,4 +1,3 @@ - + (columnDrop)="onDrop($event)" (optionsChange)="onOptionsChange()" (personnalizeTasksByStatus)="personalizeTasksByStatus()" /> diff --git a/src/app/sessions/components/table.component.spec.ts b/src/app/sessions/components/table.component.spec.ts index 00f3e35d9..b5fee52c5 100644 --- a/src/app/sessions/components/table.component.spec.ts +++ b/src/app/sessions/components/table.component.spec.ts @@ -1,6 +1,5 @@ -import { FilterDateOperator, FilterNumberOperator, FilterStatusOperator, FilterStringOperator, SessionRawEnumField, SessionStatus, SessionTaskOptionEnumField, TaskOptionEnumField, TaskStatus, TaskSummaryEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { SessionStatus, TaskStatus } from '@aneoconsultingfr/armonik.api.angular'; import { Clipboard } from '@angular/cdk/clipboard'; -import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; @@ -8,17 +7,14 @@ import { ManageGroupsDialogResult, TasksStatusesGroup } from '@app/dashboard/typ import { TaskOptions } from '@app/tasks/types'; import { TableColumn } from '@app/types/column.type'; import { ColumnKey, SessionData } from '@app/types/data'; -import { FiltersOr } from '@app/types/filters'; import { ActionTable } from '@app/types/table'; -import { Timestamp } from '@ngx-grpc/well-known-types'; import { CacheService } from '@services/cache.service'; import { FiltersService } from '@services/filters.service'; import { NotificationService } from '@services/notification.service'; import { TasksByStatusService } from '@services/tasks-by-status.service'; -import { BehaviorSubject, Observable, Subject, of, throwError } from 'rxjs'; +import { of } from 'rxjs'; import { SessionsTableComponent } from './table.component'; -import { SessionsGrpcService } from '../services/sessions-grpc.service'; -import { SessionsIndexService } from '../services/sessions-index.service'; +import { SessionsDataService } from '../services/sessions-data.service'; import { SessionsStatusesService } from '../services/sessions-statuses.service'; import { SessionRaw } from '../types'; @@ -57,59 +53,32 @@ describe('SessionsTableComponent', () => { } ]; - const mockSessionsIndexService = { - isActionsColumn: jest.fn(), - isSessionIdColumn: jest.fn(), - isStatusColumn: jest.fn(), - isDateColumn: jest.fn(), - isDurationColumn: jest.fn(), - isObjectColumn: jest.fn(), - isSelectColumn: jest.fn(), - isSimpleColumn: jest.fn(), - isNotSortableColumn: jest.fn(), - columnToLabel: jest.fn(), - saveColumns: jest.fn(), - saveOptions: jest.fn(), + const mockSessionsDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [], + refresh$: { + next: jest.fn() + }, + onPause: jest.fn(), + onResume: jest.fn(), + onCancel: jest.fn(), + onPurge: jest.fn(), + onClose: jest.fn(), + onDelete: jest.fn(), }; const mockNotificationService = { success: jest.fn(), error: jest.fn(), - warning: jest.fn(), }; const mockClipBoard = { copy: jest.fn() }; - const sessionData = { sessions: [{ sessionId: 'session1' }, { sessionId: 'session2' }, { sessionId: 'session3' }] as SessionRaw[], total: 3 }; - const taskCreatedAt: {sessionId: string, date: Timestamp | undefined} = { - sessionId: 'sessionId', - date: { - seconds: '1620000000', - nanos: 0 - } as Timestamp - }; - - const taskEndedAt: {sessionId: string, date: Timestamp | undefined} = { - sessionId: 'sessionId', - date: { - seconds: '1620001000', - nanos: 0 - } as Timestamp - }; - - const mockSessionsGrpcService = { - list$: jest.fn((): Observable<{ sessions: { sessionId: string; }[]; total: number; } | null> => of(sessionData)), - cancel$: jest.fn(() => of({})), - getTaskData$: jest.fn(), - pause$: jest.fn(() => of({})), - resume$: jest.fn(() => of({})), - close$: jest.fn(() => of({})), - delete$: jest.fn(() => of({})), - purge$: jest.fn(() => of({})), - }; - const mockTasksByStatusService = { restoreStatuses: jest.fn(() => defaultStatusesGroups), saveStatuses: jest.fn() @@ -165,12 +134,11 @@ describe('SessionsTableComponent', () => { component = TestBed.configureTestingModule({ providers: [ SessionsTableComponent, - { provide: SessionsIndexService, useValue: mockSessionsIndexService }, - { provide: SessionsGrpcService, useValue: mockSessionsGrpcService }, + { provide: SessionsDataService, useValue: mockSessionsDataService }, + { provide: NotificationService, useValue: mockNotificationService }, SessionsStatusesService, FiltersService, { provide: CacheService, useValue: mockCacheService }, - { provide: NotificationService, useValue: mockNotificationService }, { provide: Clipboard, useValue: mockClipBoard }, { provide: TasksByStatusService, useValue: mockTasksByStatusService}, { provide: MatDialog, useValue: mockMatDialog }, @@ -179,610 +147,28 @@ describe('SessionsTableComponent', () => { }).inject(SessionsTableComponent); component.displayedColumns = displayedColumns; - component.filters$ = new BehaviorSubject>([]); - component.options = { - pageIndex: 0, - pageSize: 10, - sort: { - active: 'sessionId', - direction: 'desc' - } - }; - component.refresh$ = new Subject(); - component.loading = signal(false); component.ngOnInit(); - component.ngAfterViewInit(); }); it('should run', () => { expect(component).toBeTruthy(); }); - describe('initialisation', () =>{ - it('should load cached data from cachedService', () => { - expect(mockCacheService.get).toHaveBeenCalled(); - }); - }); - - describe('loadFromCache', () => { - beforeEach(() => { - component.loadFromCache(); - }); - - it('should update total data with cached one', () => { - expect(component.total).toEqual(cachedSession.total); - }); - - it('should update data with cached one', () => { - const map1 = new Map(); - const map2 = new Map(); - map1.set('sessionId', {'0-root-1-0': 'session1'}); - map2.set('sessionId', {'0-root-1-0': 'session2'}); - expect(component.data()).toEqual([ - { - raw: { - sessionId: 'session1' - }, - queryParams: map1, - resultsQueryParams: { - '0-root-1-0': 'session1' - }, - queryTasksParams: { - '0-root-1-0': 'session1' - }, - filters: [[{for: 'root', field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'session1'}]] - }, - { - raw: { - sessionId: 'session2' - }, - queryParams: map2, - resultsQueryParams: { - '0-root-1-0': 'session2' - }, - queryTasksParams: { - '0-root-1-0': 'session2' - }, - filters: [[{for: 'root', field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'session2'}]] - }, - ]); - }); - }); - it('should return columns keys', () => { expect(component.columnKeys).toEqual(displayedColumns.map(column => column.key)); }); - - it('should create session id query params', () => { - const id = 'sessionId'; - expect(component.createSessionIdQueryParams(id)).toEqual({ - '0-root-1-0': id - }); - }); - - describe('createTasksByStatusQueryParams', () => { - const id = 'sessionId'; - - it('should create simple query params if there is no filters', () => { - expect(component.createTasksByStatusQueryParams(id)).toEqual({ - '0-root-1-0': 'sessionId' - }); - }); - - it('should create a query params with filters', () => { - component.filters = [ - [ - { - for: 'options', - field: SessionTaskOptionEnumField.TASK_OPTION_ENUM_FIELD_MAX_RETRIES, - operator: FilterNumberOperator.FILTER_NUMBER_OPERATOR_GREATER_THAN, - value: 1 - }, - { - for: 'root', // should not appear - field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_STATUS, - operator: FilterStatusOperator.FILTER_STATUS_OPERATOR_EQUAL, - value: SessionStatus.SESSION_STATUS_RUNNING - }, - { - for: 'root', // should not appear - field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_STATUS, - operator: null, - value: SessionStatus.SESSION_STATUS_RUNNING - }, - ], - [ - { - for: 'root', // should not appear - field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, - value: '2313893210' - }, - { - for: 'root', - field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, - value: 'id' - }, - { - for: 'root', // should not appear - field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_CLOSED_AT, - operator: FilterDateOperator.FILTER_DATE_OPERATOR_AFTER_OR_EQUAL, - value: null - }, - ] - ]; - expect(component.createTasksByStatusQueryParams(id)).toEqual({ - '0-root-1-0': id, - '0-options-2-5': '1', - '1-root-1-0': id, - '1-root-1-2': 'id' - }); - }); - }); - - describe('Results query params', () => { - const id = 'sessionId'; - it('should return the sessionId if there is no filter', () => { - expect(component.createResultsQueryParams(id)).toEqual({ - '0-root-1-0': id - }); - }); - - it('should add filters if there is any', () => { - component.filters = [ - [ - { - field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, - for: 'root', - operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, - value: 'session1' - }, - { - field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, - for: 'root', - operator: null, - value: 'shouldNotAppear' - }, - ], - [ - { - field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, - for: 'root', - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, - value: null - }, - { - field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, - for: 'root', - operator: FilterStringOperator.FILTER_STRING_OPERATOR_STARTS_WITH, - value: 'session2' - }, - { - field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, - for: 'root', - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, - value: 'neitherShouldIt' - }, - ] - ]; - expect(component.createResultsQueryParams(id)).toEqual({ - '0-root-1-2': 'session1', - '0-root-1-0': id, - '1-root-1-4': 'session2', - '1-root-1-0': id, - }); - }); - }); - - it('should return the tasks by status filter', () => { - const id = 'sessionId'; - expect(component.countTasksByStatusFilters(id)).toEqual([[{ - for: 'root', - field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, - value: id, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL - }]]); - }); - - it('should update data on refresh', () => { - component.refresh$.next(); - const map1 = new Map(); - const map2 = new Map(); - const map3 = new Map(); - map1.set('sessionId', {'0-root-1-0': 'session1'}); - map2.set('sessionId', {'0-root-1-0': 'session2'}); - map3.set('sessionId', {'0-root-1-0': 'session3'}); - expect(component.data()).toEqual([ - { - raw: { - sessionId: 'session1' - }, - queryParams: map1, - resultsQueryParams: { - '0-root-1-0': 'session1' - }, - queryTasksParams: { - '0-root-1-0': 'session1' - }, - filters: [[{for: 'root', field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'session1'}]] - }, - { - raw: { - sessionId: 'session2' - }, - queryParams: map2, - resultsQueryParams: { - '0-root-1-0': 'session2' - }, - queryTasksParams: { - '0-root-1-0': 'session2' - }, - filters: [[{for: 'root', field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'session2'}]] - }, - { - raw: { - sessionId: 'session3' - }, - queryParams: map3, - resultsQueryParams: { - '0-root-1-0': 'session3' - }, - queryTasksParams: { - '0-root-1-0': 'session3' - }, - filters: [[{for: 'root', field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'session3'}]] - } - ]); - }); - - it('should cache received data', () => { - component.refresh$.next(); - expect(mockCacheService.get).toHaveBeenCalled(); - }); - - it('should have an empty data if it cannot compute GrpcData', () => { - jest.spyOn(component, 'computeGrpcData').mockReturnValue(undefined); - component.refresh$.next(); - expect(component.data()).toEqual([]); - }); - - it('should prepare data before fetching', () => { - component.displayedColumns.push({key: 'duration', name: 'Duration', sortable: true}); - component.options.sort.active = 'duration'; - component.filters = []; - component.prepareBeforeFetching(component.options, component.filters); - const date = new Date(); - date.setDate(date.getDate() - 3); - expect(component.filters).toContainEqual([{ - field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_CREATED_AT, - for: 'root', - operator: FilterDateOperator.FILTER_DATE_OPERATOR_AFTER_OR_EQUAL, - value: Math.floor(date.getTime()/1000) - }]); - }); - - describe('afterDataCreation', () => { - beforeEach(() => { - component.displayedColumns = []; - }); - - it('should update dataRaw if the duration is displayed', () => { - component.displayedColumns.push({key: 'duration', name: 'Duration', sortable: true}); - component.afterDataCreation(sessionData.sessions); - expect(component.dataRaw).toEqual(sessionData.sessions); - }); - - it('should call the nextStartDuration$ subject', () => { - const spy = jest.spyOn(component.nextStartDuration$, 'next'); - component.displayedColumns.push({key: 'duration', name: 'Duration', sortable: true}); - component.afterDataCreation(sessionData.sessions); - expect(spy).toHaveBeenCalledTimes(sessionData.sessions.length); - }); - - it('should call the nextEndDuration$ subject', () => { - const spy = jest.spyOn(component.nextEndDuration$, 'next'); - component.displayedColumns.push({key: 'duration', name: 'Duration', sortable: true}); - component.afterDataCreation(sessionData.sessions); - expect(spy).toHaveBeenCalledTimes(sessionData.sessions.length); - }); - }); - - describe('nextDuration piping', () => { - const sessionId = 'sessionId'; - let spy: jest.SpyInstance; - - beforeEach(() => { - spy = jest.spyOn(component, 'durationSubscription'); - }); - - it('should get the task start data', () => { - component.nextStartDuration$.next(sessionId); - expect(mockSessionsGrpcService.getTaskData$).toHaveBeenCalledWith(sessionId, 'createdAt', 'asc'); - }); - - it('should get the task end data', () => { - component.nextEndDuration$.next(sessionId); - expect(mockSessionsGrpcService.getTaskData$).toHaveBeenCalledWith(sessionId, 'endedAt', 'desc'); - }); - - it('should subscribe with createdAt', () => { - mockSessionsGrpcService.getTaskData$.mockReturnValueOnce(of(taskCreatedAt)); - component.nextStartDuration$.next(sessionId); - expect(spy).toHaveBeenCalledWith(taskCreatedAt, 'created'); - }); - - it('should subscribe with endedAt', () => { - mockSessionsGrpcService.getTaskData$.mockReturnValueOnce(of(taskEndedAt)); - component.nextEndDuration$.next(sessionId); - expect(spy).toHaveBeenCalledWith(taskEndedAt, 'ended'); - }); - }); - - it('should check if the duration is displayed', () => { - component.displayedColumns = []; - expect(component.isDurationDisplayed()).toBe(false); - component.displayedColumns.push({key: 'duration', name: 'Duration', sortable: true}); - expect(component.isDurationDisplayed()).toBe(true); - }); - - describe('filterHadCreatedAt', () => { - it('should return true if the filters contain a "createdAt" filter', () => { - const filters: FiltersOr = [[{ - field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_CREATED_AT, - for: 'root', - operator: FilterDateOperator.FILTER_DATE_OPERATOR_AFTER_OR_EQUAL, - value: '123456' - }]]; - expect(component.filterHasCreatedAt(filters)).toBe(true); - }); - - it('should return false if the filters does not contain a "createdAt" filter', () => { - expect(component.filterHasCreatedAt([])).toBe(false); - }); - }); - - describe('orderByDuration', () => { - const sessionsWithDuration: SessionRaw[] = [ - { - sessionId: 'biggest', - duration: { - nanos: 0, - seconds: '10000' - } - }, - { - sessionId: 'smallest', - duration: { - nanos: 0, - seconds: '1000' - } - }, - { - sessionId: 'middle', - duration: { - nanos: 0, - seconds: '5000' - } - } - ] as SessionRaw[]; - - it('should order ascendantly', () => { - component.options.sort.direction = 'asc'; - component.orderByDuration(sessionsWithDuration); - expect(component.data().map(d => d.raw.sessionId)).toEqual(['smallest', 'middle', 'biggest']); - }); - - it('should order descendently', () => { - component.options.sort.direction = 'desc'; - component.orderByDuration(sessionsWithDuration); - expect(component.data().map(d => d.raw.sessionId)).toEqual(['biggest', 'middle', 'smallest']); - }); - - it('should slice data to have a length equal to the page size', () => { - component.options.sort.direction = 'asc'; - component.options.pageSize = 2; - component.orderByDuration(sessionsWithDuration); - expect(component.data().length).toEqual(2); - }); - }); - - describe('durationSubscription', () => { - it('should push the end dates to the endedDates array', () => { - component.durationSubscription(taskCreatedAt, 'created'); - expect(component.sessionCreationDates).toContainEqual(taskCreatedAt); - }); - - it('should push the starting dates to the creationDates array', () => { - component.durationSubscription(taskEndedAt, 'ended'); - expect(component.sessionEndedDates).toContainEqual(taskEndedAt); - }); - - it('should call computeDuration', () => { - const spy = jest.spyOn(component.computeDuration$, 'next'); - component.durationSubscription(taskCreatedAt, 'created'); - expect(spy).toHaveBeenCalled(); - }); - }); - - describe('computationErrorNotification', () => { - const sessionId = 'sessionId'; - beforeEach(() => { - component.computationErrorNotification(sessionId); - }); - - it('should send a notification on computation error', () => { - expect(mockNotificationService.warning).toHaveBeenCalledWith(`Error while computing duration for session: ${sessionId}`); - }); - - it('should send the notification only one time', () => { - expect(mockNotificationService.warning).toHaveBeenCalledTimes(1); - }); - }); - - describe('compute duration', () => { - it('should not compute if the ended and created array have not the same length as the dataRaw array', () => { - component.loading.set(true); // We are mocking the fact that the component is loading - component.computeDuration$.next(); - expect(component.loading()).toBeTruthy(); - }); - - it('should compute the duration for a session', () => { - component.isDurationSorted = false; - component.dataRaw = [{sessionId: 'sessionId'}] as SessionRaw[]; - component.durationSubscription(taskCreatedAt, 'created'); - component.durationSubscription(taskEndedAt, 'ended'); - const selectedData = component.data().map(d => { - return { sessionId: d.raw.sessionId, duration: d.raw.duration }; - }); - expect(selectedData).toEqual( - [ - { - sessionId: 'sessionId', - duration: { - seconds: (Number(taskEndedAt.date?.seconds) - Number(taskCreatedAt.date?.seconds)).toString(), - nanos: 0 - } - } - ] - ); - }); - - it('should also order by duration', () => { - component.isDurationSorted = true; - const spy = jest.spyOn(component, 'orderByDuration'); - component.dataRaw = [{sessionId: 'sessionId'}] as SessionRaw[]; - component.durationSubscription(taskCreatedAt, 'created'); - component.durationSubscription(taskEndedAt, 'ended'); - expect(spy).toHaveBeenCalled(); - }); - - it('should log error if there is a computation error', () => { - const spy = jest.spyOn(component, 'computationErrorNotification'); - component.dataRaw = [{sessionId: 'sessionId'}] as SessionRaw[]; - component.durationSubscription({sessionId: 'otherSession', date: {seconds: '10', nanos: 0} as Timestamp}, 'created'); - component.durationSubscription(taskEndedAt, 'ended'); - expect(spy).toHaveBeenCalledWith('sessionId'); - }); - }); - - describe('on list error', () => { - beforeEach(() => { - mockSessionsGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); - }); - - it('should log error', () => { - const spy = jest.spyOn(console, 'error').mockImplementation(() => { }); - component.refresh$.next(); - expect(spy).toHaveBeenCalled(); - }); - - it('should send a notification', () => { - component.refresh$.next(); - expect(mockNotificationService.error).toHaveBeenCalled(); - }); - - it('should send empty data', () => { - component.refresh$.next(); - expect(component.data()).toEqual([]); - }); - }); - describe('options changes', () => { - it('should refresh data', () => { - const spy = jest.spyOn(component.refresh$, 'next'); - component.onOptionsChange(); - expect(spy).toHaveBeenCalled(); - }); - - it('should save options', () => { - component.onOptionsChange(); - expect(mockSessionsIndexService.saveOptions).toHaveBeenCalled(); - }); + it('should emit on options changes', () => { + const spy = jest.spyOn(component.optionsUpdate, 'emit'); + component.onOptionsChange(); + expect(spy).toHaveBeenCalled(); }); - describe('on Pause', () => { - it('should refresh on success', () => { - const spy = jest.spyOn(component.refresh$, 'next'); - component.onPause('sessionId'); - expect(spy).toHaveBeenCalled(); - }); - - it('should notify on error', () => { - mockSessionsGrpcService.pause$.mockReturnValueOnce(throwError(() => new Error())); - component.onPause('sessionId'); - expect(mockNotificationService.error).toHaveBeenCalledWith('Unable to pause session'); - }); - }); - - describe('on Resume', () => { - it('should refresh on success', () => { - const spy = jest.spyOn(component.refresh$, 'next'); - component.onResume('sessionId'); - expect(spy).toHaveBeenCalled(); - }); - - it('should notify on error', () => { - mockSessionsGrpcService.resume$.mockReturnValueOnce(throwError(() => new Error())); - component.onResume('sessionId'); - expect(mockNotificationService.error).toHaveBeenCalledWith('Unable to resume session'); - }); - }); - - describe('on purge', () => { - it('should refresh on success', () => { - const spy = jest.spyOn(component.refresh$, 'next'); - component.onPurge('sessionId'); - expect(spy).toHaveBeenCalled(); - }); - - it('should notify on error', () => { - mockSessionsGrpcService.purge$.mockReturnValueOnce(throwError(() => new Error())); - component.onPurge('sessionId'); - expect(mockNotificationService.error).toHaveBeenCalledWith('Unable to purge session'); - }); - }); - - describe('on Cancel', () => { - it('should refresh on success', () => { - const spy = jest.spyOn(component.refresh$, 'next'); - component.onCancel('sessionId'); - expect(spy).toHaveBeenCalled(); - }); - - it('should notify on error', () => { - mockSessionsGrpcService.cancel$.mockReturnValueOnce(throwError(() => new Error())); - component.onCancel('sessionId'); - expect(mockNotificationService.error).toHaveBeenCalledWith('Unable to cancel session'); - }); - }); - - describe('on Close', () => { - it('should refresh on success', () => { - const spy = jest.spyOn(component.refresh$, 'next'); - component.onClose('sessionId'); - expect(spy).toHaveBeenCalled(); - }); - - it('should notify on error', () => { - mockSessionsGrpcService.close$.mockReturnValueOnce(throwError(() => new Error())); - component.onClose('sessionId'); - expect(mockNotificationService.error).toHaveBeenCalledWith('Unable to close session'); - }); - }); - - describe('on Delete', () => { - it('should refresh on success', () => { - const spy = jest.spyOn(component.refresh$, 'next'); - component.onDelete('sessionId'); - expect(spy).toHaveBeenCalled(); - }); - - it('should notify on error', () => { - mockSessionsGrpcService.delete$.mockReturnValueOnce(throwError(() => new Error())); - component.onDelete('sessionId'); - expect(mockNotificationService.error).toHaveBeenCalledWith('Unable to delete session'); - }); + it('should emit on column drop', () => { + const newColumns: ColumnKey[] = ['actions', 'sessionId', 'status']; + const spy = jest.spyOn(component.columnUpdate, 'emit'); + component.onDrop(newColumns); + expect(spy).toHaveBeenCalledWith(newColumns); }); it('should send a notification on copy', () => { @@ -795,12 +181,6 @@ describe('SessionsTableComponent', () => { expect(mockNotificationService.success).toHaveBeenCalledWith('Session ID copied to clipboard'); }); - test('onDrop should call sessionsIndexService', () => { - const newColumns: ColumnKey[] = ['actions', 'sessionId', 'status']; - component.onDrop(newColumns); - expect(mockSessionsIndexService.saveColumns).toHaveBeenCalledWith(newColumns); - }); - describe('personnalizeTasksByStatus', () => { beforeEach(() => { component.personalizeTasksByStatus(); @@ -858,7 +238,7 @@ describe('SessionsTableComponent', () => { it('should pause a session', () => { const action = getAction(component.actions, 'Pause session'); action.action$.next(sessionData); - expect(mockSessionsGrpcService.pause$).toHaveBeenCalledWith(sessionData.raw.sessionId); + expect(mockSessionsDataService.onPause).toHaveBeenCalledWith(sessionData.raw.sessionId); }); }); @@ -873,7 +253,7 @@ describe('SessionsTableComponent', () => { it('should resume a session', () => { const action = getAction(component.actions, 'Resume session'); action.action$.next(sessionData); - expect(mockSessionsGrpcService.resume$).toHaveBeenCalledWith(sessionData.raw.sessionId); + expect(mockSessionsDataService.onResume).toHaveBeenCalledWith(sessionData.raw.sessionId); }); }); @@ -888,7 +268,7 @@ describe('SessionsTableComponent', () => { it('should purge a session', () => { const action = getAction(component.actions, 'Purge session'); action.action$.next(sessionData); - expect(mockSessionsGrpcService.purge$).toHaveBeenCalledWith(sessionData.raw.sessionId); + expect(mockSessionsDataService.onPurge).toHaveBeenCalledWith(sessionData.raw.sessionId); }); }); @@ -903,7 +283,7 @@ describe('SessionsTableComponent', () => { it('should cancel a session', () => { const action = getAction(component.actions, 'Cancel session'); action.action$.next(sessionData); - expect(mockSessionsGrpcService.cancel$).toHaveBeenCalledWith(sessionData.raw.sessionId); + expect(mockSessionsDataService.onCancel).toHaveBeenCalledWith(sessionData.raw.sessionId); }); }); @@ -918,7 +298,7 @@ describe('SessionsTableComponent', () => { it('should close a session', () => { const action = getAction(component.actions, 'Close session'); action.action$.next(sessionData); - expect(mockSessionsGrpcService.close$).toHaveBeenCalledWith(sessionData.raw.sessionId); + expect(mockSessionsDataService.onClose).toHaveBeenCalledWith(sessionData.raw.sessionId); }); }); @@ -933,7 +313,7 @@ describe('SessionsTableComponent', () => { it('should delete a session', () => { const action = getAction(component.actions, 'Delete session'); action.action$.next(sessionData); - expect(mockSessionsGrpcService.delete$).toHaveBeenCalledWith(sessionData.raw.sessionId); + expect(mockSessionsDataService.onDelete).toHaveBeenCalledWith(sessionData.raw.sessionId); }); }); }); @@ -956,4 +336,28 @@ describe('SessionsTableComponent', () => { const session = {raw: { sessionId: 'session' }} as SessionData; expect(component.trackBy(0, session)).toEqual(session.raw.sessionId); }); + + it('should get data', () => { + expect(component.data).toEqual(mockSessionsDataService.data); + }); + + it('should get total', () => { + expect(component.total).toEqual(mockSessionsDataService.total); + }); + + it('should get options', () => { + expect(component.options).toEqual(mockSessionsDataService.options); + }); + + it('should get filters', () => { + expect(component.filters).toEqual(mockSessionsDataService.filters); + }); + + it('should get column keys', () => { + expect(component.columnKeys).toEqual(displayedColumns.map(c => c.key)); + }); + + it('should get displayedColumns', () => { + expect(component.displayedColumns).toEqual(displayedColumns); + }); }); \ No newline at end of file diff --git a/src/app/sessions/components/table.component.ts b/src/app/sessions/components/table.component.ts index 30b0cf1f4..41b026855 100644 --- a/src/app/sessions/components/table.component.ts +++ b/src/app/sessions/components/table.component.ts @@ -1,26 +1,18 @@ -import { FilterDateOperator, FilterStringOperator, ListSessionsResponse, ResultRawEnumField, SessionRawEnumField, TaskOptionEnumField, TaskSummaryEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { SessionRawEnumField, TaskOptionEnumField } from '@aneoconsultingfr/armonik.api.angular'; import { Clipboard} from '@angular/cdk/clipboard'; -import { AfterViewInit, Component, EventEmitter, OnInit, Output, inject } from '@angular/core'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { Params, Router, RouterModule } from '@angular/router'; -import { TasksGrpcService } from '@app/tasks/services/tasks-grpc.service'; -import { TaskOptions, TaskSummaryFilters } from '@app/tasks/types'; -import { AbstractTableComponent, AbstractTaskByStatusTableComponent } from '@app/types/components/table'; -import { Scope } from '@app/types/config'; -import { ArmonikData, ColumnKey, SessionData } from '@app/types/data'; -import { Filter } from '@app/types/filters'; +import { Component, OnInit, inject } from '@angular/core'; +import { MatDialogModule } from '@angular/material/dialog'; +import { Router, RouterModule } from '@angular/router'; +import { TaskOptions } from '@app/tasks/types'; +import { AbstractTaskByStatusTableComponent } from '@app/types/components/table'; +import { ArmonikData, SessionData } from '@app/types/data'; import { ActionTable } from '@app/types/table'; import { TableComponent } from '@components/table/table.component'; -import { Duration, Timestamp } from '@ngx-grpc/well-known-types'; -import { FiltersService } from '@services/filters.service'; -import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; -import { NotificationService } from '@services/notification.service'; import { TableTasksByStatus, TasksByStatusService } from '@services/tasks-by-status.service'; -import { Subject, map, mergeAll } from 'rxjs'; -import { SessionsGrpcService } from '../services/sessions-grpc.service'; -import { SessionsIndexService } from '../services/sessions-index.service'; +import { Subject } from 'rxjs'; +import { SessionsDataService } from '../services/sessions-data.service'; import { SessionsStatusesService } from '../services/sessions-statuses.service'; -import { SessionRaw, SessionRawFilters, SessionRawListOptions } from '../types'; +import { SessionRaw } from '../types'; @Component({ selector: 'app-sessions-table', @@ -28,13 +20,7 @@ import { SessionRaw, SessionRawFilters, SessionRawListOptions } from '../types'; templateUrl: './table.component.html', providers: [ TasksByStatusService, - MatDialog, - FiltersService, Clipboard, - TasksGrpcService, - SessionsGrpcService, - NotificationService, - GrpcSortFieldService, ], imports: [ TableComponent, @@ -42,28 +28,16 @@ import { SessionRaw, SessionRawFilters, SessionRawListOptions } from '../types'; MatDialogModule, ] }) -export class SessionsTableComponent extends AbstractTaskByStatusTableComponent - implements OnInit, AfterViewInit, AbstractTableComponent { - @Output() cancelSession = new EventEmitter(); - @Output() closeSession = new EventEmitter(); - @Output() deleteSession = new EventEmitter(); - - readonly grpcService = inject(SessionsGrpcService); - readonly indexService = inject(SessionsIndexService); +export class SessionsTableComponent extends AbstractTaskByStatusTableComponent + implements OnInit { readonly statusesService = inject(SessionsStatusesService); readonly router = inject(Router); readonly copyService = inject(Clipboard); - scope: Scope = 'sessions'; + readonly tableDataService = inject(SessionsDataService); + table: TableTasksByStatus = 'sessions'; - dataRaw: SessionRaw[]; - isDurationSorted: boolean = false; - sessionEndedDates: {sessionId: string, date: Timestamp | undefined}[] = []; - sessionCreationDates: {sessionId: string, date: Timestamp | undefined}[] = []; - nextStartDuration$ = new Subject(); - nextEndDuration$ = new Subject(); - computeDuration$ = new Subject(); sessionsIdsComputationError: string[] = []; copy$ = new Subject>(); @@ -148,280 +122,40 @@ export class SessionsTableComponent extends AbstractTaskByStatusTableComponent { - if (this.dataRaw.length === this.sessionEndedDates.length && this.dataRaw.length === this.sessionCreationDates.length) { - const keys: string[] = this.sessionEndedDates.map(duration => duration.sessionId); - keys.forEach(key => { - const sessionIndex = this.dataRaw.findIndex(session => session.sessionId === key); - if (sessionIndex !== -1) { - const lastDuration = this.sessionEndedDates.find(duration => duration.sessionId === key)?.date; - const firstDuration = this.sessionCreationDates.find(duration => duration.sessionId === key)?.date; - if (firstDuration && lastDuration) { - this.dataRaw[sessionIndex].duration = { - seconds: (Number(lastDuration.seconds) - Number(firstDuration.seconds)).toString(), - nanos: Math.abs(lastDuration.nanos - firstDuration.nanos) - } as Duration; - } else { - this.computationErrorNotification(key); - } - } - }); - if (this.isDurationSorted) { - this.orderByDuration(this.dataRaw); - } else { - this.newData(this.dataRaw); - } - this.sessionEndedDates = []; - this.sessionCreationDates = []; - this.loading.set(false); - } - }); - - this.nextStartDuration$.pipe( - map(sessionId => this.grpcService.getTaskData$(sessionId, 'createdAt', 'asc')), - mergeAll(), - ).subscribe(task => this.durationSubscription(task, 'created')); - - this.nextEndDuration$.pipe( - map(sessionId => this.grpcService.getTaskData$(sessionId, 'endedAt', 'desc')), - mergeAll(), - ).subscribe(task => this.durationSubscription(task, 'ended')); - } - - computeGrpcData(entries: ListSessionsResponse): SessionRaw[] | undefined { - return entries.sessions; + this.initStatuses(); } isDataRawEqual(value: SessionRaw, entry: SessionRaw): boolean { return value.sessionId === entry.sessionId; } - override prepareBeforeFetching(options: SessionRawListOptions, filters: SessionRawFilters): void { - this.indexService.saveOptions(this.options); - if(this.isDurationDisplayed() && this.options.sort.active === 'duration') { - options.sort.active = 'createdAt'; - this.isDurationSorted = true; - if(!this.filterHasCreatedAt(filters)) { - options.pageSize = 100; - const date = new Date(); - date.setDate(date.getDate() - 3); - filters.push([{ - field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_CREATED_AT, - for: 'root', - operator: FilterDateOperator.FILTER_DATE_OPERATOR_AFTER_OR_EQUAL, - value: Math.floor(date.getTime()/1000) - }]); - } - } else { - this.isDurationSorted = false; - } - } - - override afterDataCreation(data: SessionRaw[]): void { - if (this.isDurationDisplayed()) { - this.dataRaw = data; - data.forEach(session => { - this.nextStartDuration$.next(session.sessionId); - this.nextEndDuration$.next(session.sessionId); - }); - } else { - this.newData(data); - this.loading.set(false); - } - } - - createNewLine(entry: SessionRaw): SessionData { - const queryParams = new Map, Params>(); - queryParams.set('sessionId', { '0-root-1-0': entry.sessionId }); - return { - raw: entry, - queryParams, - resultsQueryParams: this.createResultsQueryParams(entry.sessionId), - queryTasksParams: this.createTasksByStatusQueryParams(entry.sessionId), - filters: this.countTasksByStatusFilters(entry.sessionId), - }; - } - onCopiedSessionId(data: ArmonikData) { this.copyService.copy(data.raw.sessionId); this.notificationService.success('Session ID copied to clipboard'); } - createSessionIdQueryParams(sessionId: string) { - const keySession = this.filtersService.createQueryParamsKey(0, 'root', FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID); - - return { - [keySession]: sessionId, - }; - } - - createTasksByStatusQueryParams(sessionId: string) { - if(this.filters.length === 0) { - return this.createSessionIdQueryParams(sessionId); - } else { - const params: Record = {}; - this.filters.forEach((filterAnd, index) => { - params[`${index}-root-${TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`] = sessionId; - filterAnd.forEach(filter => { - if (filter.field !== SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID || filter.operator !== FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL) { - const filterLabel = this.#createTaskByStatusLabel(filter, index); - if (filterLabel && filter.value) { - params[filterLabel] = filter.value.toString(); - } - } - }); - }); - return params; - } - } - - #createTaskByStatusLabel(filter: Filter, orGroup: number): string | null { - if (filter.field !== null && filter.operator !== null) { - if (filter.for === 'root' && filter.field === SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID) { - return this.filtersService.createQueryParamsKey(orGroup, 'root', filter.operator, TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID); - } else if (filter.for === 'options') { - return this.filtersService.createQueryParamsKey(orGroup, 'options', filter.operator, filter.field as TaskOptionEnumField); - } - } - return null; - } - - createResultsQueryParams(sessionId: string) { - if(this.filters.length === 0) { - const keySession = this.filtersService.createQueryParamsKey(0, 'root', FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, ResultRawEnumField.RESULT_RAW_ENUM_FIELD_SESSION_ID); - - return { - [keySession]: sessionId - }; - } else { - const params: Record = {}; - this.filters.forEach((filterAnd, index) => { - filterAnd.forEach(filter => { - if (filter.field === SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID && filter.operator !== FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL && filter.value !== null && filter.operator !== null) { - const filterLabel = this.filtersService.createQueryParamsKey(index, 'root', filter.operator, ResultRawEnumField.RESULT_RAW_ENUM_FIELD_SESSION_ID); - if (filterLabel) params[filterLabel] = filter.value.toString(); - } - }); - params[`${index}-root-${TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`] = sessionId; - }); - return params; - } - } - - countTasksByStatusFilters(sessionId: string): TaskSummaryFilters { - return [ - [ - { - for: 'root', - field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, - value: sessionId, - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL - } - ] - ]; - } - onPause(sessionId: string) { - this.grpcService.pause$(sessionId) - .subscribe( - { - error: () => this.notificationService.error('Unable to pause session'), - complete: () => this.refresh$.next() - } - ); + this.tableDataService.onPause(sessionId); } onResume(sessionId: string) { - this.grpcService.resume$(sessionId).subscribe( - { - error: () => this.notificationService.error('Unable to resume session'), - complete: () => this.refresh$.next() - } - ); + this.tableDataService.onResume(sessionId); } onCancel(sessionId: string) { - this.grpcService.cancel$(sessionId).subscribe( - { - error: () => this.notificationService.error('Unable to cancel session'), - complete: () => this.refresh$.next() - } - ); + this.tableDataService.onCancel(sessionId); } onPurge(sessionId: string) { - this.grpcService.purge$(sessionId).subscribe( - { - error: () => this.notificationService.error('Unable to purge session'), - complete: () => this.refresh$.next() - } - ); + this.tableDataService.onPurge(sessionId); } onClose(sessionId: string) { - this.grpcService.close$(sessionId).subscribe( - { - error: () => this.notificationService.error('Unable to close session'), - complete: () => this.refresh$.next() - } - ); + this.tableDataService.onClose(sessionId); } onDelete(sessionId: string) { - this.grpcService.delete$(sessionId).subscribe( - { - error: () => this.notificationService.error('Unable to delete session'), - complete: () => this.refresh$.next() - } - ); - } - - // Session Duration computation section - - filterHasCreatedAt(filters: SessionRawFilters) { - for (const filterAnd of filters) { - const result = filterAnd.some(filter => filter.field === SessionRawEnumField.SESSION_RAW_ENUM_FIELD_CREATED_AT); - if (result) { - return true; - } - } - return false; - } - - isDurationDisplayed(): boolean { - return this.displayedColumns.map(c => c.key).includes('duration'); - } - - orderByDuration(data: SessionRaw[]) { - data = data.toSorted((a, b) => { - if (this.options.sort.direction === 'asc') { - return Number(a.duration?.seconds) - Number(b.duration?.seconds); - } else { - return Number(b.duration?.seconds) - Number(a.duration?.seconds); - } - }).slice(0, this.options.pageSize); - this.newData(data); - } - - durationSubscription(data: {sessionId: string, date: Timestamp | undefined}, type: 'ended' | 'created') { - if (type === 'ended') { - this.sessionEndedDates.push({sessionId: data.sessionId, date: data.date}); - } else { - this.sessionCreationDates.push({sessionId: data.sessionId, date: data.date}); - } - this.computeDuration$.next(); - } - - computationErrorNotification(sessionId: string) { - if (!this.sessionsIdsComputationError.includes(sessionId)) { - this.sessionsIdsComputationError.push(sessionId); - this.notificationService.warning('Error while computing duration for session: ' + sessionId); - } + this.tableDataService.onDelete(sessionId); } trackBy(index: number, item: ArmonikData) { diff --git a/src/app/sessions/index.component.html b/src/app/sessions/index.component.html index 18ade8121..9786a00c9 100644 --- a/src/app/sessions/index.component.html +++ b/src/app/sessions/index.component.html @@ -6,14 +6,14 @@ diff --git a/src/app/sessions/index.component.spec.ts b/src/app/sessions/index.component.spec.ts index 3b8970c82..4cd0bf98d 100644 --- a/src/app/sessions/index.component.spec.ts +++ b/src/app/sessions/index.component.spec.ts @@ -15,16 +15,29 @@ import { NotificationService } from '@services/notification.service'; import { ShareUrlService } from '@services/share-url.service'; import { of } from 'rxjs'; import { IndexComponent } from './index.component'; +import { SessionsDataService } from './services/sessions-data.service'; import { SessionsFiltersService } from './services/sessions-filters.service'; -import { SessionsGrpcService } from './services/sessions-grpc.service'; import { SessionsIndexService } from './services/sessions-index.service'; import { SessionRaw } from './types'; describe('Sessions Index Component', () => { let component: IndexComponent; - const mockSessionsGrpcService = { - cancel$: jest.fn(() => of()), + const mockSessionsDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [], + refresh$: { + next: jest.fn() + }, + onPause: jest.fn(), + onResume: jest.fn(), + onCancel: jest.fn(), + onPurge: jest.fn(), + onClose: jest.fn(), + onDelete: jest.fn(), }; const newCustomColumns: CustomColumn[] = ['options.options.FastCompute', 'options.options.NewCustom']; @@ -96,6 +109,12 @@ describe('Sessions Index Component', () => { key: 'options.applicationNamespace', sortable: true, }, + { + name: $localize`Select`, + key: 'select', + type: 'select', + sortable: false, + }, ]; const defaultIntervalValue = 10; @@ -150,7 +169,7 @@ describe('Sessions Index Component', () => { IconsService, AutoRefreshService, { provide: SessionsIndexService, useValue: mockSessionsIndexService }, - { provide: SessionsGrpcService, useValue: mockSessionsGrpcService }, + { provide: SessionsDataService, useValue: mockSessionsDataService }, { provide: MatDialog, useValue: mockMatDialog }, { provide: DashboardIndexService, useValue: mockDashboardIndexService }, { provide: Router, useValue: mockRouter }, @@ -215,7 +234,6 @@ describe('Sessions Index Component', () => { it('should initialise filters', () => { expect(component.filters).toEqual([]); - expect(component.filters$).toBeDefined(); }); it('should init options', () => { @@ -234,14 +252,31 @@ describe('Sessions Index Component', () => { }); }); + it('should load properly', () => { + expect(component.loading).toEqual(mockSessionsDataService.loading); + }); + it('should get icons', () => { expect(component.getIcon('refresh')).toEqual('refresh'); }); it('should refresh', () => { - const spy = jest.spyOn(component.refresh$, 'next'); - component.onRefresh(); - expect(spy).toHaveBeenCalled(); + component.refresh(); + expect(mockSessionsDataService.refresh$.next).toHaveBeenCalled(); + }); + + describe('On Options Change', () => { + beforeEach(() => { + component.onOptionsChange(); + }); + + it('should save options', () => { + expect(mockSessionsIndexService.saveOptions).toHaveBeenCalledWith(mockSessionsDataService.options); + }); + + it('should refresh', () => { + expect(mockSessionsDataService.refresh$.next).toHaveBeenCalled(); + }); }); describe('On interval value change', () => { @@ -257,9 +292,8 @@ describe('Sessions Index Component', () => { }); it('should refresh if the value is not null', () => { - const spy = jest.spyOn(component.refresh$, 'next'); component.onIntervalValueChange(5); - expect(spy).toHaveBeenCalled(); + expect(mockSessionsDataService.refresh$.next).toHaveBeenCalled(); }); it('should stop the interval if the value is 0', () => { @@ -275,17 +309,23 @@ describe('Sessions Index Component', () => { }); describe('On columns change', () => { - const newColumns: ColumnKey[] = ['sessionId', 'createdAt']; + const newColumns: ColumnKey[] = ['sessionId', 'createdAt', 'select']; beforeEach(() => { component.onColumnsChange(newColumns); }); it('should update displayed column keys', () => { - expect(component.displayedColumnsKeys).toEqual(newColumns); + expect(component.displayedColumnsKeys).toEqual(['select', 'sessionId', 'createdAt']); }); it('should update displayed columns', () => { expect(component.displayedColumns()).toEqual([ + { + name: $localize`Select`, + key: 'select', + type: 'select', + sortable: false, + }, { name: $localize`Session ID`, key: 'sessionId', @@ -303,7 +343,7 @@ describe('Sessions Index Component', () => { }); it('should save columns', () => { - expect(mockSessionsIndexService.saveColumns).toHaveBeenCalledWith(['sessionId', 'createdAt']); + expect(mockSessionsIndexService.saveColumns).toHaveBeenCalledWith(['select', 'sessionId', 'createdAt']); }); }); @@ -360,10 +400,7 @@ describe('Sessions Index Component', () => { ] ]; - let filterSpy: jest.SpyInstance; - beforeEach(() => { - filterSpy = jest.spyOn(component.filters$, 'next'); component.onFiltersChange(newFilters); }); @@ -378,17 +415,10 @@ describe('Sessions Index Component', () => { it('should update page index', () => { expect(component.options.pageIndex).toEqual(0); }); - - it('should emit filters', () => { - expect(filterSpy).toHaveBeenCalledWith(newFilters); - }); }); describe('On Filter Reset', () => { - let filterSpy: jest.SpyInstance; - beforeEach(() => { - filterSpy = jest.spyOn(component.filters$, 'next'); component.onFiltersReset(); }); @@ -399,10 +429,6 @@ describe('Sessions Index Component', () => { it('should reset page index', () => { expect(component.options.pageIndex).toEqual(0); }); - - it('should emit empty filters', () => { - expect(filterSpy).toHaveBeenCalledWith([]); - }); }); describe('On lockColumns Change', () => { diff --git a/src/app/sessions/index.component.ts b/src/app/sessions/index.component.ts index a58556866..280464704 100644 --- a/src/app/sessions/index.component.ts +++ b/src/app/sessions/index.component.ts @@ -9,6 +9,7 @@ import { MatToolbarModule } from '@angular/material/toolbar'; import { DashboardIndexService } from '@app/dashboard/services/dashboard-index.service'; import { DashboardStorageService } from '@app/dashboard/services/dashboard-storage.service'; import { TasksFiltersService } from '@app/tasks/services/tasks-filters.service'; +import { TasksGrpcService } from '@app/tasks/services/tasks-grpc.service'; import { TasksIndexService } from '@app/tasks/services/tasks-index.service'; import { TasksStatusesService } from '@app/tasks/services/tasks-statuses.service'; import { TaskOptions } from '@app/tasks/types'; @@ -20,6 +21,8 @@ import { PageHeaderComponent } from '@components/page-header.component'; import { TableIndexActionsToolbarComponent } from '@components/table-index-actions-toolbar.component'; import { AutoRefreshService } from '@services/auto-refresh.service'; import { FiltersService } from '@services/filters.service'; +import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; +import { NotificationService } from '@services/notification.service'; import { QueryParamsService } from '@services/query-params.service'; import { ShareUrlService } from '@services/share-url.service'; import { StorageService } from '@services/storage.service'; @@ -28,7 +31,9 @@ import { TableURLService } from '@services/table-url.service'; import { TableService } from '@services/table.service'; import { UtilsService } from '@services/utils.service'; import { SessionsTableComponent } from './components/table.component'; +import { SessionsDataService } from './services/sessions-data.service'; import { SessionsFiltersService } from './services/sessions-filters.service'; +import { SessionsGrpcService } from './services/sessions-grpc.service'; import { SessionsIndexService } from './services/sessions-index.service'; import { SessionsStatusesService } from './services/sessions-statuses.service'; import { SessionRaw } from './types'; @@ -60,6 +65,11 @@ import { SessionRaw } from './types'; MatDialog, DashboardIndexService, DashboardStorageService, + SessionsDataService, + SessionsGrpcService, + NotificationService, + TasksGrpcService, + GrpcSortFieldService, ], imports: [ PageHeaderComponent, @@ -76,6 +86,7 @@ import { SessionRaw } from './types'; export class IndexComponent extends TableHandlerCustomValues implements OnInit, AfterViewInit, OnDestroy { readonly filtersService = inject(SessionsFiltersService); readonly indexService = inject(SessionsIndexService); + readonly tableDataService = inject(SessionsDataService); tableType: TableType = 'Sessions'; @@ -90,4 +101,13 @@ export class IndexComponent extends TableHandlerCustomValues { + let service: SessionsDataService; + + const cachedSessions = { sessions: [{ sessionId: 'session1' }, { sessionId: 'session2', }], total: 2 } as unknown as ListSessionsResponse; + const mockCacheService = { + get: jest.fn(() => cachedSessions), + save: jest.fn(), + }; + + const sessions = { sessions: [{ sessionId: 'session1' }, { sessionId: 'session2' }, { sessionId: 'session3' }] as SessionRaw[], total: 3 } as unknown as ListSessionsResponse; + let index = 0; + const mockSessionsGrpcService = { + list$: jest.fn(() => of(sessions)), + getTaskData$: jest.fn((id, type) => { + if (type === 'createdAt') { + return of({ + sessionId: id, + date: (index !== 2 ? {seconds: 1620000000, nanos: 0} : undefined) + }); + } else { + index++; + return of({ + sessionId: id, + date: {seconds: (1620000000+index*1000).toString(), nanos: 0} + }); + } + }), + cancel$: jest.fn(() => of({})), + pause$: jest.fn(() => of({})), + resume$: jest.fn(() => of({})), + close$: jest.fn(() => of({})), + delete$: jest.fn(() => of({})), + purge$: jest.fn(() => of({})), + }; + + const mockNotificationService = { + success: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }; + + const initialOptions: ListOptions = { + pageIndex: 0, + pageSize: 10, + sort: { + active: 'sessionId', + direction: 'desc' + } + }; + + const initialFilters: FiltersOr = []; + + beforeEach(() => { + service = TestBed.configureTestingModule({ + providers: [ + SessionsDataService, + FiltersService, + { provide: SessionsGrpcService, useValue: mockSessionsGrpcService }, + { provide: NotificationService, useValue: mockNotificationService }, + { provide: CacheService, useValue: mockCacheService }, + ] + }).inject(SessionsDataService); + service.options = initialOptions; + service.filters = initialFilters; + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('initialisation', () => { + it('should load data from the cache', () => { + const map1 = new Map(); + const map2 = new Map(); + map1.set('sessionId', {'0-root-1-0': 'session1'}); + map2.set('sessionId', {'0-root-1-0': 'session2'}); + expect(service.data).toEqual([ + { + raw: { + sessionId: 'session1' + }, + queryParams: map1, + resultsQueryParams: { + '0-root-1-0': 'session1' + }, + queryTasksParams: { + '0-root-1-0': 'session1' + }, + filters: [[{for: 'root', field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'session1'}]] + }, + { + raw: { + sessionId: 'session2' + }, + queryParams: map2, + resultsQueryParams: { + '0-root-1-0': 'session2' + }, + queryTasksParams: { + '0-root-1-0': 'session2' + }, + filters: [[{for: 'root', field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'session2'}]] + }, + ]); + }); + + it('should set the total cached data', () => { + expect(service.total).toEqual(cachedSessions.total); + }); + }); + + describe('Fetching data', () => { + it('should list the data', () => { + service.refresh$.next(); + expect(mockSessionsGrpcService.list$).toHaveBeenCalledWith(service.options, service.filters); + }); + + it('should update the total', () => { + service.refresh$.next(); + expect(service.total).toEqual(sessions.total); + }); + + it('should update the data', () => { + service.filters = [ + [ + { + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, + value: 'session1' + }, + { + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, + for: 'root', + operator: null, + value: 'shouldNotAppear' + }, + { + for: 'options', + field: SessionTaskOptionEnumField.TASK_OPTION_ENUM_FIELD_MAX_RETRIES, + operator: FilterNumberOperator.FILTER_NUMBER_OPERATOR_GREATER_THAN, + value: 1 + }, + ], + [ + { + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + value: null + }, + { + for: 'root', + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, + value: 'id' + }, + { + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_STARTS_WITH, + value: 'session2' + }, + { + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + value: 'neitherShouldIt' + }, + ] + ]; + const map1 = new Map(); + const map2 = new Map(); + const map3 = new Map(); + map1.set('sessionId', {'0-root-1-0': 'session1'}); + map2.set('sessionId', {'0-root-1-0': 'session2'}); + map3.set('sessionId', {'0-root-1-0': 'session3'}); + service.refresh$.next(); + expect(service.data).toEqual([ + { + raw: { + sessionId: 'session1' + }, + queryParams: map1, + resultsQueryParams: { + '0-root-1-2': 'session1', + '0-root-1-0': 'session1', + '1-root-1-4': 'session2', + '1-root-1-0': 'session1', + '1-root-1-2': 'id', + }, + queryTasksParams: { + '0-root-1-2': 'session1', + '0-root-1-0': 'session1', + '0-options-2-5': '1', + '1-root-1-0': 'session1', + '1-root-1-2': 'id', + '1-root-1-4': 'session2', + }, + filters: [[{for: 'root', field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'session1'}]] + }, + { + raw: { + sessionId: 'session2' + }, + queryParams: map2, + resultsQueryParams: { + '0-root-1-2': 'session1', + '0-root-1-0': 'session2', + '1-root-1-4': 'session2', + '1-root-1-0': 'session2', + '1-root-1-2': 'id', + }, + queryTasksParams: { + '0-root-1-2': 'session1', + '0-root-1-0': 'session2', + '0-options-2-5': '1', + '1-root-1-0': 'session2', + '1-root-1-2': 'id', + '1-root-1-4': 'session2', + }, + filters: [[{for: 'root', field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'session2'}]] + }, + { + raw: { + sessionId: 'session3' + }, + queryParams: map3, + resultsQueryParams: { + '0-root-1-2': 'session1', + '0-root-1-0': 'session3', + '1-root-1-4': 'session2', + '1-root-1-0': 'session3', + '1-root-1-2': 'id', + }, + queryTasksParams: { + '0-root-1-0': 'session3', + '0-options-2-5': '1', + '1-root-1-0': 'session3', + '1-root-1-2': 'id', + '1-root-1-4': 'session2', + '0-root-1-2': 'session1', + }, + filters: [[{for: 'root', field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, value: 'session3'}]] + } + ]); + }); + + it('should handle an empty DataRaw', () => { + const sessions = { sessions: undefined, total: 0} as unknown as ListSessionsResponse; + mockSessionsGrpcService.list$.mockReturnValueOnce(of(sessions)); + service.refresh$.next(); + expect(service.data).toEqual([]); + }); + + it('should catch errors', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockSessionsGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); + service.refresh$.next(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('should notify errors', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + mockSessionsGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); + service.refresh$.next(); + expect(mockNotificationService.error).toHaveBeenCalled(); + }); + + it('should cache the raw data', () => { + service.refresh$.next(); + expect(mockCacheService.save).toHaveBeenCalledWith(service.scope, sessions); + }); + }); + + it('should display a success message', () => { + const message = 'A success message !'; + service.success(message); + expect(mockNotificationService.success).toHaveBeenCalledWith(message); + }); + + it('should display a warning message', () => { + const message = 'A warning message !'; + service.warning(message); + expect(mockNotificationService.warning).toHaveBeenCalledWith(message); + }); + + it('should display an error message', () => { + const error: GrpcStatusEvent = { + statusMessage: 'A error status message' + } as GrpcStatusEvent; + jest.spyOn(console, 'error').mockImplementation(() => {}); + service.error(error); + expect(mockNotificationService.error).toHaveBeenCalledWith(error.statusMessage); + }); + + it('should load correctly', () => { + expect(service.loading).toBeFalsy(); + }); + + describe('computing duration', () => { + beforeEach(() => { + service.isDurationDisplayed = true; + }); + + it('should compute the duration for each session', () => { + service.refresh$.next(); + expect(service.data.map((session) => ({sessionId: session.raw.sessionId, duration: session.raw.duration}))).toEqual([ + { + sessionId: 'session1', + duration: { + seconds: '1000', + nanos: 0, + } + }, + { + sessionId: 'session2', + duration: { + seconds: '2000', + nanos: 0, + } + }, + { + sessionId: 'session3', + duration: undefined, + }, + ]); + }); + + it('should warn if there were a notification error', () => { + index = 0; + service.refresh$.next(); + expect(mockNotificationService.warning).toHaveBeenCalledTimes(1); + }); + + it('should sort in an ascending order', () => { + index = 3; + service.filters = [ + [ + { + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_CREATED_AT, + for: 'root', + operator: FilterDateOperator.FILTER_DATE_OPERATOR_AFTER, + value: 1, + } + ] + ]; + service.options.sort = { + active: 'duration', + direction: 'asc', + }; + service.refresh$.next(); + expect(service.data.map(session => session.raw.sessionId)).toEqual(['session1', 'session2', 'session3']); + }); + + it('should sort in a descending order', () => { + index = 3; + service.options.sort = { + active: 'duration', + direction: 'desc', + }; + service.refresh$.next(); + expect(service.data.map(session => session.raw.sessionId)).toEqual(['session3', 'session2', 'session1']); + }); + }); + + describe('on Pause', () => { + it('should refresh on success', () => { + const spy = jest.spyOn(service.refresh$, 'next'); + service.onPause('sessionId'); + expect(spy).toHaveBeenCalled(); + }); + + it('should notify on error', () => { + mockSessionsGrpcService.pause$.mockReturnValueOnce(throwError(() => new Error())); + service.onPause('sessionId'); + expect(mockNotificationService.error).toHaveBeenCalledWith('Unable to pause session'); + }); + }); + + describe('on Resume', () => { + it('should refresh on success', () => { + const spy = jest.spyOn(service.refresh$, 'next'); + service.onResume('sessionId'); + expect(spy).toHaveBeenCalled(); + }); + + it('should notify on error', () => { + mockSessionsGrpcService.resume$.mockReturnValueOnce(throwError(() => new Error())); + service.onResume('sessionId'); + expect(mockNotificationService.error).toHaveBeenCalledWith('Unable to resume session'); + }); + }); + + describe('on purge', () => { + it('should refresh on success', () => { + const spy = jest.spyOn(service.refresh$, 'next'); + service.onPurge('sessionId'); + expect(spy).toHaveBeenCalled(); + }); + + it('should notify on error', () => { + mockSessionsGrpcService.purge$.mockReturnValueOnce(throwError(() => new Error())); + service.onPurge('sessionId'); + expect(mockNotificationService.error).toHaveBeenCalledWith('Unable to purge session'); + }); + }); + + describe('on Cancel', () => { + it('should refresh on success', () => { + const spy = jest.spyOn(service.refresh$, 'next'); + service.onCancel('sessionId'); + expect(spy).toHaveBeenCalled(); + }); + + it('should notify on error', () => { + mockSessionsGrpcService.cancel$.mockReturnValueOnce(throwError(() => new Error())); + service.onCancel('sessionId'); + expect(mockNotificationService.error).toHaveBeenCalledWith('Unable to cancel session'); + }); + }); + + describe('on Close', () => { + it('should refresh on success', () => { + const spy = jest.spyOn(service.refresh$, 'next'); + service.onClose('sessionId'); + expect(spy).toHaveBeenCalled(); + }); + + it('should notify on error', () => { + mockSessionsGrpcService.close$.mockReturnValueOnce(throwError(() => new Error())); + service.onClose('sessionId'); + expect(mockNotificationService.error).toHaveBeenCalledWith('Unable to close session'); + }); + }); + + describe('on Delete', () => { + it('should refresh on success', () => { + const spy = jest.spyOn(service.refresh$, 'next'); + service.onDelete('sessionId'); + expect(spy).toHaveBeenCalled(); + }); + + it('should notify on error', () => { + mockSessionsGrpcService.delete$.mockReturnValueOnce(throwError(() => new Error())); + service.onDelete('sessionId'); + expect(mockNotificationService.error).toHaveBeenCalledWith('Unable to delete session'); + }); + }); +}); \ No newline at end of file diff --git a/src/app/sessions/services/sessions-data.service.ts b/src/app/sessions/services/sessions-data.service.ts new file mode 100644 index 000000000..8b1702008 --- /dev/null +++ b/src/app/sessions/services/sessions-data.service.ts @@ -0,0 +1,351 @@ +import { FilterDateOperator, FilterStringOperator, ListSessionsResponse, ResultRawEnumField, SessionRawEnumField, TaskOptionEnumField, TaskSummaryEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { Injectable, inject } from '@angular/core'; +import { Params } from '@angular/router'; +import { TaskOptions, TaskSummaryFilters } from '@app/tasks/types'; +import { Scope } from '@app/types/config'; +import { ColumnKey, SessionData } from '@app/types/data'; +import { Filter, FiltersOr } from '@app/types/filters'; +import { ListOptions } from '@app/types/options'; +import { AbstractTableDataService } from '@app/types/services/table-data.service'; +import { Duration, Timestamp } from '@ngx-grpc/well-known-types'; +import { Subject, map, mergeAll } from 'rxjs'; +import { SessionsGrpcService } from './sessions-grpc.service'; +import { SessionRaw } from '../types'; + +@Injectable() +export class SessionsDataService extends AbstractTableDataService { + readonly grpcService = inject(SessionsGrpcService); + + scope: Scope = 'sessions'; + + constructor() { + super(); + this.subscribeToDurationSubjects(); + } + + computeGrpcData(entries: ListSessionsResponse): SessionRaw[] | undefined { + return entries.sessions; + } + + // Creating new data + + override handleData(entries: SessionRaw[]): void { + this.dataRaw = entries; + if (this.isDurationDisplayed) { + this.startComputingDuration(entries); + } else { + super.handleData(entries); + } + } + + override prepareOptions(): ListOptions { + const options = super.prepareOptions(); + if (this.isDurationDisplayed && this.options.sort.active === 'duration') { + options.sort.active = 'createdAt'; + this.isDurationSorted = true; + if (this.filtersHaveNoCreatedAt()) { + options.pageSize = 100; + } + } else { + this.isDurationSorted = false; + } + return options; + } + + override preparefilters(): FiltersOr { + const filters = super.preparefilters(); + if(this.isDurationDisplayed && this.options.sort.active === 'duration' && this.filtersHaveNoCreatedAt()) { + const date = new Date(); + date.setDate(date.getDate() - 3); + filters.push([{ + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_CREATED_AT, + for: 'root', + operator: FilterDateOperator.FILTER_DATE_OPERATOR_AFTER_OR_EQUAL, + value: Math.floor(date.getTime()/1000) + }]); + } + return filters; + } + + createNewLine(entry: SessionRaw): SessionData { + const queryParams = new Map, Params>(); + queryParams.set('sessionId', { '0-root-1-0': entry.sessionId }); + return { + raw: entry, + queryParams, + resultsQueryParams: this.createResultsQueryParams(entry.sessionId), + queryTasksParams: this.createTasksByStatusQueryParams(entry.sessionId), + filters: this.countTasksByStatusFilters(entry.sessionId), + }; + } + + /** + * Create a basic filter for the task table. + */ + createSessionIdQueryParams(sessionId: string) { + const keySession = this.filtersService.createQueryParamsKey(0, 'root', FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID); + + return { + [keySession]: sessionId, + }; + } + + /** + * Create the queryParams used by the taskByStatus component to redirect to the task table. + * The partitionId filter is applied on top of every filter of the table. + */ + createTasksByStatusQueryParams(sessionId: string) { + if(this.filters.length === 0) { + return this.createSessionIdQueryParams(sessionId); + } else { + const params: Record = {}; + this.filters.forEach((filterAnd, index) => { + params[`${index}-root-${TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`] = sessionId; + filterAnd.forEach(filter => { + if (filter.field !== SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID || filter.operator !== FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL) { + const filterLabel = this.#createTaskByStatusLabel(filter, index); + if (filterLabel && filter.value) { + params[filterLabel] = filter.value.toString(); + } + } + }); + }); + return params; + } + } + + /** + * Create the filter key used by the query params. + */ + #createTaskByStatusLabel(filter: Filter, orGroup: number): string | null { + if (filter.field !== null && filter.operator !== null) { + if (filter.for === 'root' && filter.field === SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID) { + return this.filtersService.createQueryParamsKey(orGroup, 'root', filter.operator, TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID); + } else if (filter.for === 'options') { + return this.filtersService.createQueryParamsKey(orGroup, 'options', filter.operator, filter.field as TaskOptionEnumField); + } + } + return null; + } + + /** + * Create the query params to filter the result table with the current applied filters. + */ + createResultsQueryParams(sessionId: string) { + if(this.filters.length === 0) { + const keySession = this.filtersService.createQueryParamsKey(0, 'root', FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, ResultRawEnumField.RESULT_RAW_ENUM_FIELD_SESSION_ID); + + return { + [keySession]: sessionId + }; + } else { + const params: Record = {}; + this.filters.forEach((filterAnd, index) => { + filterAnd.forEach(filter => { + if (filter.field === SessionRawEnumField.SESSION_RAW_ENUM_FIELD_SESSION_ID && filter.operator !== FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL && filter.value !== null && filter.operator !== null) { + const filterLabel = this.filtersService.createQueryParamsKey(index, 'root', filter.operator, ResultRawEnumField.RESULT_RAW_ENUM_FIELD_SESSION_ID); + if (filterLabel) params[filterLabel] = filter.value.toString(); + } + }); + params[`${index}-root-${TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`] = sessionId; + }); + return params; + } + } + + /** + * Create the filter used by the **TaskByStatus** component. + */ + countTasksByStatusFilters(sessionId: string): TaskSummaryFilters { + return [ + [ + { + for: 'root', + field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, + value: sessionId, + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL + } + ] + ]; + } + + // Duration computation + isDurationDisplayed = false; + private dataRaw: SessionRaw[]; + private isDurationSorted = false; + private sessionEndedDates: {sessionId: string, date: Timestamp | undefined}[] = []; + private sessionCreationDates: {sessionId: string, date: Timestamp | undefined}[] = []; + private nextStartDuration$ = new Subject(); + private nextEndDuration$ = new Subject(); + private computeDuration$ = new Subject(); + private sessionsIdsComputationError: string[] = []; + + /** + * Start the duration computation process + */ + private startComputingDuration(data: SessionRaw[]) { + data.forEach(session => { + this.nextStartDuration$.next(session.sessionId); + this.nextEndDuration$.next(session.sessionId); + }); + } + + /** + * Subscribe to the following subjects: + * - **computeDuration$**: Will compute the duration when the **sessionEndedDates** and **sessionCreationDates** length are equals to the dataRaw length. + * To compute the duration, it will use the creation date of the first task of a session, and the end date of its last task. + * The duration is simply a soustraction between those two values. + * - **nextStartDuration**: aims to fetch the first task *startedAt** field of a session. + * - **nextEndDuration**: aims to fetch the last task **endedAt** field of a session. + */ + private subscribeToDurationSubjects() { + this.computeDuration$.subscribe(() => { + if (this.dataRaw.length === this.sessionEndedDates.length && this.dataRaw.length === this.sessionCreationDates.length) { + const keys: string[] = this.sessionEndedDates.map(duration => duration.sessionId); + keys.forEach(key => { + const sessionIndex = this.dataRaw.findIndex(session => session.sessionId === key); + if (sessionIndex !== -1) { + const lastDuration = this.sessionEndedDates.find(duration => duration.sessionId === key)?.date; + const firstDuration = this.sessionCreationDates.find(duration => duration.sessionId === key)?.date; + if (firstDuration && lastDuration) { + this.dataRaw[sessionIndex].duration = { + seconds: (Number(lastDuration.seconds) - Number(firstDuration.seconds)).toString(), + nanos: Math.abs(lastDuration.nanos - firstDuration.nanos) + } as Duration; + } else { + this.computationErrorNotification(key); + } + } + }); + if (this.isDurationSorted) { + this.orderByDuration(this.dataRaw); + } else { + this.data = this.dataRaw; + } + this.sessionEndedDates = []; + this.sessionCreationDates = []; + this.loading = false; + } + }); + + this.nextStartDuration$.pipe( + map(sessionId => this.grpcService.getTaskData$(sessionId, 'createdAt', 'asc')), + mergeAll(), + ).subscribe(task => this.durationSubscription(task, 'created')); + + this.nextEndDuration$.pipe( + map(sessionId => this.grpcService.getTaskData$(sessionId, 'endedAt', 'desc')), + mergeAll(), + ).subscribe(task => this.durationSubscription(task, 'ended')); + } + + /** + * Sort a SessionRaw array by the field *duration*. + */ + private orderByDuration(data: SessionRaw[]) { + data = data.toSorted((a, b) => { + if (this.options.sort.direction === 'asc') { + return Number(a.duration?.seconds) - Number(b.duration?.seconds); + } else { + return Number(b.duration?.seconds) - Number(a.duration?.seconds); + } + }).slice(0, this.options.pageSize); + this.data = data; + } + + /** + * Will push a date to either **sessionEndedDates** or **sessionCreationDates**. + * After this, the **computeDuration$** subject will be called to know if durations can be computed. + * @param data - a sessionId and its "endedAt" or "startedAt" date. + * @param type - Either ended or created, to put the date respectively in **sessionEndedDates** or **sessionCreationDates**. + */ + private durationSubscription(data: {sessionId: string, date: Timestamp | undefined}, type: 'ended' | 'created') { + if (type === 'ended') { + this.sessionEndedDates.push({sessionId: data.sessionId, date: data.date}); + } else { + this.sessionCreationDates.push({sessionId: data.sessionId, date: data.date}); + } + this.computeDuration$.next(); + } + + /** + * Check if the current applied filters include the field "createdAt". + */ + private filtersHaveNoCreatedAt() { + for (const filterAnd of this.filters) { + const result = filterAnd.some(filter => filter.field === SessionRawEnumField.SESSION_RAW_ENUM_FIELD_CREATED_AT); + if (result) { + return false; + } + } + return true; + } + + /** + * Will display warning message to the user saying that a session duration could not be computed. + * The message is displayed only once for each sessionId, while the user stays on the session table. + */ + private computationErrorNotification(sessionId: string) { + if (!this.sessionsIdsComputationError.includes(sessionId)) { + this.sessionsIdsComputationError.push(sessionId); + this.warning('Error while computing duration for session: ' + sessionId); + } + } + + // Session Interactions + + onPause(sessionId: string) { + this.grpcService.pause$(sessionId) + .subscribe( + { + error: (error) => this.error(error, 'Unable to pause session'), + complete: () => this.refresh$.next() + } + ); + } + + onResume(sessionId: string) { + this.grpcService.resume$(sessionId).subscribe( + { + error: (error) => this.error(error, 'Unable to resume session'), + complete: () => this.refresh$.next() + } + ); + } + + onCancel(sessionId: string) { + this.grpcService.cancel$(sessionId).subscribe( + { + error: (error) => this.error(error, 'Unable to cancel session'), + complete: () => this.refresh$.next() + } + ); + } + + onPurge(sessionId: string) { + this.grpcService.purge$(sessionId).subscribe( + { + error: (error) => this.error(error, 'Unable to purge session'), + complete: () => this.refresh$.next() + } + ); + } + + onClose(sessionId: string) { + this.grpcService.close$(sessionId).subscribe( + { + error: (error) => this.error(error, 'Unable to close session'), + complete: () => this.refresh$.next() + } + ); + } + + onDelete(sessionId: string) { + this.grpcService.delete$(sessionId).subscribe( + { + error: (error) => this.error(error, 'Unable to delete session'), + complete: () => this.refresh$.next() + } + ); + } +} \ No newline at end of file diff --git a/src/app/tasks/components/table.component.html b/src/app/tasks/components/table.component.html index 2682aeb88..e0796ce29 100644 --- a/src/app/tasks/components/table.component.html +++ b/src/app/tasks/components/table.component.html @@ -1,4 +1,3 @@ - + diff --git a/src/app/tasks/components/table.component.spec.ts b/src/app/tasks/components/table.component.spec.ts index e91f33d19..6c96cb185 100644 --- a/src/app/tasks/components/table.component.spec.ts +++ b/src/app/tasks/components/table.component.spec.ts @@ -1,17 +1,11 @@ -import { FilterStringOperator, TaskOptionEnumField, TaskStatus, TaskSummaryEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { TaskStatus } from '@aneoconsultingfr/armonik.api.angular'; import { Clipboard } from '@angular/cdk/clipboard'; -import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { TableColumn } from '@app/types/column.type'; import { ArmonikData, ColumnKey, TaskData } from '@app/types/data'; -import { FiltersOr } from '@app/types/filters'; -import { CacheService } from '@services/cache.service'; -import { FiltersService } from '@services/filters.service'; import { NotificationService } from '@services/notification.service'; -import { BehaviorSubject, Subject, of, throwError } from 'rxjs'; import { TasksTableComponent } from './table.component'; -import { TasksGrpcService } from '../services/tasks-grpc.service'; -import { TasksIndexService } from '../services/tasks-index.service'; +import TasksDataService from '../services/tasks-data.service'; import { TasksStatusesService } from '../services/tasks-statuses.service'; import { TaskOptions, TaskSummary } from '../types'; @@ -46,21 +40,6 @@ describe('TasksTableComponent', () => { } ]; - const mockTasksIndexService = { - isActionsColumn: jest.fn(), - isTaskIdColumn: jest.fn(), - isStatusColumn: jest.fn(), - isDateColumn: jest.fn(), - isDurationColumn: jest.fn(), - isObjectColumn: jest.fn(), - isSelectColumn: jest.fn(), - isSimpleColumn: jest.fn(), - isNotSortableColumn: jest.fn(), - columnToLabel: jest.fn(), - saveColumns: jest.fn(), - saveOptions: jest.fn(), - }; - const mockNotificationService = { success: jest.fn(), error: jest.fn(), @@ -70,162 +49,47 @@ describe('TasksTableComponent', () => { copy: jest.fn() }; - const tasks = { tasks: [{ id: 'task1' }, { id: 'task2' }, { id: 'task3' }], total: 3 }; - const mockTasksGrpcService = { - list$: jest.fn(() => of(tasks)), - cancel$: jest.fn(() => of({})), - }; - - const cachedtasks = { tasks: [{ id: 'task1' }, { id: 'task2' }], total: 2 }; - const mockCacheService = { - get: jest.fn(() => cachedtasks), - save: jest.fn(), + const mockTasksDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [], + refresh$: { + next: jest.fn() + }, + cancelTask: jest.fn(), }; beforeEach(() => { component = TestBed.configureTestingModule({ providers: [ TasksTableComponent, - { provide: TasksIndexService, useValue: mockTasksIndexService }, - { provide: TasksGrpcService, useValue: mockTasksGrpcService }, TasksStatusesService, - FiltersService, - { provide: CacheService, useValue: mockCacheService }, { provide: NotificationService, useValue: mockNotificationService }, { provide: Clipboard, useValue: mockClipBoard }, + { provide: TasksDataService, useValue: mockTasksDataService } ] }).inject(TasksTableComponent); component.displayedColumns = displayedColumns; component.selection = []; - component.filters$ = new BehaviorSubject>([]); - component.options = { - pageIndex: 0, - pageSize: 10, - sort: { - active: 'id', - direction: 'desc' - } - }; - component.refresh$ = new Subject(); - component.loading = signal(false); - component.ngOnInit(); - component.ngAfterViewInit(); }); it('should run', () => { expect(component).toBeTruthy(); }); - describe('initialisation', () => { - it('should load cached data from cachedService', () => { - expect(mockCacheService.get).toHaveBeenCalled(); - }); - }); - - describe('loadFromCache', () => { - beforeEach(() => { - component.loadFromCache(); - }); - - it('should update total data with cached one', () => { - expect(component.total).toEqual(cachedtasks.total); - }); - - it('should update data with cached one', () => { - expect(component.data()).toEqual([ - { - raw: { - id: 'task1' - }, - resultsQueryParams: { - '1-root-3-0': 'task1' - } - }, - { - raw: { - id: 'task2' - }, - resultsQueryParams: { - '1-root-3-0': 'task2' - } - }, - ]); - }); - }); - - it('should update data on refresh', () => { - component.refresh$.next(); - expect(component.data()).toEqual([ - { - raw: { - id: 'task1' - }, - resultsQueryParams: { - '1-root-3-0': 'task1' - } - }, - { - raw: { - id: 'task2' - }, - resultsQueryParams: { - '1-root-3-0': 'task2' - } - }, - { - raw: { - id: 'task3' - }, - resultsQueryParams: { - '1-root-3-0': 'task3' - } - } - ]); - }); - - it('should cache received data', () => { - component.refresh$.next(); - expect(mockCacheService.get).toHaveBeenCalled(); - }); - it('should return columns keys', () => { expect(component.columnKeys).toEqual(displayedColumns.map(column => column.key)); }); - - describe('on list error', () => { - beforeEach(() => { - mockTasksGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); - }); - - it('should log error', () => { - const spy = jest.spyOn(console, 'error').mockImplementation(() => { }); - component.refresh$.next(); - expect(spy).toHaveBeenCalled(); - }); - - it('should send a notification', () => { - component.refresh$.next(); - expect(mockNotificationService.error).toHaveBeenCalled(); - }); - - it('should send empty data', () => { - component.refresh$.next(); - expect(component.data()).toEqual([]); - }); - }); describe('options changes', () => { - it('should refresh data', () => { - const spy = jest.spyOn(component.refresh$, 'next'); + it('emit', () => { + const spy = jest.spyOn(component.optionsUpdate, 'emit'); component.onOptionsChange(); expect(spy).toHaveBeenCalled(); }); - - it('should save options', () => { - component.onOptionsChange(); - expect(mockTasksIndexService.saveOptions).toHaveBeenCalled(); - }); }); it('should check if the task is retried', () => { @@ -252,10 +116,10 @@ describe('TasksTableComponent', () => { expect(spy).toHaveBeenCalledWith(task); }); - it('should emit on cancel task', () => { + it('should call the cancelTask method on cancel', () => { const id = 'taskId'; component.onCancelTask(id); - expect(mockTasksGrpcService.cancel$).toHaveBeenCalledWith([id]); + expect(mockTasksDataService.cancelTask).toHaveBeenCalledWith(id); }); it('should check if task can be cancelled', () => { @@ -277,10 +141,11 @@ describe('TasksTableComponent', () => { }); }); - test('onDrop should call tasksIndexService', () => { + test('onDrop should emit', () => { + const spy = jest.spyOn(component.columnUpdate, 'emit'); const newColumns: ColumnKey[] = ['actions', 'id', 'status']; component.onDrop(newColumns); - expect(mockTasksIndexService.saveColumns).toHaveBeenCalledWith(newColumns); + expect(spy).toHaveBeenCalledWith(newColumns); }); it('should emit on selection change', () => { @@ -333,61 +198,6 @@ describe('TasksTableComponent', () => { }); }); - describe('Results query params', () => { - const id = 'taskId'; - it('should return the taskId if there is no filter', () => { - expect(component.createResultsQueryParams(id)).toEqual({ - '1-root-3-0': id - }); - }); - - it('should add filters if there is any', () => { - component.filters = [ - [ - { - field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, - for: 'root', - operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, - value: 'session1' - }, - { - field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_TASK_ID, - for: 'root', - operator: FilterStringOperator.FILTER_STRING_OPERATOR_NOT_EQUAL, - value: 'taskId' - } - ], - [ - { - field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_TASK_ID, - for: 'root', - operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, - value: 'should not appear' - }, - { - field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, - for: 'root', - operator: FilterStringOperator.FILTER_STRING_OPERATOR_STARTS_WITH, - value: 'session2' - }, - { - field: null, - for: 'root', - operator: null, - value: 'neither should this' - } - ] - ]; - expect(component.createResultsQueryParams(id)).toEqual({ - '0-root-1-2': 'session1', - '0-root-3-1': 'taskId', - '0-root-3-0': id, - '1-root-1-4': 'session2', - '1-root-3-0': id, - }); - }); - }); - describe('actions', () => { const task = { raw: { @@ -423,7 +233,7 @@ describe('TasksTableComponent', () => { it('should permit to cancel task', () => { component.actions[3].action$.next(task); - expect(mockTasksGrpcService.cancel$).toHaveBeenCalledWith([task.raw.id]); + expect(mockTasksDataService.cancelTask).toHaveBeenCalledWith(task.raw.id); }); it('should not permit to cancel tasks if the task is not cancellable', () => { @@ -433,7 +243,7 @@ describe('TasksTableComponent', () => { }); it('should open views in logs', () => { - const spy = jest.spyOn(window, 'open'); + const spy = jest.spyOn(window, 'open').mockImplementation(() => null); component.serviceIcon = 'icon'; component.serviceName = 'service'; component.urlTemplate = 'https://myurl.com?taskId=%taskId'; @@ -460,4 +270,28 @@ describe('TasksTableComponent', () => { const task = { raw: { id: 'task' } } as TaskData; expect(component.trackBy(0, task)).toEqual(task.raw.id); }); + + it('should get data', () => { + expect(component.data).toEqual(mockTasksDataService.data); + }); + + it('should get total', () => { + expect(component.total).toEqual(mockTasksDataService.total); + }); + + it('should get options', () => { + expect(component.options).toEqual(mockTasksDataService.options); + }); + + it('should get filters', () => { + expect(component.filters).toEqual(mockTasksDataService.filters); + }); + + it('should get column keys', () => { + expect(component.columnKeys).toEqual(displayedColumns.map(c => c.key)); + }); + + it('should get displayedColumns', () => { + expect(component.displayedColumns).toEqual(displayedColumns); + }); }); \ No newline at end of file diff --git a/src/app/tasks/components/table.component.ts b/src/app/tasks/components/table.component.ts index 45c06ec8d..54632d0fb 100644 --- a/src/app/tasks/components/table.component.ts +++ b/src/app/tasks/components/table.component.ts @@ -1,19 +1,14 @@ -import { FilterStringOperator, ListTasksResponse, ResultRawEnumField, TaskOptionEnumField, TaskSummaryEnumField} from '@aneoconsultingfr/armonik.api.angular'; +import { TaskOptionEnumField, TaskSummaryEnumField} from '@aneoconsultingfr/armonik.api.angular'; import { Clipboard, } from '@angular/cdk/clipboard'; -import { AfterViewInit, Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; +import { Component, EventEmitter, Input, Output, inject } from '@angular/core'; import { Router} from '@angular/router'; import { AbstractTableComponent } from '@app/types/components/table'; import { Scope } from '@app/types/config'; import { ArmonikData, TaskData } from '@app/types/data'; -import { Filter } from '@app/types/filters'; import { ActionTable } from '@app/types/table'; import { TableComponent } from '@components/table/table.component'; -import { FiltersService } from '@services/filters.service'; -import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; import { Subject } from 'rxjs'; -import { TasksGrpcService } from '../services/tasks-grpc.service'; -import { TasksIndexService } from '../services/tasks-index.service'; +import TasksDataService from '../services/tasks-data.service'; import { TasksStatusesService } from '../services/tasks-statuses.service'; import { TaskOptions, TaskSummary } from '../types'; @@ -22,18 +17,13 @@ import { TaskOptions, TaskSummary } from '../types'; standalone: true, templateUrl: './table.component.html', providers: [ - MatDialog, - FiltersService, Clipboard, - TasksGrpcService, - GrpcSortFieldService, ], imports: [ TableComponent ] }) -export class TasksTableComponent extends AbstractTableComponent - implements OnInit, AfterViewInit { +export class TasksTableComponent extends AbstractTableComponent { scope: Scope = 'tasks'; @Input({ required: false }) set serviceIcon(entry: string | null) { @@ -59,8 +49,7 @@ export class TasksTableComponent extends AbstractTableComponent(); @Output() selectionChange = new EventEmitter(); - readonly indexService = inject(TasksIndexService); - readonly grpcService = inject(TasksGrpcService); + readonly tableDataService = inject(TasksDataService); readonly router = inject(Router); readonly clipboard = inject(Clipboard); readonly tasksStatusesService = inject(TasksStatusesService); @@ -123,62 +112,10 @@ export class TasksTableComponent extends AbstractTableComponent(1, 'root', FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, ResultRawEnumField.RESULT_RAW_ENUM_FIELD_OWNER_TASK_ID); - - return { - [keyTask]: taskId - }; - } else { - const params: Record = {}; - this.filters.forEach((filterAnd, index) => { - filterAnd.forEach(filter => { - if (!(filter.field === TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_TASK_ID && filter.operator === FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL)) { - const filterLabel = this.#createResultFilterLabel(filter, index); - if (filterLabel && filter.value) params[filterLabel] = filter.value.toString(); - } - }); - params[`${index}-root-${ResultRawEnumField.RESULT_RAW_ENUM_FIELD_OWNER_TASK_ID}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`] = taskId; - }); - return params; - } - } - - #createResultFilterLabel(filter: Filter, orGroup: number) { - if (filter.field !== null && filter.operator !== null) { - if (filter.field === TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_TASK_ID) { - return this.filtersService.createQueryParamsKey(orGroup, 'root', filter.operator, ResultRawEnumField.RESULT_RAW_ENUM_FIELD_OWNER_TASK_ID); - } else if (filter.field === TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID) { - return this.filtersService.createQueryParamsKey(orGroup, 'root', filter.operator, ResultRawEnumField.RESULT_RAW_ENUM_FIELD_SESSION_ID); - } - } - return null; - } - onCopiedTaskId(element: ArmonikData) { this.clipboard.copy(element.raw.id); this.notificationService.success('Task ID copied to clipboard'); @@ -196,8 +133,8 @@ export class TasksTableComponent extends AbstractTableComponent this.notificationService.success('Task canceled')); + onCancelTask(taskId: string) { + this.tableDataService.cancelTask(taskId); } onSelectionChange($event: TaskSummary[]): void { diff --git a/src/app/tasks/index.component.html b/src/app/tasks/index.component.html index d84d80ecc..e3426868f 100644 --- a/src/app/tasks/index.component.html +++ b/src/app/tasks/index.component.html @@ -6,14 +6,14 @@ \ No newline at end of file diff --git a/src/app/tasks/index.component.spec.ts b/src/app/tasks/index.component.spec.ts index 9f2e0d03e..71ccd4784 100644 --- a/src/app/tasks/index.component.spec.ts +++ b/src/app/tasks/index.component.spec.ts @@ -12,20 +12,16 @@ import { AutoRefreshService } from '@services/auto-refresh.service'; import { IconsService } from '@services/icons.service'; import { NotificationService } from '@services/notification.service'; import { ShareUrlService } from '@services/share-url.service'; -import { of, throwError } from 'rxjs'; +import { of } from 'rxjs'; import { IndexComponent } from './index.component'; +import TasksDataService from './services/tasks-data.service'; import { TasksFiltersService } from './services/tasks-filters.service'; -import { TasksGrpcService } from './services/tasks-grpc.service'; import { TasksIndexService } from './services/tasks-index.service'; import { TaskOptions, TaskSummary } from './types'; describe('Tasks Index Component', () => { let component: IndexComponent; - const mockTasksGrpcService = { - cancel$: jest.fn(() => of()), - }; - const newCustomColumns: CustomColumn[] = ['options.options.FastCompute', 'options.options.NewCustom']; const mockMatDialog = { @@ -152,6 +148,18 @@ describe('Tasks Index Component', () => { warning: jest.fn(), }; + const mockTasksDataService = { + data: [], + total: 0, + loading: false, + options: {}, + filters: [], + refresh$: { + next: jest.fn() + }, + cancelTasks: jest.fn(), + }; + beforeEach(() => { component = TestBed.configureTestingModule({ providers: [ @@ -159,7 +167,7 @@ describe('Tasks Index Component', () => { IconsService, AutoRefreshService, { provide: TasksIndexService, useValue: mockTasksIndexService }, - { provide: TasksGrpcService, useValue: mockTasksGrpcService }, + { provide: TasksDataService, useValue: mockTasksDataService }, { provide: MatDialog, useValue: mockMatDialog }, { provide: DashboardIndexService, useValue: mockDashboardIndexService }, { provide: Router, useValue: mockRouter }, @@ -181,6 +189,10 @@ describe('Tasks Index Component', () => { expect(component.displayedColumnsKeys).toEqual([...defaultColumns, ...defaultCustomColumns]); }); + it('should load properly', () => { + expect(component.loading).toEqual(mockTasksDataService.loading); + }); + describe('initialisation', () => { it('should initialise columns (with customs)', () => { expect(component.displayedColumnsKeys).toEqual([...defaultColumns, ...defaultCustomColumns]); @@ -224,7 +236,6 @@ describe('Tasks Index Component', () => { it('should initialise filters', () => { expect(component.filters).toEqual([]); - expect(component.filters$).toBeDefined(); }); it('should init options', () => { @@ -254,9 +265,8 @@ describe('Tasks Index Component', () => { }); it('should refresh', () => { - const spy = jest.spyOn(component.refresh$, 'next'); - component.onRefresh(); - expect(spy).toHaveBeenCalled(); + component.refresh(); + expect(mockTasksDataService.refresh$.next).toHaveBeenCalled(); }); describe('On interval value change', () => { @@ -272,9 +282,8 @@ describe('Tasks Index Component', () => { }); it('should refresh if the value is not null', () => { - const spy = jest.spyOn(component.refresh$, 'next'); component.onIntervalValueChange(5); - expect(spy).toHaveBeenCalled(); + expect(mockTasksDataService.refresh$.next).toHaveBeenCalled(); }); it('should stop the interval if the value is 0', () => { @@ -367,8 +376,21 @@ describe('Tasks Index Component', () => { }); }); - describe('On Filters Change', () => { + describe('On Options Change', () => { + beforeEach(() => { + component.onOptionsChange(); + }); + + it('should save options', () => { + expect(mockTasksIndexService.saveOptions).toHaveBeenCalledWith(mockTasksDataService.options); + }); + it('should refresh', () => { + expect(mockTasksDataService.refresh$.next).toHaveBeenCalled(); + }); + }); + + describe('On Filters Change', () => { const newFilters: FiltersOr = [ [ { @@ -380,15 +402,12 @@ describe('Tasks Index Component', () => { ] ]; - let filterSpy: jest.SpyInstance; - beforeEach(() => { - filterSpy = jest.spyOn(component.filters$, 'next'); component.onFiltersChange(newFilters); }); it('should update filters', () => { - expect(component.filters).toEqual(newFilters); + expect(mockTasksDataService.filters).toEqual(newFilters); }); it('should save filters', () => { @@ -398,17 +417,10 @@ describe('Tasks Index Component', () => { it('should update page index', () => { expect(component.options.pageIndex).toEqual(0); }); - - it('should emit filters', () => { - expect(filterSpy).toHaveBeenCalledWith(newFilters); - }); }); describe('On Filter Reset', () => { - let filterSpy: jest.SpyInstance; - beforeEach(() => { - filterSpy = jest.spyOn(component.filters$, 'next'); component.onFiltersReset(); }); @@ -419,10 +431,6 @@ describe('Tasks Index Component', () => { it('should reset page index', () => { expect(component.options.pageIndex).toEqual(0); }); - - it('should emit empty filters', () => { - expect(filterSpy).toHaveBeenCalledWith([]); - }); }); describe('On lockColumns Change', () => { @@ -525,44 +533,17 @@ describe('Tasks Index Component', () => { expect(component.selection).toEqual(selection); }); - describe('Cancel Tasks', () => { - const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - it('should cancel tasks', () => { - const tasks = ['taskId']; - component.cancelTasks(tasks); - expect(mockTasksGrpcService.cancel$).toHaveBeenCalledWith(tasks); - }); - - it('should notify on success', () => { - component.cancelTasks(['taskId']); - expect(mockNotificationService.success).toHaveBeenCalledWith('Tasks canceled'); - }); - - it('should refresh on success', () => { - const spy = jest.spyOn(component.refresh$, 'next'); - component.cancelTasks(['taskId']); - expect(spy).toHaveBeenCalled(); - }); - - it('should notify on errors', () => { - mockTasksGrpcService.cancel$.mockReturnValueOnce(throwError(() => new Error())); - component.cancelTasks(['taskId']); - expect(mockNotificationService.error).toHaveBeenCalledWith('Unable to cancel tasks'); - }); - - it('should log errors', () => { - mockTasksGrpcService.cancel$.mockReturnValueOnce(throwError(() => new Error('Error'))); - component.cancelTasks(['taskId']); - expect(spy).toHaveBeenCalledWith(new Error('Error')); - }); + it('should cancel tasks', () => { + const tasks = ['taskId']; + component.cancelTasks(tasks); + expect(mockTasksDataService.cancelTasks).toHaveBeenCalledWith(tasks); }); it('should cancel selected tasks', () => { const selection = ['taskId1', 'taskId2']; component.selection = selection; component.onCancelTasksSelection(); - expect(mockTasksGrpcService.cancel$).toHaveBeenCalledWith(selection); + expect(mockTasksDataService.cancelTasks).toHaveBeenCalledWith(selection); }); describe('Manage view in logs', () => { diff --git a/src/app/tasks/index.component.ts b/src/app/tasks/index.component.ts index 1b2df15da..8bb03cd61 100644 --- a/src/app/tasks/index.component.ts +++ b/src/app/tasks/index.component.ts @@ -26,6 +26,7 @@ import { TableService } from '@services/table.service'; import { UtilsService } from '@services/utils.service'; import { ManageViewInLogsDialogComponent } from './components/manage-view-in-logs-dialog.component'; import { TasksTableComponent } from './components/table.component'; +import TasksDataService from './services/tasks-data.service'; import { TasksFiltersService } from './services/tasks-filters.service'; import { TasksGrpcService } from './services/tasks-grpc.service'; import { TasksIndexService } from './services/tasks-index.service'; @@ -69,13 +70,14 @@ import { TaskOptions, TaskSummary, TaskSummaryFilter } from './types'; DashboardIndexService, DashboardStorageService, GrpcSortFieldService, + TasksDataService, ], }) export class IndexComponent extends TableHandlerCustomValues implements OnInit, AfterViewInit, OnDestroy { - readonly tasksGrpcService = inject(TasksGrpcService); readonly notificationService = inject(NotificationService); readonly indexService = inject(TasksIndexService); readonly filtersService = inject(TasksFiltersService); + readonly tableDataService = inject(TasksDataService); tableType: TableType = 'Tasks'; @@ -122,16 +124,7 @@ export class IndexComponent extends TableHandlerCustomValues { - this.notificationService.success('Tasks canceled'); - this.refresh$.next(); - }, - error: (error) => { - console.error(error); - this.notificationService.error('Unable to cancel tasks'); - }, - }); + this.tableDataService.cancelTasks(tasksIds); } manageViewInLogs(): void { diff --git a/src/app/tasks/services/tasks-data.service.spec.ts b/src/app/tasks/services/tasks-data.service.spec.ts new file mode 100644 index 000000000..de101aeef --- /dev/null +++ b/src/app/tasks/services/tasks-data.service.spec.ts @@ -0,0 +1,299 @@ +import { TaskSummaryEnumField, FilterStringOperator, ListTasksResponse, TaskOptionEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { TestBed } from '@angular/core/testing'; +import { FiltersOr } from '@app/types/filters'; +import { ListOptions } from '@app/types/options'; +import { GrpcStatusEvent } from '@ngx-grpc/common'; +import { CacheService } from '@services/cache.service'; +import { FiltersService } from '@services/filters.service'; +import { NotificationService } from '@services/notification.service'; +import { of, throwError } from 'rxjs'; +import TasksDataService from './tasks-data.service'; +import { TaskOptions, TaskSummary } from '../types'; +import { TasksGrpcService } from './tasks-grpc.service'; + +describe('TasksDataService', () => { + let service: TasksDataService; + + const cachedTasks = { tasks: [{ id: 'task1' }, { id: 'task2' }], total: 2 } as unknown as ListTasksResponse; + const mockCacheService = { + get: jest.fn(() => cachedTasks), + save: jest.fn(), + }; + + const tasks = { tasks: [{ id: 'task1' }, { id: 'task2' }, { id: 'task3' }], total: 3 } as unknown as ListTasksResponse; + const mockTasksGrpcService = { + list$: jest.fn(() => of(tasks)), + cancel$: jest.fn(() => of({})), + }; + + const mockNotificationService = { + success: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }; + + const initialOptions: ListOptions = { + pageIndex: 0, + pageSize: 10, + sort: { + active: 'id', + direction: 'desc' + } + }; + + const initialFilters: FiltersOr = []; + + beforeEach(() => { + service = TestBed.configureTestingModule({ + providers: [ + TasksDataService, + FiltersService, + { provide: TasksGrpcService, useValue: mockTasksGrpcService }, + { provide: NotificationService, useValue: mockNotificationService }, + { provide: CacheService, useValue: mockCacheService }, + ] + }).inject(TasksDataService); + service.options = initialOptions; + service.filters = initialFilters; + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('initialisation', () => { + it('should load data from the cache', () => { + expect(service.data).toEqual([ + { + raw: { + id: 'task1' + }, + resultsQueryParams: { + '1-root-3-0': 'task1' + } + }, + { + raw: { + id: 'task2' + }, + resultsQueryParams: { + '1-root-3-0': 'task2' + } + }, + ]); + }); + + it('should set the total cached data', () => { + expect(service.total).toEqual(cachedTasks.total); + }); + }); + + describe('Fetching data', () => { + it('should list the data', () => { + service.refresh$.next(); + expect(mockTasksGrpcService.list$).toHaveBeenCalledWith(service.options, service.filters); + }); + + it('should update the total', () => { + service.refresh$.next(); + expect(service.total).toEqual(tasks.total); + }); + + it('should update the data', () => { + service.refresh$.next(); + expect(service.data).toEqual([ + { + raw: { + id: 'task1' + }, + resultsQueryParams: { + '1-root-3-0': 'task1' + } + }, + { + raw: { + id: 'task2' + }, + resultsQueryParams: { + '1-root-3-0': 'task2' + } + }, + { + raw: { + id: 'task3' + }, + resultsQueryParams: { + '1-root-3-0': 'task3' + } + } + ]); + }); + + it('should handle an empty DataSummary', () => { + const emptyTasks = { tasks: undefined, total: 0 } as unknown as ListTasksResponse; + mockTasksGrpcService.list$.mockReturnValueOnce(of(emptyTasks)); + service.refresh$.next(); + expect(service.data).toEqual([]); + }); + + it('should catch errors', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockTasksGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); + service.refresh$.next(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('should notify errors', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + mockTasksGrpcService.list$.mockReturnValueOnce(throwError(() => new Error())); + service.refresh$.next(); + expect(mockNotificationService.error).toHaveBeenCalled(); + }); + + it('should cache the summary data', () => { + service.refresh$.next(); + expect(mockCacheService.save).toHaveBeenCalledWith(service.scope, tasks); + }); + }); + + it('should display a success message', () => { + const message = 'A success message !'; + service.success(message); + expect(mockNotificationService.success).toHaveBeenCalledWith(message); + }); + + it('should display a warning message', () => { + const message = 'A warning message !'; + service.warning(message); + expect(mockNotificationService.warning).toHaveBeenCalledWith(message); + }); + + it('should display an error message', () => { + const error: GrpcStatusEvent = { + statusMessage: 'A error status message' + } as GrpcStatusEvent; + jest.spyOn(console, 'error').mockImplementation(() => {}); + service.error(error); + expect(mockNotificationService.error).toHaveBeenCalledWith(error.statusMessage); + }); + + it('should load correctly', () => { + expect(service.loading).toBeFalsy(); + }); + + describe('Applying filters', () => { + const filters: FiltersOr = [ + [ + { + field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, + value: 'session1' + }, + { + field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_TASK_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_NOT_EQUAL, + value: 'taskId' + } + ], + [ + { + field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_TASK_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, + value: 'should not appear' + }, + { + field: TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_STARTS_WITH, + value: 'session2' + }, + { + field: null, + for: 'root', + operator: null, + value: 'neither should this' + } + ] + ]; + + it('should apply the filters correctly when transforming the data', () => { + service.filters = filters; + service.refresh$.next(); + expect(service.data).toEqual([ + { + raw: { + id: 'task1' + }, + resultsQueryParams: { + '0-root-1-2': 'session1', + '0-root-3-1': 'taskId', + '0-root-3-0': 'task1', + '1-root-1-4': 'session2', + '1-root-3-0': 'task1', + } + }, + { + raw: { + id: 'task2' + }, + resultsQueryParams: { + '0-root-1-2': 'session1', + '0-root-3-1': 'taskId', + '0-root-3-0': 'task2', + '1-root-1-4': 'session2', + '1-root-3-0': 'task2', + } + }, + { + raw: { + id: 'task3' + }, + resultsQueryParams: { + '0-root-1-2': 'session1', + '0-root-3-1': 'taskId', + '0-root-3-0': 'task3', + '1-root-1-4': 'session2', + '1-root-3-0': 'task3', + } + } + ]); + }); + }); + + describe('Cancel tasks', () => { + const tasksToCancel = ['1', '2', '3']; + + it('should cancel tasks', () => { + service.cancelTasks(tasksToCancel); + expect(mockTasksGrpcService.cancel$).toHaveBeenCalledWith(tasksToCancel); + }); + + it('should display a success message', () => { + service.cancelTasks(tasksToCancel); + expect(mockNotificationService.success).toHaveBeenCalled(); + }); + + it('should log errors', () => { + mockTasksGrpcService.cancel$.mockReturnValueOnce(throwError(() => new Error())); + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + service.cancelTasks(tasksToCancel); + expect(spy).toHaveBeenCalled(); + }); + + it('should display an error message', () => { + mockTasksGrpcService.cancel$.mockReturnValueOnce(throwError(() => new Error())); + jest.spyOn(console, 'error').mockImplementation(() => {}); + service.cancelTasks(tasksToCancel); + expect(mockNotificationService.error).toHaveBeenCalled(); + }); + }); + + it('should cancel one task', () => { + const task = '1'; + service.cancelTask(task); + expect(mockTasksGrpcService.cancel$).toHaveBeenCalledWith([task]); + }); +}); \ No newline at end of file diff --git a/src/app/tasks/services/tasks-data.service.ts b/src/app/tasks/services/tasks-data.service.ts new file mode 100644 index 000000000..2fe9a98fe --- /dev/null +++ b/src/app/tasks/services/tasks-data.service.ts @@ -0,0 +1,88 @@ +import { FilterStringOperator, ListTasksResponse, ResultRawEnumField, TaskOptionEnumField, TaskSummaryEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { Injectable, inject } from '@angular/core'; +import { Scope } from '@app/types/config'; +import { TaskData } from '@app/types/data'; +import { Filter } from '@app/types/filters'; +import { AbstractTableDataService } from '@app/types/services/table-data.service'; +import { catchError, of } from 'rxjs'; +import { TaskOptions, TaskSummary } from '../types'; +import { TasksGrpcService } from './tasks-grpc.service'; + +@Injectable() +export default class TasksDataService extends AbstractTableDataService { + readonly grpcService = inject(TasksGrpcService); + + scope: Scope = 'tasks'; + + computeGrpcData(entries: ListTasksResponse): TaskSummary[] | undefined { + return entries.tasks; + } + + createNewLine(entry: TaskSummary): TaskData { + return { + raw: entry, + resultsQueryParams: this.createResultsQueryParams(entry.id), + }; + } + + /** + * Create the Params required to go to the result page and filtering it on the **ownerTaskId** Field. + */ + createResultsQueryParams(taskId: string) { + if (this.filters.length === 0) { + const keyTask = this.filtersService.createQueryParamsKey(1, 'root', FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, ResultRawEnumField.RESULT_RAW_ENUM_FIELD_OWNER_TASK_ID); + + return { + [keyTask]: taskId + }; + } else { + const params: Record = {}; + this.filters.forEach((filterAnd, index) => { + filterAnd.forEach(filter => { + if (!(filter.field === TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_TASK_ID && filter.operator === FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL)) { + const filterLabel = this.#createResultFilterLabel(filter, index); + if (filterLabel && filter.value) params[filterLabel] = filter.value.toString(); + } + }); + params[`${index}-root-${ResultRawEnumField.RESULT_RAW_ENUM_FIELD_OWNER_TASK_ID}-${FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL}`] = taskId; + }); + return params; + } + } + + /** + * Transform an applied filter into a query param adapted to the result page. + * + * Fields supported: + * - **ownerTaskId** + * - **sessionId** + */ + #createResultFilterLabel(filter: Filter, orGroup: number) { + if (filter.field !== null && filter.operator !== null) { + if (filter.field === TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_TASK_ID) { + return this.filtersService.createQueryParamsKey(orGroup, 'root', filter.operator, ResultRawEnumField.RESULT_RAW_ENUM_FIELD_OWNER_TASK_ID); + } else if (filter.field === TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_SESSION_ID) { + return this.filtersService.createQueryParamsKey(orGroup, 'root', filter.operator, ResultRawEnumField.RESULT_RAW_ENUM_FIELD_SESSION_ID); + } + } + return null; + } + + cancelTasks(ids: string[]) { + this.grpcService.cancel$(ids) + .pipe( + catchError((error) => { + this.error(error, 'Could not cancel Tasks'); + return of(null); + }) + ).subscribe((data) => { + if (data) { + this.success('Tasks cancelled'); + } + }); + } + + cancelTask(id: string) { + this.cancelTasks([id]); + } +} \ No newline at end of file diff --git a/src/app/types/components/dashboard-line-table.ts b/src/app/types/components/dashboard-line-table.ts index ca6f36292..4ea85a281 100644 --- a/src/app/types/components/dashboard-line-table.ts +++ b/src/app/types/components/dashboard-line-table.ts @@ -7,8 +7,7 @@ import { ManageCustomColumnDialogComponent } from '@components/manage-custom-dia import { AutoRefreshService } from '@services/auto-refresh.service'; import { DefaultConfigService } from '@services/default-config.service'; import { IconsService } from '@services/icons.service'; -import { NotificationService } from '@services/notification.service'; -import { BehaviorSubject, Observable, Subject, Subscription, merge } from 'rxjs'; +import { Observable, Subject, Subscription } from 'rxjs'; import { TableColumn } from '../column.type'; import { ScopeConfig } from '../config'; import { ColumnKey, CustomColumn, DataRaw } from '../data'; @@ -16,6 +15,7 @@ import { EditNameLineData } from '../dialog'; import { FiltersEnums, FiltersOptionsEnums, FiltersOr } from '../filters'; import { ListOptions } from '../options'; import { IndexServiceCustomInterface, IndexServiceInterface } from '../services/indexService'; +import { AbstractTableDataService } from '../services/table-data.service'; @Component({ selector: 'app-dashboard-line-table', @@ -26,8 +26,8 @@ export abstract class DashboardLineTableComponent; abstract readonly indexService: IndexServiceInterface; abstract readonly defaultConfig: ScopeConfig; @@ -35,14 +35,8 @@ export abstract class DashboardLineTableComponent = new EventEmitter(); @Output() lineDelete: EventEmitter> = new EventEmitter>(); - loading = signal(false); - - filters: FiltersOr; - filters$: Subject>; showFilters: boolean; - options: ListOptions; - displayedColumnsKeys: ColumnKey[] = []; readonly displayedColumns = signal[]>([]); availableColumns: ColumnKey[] = []; @@ -51,25 +45,37 @@ export abstract class DashboardLineTableComponent = new Subject(); - refresh: Subject = new Subject(); stopInterval: Subject = new Subject(); interval: Subject = new Subject(); subscriptions: Subscription = new Subscription(); interval$: Observable = this.autoRefreshService.createInterval(this.interval, this.stopInterval); + get options() { + return this.tableDataService.options; + } + + get filters() { + return this.tableDataService.filters; + } + + get loading() { + return this.tableDataService.loading; + } + initLineEnvironment() { this.initColumns(); + this.updateDisplayedColumns(); this.initOptions(); this.initFilters(); this.initFilters(); this.initInterval(); + this.handleAutoRefreshStart(); } initColumns() { this.availableColumns = this.indexService.availableTableColumns.map(c => c.key); this.displayedColumnsKeys = this.line.displayedColumns ?? this.indexService.defaultColumns; - this.updateDisplayedColumns(); + this.indexService.availableTableColumns.forEach(column => { this.columnsLabels[column.key] = column.name; }); @@ -77,9 +83,8 @@ export abstract class DashboardLineTableComponent; + this.tableDataService.filters = this.line.filters as FiltersOr; this.showFilters = this.line.showFilters ?? this.defaultConfig.showFilters; - this.filters$ = new BehaviorSubject(this.filters); } initInterval() { @@ -88,12 +93,12 @@ export abstract class DashboardLineTableComponent) ?? this.defaultConfig.options; + this.tableDataService.options = (this.line.options as ListOptions) ?? this.defaultConfig.options; } mergeSubscriptions() { - const mergeSubscription = merge(this.refresh, this.interval$).subscribe(() => this.refresh$.next()); - this.subscriptions.add(mergeSubscription); + const intervalSubscription = this.interval$.subscribe(() => this.refresh()); + this.subscriptions.add(intervalSubscription); } unsubscribe() { @@ -114,8 +119,17 @@ export abstract class DashboardLineTableComponent) { - this.filters = value; + this.tableDataService.filters = value; this.line.filters = value as []; + this.tableDataService.options.pageIndex = 0; this.lineChange.emit(); - this.filters$.next(this.filters); } onFiltersReset() { - this.filters = []; + this.tableDataService.filters = []; this.line.filters = []; + this.tableDataService.options.pageIndex = 0; this.lineChange.emit(); - this.filters$.next([]); } onShowFiltersChange(value: boolean) { @@ -171,10 +188,13 @@ export abstract class DashboardLineTableComponent[]) { - this.displayedColumnsKeys = data; + onColumnsChange(columns: ColumnKey[]) { + if ((columns as string[]).includes('select')) { + columns = ['select' as ColumnKey, ...columns.filter(column => column !== 'select')]; + } + this.displayedColumnsKeys = columns; this.updateDisplayedColumns(); - this.line.displayedColumns = data; + this.line.displayedColumns = columns; this.lineChange.emit(); } @@ -202,15 +222,9 @@ export abstract class DashboardLineCustomColumnsComponent[] ?? this.indexService.defaultColumns)]; - this.availableColumns = this.indexService.availableTableColumns.map(column => column.key); this.availableColumns.push(...this.customColumns as ColumnKey[]); - this.lockColumns = this.line.lockColumns ?? false; - this.indexService.availableTableColumns.forEach(column => { - this.columnsLabels[column.key] = column.name; - }); - this.updateDisplayedColumns(); } override updateDisplayedColumns(): void { diff --git a/src/app/types/components/index.ts b/src/app/types/components/index.ts index 90c84fe6e..acf8d7cf5 100644 --- a/src/app/types/components/index.ts +++ b/src/app/types/components/index.ts @@ -8,13 +8,13 @@ import { ManageCustomColumnDialogComponent } from '@components/manage-custom-dia import { AutoRefreshService } from '@services/auto-refresh.service'; import { IconsService } from '@services/icons.service'; import { ShareUrlService } from '@services/share-url.service'; -import { BehaviorSubject, Observable, Subject, Subscription, merge } from 'rxjs'; +import { Observable, Subject, Subscription } from 'rxjs'; import { TableColumn } from '../column.type'; import { ColumnKey, CustomColumn, DataRaw } from '../data'; import { FiltersEnums, FiltersOptionsEnums, FiltersOr } from '../filters'; -import { ListOptions } from '../options'; import { FiltersServiceInterface } from '../services/filtersService'; import { IndexServiceCustomInterface, IndexServiceInterface } from '../services/indexService'; +import { AbstractTableDataService } from '../services/table-data.service'; import { TableType } from '../table'; export abstract class TableHandler { @@ -27,6 +27,7 @@ export abstract class TableHandler; abstract readonly filtersService: FiltersServiceInterface; + abstract readonly tableDataService: AbstractTableDataService; abstract tableType: TableType; @@ -36,26 +37,31 @@ export abstract class TableHandler, string> = {} as Record, string>; - loading = signal(false); - - options: ListOptions; - - filters: FiltersOr; - filters$: Subject>; showFilters: boolean; intervalValue = 0; sharableURL = ''; - - refresh$: Subject = new Subject(); stopInterval: Subject = new Subject(); interval: Subject = new Subject(); interval$: Observable = this.autoRefreshService.createInterval(this.interval, this.stopInterval); subscriptions: Subscription = new Subscription(); + get options() { + return this.tableDataService.options; + } + + get filters() { + return this.tableDataService.filters; + } + + get loading() { + return this.tableDataService.loading; + } + initTableEnvironment() { this.initColumns(); + this.updateDisplayedColumns(); this.initFilters(); this.initOptions(); this.intervalValue = this.indexService.restoreIntervalValue(); @@ -64,7 +70,6 @@ export abstract class TableHandler column.key); this.indexService.availableTableColumns.forEach(column => { this.columnsLabels[column.key] = column.name; @@ -73,18 +78,17 @@ export abstract class TableHandler this.refresh$.next()); - this.subscriptions.add(mergeSubscription); + const intervalSubscription = this.interval$.subscribe(() => this.refresh()); + this.subscriptions.add(intervalSubscription); this.handleAutoRefreshStart(); } @@ -102,8 +106,8 @@ export abstract class TableHandler) { - this.filters = value; + onOptionsChange() { + this.indexService.saveOptions(this.options); + this.refresh(); + } - this.filtersService.saveFilters(value); - this.options.pageIndex = 0; - this.filters$.next(this.filters); + onFiltersChange(value: FiltersOr) { + this.tableDataService.options.pageIndex = 0; + this.tableDataService.filters = value; + this.filtersService.saveFilters(this.filters); + this.refresh(); } onFiltersReset(): void { - this.filters = this.filtersService.resetFilters(); - this.options.pageIndex = 0; - this.filters$.next([]); + this.tableDataService.options.pageIndex = 0; + this.tableDataService.filters = this.filtersService.resetFilters(); + this.refresh(); } onShowFiltersChange(value: boolean) { @@ -162,7 +170,7 @@ export abstract class TableHandler>({ + this.dashboardIndexService.addLine>(this.createDashboardLine()); + this.router.navigate(['/dashboard']); + } + + protected createDashboardLine(): TableLine { + return { name: this.tableType, type: this.tableType, interval: 10, @@ -180,27 +193,19 @@ export abstract class TableHandler extends TableHandler { - abstract override readonly indexService: IndexServiceCustomInterface; customColumns: CustomColumn[]; protected override initColumns() { + super.initColumns(); this.customColumns = this.indexService.restoreCustomColumns(); - this.displayedColumnsKeys = [...this.indexService.restoreColumns()]; - this.availableColumns = this.indexService.availableTableColumns.map(column => column.key); this.availableColumns.push(...this.customColumns as ColumnKey[]); - this.lockColumns = this.indexService.restoreLockColumns(); - this.indexService.availableTableColumns.forEach(column => { - this.columnsLabels[column.key] = column.name; - }); - this.updateDisplayedColumns(); } override updateDisplayedColumns(): void { @@ -239,18 +244,7 @@ export abstract class TableHandlerCustomValues>({ - name: this.tableType, - type: this.tableType, - interval: 10, - filters: this.filters, - options: this.options, - displayedColumns: this.displayedColumnsKeys, - lockColumns: this.lockColumns, - showFilters: this.showFilters, - customColumns: this.customColumns - }); - this.router.navigate(['/dashboard']); + override createDashboardLine(): TableLine { + return {...super.createDashboardLine(), customColumns: this.customColumns}; } } \ No newline at end of file diff --git a/src/app/types/components/table.ts b/src/app/types/components/table.ts index f67c2112a..9503cc80e 100644 --- a/src/app/types/components/table.ts +++ b/src/app/types/components/table.ts @@ -1,22 +1,15 @@ import { SelectionModel } from '@angular/cdk/collections'; -import { Component, Input, WritableSignal, inject, signal } from '@angular/core'; +import { Component, EventEmitter, Input, Output, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { ManageGroupsDialogData, ManageGroupsDialogResult, TasksStatusesGroup } from '@app/dashboard/types'; -import { TaskOptions, TaskSummaryFilters } from '@app/tasks/types'; +import { TaskOptions } from '@app/tasks/types'; import { ManageGroupsDialogComponent } from '@components/statuses/manage-groups-dialog.component'; -import { GrpcStatusEvent } from '@ngx-grpc/common'; -import { CacheService } from '@services/cache.service'; -import { FiltersService } from '@services/filters.service'; import { NotificationService } from '@services/notification.service'; import { TableTasksByStatus, TasksByStatusService } from '@services/tasks-by-status.service'; -import { Observable, Subject, catchError, first, map, of, switchMap } from 'rxjs'; import { TableColumn } from '../column.type'; -import { Scope } from '../config'; -import { ArmonikData, ColumnKey, DataRaw, GrpcResponse } from '../data'; -import { FiltersEnums, FiltersOptionsEnums, FiltersOr } from '../filters'; -import { ListOptions } from '../options'; -import { GrpcTableService } from '../services/grpcService'; -import { IndexServiceInterface } from '../services/indexService'; +import { ArmonikData, ColumnKey, DataRaw } from '../data'; +import { FiltersEnums, FiltersOptionsEnums } from '../filters'; +import { AbstractTableDataService } from '../services/table-data.service'; export interface SelectableTable { selection: SelectionModel; @@ -25,131 +18,58 @@ export interface SelectableTable { checkboxLabel(row?: ArmonikData): string; } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export interface AbstractTableComponent { - /** - * Modify environment before fetching the data. - * It is required for computing values that are not directly returned by the API (example: sessions durations). - * @param options - * @param filters - */ - prepareBeforeFetching(...args: unknown[]): void; - - /** - * Call functions required to compute values after fetching the data. - * @param args - */ - afterDataCreation(...args: unknown[]): void; -} - @Component({ - selector: 'app-results-table', + selector: 'app-abstract-table', template: '', }) -// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export abstract class AbstractTableComponent { - @Input({ required: true }) displayedColumns: TableColumn[] = []; - @Input({ required: true }) options: ListOptions; - @Input({ required: true }) filters$: Subject>; - @Input({ required: true }) refresh$: Subject; - @Input({ required: true }) loading: WritableSignal; + @Input({ required: true }) set displayedColumns(columns: TableColumn[]) { + this._displayedColumns = columns; + this._columnKeys = columns.map(column => column.key); + } @Input() lockColumns = false; - - data: WritableSignal[]> = signal([]); - total: number = 0; - filters: FiltersOr = [] as FiltersOr; + @Output() columnUpdate = new EventEmitter[]>(); + @Output() optionsUpdate = new EventEmitter(); - abstract scope: Scope; + private _displayedColumns: TableColumn[] = []; + private _columnKeys: ColumnKey[]; - get columnKeys() { - return this.displayedColumns.map(c => c.key); + get data() { + return this.tableDataService.data; } - abstract readonly grpcService: GrpcTableService; - abstract readonly indexService: IndexServiceInterface; - readonly cacheService = inject(CacheService); - readonly filtersService = inject(FiltersService); - readonly notificationService = inject(NotificationService); - - initTable() { - this.loadFromCache(); + get total() { + return this.tableDataService.total; } - loadFromCache() { - this.refresh$.pipe(first()).subscribe(() => { - const cachedResponse = this.cacheService.get(this.scope); - if (cachedResponse) { - const cachedData = this.computeGrpcData(cachedResponse); - this.total = cachedResponse.total; - if (cachedData) { - this.newData(cachedData); - } - } - }); + get options() { + return this.tableDataService.options; } - list$(options: ListOptions, filters: FiltersOr): Observable { - return this.grpcService.list$(options, filters); + get filters() { + return this.tableDataService.filters; } - subscribeToData() { - this.filters$.subscribe(filters => { - this.filters = filters; - this.refresh$.next(); - }); - this.refresh$.pipe( - switchMap( - () => { - this.loading.set(true); - const options = structuredClone(this.options); - const filters = structuredClone(this.filters); - - if (this.prepareBeforeFetching) { - this.prepareBeforeFetching(options, filters); - } - - return this.list$(options, filters).pipe( - catchError((err: GrpcStatusEvent) => { - console.error(err); - this.notificationService.error(err.statusMessage); - return of(null); - }) - ); - }), - map(entries => { - this.total = entries?.total ?? 0; - if (entries) { - this.cacheService.save(this.scope, entries); - return this.computeGrpcData(entries) ?? []; - } - return []; - }) - ).subscribe(data => { - if (this.total !== 0 && this.afterDataCreation) { - this.afterDataCreation(data); - } else { - this.newData(data); - this.loading.set(false); - } - }); + get columnKeys() { + return this._columnKeys; } - protected newData(entries: T[]) { - this.data.set(entries.map(entry => this.createNewLine(entry))); + get displayedColumns() { + return this._displayedColumns; } + + readonly notificationService = inject(NotificationService); + abstract readonly tableDataService: AbstractTableDataService; onDrop(columnsKeys: ColumnKey[]) { - this.indexService.saveColumns(columnsKeys); + this.columnUpdate.emit(columnsKeys); } onOptionsChange() { - this.indexService.saveOptions(this.options); - this.refresh$.next(); + this.optionsUpdate.emit(); } - abstract computeGrpcData(entries: GrpcResponse): T[] | undefined; abstract isDataRawEqual(value: T, entry: T): boolean; - abstract createNewLine(entry: T): ArmonikData; abstract trackBy(index: number, item: ArmonikData): string | number; } @@ -165,11 +85,6 @@ export abstract class AbstractTaskByStatusTableComponent { + abstract readonly grpcService: GrpcTableService; + private readonly cacheService = inject(CacheService); + private readonly notificationService = inject(NotificationService); + readonly filtersService = inject(FiltersService); + + readonly refresh$ = new Subject(); + + private readonly _loading = signal(false); + private readonly _data: WritableSignal[]> = signal([]); + private readonly _total = signal(0); + + filters: FiltersOr = []; + options: ListOptions; + + abstract scope: Scope; + + protected set data(entries: T[]) { + this._data.set(entries.map(entry => this.createNewLine(entry))); + } + + protected set loading(value: boolean) { + this._loading.set(value); + } + + /** + * Handle the loading state of the table. + */ + get loading(): boolean { + return this._loading(); + } + + /** + * The current loaded data. + */ + get data(): ArmonikData[] { + return this._data(); + } + + /** + * Total number of this data stored in the database. + */ + get total(): number { + return this._total(); + } + + constructor() { + this.loadFromCache(); + this.subscribeToGrpcList(); + } + + /** + * Load data from the cache on initialisation. + */ + private loadFromCache() { + const cachedResponse = this.cacheService.get(this.scope); + if (cachedResponse) { + const cachedData = this.computeGrpcData(cachedResponse); + this._total.set(cachedResponse.total); + if (cachedData) { + this.data = cachedData; + } + } + } + + /** + * Subscribe to the refresh subject to fetch the data. + * Handle the **loading** state. + * Catch errors if needed. + */ + private subscribeToGrpcList() { + this.refresh$.pipe( + switchMap(() => { + this._loading.set(true); + + const options = this.prepareOptions(); + const filters = this.preparefilters(); + + return this.grpcService.list$(options, filters) + .pipe( + catchError((error: GrpcStatusEvent) => { + this.error(error, 'An error occured while fetching data'); + return of(null); + }), + ); + }), + map((entries) => { + this._total.set(entries?.total ?? 0); + if (entries) { + this.cacheService.save(this.scope, entries); + return this.computeGrpcData(entries) ?? []; + } + return []; + }) + ).subscribe((entries) => { + this.handleData(entries); + this._loading.set(false); + }); + } + + /** + * Clone the options object and transform it if needed. + */ + prepareOptions(): ListOptions { + return structuredClone(this.options); + } + + /** + * Clone the filter object and transform it if needed. + */ + preparefilters(): FiltersOr { + return structuredClone(this.filters); + } + + /** + * Handle the data after it has been fetched. + * @param entries + */ + handleData(entries: T[]): void { + this.data = entries; + } + + /** + * Display a success message to the user. + */ + success(message: string) { + this.notificationService.success(message); + } + + /** + * Display a message error in the console and show a error notification to the user. + * @param error - GrpcStatusEvent, the logged object. + * @param message - (Optional) The message displayed to the user. If not present, display the error statusMessage. + */ + error(error: GrpcStatusEvent, message?: string) { + this.notificationService.error(message ?? error.statusMessage); + console.error(error); + } + + /** + * Display a warning message to the user. + */ + warning(message: string) { + this.notificationService.warning(message); + } + + /** + * Transform a ListGrpcResponse into a readable DataRaw array. + */ + abstract computeGrpcData(entries: GrpcResponse): T[] | undefined; + + /** + * Transform a DataRaw object into an ArmoniKData object. + * @param entry + */ + abstract createNewLine(entry: T): ArmonikData; +} \ No newline at end of file From 5890a49c3eb137f259bb502d84a8e9be2f159a93 Mon Sep 17 00:00:00 2001 From: Faustin Date: Tue, 29 Oct 2024 14:37:44 +0100 Subject: [PATCH 2/4] chore: updated code accordingly to sonarcloud review --- src/app/sessions/services/sessions-data.service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/sessions/services/sessions-data.service.ts b/src/app/sessions/services/sessions-data.service.ts index 8b1702008..783786508 100644 --- a/src/app/sessions/services/sessions-data.service.ts +++ b/src/app/sessions/services/sessions-data.service.ts @@ -175,10 +175,10 @@ export class SessionsDataService extends AbstractTableDataService(); - private nextEndDuration$ = new Subject(); - private computeDuration$ = new Subject(); - private sessionsIdsComputationError: string[] = []; + private readonly nextStartDuration$ = new Subject(); + private readonly nextEndDuration$ = new Subject(); + private readonly computeDuration$ = new Subject(); + private readonly sessionsIdsComputationError: string[] = []; /** * Start the duration computation process From 21e35fa5ead2e3e4e636d446b127e895e792497b Mon Sep 17 00:00:00 2001 From: Faustin Date: Fri, 13 Dec 2024 17:11:01 +0100 Subject: [PATCH 3/4] fix: sessions duration filter --- .../components/table.component.html | 2 +- .../components/table.component.spec.ts | 2 +- .../components/table.component.ts | 1 + src/app/applications/index.component.html | 2 +- .../applications-data.service.spec.ts | 14 ++--- .../lines/applications-line.component.html | 2 +- .../lines/partitions-line.component.html | 2 +- .../lines/results-line.component.html | 2 +- .../lines/sessions-line.component.html | 2 +- .../lines/tasks-line.component.html | 2 +- .../components/table.component.html | 2 +- .../partitions/components/table.component.ts | 1 + src/app/partitions/index.component.html | 2 +- .../services/partitions-data.service.spec.ts | 14 ++--- .../results/components/table.component.html | 2 +- .../components/table.component.spec.ts | 2 +- src/app/results/components/table.component.ts | 8 ++- src/app/results/index.component.html | 2 +- .../services/results-data.service.spec.ts | 12 ++-- .../sessions/components/table.component.html | 2 +- .../components/table.component.spec.ts | 2 +- .../sessions/components/table.component.ts | 1 + src/app/sessions/index.component.html | 2 +- .../services/sessions-data.service.spec.ts | 61 +++++++++++++++---- .../services/sessions-data.service.ts | 26 +++++--- src/app/tasks/components/table.component.html | 2 +- .../tasks/components/table.component.spec.ts | 2 +- src/app/tasks/components/table.component.ts | 8 ++- src/app/tasks/index.component.html | 2 +- .../tasks/services/tasks-data.service.spec.ts | 14 ++--- .../types/components/dashboard-line-table.ts | 9 --- src/app/types/components/table.ts | 44 ++++++------- src/app/types/services/table-data.service.ts | 49 +++------------ 33 files changed, 156 insertions(+), 144 deletions(-) diff --git a/src/app/applications/components/table.component.html b/src/app/applications/components/table.component.html index 21d0fd4bf..f0db90e2d 100644 --- a/src/app/applications/components/table.component.html +++ b/src/app/applications/components/table.component.html @@ -1,3 +1,3 @@ - \ 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 738edf54a..b74e70fca 100644 --- a/src/app/applications/components/table.component.spec.ts +++ b/src/app/applications/components/table.component.spec.ts @@ -213,7 +213,7 @@ describe('TasksTableComponent', () => { }); it('should get data', () => { - expect(component.data).toEqual(mockApplicationsDataService.data); + expect(component.data()).toEqual(mockApplicationsDataService.data); }); it('should get total', () => { diff --git a/src/app/applications/components/table.component.ts b/src/app/applications/components/table.component.ts index 0be1d8777..10b36cb9b 100644 --- a/src/app/applications/components/table.component.ts +++ b/src/app/applications/components/table.component.ts @@ -46,6 +46,7 @@ export class ApplicationsTableComponent extends AbstractTaskByStatusTableCompone ]; ngOnInit(): void { + this.initTableDataService(); this.initStatuses(); } diff --git a/src/app/applications/index.component.html b/src/app/applications/index.component.html index 63c0a6ae0..33d861f3b 100644 --- a/src/app/applications/index.component.html +++ b/src/app/applications/index.component.html @@ -6,7 +6,7 @@ { describe('initialisation', () => { it('should load data from the cache', () => { - expect(service.data).toEqual([ + expect(service.data()).toEqual([ { raw: { name: 'application1', @@ -95,7 +95,7 @@ describe('ApplicationDataService', () => { }); it('should set the total cached data', () => { - expect(service.total).toEqual(cachedApplications.total); + expect(service.total()).toEqual(cachedApplications.total); }); }); @@ -107,12 +107,12 @@ describe('ApplicationDataService', () => { it('should update the total', () => { service.refresh$.next(); - expect(service.total).toEqual(applications.total); + expect(service.total()).toEqual(applications.total); }); it('should update the data', () => { service.refresh$.next(); - expect(service.data).toEqual([ + expect(service.data()).toEqual([ { raw: { name: 'application1', @@ -162,7 +162,7 @@ describe('ApplicationDataService', () => { const emptyApplications = { applications: undefined, total: 0 } as unknown as ListApplicationsResponse; mockApplicationsGrpcService.list$.mockReturnValueOnce(of(emptyApplications)); service.refresh$.next(); - expect(service.data).toEqual([]); + expect(service.data()).toEqual([]); }); it('should catch errors', () => { @@ -207,7 +207,7 @@ describe('ApplicationDataService', () => { }); it('should load correctly', () => { - expect(service.loading).toBeFalsy(); + expect(service.loading()).toBeFalsy(); }); describe('Applying filters', () => { @@ -281,7 +281,7 @@ describe('ApplicationDataService', () => { it('should apply the filters correctly when transforming the data', () => { service.filters = filters; service.refresh$.next(); - expect(service.data).toEqual([ + expect(service.data()).toEqual([ { raw: { name: 'application1', diff --git a/src/app/dashboard/components/lines/applications-line.component.html b/src/app/dashboard/components/lines/applications-line.component.html index b5d0f79db..a4b26d040 100644 --- a/src/app/dashboard/components/lines/applications-line.component.html +++ b/src/app/dashboard/components/lines/applications-line.component.html @@ -1,7 +1,7 @@ diff --git a/src/app/partitions/components/table.component.ts b/src/app/partitions/components/table.component.ts index 005621e59..a39e62491 100644 --- a/src/app/partitions/components/table.component.ts +++ b/src/app/partitions/components/table.component.ts @@ -28,6 +28,7 @@ export class PartitionsTableComponent extends AbstractTaskByStatusTableComponent table: TableTasksByStatus = 'partitions'; ngOnInit(): void { + this.initTableDataService(); this.initStatuses(); } diff --git a/src/app/partitions/index.component.html b/src/app/partitions/index.component.html index c2ace5d0f..dd69db783 100644 --- a/src/app/partitions/index.component.html +++ b/src/app/partitions/index.component.html @@ -6,7 +6,7 @@ { describe('initialisation', () => { it('should load data from the cache', () => { - expect(service.data).toEqual([ + expect(service.data()).toEqual([ { raw: { id: 'partition1', @@ -99,7 +99,7 @@ describe('PartitionsDataService', () => { }); it('should set the total cached data', () => { - expect(service.total).toEqual(cachedPartitions.total); + expect(service.total()).toEqual(cachedPartitions.total); }); }); @@ -111,12 +111,12 @@ describe('PartitionsDataService', () => { it('should update the total', () => { service.refresh$.next(); - expect(service.total).toEqual(partitions.total); + expect(service.total()).toEqual(partitions.total); }); it('should update the data', () => { service.refresh$.next(); - expect(service.data).toEqual([ + expect(service.data()).toEqual([ { raw: { id: 'partition1', @@ -157,7 +157,7 @@ describe('PartitionsDataService', () => { const partitions = { partitions: undefined, total: 0} as unknown as ListPartitionsResponse; mockPartitionsGrpcService.list$.mockReturnValueOnce(of(partitions)); service.refresh$.next(); - expect(service.data).toEqual([]); + expect(service.data()).toEqual([]); }); it('should catch errors', () => { @@ -202,7 +202,7 @@ describe('PartitionsDataService', () => { }); it('should load correctly', () => { - expect(service.loading).toBeFalsy(); + expect(service.loading()).toBeFalsy(); }); describe('Applying filters', () => { @@ -234,7 +234,7 @@ describe('PartitionsDataService', () => { it('should apply the filters correctly when transforming the data', () => { service.filters = filters; service.refresh$.next(); - expect(service.data).toEqual([ + expect(service.data()).toEqual([ { raw: { id: 'partition1', diff --git a/src/app/results/components/table.component.html b/src/app/results/components/table.component.html index 05f197934..0867fc5f8 100644 --- a/src/app/results/components/table.component.html +++ b/src/app/results/components/table.component.html @@ -1,3 +1,3 @@ - diff --git a/src/app/results/components/table.component.spec.ts b/src/app/results/components/table.component.spec.ts index 17495389f..afefa4732 100644 --- a/src/app/results/components/table.component.spec.ts +++ b/src/app/results/components/table.component.spec.ts @@ -112,7 +112,7 @@ describe('TasksTableComponent', () => { }); it('should get data', () => { - expect(component.data).toEqual(mockResultsDataService.data); + expect(component.data()).toEqual(mockResultsDataService.data); }); it('should get total', () => { diff --git a/src/app/results/components/table.component.ts b/src/app/results/components/table.component.ts index cc9fb1520..46fec411e 100644 --- a/src/app/results/components/table.component.ts +++ b/src/app/results/components/table.component.ts @@ -1,5 +1,5 @@ import { ResultRawEnumField } from '@aneoconsultingfr/armonik.api.angular'; -import { Component, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { AbstractTableComponent } from '@app/types/components/table'; import { ArmonikData } from '@app/types/data'; import { TableComponent } from '@components/table/table.component'; @@ -20,10 +20,14 @@ import { ResultRaw } from '../types'; TableComponent, ] }) -export class ResultsTableComponent extends AbstractTableComponent { +export class ResultsTableComponent extends AbstractTableComponent implements OnInit { readonly tableDataService = inject(ResultsDataService); readonly statusesService = inject(ResultsStatusesService); + ngOnInit(): void { + this.initTableDataService(); + } + isDataRawEqual(value: ResultRaw, entry: ResultRaw): boolean { return value.resultId === entry.resultId; } diff --git a/src/app/results/index.component.html b/src/app/results/index.component.html index 658c38b5d..c928b5511 100644 --- a/src/app/results/index.component.html +++ b/src/app/results/index.component.html @@ -6,7 +6,7 @@ { describe('initialisation', () => { it('should load data from the cache', () => { - expect(service.data).toEqual([ + expect(service.data()).toEqual([ { raw: { resultId: 'result1' @@ -77,7 +77,7 @@ describe('ResultsDataService', () => { }); it('should set the total cached data', () => { - expect(service.total).toEqual(cachedResults.total); + expect(service.total()).toEqual(cachedResults.total); }); }); @@ -89,12 +89,12 @@ describe('ResultsDataService', () => { it('should update the total', () => { service.refresh$.next(); - expect(service.total).toEqual(results.total); + expect(service.total()).toEqual(results.total); }); it('should update the data', () => { service.refresh$.next(); - expect(service.data).toEqual([ + expect(service.data()).toEqual([ { raw: { resultId: 'result1' @@ -117,7 +117,7 @@ describe('ResultsDataService', () => { const emptyResults = { results: undefined, total: 0 } as unknown as ListResultsResponse; mockResultsGrpcService.list$.mockReturnValueOnce(of(emptyResults)); service.refresh$.next(); - expect(service.data).toEqual([]); + expect(service.data()).toEqual([]); }); it('should catch errors', () => { @@ -162,6 +162,6 @@ describe('ResultsDataService', () => { }); it('should load correctly', () => { - expect(service.loading).toBeFalsy(); + expect(service.loading()).toBeFalsy(); }); }); \ No newline at end of file diff --git a/src/app/sessions/components/table.component.html b/src/app/sessions/components/table.component.html index 6784db80d..b2f394052 100644 --- a/src/app/sessions/components/table.component.html +++ b/src/app/sessions/components/table.component.html @@ -1,3 +1,3 @@ - diff --git a/src/app/sessions/components/table.component.spec.ts b/src/app/sessions/components/table.component.spec.ts index b5fee52c5..aea6394ed 100644 --- a/src/app/sessions/components/table.component.spec.ts +++ b/src/app/sessions/components/table.component.spec.ts @@ -338,7 +338,7 @@ describe('SessionsTableComponent', () => { }); it('should get data', () => { - expect(component.data).toEqual(mockSessionsDataService.data); + expect(component.data()).toEqual(mockSessionsDataService.data); }); it('should get total', () => { diff --git a/src/app/sessions/components/table.component.ts b/src/app/sessions/components/table.component.ts index 41b026855..b30913cd8 100644 --- a/src/app/sessions/components/table.component.ts +++ b/src/app/sessions/components/table.component.ts @@ -122,6 +122,7 @@ export class SessionsTableComponent extends AbstractTaskByStatusTableComponent { const map2 = new Map(); map1.set('sessionId', {'0-root-1-0': 'session1'}); map2.set('sessionId', {'0-root-1-0': 'session2'}); - expect(service.data).toEqual([ + expect(service.data()).toEqual([ { raw: { sessionId: 'session1' @@ -119,7 +119,7 @@ describe('SessionsDataService', () => { }); it('should set the total cached data', () => { - expect(service.total).toEqual(cachedSessions.total); + expect(service.total()).toEqual(cachedSessions.total); }); }); @@ -131,7 +131,7 @@ describe('SessionsDataService', () => { it('should update the total', () => { service.refresh$.next(); - expect(service.total).toEqual(sessions.total); + expect(service.total()).toEqual(sessions.total); }); it('should update the data', () => { @@ -190,7 +190,7 @@ describe('SessionsDataService', () => { map2.set('sessionId', {'0-root-1-0': 'session2'}); map3.set('sessionId', {'0-root-1-0': 'session3'}); service.refresh$.next(); - expect(service.data).toEqual([ + expect(service.data()).toEqual([ { raw: { sessionId: 'session1' @@ -264,7 +264,7 @@ describe('SessionsDataService', () => { const sessions = { sessions: undefined, total: 0} as unknown as ListSessionsResponse; mockSessionsGrpcService.list$.mockReturnValueOnce(of(sessions)); service.refresh$.next(); - expect(service.data).toEqual([]); + expect(service.data()).toEqual([]); }); it('should catch errors', () => { @@ -309,7 +309,7 @@ describe('SessionsDataService', () => { }); it('should load correctly', () => { - expect(service.loading).toBeFalsy(); + expect(service.loading()).toBeFalsy(); }); describe('computing duration', () => { @@ -319,7 +319,7 @@ describe('SessionsDataService', () => { it('should compute the duration for each session', () => { service.refresh$.next(); - expect(service.data.map((session) => ({sessionId: session.raw.sessionId, duration: session.raw.duration}))).toEqual([ + expect(service.data().map((session) => ({sessionId: session.raw.sessionId, duration: session.raw.duration}))).toEqual([ { sessionId: 'session1', duration: { @@ -364,7 +364,7 @@ describe('SessionsDataService', () => { direction: 'asc', }; service.refresh$.next(); - expect(service.data.map(session => session.raw.sessionId)).toEqual(['session1', 'session2', 'session3']); + expect(service.data().map(session => session.raw.sessionId)).toEqual(['session1', 'session2', 'session3']); }); it('should sort in a descending order', () => { @@ -374,7 +374,46 @@ describe('SessionsDataService', () => { direction: 'desc', }; service.refresh$.next(); - expect(service.data.map(session => session.raw.sessionId)).toEqual(['session3', 'session2', 'session1']); + expect(service.data().map(session => session.raw.sessionId)).toEqual(['session3', 'session2', 'session1']); + }); + + it('should append the filters to existing ones if they do not contain a CREATED_AT field', () => { + const dateFilter: Filter = { + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_CREATED_AT, + for: 'root', + operator: FilterDateOperator.FILTER_DATE_OPERATOR_AFTER, + value: 1 + }; + const stringFilter: Filter = { + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_CLIENT_SUBMISSION, + for: 'root', + operator: FilterStringOperator.FILTER_STRING_OPERATOR_CONTAINS, + value: 1 + }; + const arrayFilter: Filter = + { + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_OPTIONS, + for: 'root', + operator: FilterArrayOperator.FILTER_ARRAY_OPERATOR_NOT_CONTAINS, + value: 1 + }; + jest.useFakeTimers().setSystemTime(new Date('1970-01-01')); + const date = new Date(); + date.setDate(date.getDate() - 3); + const appliedFilter: Filter = { + field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_CREATED_AT, + for: 'root', + operator: FilterDateOperator.FILTER_DATE_OPERATOR_AFTER_OR_EQUAL, + value: Math.floor(date.getTime()/1000) + }; + service.options = { pageIndex: 0, pageSize: 0, sort: { active: 'duration', direction: 'asc'}}; + service.isDurationDisplayed = true; + service.filters = [[arrayFilter, stringFilter], [dateFilter]]; + service.refresh$.next(); + expect(mockSessionsGrpcService.list$).toHaveBeenCalledWith( + { pageIndex: 0, pageSize: 0, sort: { active: 'createdAt', direction: 'asc'}}, + [[arrayFilter, stringFilter, appliedFilter], [dateFilter]] + ); }); }); diff --git a/src/app/sessions/services/sessions-data.service.ts b/src/app/sessions/services/sessions-data.service.ts index 783786508..df89c0cdf 100644 --- a/src/app/sessions/services/sessions-data.service.ts +++ b/src/app/sessions/services/sessions-data.service.ts @@ -31,7 +31,7 @@ export class SessionsDataService extends AbstractTableDataService { - const filters = super.preparefilters(); - if(this.isDurationDisplayed && this.options.sort.active === 'duration' && this.filtersHaveNoCreatedAt()) { + const filtersOr = super.preparefilters(); + if(this.isDurationDisplayed && this.options.sort.active === 'duration') { const date = new Date(); date.setDate(date.getDate() - 3); - filters.push([{ + const filter: Filter = { field: SessionRawEnumField.SESSION_RAW_ENUM_FIELD_CREATED_AT, for: 'root', operator: FilterDateOperator.FILTER_DATE_OPERATOR_AFTER_OR_EQUAL, value: Math.floor(date.getTime()/1000) - }]); + }; + if (filtersOr.length !== 0) { + filtersOr.forEach(filtersAnd => { + if (filtersAnd.find(filter => filter.field === SessionRawEnumField.SESSION_RAW_ENUM_FIELD_CREATED_AT) === undefined) { + filtersAnd.push(filter); + } + }); + } else { + filtersOr.push([filter]); + } } - return filters; + return filtersOr; } createNewLine(entry: SessionRaw): SessionData { @@ -220,11 +229,10 @@ export class SessionsDataService extends AbstractTableDataService diff --git a/src/app/tasks/components/table.component.spec.ts b/src/app/tasks/components/table.component.spec.ts index 6c96cb185..0ff5103d5 100644 --- a/src/app/tasks/components/table.component.spec.ts +++ b/src/app/tasks/components/table.component.spec.ts @@ -272,7 +272,7 @@ describe('TasksTableComponent', () => { }); it('should get data', () => { - expect(component.data).toEqual(mockTasksDataService.data); + expect(component.data()).toEqual(mockTasksDataService.data); }); it('should get total', () => { diff --git a/src/app/tasks/components/table.component.ts b/src/app/tasks/components/table.component.ts index 54632d0fb..0af17419a 100644 --- a/src/app/tasks/components/table.component.ts +++ b/src/app/tasks/components/table.component.ts @@ -1,6 +1,6 @@ import { TaskOptionEnumField, TaskSummaryEnumField} from '@aneoconsultingfr/armonik.api.angular'; import { Clipboard, } from '@angular/cdk/clipboard'; -import { Component, EventEmitter, Input, Output, inject } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; import { Router} from '@angular/router'; import { AbstractTableComponent } from '@app/types/components/table'; import { Scope } from '@app/types/config'; @@ -23,7 +23,7 @@ import { TaskOptions, TaskSummary } from '../types'; TableComponent ] }) -export class TasksTableComponent extends AbstractTableComponent { +export class TasksTableComponent extends AbstractTableComponent implements OnInit { scope: Scope = 'tasks'; @Input({ required: false }) set serviceIcon(entry: string | null) { @@ -112,6 +112,10 @@ export class TasksTableComponent extends AbstractTableComponent { describe('initialisation', () => { it('should load data from the cache', () => { - expect(service.data).toEqual([ + expect(service.data()).toEqual([ { raw: { id: 'task1' @@ -84,7 +84,7 @@ describe('TasksDataService', () => { }); it('should set the total cached data', () => { - expect(service.total).toEqual(cachedTasks.total); + expect(service.total()).toEqual(cachedTasks.total); }); }); @@ -96,12 +96,12 @@ describe('TasksDataService', () => { it('should update the total', () => { service.refresh$.next(); - expect(service.total).toEqual(tasks.total); + expect(service.total()).toEqual(tasks.total); }); it('should update the data', () => { service.refresh$.next(); - expect(service.data).toEqual([ + expect(service.data()).toEqual([ { raw: { id: 'task1' @@ -133,7 +133,7 @@ describe('TasksDataService', () => { const emptyTasks = { tasks: undefined, total: 0 } as unknown as ListTasksResponse; mockTasksGrpcService.list$.mockReturnValueOnce(of(emptyTasks)); service.refresh$.next(); - expect(service.data).toEqual([]); + expect(service.data()).toEqual([]); }); it('should catch errors', () => { @@ -178,7 +178,7 @@ describe('TasksDataService', () => { }); it('should load correctly', () => { - expect(service.loading).toBeFalsy(); + expect(service.loading()).toBeFalsy(); }); describe('Applying filters', () => { @@ -222,7 +222,7 @@ describe('TasksDataService', () => { it('should apply the filters correctly when transforming the data', () => { service.filters = filters; service.refresh$.next(); - expect(service.data).toEqual([ + expect(service.data()).toEqual([ { raw: { id: 'task1' diff --git a/src/app/types/components/dashboard-line-table.ts b/src/app/types/components/dashboard-line-table.ts index 211c3c15c..4ea85a281 100644 --- a/src/app/types/components/dashboard-line-table.ts +++ b/src/app/types/components/dashboard-line-table.ts @@ -144,15 +144,6 @@ export abstract class DashboardLineTableComponent = this.dialog.open(EditNameLineDialogComponent, { data: { diff --git a/src/app/types/components/table.ts b/src/app/types/components/table.ts index 9503cc80e..12959502b 100644 --- a/src/app/types/components/table.ts +++ b/src/app/types/components/table.ts @@ -1,5 +1,5 @@ import { SelectionModel } from '@angular/cdk/collections'; -import { Component, EventEmitter, Input, Output, inject } from '@angular/core'; +import { Component, EventEmitter, Input, Output, Signal, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { ManageGroupsDialogData, ManageGroupsDialogResult, TasksStatusesGroup } from '@app/dashboard/types'; import { TaskOptions } from '@app/tasks/types'; @@ -8,7 +8,8 @@ import { NotificationService } from '@services/notification.service'; import { TableTasksByStatus, TasksByStatusService } from '@services/tasks-by-status.service'; import { TableColumn } from '../column.type'; import { ArmonikData, ColumnKey, DataRaw } from '../data'; -import { FiltersEnums, FiltersOptionsEnums } from '../filters'; +import { FiltersEnums, FiltersOptionsEnums, FiltersOr } from '../filters'; +import { ListOptions } from '../options'; import { AbstractTableDataService } from '../services/table-data.service'; export interface SelectableTable { @@ -24,43 +25,34 @@ export interface SelectableTable { }) export abstract class AbstractTableComponent { @Input({ required: true }) set displayedColumns(columns: TableColumn[]) { - this._displayedColumns = columns; - this._columnKeys = columns.map(column => column.key); + this.columns = columns; + this.columnKeys = columns.map(column => column.key); } @Input() lockColumns = false; @Output() columnUpdate = new EventEmitter[]>(); @Output() optionsUpdate = new EventEmitter(); - private _displayedColumns: TableColumn[] = []; - private _columnKeys: ColumnKey[]; + columns: TableColumn[] = []; + columnKeys: ColumnKey[]; - get data() { - return this.tableDataService.data; - } - - get total() { - return this.tableDataService.total; - } - - get options() { - return this.tableDataService.options; - } + data: Signal[]>; - get filters() { - return this.tableDataService.filters; - } + total: Signal; - get columnKeys() { - return this._columnKeys; - } + options: ListOptions; - get displayedColumns() { - return this._displayedColumns; - } + filters: FiltersOr; readonly notificationService = inject(NotificationService); abstract readonly tableDataService: AbstractTableDataService; + protected initTableDataService() { + this.data = this.tableDataService.data; + this.total = this.tableDataService.total; + this.options = this.tableDataService.options; + this.filters = this.tableDataService.filters; + } + onDrop(columnsKeys: ColumnKey[]) { this.columnUpdate.emit(columnsKeys); } diff --git a/src/app/types/services/table-data.service.ts b/src/app/types/services/table-data.service.ts index 969c46561..9b3cca2a2 100644 --- a/src/app/types/services/table-data.service.ts +++ b/src/app/types/services/table-data.service.ts @@ -1,4 +1,4 @@ -import { Injectable, WritableSignal, inject, signal } from '@angular/core'; +import { Injectable, inject, signal } from '@angular/core'; import { TaskOptions } from '@app/tasks/types'; import { ArmonikData, DataRaw, GrpcResponse } from '@app/types/data'; import { FiltersEnums, FiltersOptionsEnums, FiltersOr } from '@app/types/filters'; @@ -23,44 +23,15 @@ export abstract class AbstractTableDataService(); - private readonly _loading = signal(false); - private readonly _data: WritableSignal[]> = signal([]); - private readonly _total = signal(0); + readonly loading = signal(false); + readonly total = signal(0); + readonly data = signal[]>([]); filters: FiltersOr = []; options: ListOptions; abstract scope: Scope; - protected set data(entries: T[]) { - this._data.set(entries.map(entry => this.createNewLine(entry))); - } - - protected set loading(value: boolean) { - this._loading.set(value); - } - - /** - * Handle the loading state of the table. - */ - get loading(): boolean { - return this._loading(); - } - - /** - * The current loaded data. - */ - get data(): ArmonikData[] { - return this._data(); - } - - /** - * Total number of this data stored in the database. - */ - get total(): number { - return this._total(); - } - constructor() { this.loadFromCache(); this.subscribeToGrpcList(); @@ -73,9 +44,9 @@ export abstract class AbstractTableDataService { - this._loading.set(true); + this.loading.set(true); const options = this.prepareOptions(); const filters = this.preparefilters(); @@ -102,7 +73,7 @@ export abstract class AbstractTableDataService { - this._total.set(entries?.total ?? 0); + this.total.set(entries?.total ?? 0); if (entries) { this.cacheService.save(this.scope, entries); return this.computeGrpcData(entries) ?? []; @@ -111,7 +82,6 @@ export abstract class AbstractTableDataService { this.handleData(entries); - this._loading.set(false); }); } @@ -134,7 +104,8 @@ export abstract class AbstractTableDataService this.createNewLine(entry))); + this.loading.set(false); } /** From 27f001960e28e753e312c59c92c5a2449c6bcfaa Mon Sep 17 00:00:00 2001 From: Faustin Date: Fri, 13 Dec 2024 17:37:59 +0100 Subject: [PATCH 4/4] fix: tests --- src/app/applications/components/table.component.spec.ts | 4 ++-- src/app/partitions/components/table.component.spec.ts | 4 ++-- src/app/results/components/table.component.spec.ts | 7 ++++--- src/app/sessions/components/table.component.spec.ts | 4 ++-- src/app/sessions/index.component.spec.ts | 3 +-- src/app/tasks/components/table.component.spec.ts | 6 ++++-- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/app/applications/components/table.component.spec.ts b/src/app/applications/components/table.component.spec.ts index b74e70fca..f03f3a63c 100644 --- a/src/app/applications/components/table.component.spec.ts +++ b/src/app/applications/components/table.component.spec.ts @@ -213,7 +213,7 @@ describe('TasksTableComponent', () => { }); it('should get data', () => { - expect(component.data()).toEqual(mockApplicationsDataService.data); + expect(component.data).toEqual(mockApplicationsDataService.data); }); it('should get total', () => { @@ -233,6 +233,6 @@ describe('TasksTableComponent', () => { }); it('should get displayedColumns', () => { - expect(component.displayedColumns).toEqual(displayedColumns); + expect(component.columns).toEqual(displayedColumns); }); }); \ No newline at end of file diff --git a/src/app/partitions/components/table.component.spec.ts b/src/app/partitions/components/table.component.spec.ts index 63cc9b9a1..61f6edbcf 100644 --- a/src/app/partitions/components/table.component.spec.ts +++ b/src/app/partitions/components/table.component.spec.ts @@ -12,7 +12,7 @@ import { PartitionsTableComponent } from './table.component'; import PartitionsDataService from '../services/partitions-data.service'; import { PartitionRaw } from '../types'; -describe('TasksTableComponent', () => { +describe('PartitionsTableComponent', () => { let component: PartitionsTableComponent; const displayedColumns: TableColumn[] = [ @@ -190,6 +190,6 @@ describe('TasksTableComponent', () => { }); it('should get displayedColumns', () => { - expect(component.displayedColumns).toEqual(displayedColumns); + expect(component.columns).toEqual(displayedColumns); }); }); \ No newline at end of file diff --git a/src/app/results/components/table.component.spec.ts b/src/app/results/components/table.component.spec.ts index afefa4732..6a01d8d67 100644 --- a/src/app/results/components/table.component.spec.ts +++ b/src/app/results/components/table.component.spec.ts @@ -8,7 +8,7 @@ import ResultsDataService from '../services/results-data.service'; import { ResultsStatusesService } from '../services/results-statuses.service'; import { ResultRaw } from '../types'; -describe('TasksTableComponent', () => { +describe('ResultsTableComponent', () => { let component: ResultsTableComponent; const displayedColumns: TableColumn[] = [ @@ -71,6 +71,7 @@ describe('TasksTableComponent', () => { }).inject(ResultsTableComponent); component.displayedColumns = displayedColumns; + component.ngOnInit(); }); it('should run', () => { @@ -112,7 +113,7 @@ describe('TasksTableComponent', () => { }); it('should get data', () => { - expect(component.data()).toEqual(mockResultsDataService.data); + expect(component.data).toEqual(mockResultsDataService.data); }); it('should get total', () => { @@ -132,6 +133,6 @@ describe('TasksTableComponent', () => { }); it('should get displayedColumns', () => { - expect(component.displayedColumns).toEqual(displayedColumns); + expect(component.columns).toEqual(displayedColumns); }); }); \ No newline at end of file diff --git a/src/app/sessions/components/table.component.spec.ts b/src/app/sessions/components/table.component.spec.ts index aea6394ed..97ccc900f 100644 --- a/src/app/sessions/components/table.component.spec.ts +++ b/src/app/sessions/components/table.component.spec.ts @@ -338,7 +338,7 @@ describe('SessionsTableComponent', () => { }); it('should get data', () => { - expect(component.data()).toEqual(mockSessionsDataService.data); + expect(component.data).toEqual(mockSessionsDataService.data); }); it('should get total', () => { @@ -358,6 +358,6 @@ describe('SessionsTableComponent', () => { }); it('should get displayedColumns', () => { - expect(component.displayedColumns).toEqual(displayedColumns); + expect(component.columns).toEqual(displayedColumns); }); }); \ No newline at end of file diff --git a/src/app/sessions/index.component.spec.ts b/src/app/sessions/index.component.spec.ts index 79fb62845..43a2dd2dd 100644 --- a/src/app/sessions/index.component.spec.ts +++ b/src/app/sessions/index.component.spec.ts @@ -347,9 +347,8 @@ describe('Sessions Index Component', () => { }); it('should refresh if duration is included', () => { - const spy = jest.spyOn(component.refresh$, 'next'); component.onColumnsChange(['duration']); - expect(spy).toHaveBeenCalled(); + expect(mockSessionsDataService.refresh$.next).toHaveBeenCalled(); }); }); diff --git a/src/app/tasks/components/table.component.spec.ts b/src/app/tasks/components/table.component.spec.ts index 0ff5103d5..55072bc2c 100644 --- a/src/app/tasks/components/table.component.spec.ts +++ b/src/app/tasks/components/table.component.spec.ts @@ -74,6 +74,8 @@ describe('TasksTableComponent', () => { component.displayedColumns = displayedColumns; component.selection = []; + + component.ngOnInit(); }); it('should run', () => { @@ -272,7 +274,7 @@ describe('TasksTableComponent', () => { }); it('should get data', () => { - expect(component.data()).toEqual(mockTasksDataService.data); + expect(component.data).toEqual(mockTasksDataService.data); }); it('should get total', () => { @@ -292,6 +294,6 @@ describe('TasksTableComponent', () => { }); it('should get displayedColumns', () => { - expect(component.displayedColumns).toEqual(displayedColumns); + expect(component.columns).toEqual(displayedColumns); }); }); \ No newline at end of file