Commit c14146b0 authored by Christopher Keim's avatar Christopher Keim
Browse files

[TOB-169,290,188,53,350,351,26,23,358] feat: v0.8.0

[TOB-169] feat: Add error messages and extended error handling

 * Add utility rxjs operators for error handling
 * Add toast component and effect for displaying toast
 * Add actions and reducers for statement errors
 * Show error messages in details page
 * Show error messages in edit page
 * Show default error message for unexpected errors
 * Improve error handling in effects
 * Reorganize translations

[TOB-290] feat: Add authorization error handling for process tasks

 * Disable side menu buttons if task is claimed by another user
 * Hide side menu buttons if task can not be claimed at all

[TOB-188] feat: Select geographic position in workflow data form

 * Add leaflet configuration to package.json
 * Add styles for leaflet
 * Add rxjs operator for entering/leaving ngZone
 * Add wrapper directives for leaflet
 * Add select component for map coordinates
 * Submit geographic position in workflow form effect

[TOB-53] feat: Add email functionality

* A...
parent ebb6e8bb
......@@ -11,7 +11,11 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
import {BreakpointObserver} from "@angular/cdk/layout";
import {Component, Input} from "@angular/core";
import {ISelectOption} from "../../../../shared/controls/select/model";
import {StatementTableComponent} from "../../../../shared/layout/statement-table/components";
import {IStatementTableEntry} from "../../../../shared/layout/statement-table/model";
@Component({
selector: "app-dashboard-list",
......@@ -21,9 +25,31 @@ import {Component, Input} from "@angular/core";
export class DashboardListComponent {
@Input()
public appTitle: string;
public appCaption: string;
@Input()
public appItems: any[];
public appEntries: IStatementTableEntry[];
@Input()
public appShowContributionStatusForMyDepartment: boolean;
@Input()
public appShowSubCaption: boolean;
@Input()
public appStatementTypeOptions: ISelectOption<number>[];
@Input()
public appSubCaption: string;
public columns = [...StatementTableComponent.DASHBOARD_COLUMNS];
public columnsShort = [...StatementTableComponent.DASHBOARD_COLUMNS_SHORT];
public large$ = this.breakpointObserver.observe("(min-width: 1280px)");
public constructor(public breakpointObserver: BreakpointObserver) {
}
}
......@@ -12,23 +12,40 @@
-------------------------------------------------------------------------------->
<div class="dashboard-header">
<span class="dashboard-header-title">{{'core.title' | translate}}</span>
<span class="dashboard-header-title">Stellungnahmen öffentlicher Belange</span>
<button
(click)="showOnlyStatementsEditedByMe = !showOnlyStatementsEditedByMe;"
[class.openk-info]="!showOnlyStatementsEditedByMe"
class="openk-button openk-chip dashboard-toggle openk-primary">
{{(showOnlyStatementsEditedByMe ? "dashboard.showAll" : "dashboard.showEditedByMe") | translate }}
</button>
</div>
<div class="dashboard">
<app-dashboard-list
[appItems]="unfinishedStatements$ | async"
[appTitle]="'Aktive Vorgänge'"
class="dashboard-list">
<app-side-menu-status
*ngIf="loading$ | async; else dashboardRef"
[appLoadingMessage]="'core.loading' | translate"
[appLoading]="true"
class="loading">
</app-side-menu-status>
</app-dashboard-list>
<ng-template #dashboardRef>
<ng-container *ngFor="let list of config">
<app-dashboard-list
*ngIf="list.hasUserRole$ | async"
[appCaption]="list.caption | translate"
[appEntries]="list.entries$ | async | getDashboardEntries: showOnlyStatementsEditedByMe"
[appShowContributionStatusForMyDepartment]="list.showContributionStatusForMyDepartment"
[appShowSubCaption]="list.showSubCaption$ | async"
[appStatementTypeOptions]="statementTypeOptions$ | async"
class="dashboard-list">
<a [routerLink]="'/mail'">
{{'dashboard.toInbox' | translate}}
</a>
</app-dashboard-list>
</ng-container>
</ng-template>
<app-dashboard-list
[appItems]="finishedStatements$ | async"
[appTitle]="'Abgeschlossene Vorgänge'"
class="dashboard-list">
</app-dashboard-list>
</div>
......@@ -17,6 +17,14 @@
display: flex;
flex-flow: column;
padding: 1em;
box-sizing: border-box;
min-height: 100%;
position: relative;
}
.dashboard-toggle {
min-width: 12em;
font-size: small;
}
.dashboard-header {
......@@ -32,40 +40,18 @@
font-weight: 600;
}
.dashboard-header-actions {
display: flex;
flex-flow: row wrap;
margin-left: auto;
justify-content: flex-end;
.dashboard-list {
margin-top: 2.5em;
.openk-button {
margin: 0.25em;
&:first-of-type {
margin-top: 1em;
}
}
.dashboard {
.loading {
width: 100%;
height: 100%;
display: flex;
flex-flow: row;
margin-top: 1em;
}
.dashboard-list {
margin: 0 0.5em;
flex: 1 1 50%;
}
@media (max-width: 50em) {
.dashboard {
flex-flow: row wrap;
}
.dashboard-list {
margin-bottom: 1em;
&:last-child {
margin-bottom: 0;
}
}
justify-content: center;
align-items: center;
}
......@@ -12,28 +12,81 @@
********************************************************************************/
import {async, ComponentFixture, TestBed} from "@angular/core/testing";
import {provideMockStore} from "@ngrx/store/testing";
import {RouterTestingModule} from "@angular/router/testing";
import {MockStore, provideMockStore} from "@ngrx/store/testing";
import {take} from "rxjs/operators";
import {I18nModule} from "../../../../core/i18n";
import {fetchEmailInboxAction} from "../../../../store/mail/actions";
import {isDivisionMemberSelector, isOfficialInChargeSelector} from "../../../../store/root/selectors";
import {fetchDashboardStatementsAction} from "../../../../store/statements/actions";
import {DashboardModule} from "../../dashboard.module";
import {DashboardComponent} from "./dashboard.component";
describe("DashboardComponent", () => {
let component: DashboardComponent;
let store: MockStore;
let fixture: ComponentFixture<DashboardComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [DashboardComponent],
providers: [provideMockStore({})]
})
.compileComponents();
imports: [
DashboardModule,
I18nModule,
RouterTestingModule
],
providers: [
provideMockStore({})
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DashboardComponent);
component = fixture.componentInstance;
store = TestBed.inject(MockStore);
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should fetch data on init", () => {
const dispatchSpy = spyOn(store, "dispatch");
dispatchSpy.calls.reset();
component.ngOnInit();
expect(dispatchSpy).toHaveBeenCalledTimes(1);
expect(dispatchSpy).toHaveBeenCalledWith(fetchDashboardStatementsAction());
store.overrideSelector(isOfficialInChargeSelector, true);
dispatchSpy.calls.reset();
component.ngOnInit();
expect(dispatchSpy).toHaveBeenCalledTimes(2);
expect(dispatchSpy).toHaveBeenCalledWith(fetchDashboardStatementsAction());
expect(dispatchSpy).toHaveBeenCalledWith(fetchEmailInboxAction());
});
it("should check if user is not an official in charge but a department member", () => {
store.overrideSelector(isOfficialInChargeSelector, false);
store.overrideSelector(isDivisionMemberSelector, false);
component.isNotOfficialInChargeAndDivisionMember$.pipe(take(1))
.subscribe((result) => expect(result).toBe(false));
store.overrideSelector(isOfficialInChargeSelector, true);
store.overrideSelector(isDivisionMemberSelector, false);
component.isNotOfficialInChargeAndDivisionMember$.pipe(take(1))
.subscribe((result) => expect(result).toBe(false));
store.overrideSelector(isOfficialInChargeSelector, true);
store.overrideSelector(isDivisionMemberSelector, true);
component.isNotOfficialInChargeAndDivisionMember$.pipe(take(1))
.subscribe((result) => expect(result).toBe(false));
store.overrideSelector(isOfficialInChargeSelector, false);
store.overrideSelector(isDivisionMemberSelector, true);
component.isNotOfficialInChargeAndDivisionMember$.pipe(take(1))
.subscribe((result) => expect(result).toBe(true));
});
});
......@@ -13,12 +13,31 @@
import {Component, OnInit} from "@angular/core";
import {select, Store} from "@ngrx/store";
import {combineLatest, Observable, of} from "rxjs";
import {filter, map, take} from "rxjs/operators";
import {
finishedStatementListSelector,
fetchDashboardStatementsAction,
getDashboardDivisionMemberStatementsSelector,
getDashboardLoadingSelector,
getDashboardOfficialInChargeStatementsSelector,
getDashboardStatementsToApproveSelector,
getOtherDashboardStatementsSelector,
isApproverSelector,
isDivisionMemberSelector,
isOfficialInChargeSelector,
startStatementSearchAction,
unfinishedStatementListSelector
IStatementEntityWithTasks,
statementTypesSelector
} from "../../../../store";
import {fetchEmailInboxAction} from "../../../../store/mail/actions";
import {getIsEmailInInboxSelector} from "../../../../store/mail/selectors";
export interface IDashboardListConfiguration {
caption: string;
showContributionStatusForMyDepartment?: boolean;
hasUserRole$: Observable<boolean>;
entries$: Observable<IStatementEntityWithTasks[]>;
showSubCaption$?: Observable<boolean>;
}
@Component({
selector: "app-dashboard",
......@@ -29,16 +48,64 @@ export class DashboardComponent implements OnInit {
public isOfficialInCharge$ = this.store.pipe(select(isOfficialInChargeSelector));
public readonly finishedStatements$ = this.store.pipe(select(finishedStatementListSelector));
public isNotOfficialInChargeAndDivisionMember$ = combineLatest([
this.store.pipe(select(isOfficialInChargeSelector)),
this.store.pipe(select(isDivisionMemberSelector))
]).pipe(map(([isOfficialInCharge, isDivisionMember]) => isDivisionMember && !isOfficialInCharge));
public loading$ = this.store.pipe(select(getDashboardLoadingSelector));
public statementTypeOptions$ = this.store.pipe(select(statementTypesSelector));
public showOnlyStatementsEditedByMe: boolean;
public config: IDashboardListConfiguration[] = [
{
caption: "dashboard.statements.forOfficialInCharge",
hasUserRole$: this.store.pipe(select(isOfficialInChargeSelector)),
entries$: this.store.pipe(select(getDashboardOfficialInChargeStatementsSelector)),
showSubCaption$: this.store.pipe(select(getIsEmailInInboxSelector))
},
{
caption: "dashboard.statements.forAllDepartments",
hasUserRole$: this.store.pipe(select(isOfficialInChargeSelector)),
entries$: this.store.pipe(select(getDashboardDivisionMemberStatementsSelector)),
},
{
caption: "dashboard.statements.forMyDepartment",
showContributionStatusForMyDepartment: true,
hasUserRole$: this.isNotOfficialInChargeAndDivisionMember$,
entries$: this.store.pipe(select(getDashboardDivisionMemberStatementsSelector))
},
{
caption: "dashboard.statements.forApprover",
hasUserRole$: this.store.pipe(select(isApproverSelector)),
entries$: this.store.pipe(select(getDashboardStatementsToApproveSelector))
},
{
caption: "dashboard.statements.other",
hasUserRole$: of(true),
entries$: this.store.pipe(select(getOtherDashboardStatementsSelector))
}
];
public readonly unfinishedStatements$ = this.store.pipe(select(unfinishedStatementListSelector));
public constructor(private readonly store: Store) {
}
public ngOnInit(): void {
this.store.dispatch(startStatementSearchAction({options: {q: ""}}));
this.fetchStatements();
this.fetchEmailInbox();
}
public fetchStatements() {
this.store.dispatch(fetchDashboardStatementsAction());
}
public fetchEmailInbox() {
this.isOfficialInCharge$.pipe(take(1), filter((isOfficialInCharge) => isOfficialInCharge))
.subscribe(() => this.store.dispatch(fetchEmailInboxAction()));
}
}
......@@ -12,5 +12,4 @@
********************************************************************************/
export * from "./dashboard";
export * from "./dashboard-item";
export * from "./dashboard-list";
......@@ -15,27 +15,34 @@ import {CommonModule} from "@angular/common";
import {NgModule} from "@angular/core";
import {MatIconModule} from "@angular/material/icon";
import {RouterModule} from "@angular/router";
import {CardModule} from "../../shared/layout/card";
import {TranslateModule} from "@ngx-translate/core";
import {SideMenuModule} from "../../shared/layout/side-menu";
import {StatementTableModule} from "../../shared/layout/statement-table";
import {SharedPipesModule} from "../../shared/pipes";
import {DashboardComponent, DashboardItemComponent, DashboardListComponent} from "./components";
import {ProgressSpinnerModule} from "../../shared/progress-spinner";
import {DashboardComponent, DashboardListComponent} from "./components";
import {GetDashboardEntriesPipe} from "./pipe";
@NgModule({
imports: [
CommonModule,
RouterModule,
MatIconModule,
CardModule,
SharedPipesModule
SharedPipesModule,
StatementTableModule,
ProgressSpinnerModule,
SideMenuModule,
TranslateModule
],
declarations: [
DashboardComponent,
DashboardListComponent,
DashboardItemComponent
GetDashboardEntriesPipe
],
exports: [
DashboardComponent,
DashboardListComponent,
DashboardItemComponent
GetDashboardEntriesPipe
]
})
export class DashboardModule {
......
/********************************************************************************
* Copyright (c) 2020 Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
import {EAPIProcessTaskDefinitionKey, IAPIProcessTask} from "../../../core/api/process";
import {IStatementTableEntry} from "../../../shared/layout/statement-table/model";
import {IStatementEntityWithTasks} from "../../../store/statements/model";
import {createStatementModelMock} from "../../../test";
import {GetDashboardEntriesPipe} from "./get-dashboard-entries.pipe";
describe("GetDashboardEntriesPipe", () => {
const pipe = new GetDashboardEntriesPipe();
it("should transform a list of statements to table entries", () => {
const entities: IStatementEntityWithTasks[] = Array(100).fill(0).map((_, id) => ({
info: createStatementModelMock(id)
}));
entities.push(null);
entities.push({});
expect(pipe.transform(entities)).toEqual(entities
.filter((_) => _?.info?.id != null)
.map((_) => ({
..._.info,
contributionStatus: "-",
contributionStatusForMyDepartment: false,
currentTaskName: undefined
}))
);
entities[19] = {
...entities[19],
editedByMe: true,
mandatoryContributionsCount: 10,
mandatoryDepartmentsCount: 19,
completedForMyDepartment: true,
optionalForMyDepartment: true,
tasks: [{
...{} as IAPIProcessTask,
taskDefinitionKey: EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA
}]
};
const expectedResult: IStatementTableEntry[] = [{
...entities[19].info,
contributionStatus: "10/19",
contributionStatusForMyDepartment: true,
currentTaskName: EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA,
}];
expect(pipe.transform(entities, true)).toEqual(expectedResult);
entities[19].completedForMyDepartment = false;
expectedResult[0].contributionStatusForMyDepartment = null;
expect(pipe.transform(entities, true)).toEqual(expectedResult);
entities[19].tasks[0].name = "TaskName";
expectedResult[0].currentTaskName = "TaskName";
expect(pipe.transform(entities, true)).toEqual(expectedResult);
});
});
/********************************************************************************
* Copyright (c) 2020 Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
import {Pipe, PipeTransform} from "@angular/core";
import {IStatementTableEntry} from "../../../shared/layout/statement-table/model";
import {IStatementEntityWithTasks} from "../../../store/statements/model";
import {arrayJoin} from "../../../util/store";
@Pipe({name: "getDashboardEntries"})
export class GetDashboardEntriesPipe implements PipeTransform {
public transform(value: IStatementEntityWithTasks[], editedByMe?: boolean): IStatementTableEntry[] {
return arrayJoin(value)
.filter((statement) => statement?.info?.id != null)
.filter((statement) => !editedByMe || statement.editedByMe)
.map((statement) => {
const {
mandatoryContributionsCount,
mandatoryDepartmentsCount,
optionalForMyDepartment,
completedForMyDepartment,
tasks,
info
} = statement;
const contributionStatus: string = Number.isFinite(mandatoryDepartmentsCount) ?
`${Number.isFinite(mandatoryContributionsCount) ? mandatoryContributionsCount : 0}/${mandatoryDepartmentsCount}` :
"-";
const contributionStatusForMyDepartment: boolean = completedForMyDepartment ?
true :
(optionalForMyDepartment ? null : false);
const {name, taskDefinitionKey} = {...arrayJoin(tasks)[0]};
return {
...info,
contributionStatus,
contributionStatusForMyDepartment,
currentTaskName: name == null ? taskDefinitionKey : name
};
});
}
}
/********************************************************************************
* Copyright (c) 2020 Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
export * from "./get-dashboard-entries.pipe";
......@@ -66,7 +66,7 @@
}
.history--table--cell--icon---red {
color: get-color($openk-danger-palette, 300);
color: $openk-error-color;
}
.mat-header-cell:first-of-type {
......
......@@ -11,39 +11,38 @@
* SPDX-License-Identifier: EPL-2.0
-------------------------------------------------------------------------------->
<ng-container *ngIf="buttonLayout?.length > 0">
<ng-container *appSideMenu="'top'; title: 'details.sideMenu.title' | translate">
<app-action-button
[appDisabled]="appLoading"
[appIcon]="'home'"
[appRouterLink]="'/'"
class="side-menu-button openk-info">
{{'details.sideMenu.backToDashboard' | translate}}
</app-action-button>