diff --git a/lib/taskana-core/src/main/resources/pro/taskana/common/internal/defaultCustomAttributes.json b/lib/taskana-core/src/main/resources/pro/taskana/common/internal/defaultCustomAttributes.json index cc4a5d56c..babf46f53 100644 --- a/lib/taskana-core/src/main/resources/pro/taskana/common/internal/defaultCustomAttributes.json +++ b/lib/taskana-core/src/main/resources/pro/taskana/common/internal/defaultCustomAttributes.json @@ -1,100 +1,64 @@ { - "nameHighPrio": "High Prio", - "nameMediumPrio": "Medium P", - "nameLowPrio": "Low Prio", - "intervalHighPrio": [ - 100, - 200 - ], - "intervalMediumPrio": [ - 21, - 100 - ], - "intervalLowPrio": [ - 1, - 20 - ], - "colorHighPrio": "#ff0000", - "colorLowPrio": "#5FAD00", - "colorMediumPrio": "#FFEE00", - "filter": { - "Filter": { - "custom-1": [ - "true" - ], - "custom-5": [ - "3" - ] - }, - "Filter neu": { - "custom-5": [ - "7", - "5" - ] - }, - "Filter Doppelt": { - "custom-5": [ - "1", - "2", - "90" - ] - } - }, + "nameHighPriority": "High Priority", + "nameMediumPriority": "Medium Priority", + "nameLowPriority": "Low Priority", + "intervalHighPriority": [3, 300], + "intervalMediumPriority": [2, 2], + "intervalLowPriority": [0, 1], + "colorHighPriority": "#FF0000", + "colorLowPriority": "#5FAD00", + "colorMediumPriority": "#FFD700", + "filter": "{ \"Tasks with state READY\": { \"state\": [\"READY\"]}, \"Tasks with state CLAIMED\": {\"state\": [\"CLAIMED\"] }}", "schema": { - "Priority-Report": { - "displayName": "Priority Report", + "Monitor Workbasket-Priority-Report": { + "displayName": "Monitor Workbasket-Priority-Report", "members": { - "nameHighPrio": { - "displayName": "High Prio Name", + "nameHighPriority": { + "displayName": "High Priority Name", "type": "text", - "min": 1 + "max": 32 }, - "nameMediumPrio": { - "displayName": "Medium Prio Name", + "nameMediumPriority": { + "displayName": "Medium Priority Name", "type": "text", - "min": 1, - "max": 8 + "max": 32 }, - "nameLowPrio": { - "displayName": "Low Prio Name", + "nameLowPriority": { + "displayName": "Low Priority Name", "type": "text", - "min": 2, - "max": 64 + "max": 32 }, - "intervalHighPrio": { - "displayName": "High Prio Interval", - "type": "interval", - "min": 0, - "max": 300 - }, - "intervalMediumPrio": { - "displayName": "Medium Prio Interval", - "type": "interval", - "min": -5, - "max": 300 - }, - "intervalLowPrio": { - "displayName": "Low Prio Interval", + "intervalHighPriority": { + "displayName": "High Priority Interval", "type": "interval", "min": 0 }, - "colorHighPrio": { - "displayName": "High Prio Color", + "intervalMediumPriority": { + "displayName": "Medium Priority Interval", + "type": "interval", + "min": 0 + }, + "intervalLowPriority": { + "displayName": "Low Priority Interval", + "type": "interval", + "min": 0 + }, + "colorHighPriority": { + "displayName": "High Priority Color", "type": "color" }, - "colorMediumPrio": { - "displayName": "Medium Prio Color", + "colorMediumPriority": { + "displayName": "Medium Priority Color", "type": "color" }, - "colorLowPrio": { - "displayName": "Low Prio Color", + "colorLowPriority": { + "displayName": "Low Priority Color", "type": "color" } } }, - "Filter": { - "displayName": "Filter for Task-Priority-Report", - "members": { + "Monitor Workbasket-Priority-Report-Filter": { + "displayName": "Monitor Workbasket-Priority-Report-Filter", "filter": { "displayName": "Filter values", "type": "json", diff --git a/web/src/app/app-routing.module.ts b/web/src/app/app-routing.module.ts index d5119891b..5986231e0 100644 --- a/web/src/app/app-routing.module.ts +++ b/web/src/app/app-routing.module.ts @@ -39,6 +39,11 @@ const appRoutes: Routes = [ path: 'administration', redirectTo: 'administration/workbaskets' }, + { + canActivate: [BusinessAdminGuard], + path: 'settings', + loadChildren: () => import('./settings/settings.module').then((m) => m.SettingsModule) + }, { path: '**', redirectTo: 'workplace' diff --git a/web/src/app/monitor/components/canvas/canvas.component.spec.ts b/web/src/app/monitor/components/canvas/canvas.component.spec.ts index e6ef9933c..80b69e040 100644 --- a/web/src/app/monitor/components/canvas/canvas.component.spec.ts +++ b/web/src/app/monitor/components/canvas/canvas.component.spec.ts @@ -1,8 +1,12 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { DebugElement } from '@angular/core'; -import { NgxsModule } from '@ngxs/store'; +import { NgxsModule, Store } from '@ngxs/store'; import { CanvasComponent } from './canvas.component'; import { workbasketReportMock } from '../task-priority-report/monitor-mock-data'; +import { SettingsState } from '../../../shared/store/settings-store/settings.state'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { settingsStateMock } from '../../../shared/store/mock-data/mock-store'; +import { MatDialogModule } from '@angular/material/dialog'; describe('CanvasComponent', () => { let fixture: ComponentFixture; @@ -12,7 +16,7 @@ describe('CanvasComponent', () => { beforeEach( waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NgxsModule.forRoot([])], + imports: [NgxsModule.forRoot([SettingsState]), HttpClientTestingModule, MatDialogModule], declarations: [CanvasComponent] }).compileComponents(); @@ -20,6 +24,11 @@ describe('CanvasComponent', () => { debugElement = fixture.debugElement; component = fixture.debugElement.componentInstance; component.id = '1'; + const store: Store = TestBed.inject(Store); + store.reset({ + ...store.snapshot(), + settings: settingsStateMock + }); fixture.detectChanges(); }) ); diff --git a/web/src/app/monitor/components/canvas/canvas.component.ts b/web/src/app/monitor/components/canvas/canvas.component.ts index 7a26703e5..323039656 100644 --- a/web/src/app/monitor/components/canvas/canvas.component.ts +++ b/web/src/app/monitor/components/canvas/canvas.component.ts @@ -1,18 +1,47 @@ -import { AfterViewInit, Component, Input } from '@angular/core'; +import { AfterViewInit, Component, Input, OnDestroy, OnInit } from '@angular/core'; import { Chart } from 'chart.js'; -import { priorityTypes } from '../../models/priority'; - import { ReportRow } from '../../models/report-row'; +import { Select } from '@ngxs/store'; +import { SettingsSelectors } from '../../../shared/store/settings-store/settings.selectors'; +import { Observable, Subject } from 'rxjs'; +import { Settings } from '../../../settings/models/settings'; +import { takeUntil } from 'rxjs/operators'; +import { SettingMembers } from '../../../settings/components/Settings/expected-members'; @Component({ selector: 'taskana-monitor-canvas', templateUrl: './canvas.component.html', styleUrls: ['./canvas.component.scss'] }) -export class CanvasComponent implements AfterViewInit { +export class CanvasComponent implements OnInit, AfterViewInit, OnDestroy { @Input() row: ReportRow; @Input() id: string; - @Input() isReversed: boolean; + + labels: string[]; + colors: string[]; + destroy$ = new Subject(); + + @Select(SettingsSelectors.getSettings) + settings$: Observable; + + ngOnInit() { + this.settings$.pipe(takeUntil(this.destroy$)).subscribe((settings) => { + this.setValuesFromSettings(settings); + }); + } + + setValuesFromSettings(settings: Settings) { + this.labels = [ + settings[SettingMembers.nameHighPriority], + settings[SettingMembers.nameMediumPriority], + settings[SettingMembers.nameLowPriority] + ]; + this.colors = [ + settings[SettingMembers.colorHighPriority], + settings[SettingMembers.colorMediumPriority], + settings[SettingMembers.colorLowPriority] + ]; + } ngAfterViewInit() { const canvas = document.getElementById(this.id) as HTMLCanvasElement; @@ -26,13 +55,12 @@ export class CanvasComponent implements AfterViewInit { new Chart(canvas, { type: 'doughnut', data: { - labels: [priorityTypes.HIGH, priorityTypes.MEDIUM, priorityTypes.LOW], + labels: this.labels, datasets: [ { label: 'Tasks by Priority', - // depends on whether backend sends data sorted in ascending or descending order - data: this.isReversed ? row.cells.reverse() : row.cells, - backgroundColor: ['red', 'gold', 'limegreen'], + data: row.cells, + backgroundColor: this.colors, borderWidth: 0 } ] @@ -49,4 +77,11 @@ export class CanvasComponent implements AfterViewInit { } }); } + + ngOnDestroy() { + document.getElementById(this.id).outerHTML = ''; // destroy HTML element + + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/web/src/app/monitor/components/task-priority-report-filter/task-priority-report-filter.component.html b/web/src/app/monitor/components/task-priority-report-filter/task-priority-report-filter.component.html new file mode 100644 index 000000000..4d3f1d116 --- /dev/null +++ b/web/src/app/monitor/components/task-priority-report-filter/task-priority-report-filter.component.html @@ -0,0 +1,18 @@ + + + + + Task Filter + + + + +
+ {{key}} +
+ +
There are not filters specified.
+ +
+
diff --git a/web/src/app/monitor/components/task-priority-report-filter/task-priority-report-filter.component.scss b/web/src/app/monitor/components/task-priority-report-filter/task-priority-report-filter.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/web/src/app/monitor/components/task-priority-report-filter/task-priority-report-filter.component.spec.ts b/web/src/app/monitor/components/task-priority-report-filter/task-priority-report-filter.component.spec.ts new file mode 100644 index 000000000..40a8c9bbb --- /dev/null +++ b/web/src/app/monitor/components/task-priority-report-filter/task-priority-report-filter.component.spec.ts @@ -0,0 +1,66 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { DebugElement } from '@angular/core'; +import { NgxsModule, Store } from '@ngxs/store'; +import { settingsStateMock } from '../../../shared/store/mock-data/mock-store'; +import { SettingsState } from '../../../shared/store/settings-store/settings.state'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TaskPriorityReportFilterComponent } from './task-priority-report-filter.component'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatDialogModule } from '@angular/material/dialog'; + +describe('TaskPriorityReportFilterComponent', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + let component: TaskPriorityReportFilterComponent; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NgxsModule.forRoot([SettingsState]), + HttpClientTestingModule, + MatCheckboxModule, + MatExpansionModule, + BrowserAnimationsModule, + MatDialogModule + ], + declarations: [TaskPriorityReportFilterComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(TaskPriorityReportFilterComponent); + debugElement = fixture.debugElement; + component = fixture.debugElement.componentInstance; + const store: Store = TestBed.inject(Store); + store.reset({ + ...store.snapshot(), + settings: settingsStateMock + }); + fixture.detectChanges(); + }) + ); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should append filter name to activeFilters list when it is selected', () => { + component.activeFilters = ['Tasks with state READY']; + component.emitFilter(true, 'Tasks with state CLAIMED'); + expect(component.activeFilters).toStrictEqual(['Tasks with state READY', 'Tasks with state CLAIMED']); + }); + + it('should remove filter name from list when it is not selected anymore', () => { + component.activeFilters = ['Tasks with state READY', 'Tasks with state CLAIMED']; + component.emitFilter(false, 'Tasks with state CLAIMED'); + expect(component.activeFilters).toStrictEqual(['Tasks with state READY']); + }); + + it('should emit query according to values in activeFilters', () => { + const emitSpy = jest.spyOn(component.applyFilter, 'emit'); + component.activeFilters = ['Tasks with state READY']; + component.emitFilter(true, 'Tasks with state CLAIMED'); + expect(emitSpy).toBeCalledWith({ state: ['READY', 'CLAIMED'] }); + }); +}); diff --git a/web/src/app/monitor/components/task-priority-report-filter/task-priority-report-filter.component.ts b/web/src/app/monitor/components/task-priority-report-filter/task-priority-report-filter.component.ts new file mode 100644 index 000000000..6a50f4e7e --- /dev/null +++ b/web/src/app/monitor/components/task-priority-report-filter/task-priority-report-filter.component.ts @@ -0,0 +1,61 @@ +import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { Select } from '@ngxs/store'; +import { Observable, Subject } from 'rxjs'; +import { SettingsSelectors } from '../../../shared/store/settings-store/settings.selectors'; +import { Settings } from '../../../settings/models/settings'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + selector: 'taskana-monitor-task-priority-report-filter', + templateUrl: './task-priority-report-filter.component.html', + styleUrls: ['./task-priority-report-filter.component.scss'] +}) +export class TaskPriorityReportFilterComponent implements OnInit, OnDestroy { + isPanelOpen = false; + filters: {}[]; + keys: string[]; + activeFilters = []; + filtersAreSpecified: boolean = true; + destroy$ = new Subject(); + + @Output() applyFilter = new EventEmitter(); + + @Select(SettingsSelectors.getSettings) + settings$: Observable; + + ngOnInit() { + this.settings$.pipe(takeUntil(this.destroy$)).subscribe((settings) => { + this.filtersAreSpecified = settings['filter'] && settings['filter'] !== ''; + if (this.filtersAreSpecified) { + this.filters = JSON.parse(settings['filter']); + this.keys = Object.keys(this.filters); + } + }); + } + + emitFilter(isEnabled: boolean, key: string) { + this.activeFilters = isEnabled + ? [...this.activeFilters, key] + : this.activeFilters.filter((element) => element !== key); + + this.applyFilter.emit(this.buildQuery()); + } + + buildQuery(): {} { + let filterQuery = {}; + this.activeFilters.forEach((activeFilter) => { + const filter = this.filters[activeFilter]; + const keys = Object.keys(filter); + keys.forEach((key) => { + const newValue = filter[key]; + filterQuery[key] = filterQuery[key] ? [...filterQuery[key], ...newValue] : newValue; + }); + }); + return filterQuery; + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/web/src/app/monitor/components/task-priority-report/monitor-mock-data.ts b/web/src/app/monitor/components/task-priority-report/monitor-mock-data.ts index 83f28e540..040708e72 100644 --- a/web/src/app/monitor/components/task-priority-report/monitor-mock-data.ts +++ b/web/src/app/monitor/components/task-priority-report/monitor-mock-data.ts @@ -34,15 +34,3 @@ export const workbasketReportMock: ReportData = { } ] }; - -export const workbasketReportUnexpectedHeaderMock: ReportData = { - meta: { - name: 'WorkbasketPriorityReport', - date: '2021-08-24T11:44:34.023901Z', - header: ['<10', '11 - 100', '>101'], - rowDesc: ['WORKBASKET'], - sumRowDesc: 'Total' - }, - rows: [], - sumRow: [] -}; diff --git a/web/src/app/monitor/components/task-priority-report/task-priority-report.component.html b/web/src/app/monitor/components/task-priority-report/task-priority-report.component.html index 12b2ec583..19ba714a6 100644 --- a/web/src/app/monitor/components/task-priority-report/task-priority-report.component.html +++ b/web/src/app/monitor/components/task-priority-report/task-priority-report.component.html @@ -1,5 +1,12 @@
-

{{reportData?.meta.name}} ({{reportData?.meta.date | germanTimeFormat }})

+ + +
+

{{reportData?.meta.name}} ({{reportData?.meta.date | germanTimeFormat }})

+ +
+ +
There are no Workbaskets with type TOPIC.
@@ -7,12 +14,12 @@
-
+
{{row.desc[0]}}
- +
@@ -22,9 +29,9 @@ Priority + 'task-priority-report__row--high': element.priority == nameHighPriority, + 'task-priority-report__row--medium': element.priority == nameMediumPriority, + 'task-priority-report__row--low': element.priority == nameLowPriority}"> {{element.priority}} @@ -32,9 +39,9 @@ Number of Tasks + 'task-priority-report__row--high': element.priority == nameHighPriority, + 'task-priority-report__row--medium': element.priority == nameMediumPriority, + 'task-priority-report__row--low': element.priority == nameLowPriority}"> {{element.number}} diff --git a/web/src/app/monitor/components/task-priority-report/task-priority-report.component.scss b/web/src/app/monitor/components/task-priority-report/task-priority-report.component.scss index f88bf241c..7b0886117 100644 --- a/web/src/app/monitor/components/task-priority-report/task-priority-report.component.scss +++ b/web/src/app/monitor/components/task-priority-report/task-priority-report.component.scss @@ -7,6 +7,22 @@ table { overflow-y: scroll; height: calc(100vh - 104px); + &__header { + display: grid; + grid-gap: 4px; + grid-template-columns: 2fr 1fr; + } + + &__headline { + margin: 0 0 0 8px; + position: relative; + top: 12px; + } + + &__divider { + margin-top: 8px; + } + &__workbaskets { display: flex; flex-direction: row; @@ -20,21 +36,8 @@ table { margin: 24px 24px 36px 24px; } - &__headline { + &__workbasket-headline { text-align: center; margin-bottom: 8px; } - - &__row--low { - color: limegreen; - } - - &__row--medium { - color: gold; - } - - &__row--high { - color: red; - } - } diff --git a/web/src/app/monitor/components/task-priority-report/task-priority-report.component.spec.ts b/web/src/app/monitor/components/task-priority-report/task-priority-report.component.spec.ts index 33716425a..e7b61add4 100644 --- a/web/src/app/monitor/components/task-priority-report/task-priority-report.component.spec.ts +++ b/web/src/app/monitor/components/task-priority-report/task-priority-report.component.spec.ts @@ -1,14 +1,16 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { Component, DebugElement, Input, Pipe, PipeTransform } from '@angular/core'; -import { NgxsModule } from '@ngxs/store'; -import { WorkbasketService } from '../../../shared/services/workbasket/workbasket.service'; +import { NgxsModule, Store } from '@ngxs/store'; import { NotificationService } from '../../../shared/services/notifications/notification.service'; import { TaskPriorityReportComponent } from './task-priority-report.component'; import { MonitorService } from '../../services/monitor.service'; import { of } from 'rxjs'; import { MatTableModule } from '@angular/material/table'; -import { priorityTypes } from '../../models/priority'; -import { workbasketReportMock, workbasketReportUnexpectedHeaderMock } from './monitor-mock-data'; +import { workbasketReportMock } from './monitor-mock-data'; +import { settingsStateMock } from '../../../shared/store/mock-data/mock-store'; +import { SettingsState } from '../../../shared/store/settings-store/settings.state'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { MatDividerModule } from '@angular/material/divider'; @Pipe({ name: 'germanTimeFormat' }) class GermanTimeFormatPipe implements PipeTransform { @@ -24,14 +26,13 @@ class CanvasStub { @Input() isReversed; } +@Component({ selector: 'taskana-monitor-task-priority-report-filter', template: '' }) +class TaskPriorityReportFilterStub {} + const monitorServiceSpy: Partial = { getTasksByPriorityReport: jest.fn().mockReturnValue(of(workbasketReportMock)) }; -const monitorServiceWithDifferentDataSpy: Partial = { - getTasksByPriorityReport: jest.fn().mockReturnValue(of(workbasketReportUnexpectedHeaderMock)) -}; - const notificationServiceSpy: Partial = { showWarning: jest.fn() }; @@ -44,8 +45,8 @@ describe('TaskPriorityReportComponent', () => { beforeEach( waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NgxsModule.forRoot([]), MatTableModule], - declarations: [TaskPriorityReportComponent, GermanTimeFormatPipe, CanvasStub], + imports: [NgxsModule.forRoot([SettingsState]), MatTableModule, HttpClientTestingModule, MatDividerModule], + declarations: [TaskPriorityReportComponent, GermanTimeFormatPipe, CanvasStub, TaskPriorityReportFilterStub], providers: [ { provide: MonitorService, useValue: monitorServiceSpy }, { provide: NotificationService, useValue: notificationServiceSpy } @@ -55,6 +56,11 @@ describe('TaskPriorityReportComponent', () => { fixture = TestBed.createComponent(TaskPriorityReportComponent); debugElement = fixture.debugElement; component = fixture.debugElement.componentInstance; + const store: Store = TestBed.inject(Store); + store.reset({ + ...store.snapshot(), + settings: settingsStateMock + }); fixture.detectChanges(); }) ); @@ -78,56 +84,4 @@ describe('TaskPriorityReportComponent', () => { component.ngOnInit(); expect(showWarningSpy).toHaveBeenCalledTimes(0); }); - - it('should set isReserved to true when high priority has a higher index than low priority', () => { - expect(component.isReversed).toBeTruthy(); - }); - - it('should set tableDataArray', () => { - const expectedTableData = [ - [ - { priority: priorityTypes.HIGH, number: 0 }, - { priority: priorityTypes.MEDIUM, number: 0 }, - { priority: priorityTypes.LOW, number: 5 }, - { priority: 'Total', number: 5 } - ], - [ - { priority: priorityTypes.HIGH, number: 2 }, - { priority: priorityTypes.MEDIUM, number: 5 }, - { priority: priorityTypes.LOW, number: 3 }, - { priority: 'Total', number: 10 } - ] - ]; - expect(component.tableDataArray).toStrictEqual(expectedTableData); - }); -}); - -describe('TaskPriorityReportComponent with report data containing an unexpected header', () => { - let fixture: ComponentFixture; - let debugElement: DebugElement; - let component: TaskPriorityReportComponent; - - beforeEach( - waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [NgxsModule.forRoot([]), MatTableModule], - declarations: [TaskPriorityReportComponent, GermanTimeFormatPipe, CanvasStub], - providers: [ - WorkbasketService, - { provide: MonitorService, useValue: monitorServiceWithDifferentDataSpy }, - { provide: NotificationService, useValue: notificationServiceSpy } - ] - }).compileComponents(); - - fixture = TestBed.createComponent(TaskPriorityReportComponent); - debugElement = fixture.debugElement; - component = fixture.debugElement.componentInstance; - fixture.detectChanges(); - }) - ); - - it('should show warning when actual header does not match the expected header', () => { - const showWarningSpy = jest.spyOn(notificationServiceSpy, 'showWarning'); - expect(showWarningSpy).toHaveBeenCalledTimes(1); - }); }); diff --git a/web/src/app/monitor/components/task-priority-report/task-priority-report.component.ts b/web/src/app/monitor/components/task-priority-report/task-priority-report.component.ts index 0dc3d417c..62c6a0197 100644 --- a/web/src/app/monitor/components/task-priority-report/task-priority-report.component.ts +++ b/web/src/app/monitor/components/task-priority-report/task-priority-report.component.ts @@ -1,52 +1,131 @@ -import { Component, OnInit } from '@angular/core'; -import { priorityTypes } from '../../models/priority'; +import { AfterViewChecked, Component, OnDestroy, OnInit } from '@angular/core'; import { ReportData } from '../../models/report-data'; import { MonitorService } from '../../services/monitor.service'; -import { take } from 'rxjs/operators'; import { NotificationService } from '../../../shared/services/notifications/notification.service'; import { WorkbasketType } from '../../../shared/models/workbasket-type'; +import { Select } from '@ngxs/store'; +import { Observable, Subject } from 'rxjs'; +import { SettingsSelectors } from '../../../shared/store/settings-store/settings.selectors'; +import { Settings } from '../../../settings/models/settings'; +import { mergeMap, take, takeUntil } from 'rxjs/operators'; +import { SettingMembers } from '../../../settings/components/Settings/expected-members'; @Component({ selector: 'taskana-monitor-task-priority-report', templateUrl: './task-priority-report.component.html', styleUrls: ['./task-priority-report.component.scss'] }) -export class TaskPriorityReportComponent implements OnInit { +export class TaskPriorityReportComponent implements OnInit, AfterViewChecked, OnDestroy { columns: string[] = ['priority', 'number']; reportData: ReportData; tableDataArray: { priority: string; number: number }[][] = []; - isReversed = true; + colorShouldChange = true; + priority = []; + + nameHighPriority: string; + nameMediumPriority: string; + nameLowPriority: string; + colorHighPriority: string; + colorMediumPriority: string; + colorLowPriority: string; + + destroy$ = new Subject(); + + @Select(SettingsSelectors.getSettings) + settings$: Observable; constructor(private monitorService: MonitorService, private notificationService: NotificationService) {} ngOnInit() { - this.monitorService - .getTasksByPriorityReport([WorkbasketType.TOPIC]) - .pipe(take(1)) + this.settings$ + .pipe( + takeUntil(this.destroy$), + mergeMap((settings) => { + this.setValuesFromSettings(settings); + // the order must be high, medium, low because the canvas component defines its labels in this order + this.priority = [ + settings[SettingMembers.intervalHighPriority], + settings[SettingMembers.intervalMediumPriority], + settings[SettingMembers.intervalLowPriority] + ].map((arr) => ({ lowerBound: arr[0], upperBound: arr[1] })); + return this.monitorService.getTasksByPriorityReport([WorkbasketType.TOPIC], this.priority); + }) + ) .subscribe((reportData) => { - this.reportData = reportData; - let indexHigh = reportData.meta.header.indexOf('>501'); - let indexMedium = reportData.meta.header.indexOf('250 - 500'); - let indexLow = reportData.meta.header.indexOf('<249'); - if (indexHigh == -1 || indexMedium == -1 || indexLow == -1) { - this.notificationService.showWarning('REPORT_DATA_WRONG_HEADER'); - indexHigh = 2; - indexMedium = 1; - indexLow = 0; - } - this.isReversed = indexHigh > indexLow; - reportData.rows.forEach((row) => { - this.tableDataArray.push([ - { priority: priorityTypes.HIGH, number: row.cells[indexHigh] }, - { priority: priorityTypes.MEDIUM, number: row.cells[indexMedium] }, - { priority: priorityTypes.LOW, number: row.cells[indexLow] }, - { priority: 'Total', number: row.total } - ]); - }); + this.setValuesFromReportData(reportData); }); } - toString(i: number): string { + ngAfterViewChecked() { + if (this.colorShouldChange) { + const highPriorityElements = document.getElementsByClassName('task-priority-report__row--high'); + if (highPriorityElements.length > 0) { + this.colorShouldChange = false; + this.changeColor(); + } + } + } + + setValuesFromSettings(settings: Settings) { + this.nameHighPriority = settings[SettingMembers.nameHighPriority]; + this.nameMediumPriority = settings[SettingMembers.nameMediumPriority]; + this.nameLowPriority = settings[SettingMembers.nameLowPriority]; + this.colorHighPriority = settings[SettingMembers.colorHighPriority]; + this.colorMediumPriority = settings[SettingMembers.colorMediumPriority]; + this.colorLowPriority = settings[SettingMembers.colorLowPriority]; + } + + setValuesFromReportData(reportData) { + this.reportData = reportData; + + // the order must be high, medium, low because the canvas component defines its labels in this order + let indexHigh = 0; + let indexMedium = 1; + let indexLow = 2; + + this.tableDataArray = []; + reportData.rows.forEach((row) => { + this.tableDataArray.push([ + { priority: this.nameHighPriority, number: row.cells[indexHigh] }, + { priority: this.nameMediumPriority, number: row.cells[indexMedium] }, + { priority: this.nameLowPriority, number: row.cells[indexLow] }, + { priority: 'Total', number: row.total } + ]); + }); + } + + changeColor() { + const highPriorityElements = document.getElementsByClassName('task-priority-report__row--high'); + const mediumPriorityElements = document.getElementsByClassName('task-priority-report__row--medium'); + const lowPriorityElements = document.getElementsByClassName('task-priority-report__row--low'); + this.applyColorOnClasses(highPriorityElements, this.colorHighPriority); + this.applyColorOnClasses(mediumPriorityElements, this.colorMediumPriority); + this.applyColorOnClasses(lowPriorityElements, this.colorLowPriority); + } + + applyColorOnClasses(elements: HTMLCollectionOf, color: string) { + for (let i = 0; i < elements.length; i++) { + (elements[i]).style.color = color; + } + } + + indexToString(i: number): string { return String(i); } + + applyFilter(filter: {}) { + this.monitorService + .getTasksByPriorityReport([WorkbasketType.TOPIC], this.priority, filter) + .pipe(take(1)) + .subscribe((reportData) => { + this.colorShouldChange = true; + this.setValuesFromReportData(reportData); + return; + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/web/src/app/monitor/monitor.module.ts b/web/src/app/monitor/monitor.module.ts index c6c6e8c7b..919bdeb18 100644 --- a/web/src/app/monitor/monitor.module.ts +++ b/web/src/app/monitor/monitor.module.ts @@ -23,6 +23,7 @@ import { WorkbasketReportPlannedDateComponent } from './components/workbasket-re import { WorkbasketReportDueDateComponent } from './components/workbasket-report-due-date/workbasket-report-due-date.component'; import { TaskPriorityReportComponent } from './components/task-priority-report/task-priority-report.component'; import { CanvasComponent } from './components/canvas/canvas.component'; +import { TaskPriorityReportFilterComponent } from './components/task-priority-report-filter/task-priority-report-filter.component'; /** * Services @@ -35,6 +36,9 @@ import { MonitorService } from './services/monitor.service'; import { MatTabsModule } from '@angular/material/tabs'; import { MatButtonModule } from '@angular/material/button'; import { MatTableModule } from '@angular/material/table'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatDividerModule } from '@angular/material/divider'; const MODULES = [ CommonModule, @@ -48,12 +52,16 @@ const MODULES = [ SharedModule, MatTabsModule, MatButtonModule, - MatTableModule + MatTableModule, + MatExpansionModule, + MatCheckboxModule, + MatDividerModule ]; const DECLARATIONS = [ ReportTableComponent, MonitorComponent, TaskPriorityReportComponent, + TaskPriorityReportFilterComponent, CanvasComponent, TimestampReportComponent, WorkbasketReportComponent, diff --git a/web/src/app/monitor/services/monitor.service.ts b/web/src/app/monitor/services/monitor.service.ts index 4a75a8406..1d846c54f 100644 --- a/web/src/app/monitor/services/monitor.service.ts +++ b/web/src/app/monitor/services/monitor.service.ts @@ -59,10 +59,13 @@ export class MonitorService { }); } - getTasksByPriorityReport(type: WorkbasketType[] = []): Observable { + getTasksByPriorityReport(type: string[], priority: any[], customFilters: {} = {}): Observable { const queryParams = { - 'workbasket-type': type + 'workbasket-type': type, + columnHeader: priority, + ...customFilters }; + return this.httpClient.get( `${environment.taskanaRestUrl + monitorUrl}workbasket-priority-report${asUrlQueryString(queryParams)}` ); diff --git a/web/src/app/settings/components/Settings/expected-members.ts b/web/src/app/settings/components/Settings/expected-members.ts new file mode 100644 index 000000000..ef85b3a79 --- /dev/null +++ b/web/src/app/settings/components/Settings/expected-members.ts @@ -0,0 +1,11 @@ +export enum SettingMembers { + nameHighPriority = 'nameHighPriority', + nameMediumPriority = 'nameMediumPriority', + nameLowPriority = 'nameLowPriority', + colorHighPriority = 'colorHighPriority', + colorMediumPriority = 'colorMediumPriority', + colorLowPriority = 'colorLowPriority', + intervalHighPriority = 'intervalHighPriority', + intervalMediumPriority = 'intervalMediumPriority', + intervalLowPriority = 'intervalLowPriority' +} diff --git a/web/src/app/settings/components/Settings/settings.component.html b/web/src/app/settings/components/Settings/settings.component.html new file mode 100644 index 000000000..921495eda --- /dev/null +++ b/web/src/app/settings/components/Settings/settings.component.html @@ -0,0 +1,76 @@ +
+ + +
+ + + +
+ +
+ +
+

{{settings.schema[group].displayName}}

+
+ + +
+ {{getMember(group,member).displayName}} + + {{getMember(group,member).displayName}} + + +
+ + +
+ {{getMember(group,member).displayName}} + + + Lower boundary + + + + + Upper boundary + + +
+ + + +
+ {{getMember(group,member).displayName}} + +
+ + +
+ {{getMember(group,member).displayName}} + + + {{getMember(group,member).displayName}} + + + +
+ +
+
+ +
+
+ + +
diff --git a/web/src/app/settings/components/Settings/settings.component.scss b/web/src/app/settings/components/Settings/settings.component.scss new file mode 100644 index 000000000..8d3cf783b --- /dev/null +++ b/web/src/app/settings/components/Settings/settings.component.scss @@ -0,0 +1,66 @@ +@import 'src/theme/_colors.scss'; + +.settings { + padding: 8px; + + &__content { + height: calc(100vh - 116px); + overflow-y: scroll; + padding: 4px; + } + + &__domain-name { + margin-bottom: 20px; + } + + &__buttons { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; + } + + &__button--primary { + background-color: $aquamarine; + color: white; + margin: 4px; + } + + &__button--secondary { + background-color: #fff; + margin: 4px; + } + + &__spacer { + height: 24px; + } + + &__grid { + display: grid; + grid-gap: 4px; + grid-template-columns: 300px 1fr 1fr; + } + + &__grid--center { + align-self: center; + } + + &__grid--two-columns { + grid-column: 2 / 4; + } + + &__color { + margin-bottom: 16px; + } + + &__colors--input { + width: 100px; + } + + &__icon { + color: $aquamarine; + } +} + +::ng-deep .ng-invalid.ng-touched:not(form) { + box-shadow: unset; +} diff --git a/web/src/app/settings/components/Settings/settings.component.spec.ts b/web/src/app/settings/components/Settings/settings.component.spec.ts new file mode 100644 index 000000000..e88eea087 --- /dev/null +++ b/web/src/app/settings/components/Settings/settings.component.spec.ts @@ -0,0 +1,84 @@ +import { DebugElement } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { Actions, NgxsModule, ofActionDispatched, Store } from '@ngxs/store'; +import { FormsModule } from '@angular/forms'; +import { NotificationService } from '../../../shared/services/notifications/notification.service'; +import { MatIconModule } from '@angular/material/icon'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { SettingsState } from '../../../shared/store/settings-store/settings.state'; +import { SettingsComponent } from './settings.component'; +import { settingsStateMock } from '../../../shared/store/mock-data/mock-store'; +import { SetSettings } from '../../../shared/store/settings-store/settings.actions'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +const notificationServiceSpy: Partial = { + showError: jest.fn(), + showSuccess: jest.fn(), + showDialog: jest.fn() +}; + +describe('SettingsComponent', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + let component: SettingsComponent; + let store: Store; + let actions$: Observable; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NgxsModule.forRoot([SettingsState]), + HttpClientTestingModule, + FormsModule, + MatIconModule, + MatFormFieldModule, + MatInputModule, + MatTooltipModule, + BrowserAnimationsModule + ], + declarations: [SettingsComponent], + providers: [{ provide: NotificationService, useValue: notificationServiceSpy }] + }).compileComponents(); + + fixture = TestBed.createComponent(SettingsComponent); + debugElement = fixture.debugElement; + component = fixture.debugElement.componentInstance; + store = TestBed.inject(Store); + actions$ = TestBed.inject(Actions); + store.reset({ + ...store.snapshot(), + settings: settingsStateMock + }); + fixture.detectChanges(); + }) + ); + + it('should create component', () => { + expect(component).toBeTruthy(); + }); + + it('should show success when form is saved successfully', () => { + const showSuccessSpy = jest.spyOn(notificationServiceSpy, 'showSuccess'); + component.onSave(); + expect(showSuccessSpy).toHaveBeenCalled(); + }); + + it('should show error when an invalid form is tried to be saved', () => { + component.settings['intervalHighPriority'] = [-100, 100]; + const showErrorSpy = jest.spyOn(notificationServiceSpy, 'showError'); + component.onSave(); + expect(showErrorSpy).toHaveBeenCalled(); + }); + + it('should dispatch action onValidate() returns true', async () => { + let isActionDispatched = false; + actions$.pipe(ofActionDispatched(SetSettings)).subscribe(() => (isActionDispatched = true)); + component.onSave(); + expect(isActionDispatched).toBe(true); + }); +}); diff --git a/web/src/app/settings/components/Settings/settings.component.ts b/web/src/app/settings/components/Settings/settings.component.ts new file mode 100644 index 000000000..76220b363 --- /dev/null +++ b/web/src/app/settings/components/Settings/settings.component.ts @@ -0,0 +1,93 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { Settings, SettingsMember, SettingTypes } from '../../models/settings'; +import { Select, Store } from '@ngxs/store'; +import { NotificationService } from '../../../shared/services/notifications/notification.service'; +import { SetSettings } from '../../../shared/store/settings-store/settings.actions'; +import { SettingsSelectors } from '../../../shared/store/settings-store/settings.selectors'; +import { takeUntil } from 'rxjs/operators'; +import { validateForm } from './settings.validators'; + +@Component({ + selector: 'taskana-administration-settings', + templateUrl: './settings.component.html', + styleUrls: ['./settings.component.scss'] +}) +export class SettingsComponent implements OnInit, OnDestroy { + settingTypes = SettingTypes; + settings: Settings; + oldSettings: Settings; + groups: string[]; + members: string[][] = []; + invalidMembers: string[] = []; + destroy$ = new Subject(); + + @Select(SettingsSelectors.getSettings) settings$: Observable; + + constructor(private store: Store, private notificationService: NotificationService) {} + + ngOnInit() { + this.settings$.pipe(takeUntil(this.destroy$)).subscribe((settings) => { + this.settings = this.deepCopy(settings); + this.oldSettings = this.deepCopy(settings); + this.getKeysOfSettings(); + }); + } + + deepCopy(settings: Settings): Settings { + return JSON.parse(JSON.stringify(settings)); + } + + getKeysOfSettings() { + this.groups = Object.keys(this.settings.schema); + this.groups.forEach((group) => { + let groupMembers = Object.keys(this.settings.schema[group].members); + this.members.push(groupMembers); + groupMembers.forEach((member) => { + if (!(member in this.settings)) { + this.notificationService.showWarning('SETTINGS_INVALID_DATA', { setting: member }); + } + }); + }); + } + + onSave() { + this.changeLabelColor('grey'); + this.invalidMembers = validateForm(this.members, this.settings, this.groups); + if (this.invalidMembers.length === 0) { + this.store.dispatch(new SetSettings(this.settings)).subscribe(() => { + this.notificationService.showSuccess('SETTINGS_SAVE'); + }); + } else { + this.changeLabelColor('red'); + this.notificationService.showError('SETTINGS_SAVE'); + } + } + + changeLabelColor(color: string) { + this.invalidMembers.forEach((member) => { + const elements = Array.from(document.getElementsByClassName(member)); + elements.forEach((element) => { + (element as HTMLElement).style.color = color; + }); + }); + } + + onReset() { + this.changeLabelColor('grey'); + this.settings = this.deepCopy(this.oldSettings); + } + + getMember(group: string, member: string): SettingsMember { + return this.settings.schema[group].members[member]; + } + + onColorChange(member: string) { + this.settings[member] = (document.getElementById(member) as HTMLInputElement).value; + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/web/src/app/settings/components/Settings/settings.validators.ts b/web/src/app/settings/components/Settings/settings.validators.ts new file mode 100644 index 000000000..c2eb930f3 --- /dev/null +++ b/web/src/app/settings/components/Settings/settings.validators.ts @@ -0,0 +1,54 @@ +import { Settings, SettingTypes } from '../../models/settings'; + +export const validateForm = (members: string[][], settings: Settings, groups: string[]): string[] => { + const invalidMembers = []; + + for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { + for (let memberIndex = 0; memberIndex < members[groupIndex].length; memberIndex++) { + const memberKey = members[groupIndex][memberIndex]; + const member = settings.schema[groups[groupIndex]].members[memberKey]; + const value = settings[memberKey]; + + if (member.type == SettingTypes.TEXT || member.type == SettingTypes.INTERVAL) { + let compareWithMin; + let compareWithMax; + switch (member.type) { + case SettingTypes.TEXT: + compareWithMin = value.length; + compareWithMax = value.length; + break; + case SettingTypes.INTERVAL: + compareWithMin = value[0]; + compareWithMax = value[1]; + break; + } + + let isValid = true; + if ((member.min || member.min == 0) && member.max) { + isValid = compareWithMin >= member.min && compareWithMax <= member.max; + } else if (member.min || member.min == 0) { + isValid = compareWithMin >= member.min; + } else if (member.max) { + isValid = compareWithMax <= member.max; + } + + if (!isValid) { + invalidMembers.push(memberKey); + } + + if (member.type == SettingTypes.INTERVAL && compareWithMin > compareWithMax) { + invalidMembers.push(memberKey); + } + } + + if (member.type == SettingTypes.JSON) { + try { + JSON.parse(value); + } catch { + invalidMembers.push(memberKey); + } + } + } + } + return invalidMembers; +}; diff --git a/web/src/app/settings/models/settings.ts b/web/src/app/settings/models/settings.ts new file mode 100644 index 000000000..bca3ba19f --- /dev/null +++ b/web/src/app/settings/models/settings.ts @@ -0,0 +1,25 @@ +export interface SettingsMember { + displayName: string; + type: string; + min?: number; + max?: number; +} + +export interface Settings { + [setting: string]: any; + schema: { + [parameterGroup: string]: { + displayName: string; + members: { + [memberName: string]: SettingsMember; + }; + }; + }; +} + +export enum SettingTypes { + TEXT = 'text', + INTERVAL = 'interval', + COLOR = 'color', + JSON = 'json' +} diff --git a/web/src/app/settings/services/settings-service.ts b/web/src/app/settings/services/settings-service.ts new file mode 100644 index 000000000..0b467bf1d --- /dev/null +++ b/web/src/app/settings/services/settings-service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Settings } from '../models/settings'; +import { environment } from '../../../environments/environment'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class SettingsService { + constructor(private httpClient: HttpClient) {} + + // GET + getSettings(): Observable { + return this.httpClient + .get(`${environment.taskanaRestUrl}/v1/config/custom-attributes`) + .pipe(map((b) => b.customAttributes)); + } + + // PUT + updateSettings(settings: Settings) { + return this.httpClient.put(`${environment.taskanaRestUrl}/v1/config/custom-attributes`, { + customAttributes: settings + }); + } +} + +interface SettingsRepresentation { + customAttributes: Settings; +} diff --git a/web/src/app/settings/settings-routing.module.ts b/web/src/app/settings/settings-routing.module.ts new file mode 100644 index 000000000..23862b7f7 --- /dev/null +++ b/web/src/app/settings/settings-routing.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { SettingsComponent } from './components/Settings/settings.component'; + +const routes: Routes = [ + { + path: '', + component: SettingsComponent + }, + { + path: '', + redirectTo: '', + pathMatch: 'full' + }, + { + path: '**', + redirectTo: '' + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class SettingsRoutingModule {} diff --git a/web/src/app/settings/settings.module.ts b/web/src/app/settings/settings.module.ts new file mode 100644 index 000000000..4f03bed95 --- /dev/null +++ b/web/src/app/settings/settings.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SettingsComponent } from './components/Settings/settings.component'; +import { SettingsRoutingModule } from './settings-routing.module'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { FormsModule } from '@angular/forms'; +import { SettingsService } from './services/settings-service'; + +@NgModule({ + declarations: [SettingsComponent], + imports: [ + CommonModule, + SettingsRoutingModule, + MatIconModule, + MatTooltipModule, + MatButtonModule, + MatInputModule, + FormsModule + ], + providers: [SettingsService] +}) +export class SettingsModule {} diff --git a/web/src/app/shared/components/nav-bar/nav-bar.component.ts b/web/src/app/shared/components/nav-bar/nav-bar.component.ts index a1517f913..53890ff2d 100644 --- a/web/src/app/shared/components/nav-bar/nav-bar.component.ts +++ b/web/src/app/shared/components/nav-bar/nav-bar.component.ts @@ -18,6 +18,7 @@ export class NavBarComponent implements OnInit { titleMonitor = 'Monitor'; titleWorkplace = 'Workplace'; titleHistory = 'History'; + titleSettings = 'Settings'; toggle: boolean = false; title = this.titleWorkplace; @@ -27,6 +28,8 @@ export class NavBarComponent implements OnInit { ngOnInit() { this.selectedRouteSubscription = this.selectedRouteService.getSelectedRoute().subscribe((value: string) => { + // does not work + // console.log('router', value); this.selectedRoute = value; this.setTitle(value); }); @@ -50,6 +53,8 @@ export class NavBarComponent implements OnInit { this.title = this.titleAccessItems; } else if (value.indexOf('history') === 0) { this.title = this.titleHistory; + } else if (value.indexOf('settings') === 0) { + this.title = this.titleSettings; } } } diff --git a/web/src/app/shared/components/sidenav-list/sidenav-list.component.html b/web/src/app/shared/components/sidenav-list/sidenav-list.component.html index de00de2a1..726014b24 100644 --- a/web/src/app/shared/components/sidenav-list/sidenav-list.component.html +++ b/web/src/app/shared/components/sidenav-list/sidenav-list.component.html @@ -24,4 +24,6 @@
History + UI Settings diff --git a/web/src/app/shared/components/sidenav-list/sidenav-list.component.spec.ts b/web/src/app/shared/components/sidenav-list/sidenav-list.component.spec.ts index ec5e701b3..db1f41164 100644 --- a/web/src/app/shared/components/sidenav-list/sidenav-list.component.spec.ts +++ b/web/src/app/shared/components/sidenav-list/sidenav-list.component.spec.ts @@ -72,7 +72,7 @@ describe('SidenavListComponent', () => { component.historyAccess = true; fixture.detectChanges(); const menuList = debugElement.queryAll(By.css('.navlist__item')); - expect(menuList.length).toBe(9); + expect(menuList.length).toBe(10); fixture.detectChanges(); }); @@ -81,6 +81,7 @@ describe('SidenavListComponent', () => { component.monitorAccess = true; component.workplaceAccess = false; component.historyAccess = false; + component.settingsAccess = false; fixture.detectChanges(); const menuList = debugElement.queryAll(By.css('.navlist__item')); expect(menuList.length).toBe(1); diff --git a/web/src/app/shared/components/sidenav-list/sidenav-list.component.ts b/web/src/app/shared/components/sidenav-list/sidenav-list.component.ts index feb3e2aa7..9cc8139e6 100644 --- a/web/src/app/shared/components/sidenav-list/sidenav-list.component.ts +++ b/web/src/app/shared/components/sidenav-list/sidenav-list.component.ts @@ -20,11 +20,13 @@ export class SidenavListComponent implements OnInit { classificationUrl = 'taskana/administration/classifications'; workbasketsUrl = 'taskana/administration/workbaskets'; administrationsUrl = 'taskana/administration/workbaskets'; + settingsURL = 'taskana/settings'; administrationAccess = false; monitorAccess = false; workplaceAccess = false; historyAccess = false; + settingsAccess = false; constructor(private taskanaEngineService: TaskanaEngineService, private sidenavService: SidenavService) {} @@ -35,6 +37,7 @@ export class SidenavListComponent implements OnInit { this.taskanaEngineService.isHistoryProviderEnabled().subscribe((value) => { this.historyAccess = value; }); + this.settingsAccess = this.administrationAccess; } toggleSidenav() { diff --git a/web/src/app/shared/services/obtain-message/message-by-error-code.ts b/web/src/app/shared/services/obtain-message/message-by-error-code.ts index 7c11ca711..8485d3f0b 100644 --- a/web/src/app/shared/services/obtain-message/message-by-error-code.ts +++ b/web/src/app/shared/services/obtain-message/message-by-error-code.ts @@ -58,7 +58,11 @@ export const messageByErrorCode = { IMPORT_EXPORT_UPLOAD_FAILED_NOT_FOUND: 'Upload failed because operation was not found', IMPORT_EXPORT_UPLOAD_FAILED_CONFLICTS: 'Upload failed because operation has conflicts', IMPORT_EXPORT_UPLOAD_FAILED_SIZE: 'Upload failed because maximum file size exceeded', - IMPORT_EXPORT_UPLOAD_FILE_FORMAT: 'File format is not allowed. Please use a .json file.' + IMPORT_EXPORT_UPLOAD_FILE_FORMAT: 'File format is not allowed. Please use a .json file.', + + SETTINGS_SAVE: 'Settings cannot be saved since the form contains invalid values.', + SETTINGS_NO_SCHEMA: + 'Wrong data format of UI settings. The object must contain field "schema". Please contact your administrator.' }, [messageTypes.SUCCESS]: { @@ -89,7 +93,9 @@ export const messageByErrorCode = { TASK_CREATE: 'Task with name {taskName} was created', TASK_UPDATE: 'Task with name {taskName} was updated', TASK_DELETE: 'Task with name {taskName} was deleted', - TASK_RESTORE: 'Task restored' + TASK_RESTORE: 'Task restored', + + SETTINGS_SAVE: 'Settings were updated' }, [messageTypes.INFORMATION]: { @@ -99,7 +105,10 @@ export const messageByErrorCode = { [messageTypes.WARNING]: { REPORT_DATA_WRONG_HEADER: 'The received header of the Report data does not match the expected header. ' + - 'The data might be displayed incorrectly. Please contact your administrator.' + 'The data might be displayed incorrectly. Please contact your administrator.', + SETTINGS_INVALID_DATA: + 'The data structure is invalid. The setting {setting} is configured under UI settings but there is no matching ' + + 'attribute to save the value. Please contact your administrator.' }, [messageTypes.DIALOG]: { diff --git a/web/src/app/shared/services/taskana-engine/taskana-engine.service.ts b/web/src/app/shared/services/taskana-engine/taskana-engine.service.ts index f692a364e..ffe55e3bd 100644 --- a/web/src/app/shared/services/taskana-engine/taskana-engine.service.ts +++ b/web/src/app/shared/services/taskana-engine/taskana-engine.service.ts @@ -24,14 +24,11 @@ export class TaskanaEngineService { .toPromise(); } - hasRole(roles2Find: Array): boolean { + hasRole(roles2Find: string[]): boolean { if (!this.currentUserInfo || this.currentUserInfo.roles.length < 1) { return false; } - if (this.findRole(roles2Find)) { - return true; - } - return false; + return !!this.findRole(roles2Find); } getVersion(): Observable { @@ -46,7 +43,7 @@ export class TaskanaEngineService { return this.httpClient.get(`${environment.taskanaRestUrl}/v1/history-provider-enabled`); } - private findRole(roles2Find: Array) { + private findRole(roles2Find: string[]) { return this.currentUserInfo.roles.find((role) => roles2Find.some((roleLookingFor) => role === roleLookingFor)); } } diff --git a/web/src/app/shared/store/classification-store/classification.selectors.ts b/web/src/app/shared/store/classification-store/classification.selectors.ts index 63eb85a46..b85f79fdf 100644 --- a/web/src/app/shared/store/classification-store/classification.selectors.ts +++ b/web/src/app/shared/store/classification-store/classification.selectors.ts @@ -1,7 +1,6 @@ import { Selector } from '@ngxs/store'; import { ClassificationStateModel, ClassificationState } from './classification.state'; import { Classification } from '../../models/classification'; -import { CategoriesResponse } from '../../services/classification-categories/classification-categories.service'; export class ClassificationSelectors { @Selector([ClassificationState]) diff --git a/web/src/app/shared/store/index.ts b/web/src/app/shared/store/index.ts index d6cd4a497..f78660d9f 100644 --- a/web/src/app/shared/store/index.ts +++ b/web/src/app/shared/store/index.ts @@ -4,6 +4,7 @@ import { WorkbasketState } from './workbasket-store/workbasket.state'; import { AccessItemsManagementState } from './access-items-management-store/access-items-management.state'; import { FilterState } from './filter-store/filter.state'; import { WorkplaceState } from './workplace-store/workplace.state'; +import { SettingsState } from './settings-store/settings.state'; export const STATES = [ EngineConfigurationState, @@ -11,5 +12,6 @@ export const STATES = [ WorkbasketState, AccessItemsManagementState, FilterState, - WorkplaceState + WorkplaceState, + SettingsState ]; diff --git a/web/src/app/shared/store/mock-data/mock-store.ts b/web/src/app/shared/store/mock-data/mock-store.ts index 7c0f3d083..b497793e7 100644 --- a/web/src/app/shared/store/mock-data/mock-store.ts +++ b/web/src/app/shared/store/mock-data/mock-store.ts @@ -2,6 +2,7 @@ import { Workbasket } from '../../models/workbasket'; import { WorkbasketType } from '../../models/workbasket-type'; import { ACTION } from '../../models/action'; import { WorkbasketAccessItemsRepresentation } from '../../models/workbasket-access-items-representation'; +import { Settings } from '../../../settings/models/settings'; export const classificationStateMock = { classifications: [], @@ -515,3 +516,79 @@ export const workbasketReadStateMock = { ], workbasketAccessItems: workbasketAccessItemsMock }; + +export const settingsStateMock = { + settings: { + nameHighPriority: 'High Priority', + nameMediumPriority: 'Medium Priority', + nameLowPriority: 'Low Priority', + intervalHighPriority: [3, 300], + intervalMediumPriority: [2, 2], + intervalLowPriority: [0, 1], + colorHighPriority: '#FF0000', + colorLowPriority: '#5FAD00', + colorMediumPriority: '#FFD700', + filter: '{ "Tasks with state READY": { "state": ["READY"]}, "Tasks with state CLAIMED": {"state": ["CLAIMED"] }}', + schema: { + 'Monitor Workbasket-Priority-Report': { + displayName: 'Priority Report', + members: { + nameHighPriority: { + displayName: 'High Priority Name', + type: 'text', + max: 32 + }, + nameMediumPriority: { + displayName: 'Medium Priority Name', + type: 'text', + min: 0, + max: 32 + }, + nameLowPriority: { + displayName: 'Low Priority Name', + type: 'text', + min: 0, + max: 32 + }, + intervalHighPriority: { + displayName: 'High Priority Interval', + type: 'interval', + min: 0 + }, + intervalMediumPriority: { + displayName: 'Medium Priority Interval', + type: 'interval', + min: 0 + }, + intervalLowPriority: { + displayName: 'Low Priority Interval', + type: 'interval', + min: 0 + }, + colorHighPriority: { + displayName: 'High Priority Color', + type: 'color' + }, + colorMediumPriority: { + displayName: 'Medium Priority Color', + type: 'color' + }, + colorLowPriority: { + displayName: 'Low Priority Color', + type: 'color' + } + } + }, + 'Monitor Workbasket-Priority-Report-Filter': { + displayName: 'Filter for Task-Priority-Report', + members: { + filter: { + displayName: 'Filter values', + type: 'json', + min: 1 + } + } + } + } + } +}; diff --git a/web/src/app/shared/store/settings-store/settings.actions.ts b/web/src/app/shared/store/settings-store/settings.actions.ts new file mode 100644 index 000000000..9c6f705c8 --- /dev/null +++ b/web/src/app/shared/store/settings-store/settings.actions.ts @@ -0,0 +1,10 @@ +import { Settings } from '../../../settings/models/settings'; + +export class RetrieveSettings { + static readonly type = '[Settings] Get settings from backend'; +} + +export class SetSettings { + static readonly type = '[Settings] Modify settings according to user input'; + constructor(public settings: Settings) {} +} diff --git a/web/src/app/shared/store/settings-store/settings.selectors.ts b/web/src/app/shared/store/settings-store/settings.selectors.ts new file mode 100644 index 000000000..a9309b862 --- /dev/null +++ b/web/src/app/shared/store/settings-store/settings.selectors.ts @@ -0,0 +1,10 @@ +import { Selector } from '@ngxs/store'; +import { SettingsState, SettingsStateModel } from './settings.state'; +import { Settings } from '../../../settings/models/settings'; + +export class SettingsSelectors { + @Selector([SettingsState]) + static getSettings(state: SettingsStateModel): Settings { + return state.settings; + } +} diff --git a/web/src/app/shared/store/settings-store/settings.state.ts b/web/src/app/shared/store/settings-store/settings.state.ts new file mode 100644 index 000000000..df5c7f07c --- /dev/null +++ b/web/src/app/shared/store/settings-store/settings.state.ts @@ -0,0 +1,49 @@ +import { Action, NgxsAfterBootstrap, State, StateContext } from '@ngxs/store'; +import { Injectable } from '@angular/core'; +import { RetrieveSettings, SetSettings } from './settings.actions'; +import { Settings } from '../../../settings/models/settings'; +import { SettingsService } from '../../../settings/services/settings-service'; +import { take } from 'rxjs/operators'; +import { NotificationService } from '../../services/notifications/notification.service'; + +@Injectable() +@State({ name: 'settings' }) +export class SettingsState implements NgxsAfterBootstrap { + constructor(private settingsService: SettingsService, private notificationService: NotificationService) {} + + @Action(RetrieveSettings) + initializeStore(ctx: StateContext) { + return this.settingsService + .getSettings() + .pipe(take(1)) + .subscribe((settings) => { + if (!settings.schema) { + this.notificationService.showError('SETTINGS_NO_SCHEMA'); + } else { + ctx.patchState({ + settings: settings + }); + } + }); + } + + ngxsAfterBootstrap(ctx?: StateContext): void { + ctx.dispatch(new RetrieveSettings()); + } + + @Action(SetSettings) + setSettings(ctx: StateContext, action: SetSettings) { + return this.settingsService + .updateSettings(action.settings) + .pipe(take(1)) + .subscribe(() => { + ctx.patchState({ + settings: action.settings + }); + }); + } +} + +export interface SettingsStateModel { + settings: Settings; +} diff --git a/web/src/app/shared/util/query-parameters-v2.ts b/web/src/app/shared/util/query-parameters-v2.ts index 02b9ffcb8..efecd85a5 100644 --- a/web/src/app/shared/util/query-parameters-v2.ts +++ b/web/src/app/shared/util/query-parameters-v2.ts @@ -4,8 +4,17 @@ export function asUrlQueryString(params: Object): string { for (const [key, value] of Object.entries(params)) { if (value) { let values: any[] = value instanceof Array ? value : [value]; - values.filter((v) => v !== undefined).forEach((v) => (query += (query ? '&' : '?') + `${key}=${v}`)); + values + .filter((v) => v !== undefined) + .forEach((v) => (query += (query ? '&' : '?') + `${key}=${convertValue(v)}`)); } } return query; } + +function convertValue(value: any) { + if (value instanceof Object) { + return encodeURIComponent(JSON.stringify(value)); + } + return value; +}