[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
* Add abstract route guard service for user roles
* Add optional styling attribute to side menu
* Optionally hide toggle button in collapsible component
* Use general user role route guards
* Add back end calls for email endpoints
* Add store module for emails
* Add component for displaying email inbox
* Add component for displaying email details
* Automatically select values from email in statement info form
* Extend attachment form components for email information
* Extend attachment store module to handle email attachments
[TOB-350,351] feat: Add reference and creation date to statement model
* Add additional properties to statement info model
* Add additional input fields to statement info form
[TOB-26] feat: Add dashboard as general landing page
* Add utility function to compute time diffs of dates
* Add back end call for fetching dashboard statements
* Add store functionality for dashboard
* Add name attribute for process tasks
* Add global styles for tables
* Refactor statement table component
* Reuse statement table component in dashboard component
* Integrate store in dashboard component
* Remove unused code
[TOB-23] feat: Adjust statement editor for negative statements
* Use seperate text block groups for negative statements
* Filter displayed arrangement for available text blocks
[TOB-358] fix: Fix minor bugs
* Reword translations
* Add tooltips to navigation header
* Fix position and button style of navigation drop down
* Fix resizing of side menu in Firefox
Signed-off-by: Christopher Keim <keim@develop-group.de>
diff --git a/NOTICE.md b/NOTICE.md
index 4f620ac..c1e80de 100644
--- a/NOTICE.md
+++ b/NOTICE.md
@@ -47,7 +47,7 @@
## Third-party Content
-@angular-devkit/build-angular (0.901.11)
+@angular-devkit/build-angular (0.901.12)
* License: MIT
* Homepage: https://github.com/angular/angular-cli
diff --git a/README.md b/README.md
index 06cece4..00f4205 100644
--- a/README.md
+++ b/README.md
@@ -22,10 +22,21 @@
* `routes.spaFrontend`: Route on which the website is served
* `routes.spaBackend`: Route on which the website's backend is served
* `routes.portal`: Route on which the main portal is served
+* `routes.contactDataBase`: Route on which the contact data base module is served
Changes to these properties take only effect after rebuilding the
application.
+Additionally, the following options can be used to configure all map views based
+on [Leaflet](https://leafletjs.com):
+
+* `leaflet.templateUrl`: Route to the map tile server required by leaflet
+* `leaflet.attribution`: Attribution which is added to the leaflet map, e.g.
+`© <a>TileServer</a> contributors`
+* `leaflet.gis`: Route to a GIS system
+* `leaflet.lat`/`leaflet.lng`/`leaflet.zoom`: Default coordinates and zoom
+level to which all leaflet maps are initially configured
+
## Build
Building the application is done via the Angular CLI or by the
diff --git a/package-lock.json b/package-lock.json
index 565e02f..d972f7e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "openkonsequenz-statement-public-affairs",
- "version": "0.6.0",
+ "version": "0.8.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
diff --git a/package.json b/package.json
index 84d25e5..d4d2add 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openkonsequenz-statement-public-affairs",
- "version": "0.7.0",
+ "version": "0.8.0",
"description": "Statement Public Affairs",
"license": "Eclipse Public License - v 2.0",
"repository": {
@@ -13,6 +13,14 @@
"portal": "/portalFE",
"contactDataBase": "/contactdatabase"
},
+ "leaflet": {
+ "urlTemplate": "https://localhost:4200/{s}/{z}/{x}/{y}.png",
+ "attribution": "©",
+ "gis": "http://localhost:4200?X=##C_X##&Y=##C_Y##pLLX=##LL_X##&pLLY=##LL_Y##&pURX=##UR_X##&pURY=##UR_Y##&user=##OS_USER##",
+ "lat": 49.87282103349044,
+ "lng": 8.651196956634523,
+ "zoom": 12
+ },
"scripts": {
"-- Build ----------------": "",
"build": "ng build --prod --base-href /statementpaFE/",
diff --git a/src/app/app-routing.module.spec.ts b/src/app/app-routing.module.spec.ts
index 4dfa9ce..c9c28c3 100644
--- a/src/app/app-routing.module.spec.ts
+++ b/src/app/app-routing.module.spec.ts
@@ -14,18 +14,12 @@
import {Location} from "@angular/common";
import {NgZone} from "@angular/core";
import {async, TestBed} from "@angular/core/testing";
-import {CanActivate, Router} from "@angular/router";
+import {Router} from "@angular/router";
import {RouterTestingModule} from "@angular/router/testing";
+import {provideMockStore} from "@ngrx/store/testing";
import {appRoutes} from "./app-routing.module";
-import {NewStatementRouteGuardService} from "./features/new/services/new-statement-route-guard.service";
-
-class RouteGuardMock implements CanActivate {
-
- public canActivate() {
- return true;
- }
-
-}
+import {ALL_NON_TRIVIAL_USER_ROLES} from "./core/api/core";
+import {userRolesSelector} from "./store/root/selectors";
describe("AppRoutingModule", () => {
let router: Router;
@@ -44,10 +38,12 @@
RouterTestingModule.withRoutes(appRoutes)
],
providers: [
- {
- provide: NewStatementRouteGuardService,
- useClass: RouteGuardMock
- }
+ provideMockStore({
+ selectors: [{
+ selector: userRolesSelector,
+ value: [...ALL_NON_TRIVIAL_USER_ROLES]
+ }]
+ })
]
}).compileComponents();
router = TestBed.inject(Router);
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 8ac6768..d88839b 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -20,6 +20,8 @@
import {AppComponent} from "./app.component";
import {CoreModule} from "./core";
import {AppNavigationFrameModule} from "./features/navigation";
+import {LeafletModule} from "./shared/layout/leaflet";
+import {SideMenuRegistrationService} from "./shared/layout/side-menu/services";
import {AppStoreModule} from "./store";
@NgModule({
@@ -36,6 +38,8 @@
AppStoreModule,
AppNavigationFrameModule,
+ LeafletModule.for(SideMenuRegistrationService),
+
// This import is only important for development; in production, nothing is imported.
// ! This import must come after AppStoreModule in order make the NGRX Store Devtools available. !
...environment.imports
diff --git a/src/app/core/api/core/EAPIUserRoles.ts b/src/app/core/api/core/EAPIUserRoles.ts
index 6414122..3e2e8d8 100644
--- a/src/app/core/api/core/EAPIUserRoles.ts
+++ b/src/app/core/api/core/EAPIUserRoles.ts
@@ -15,11 +15,13 @@
DIVISION_MEMBER = "ROLE_SPA_DIVISION_MEMBER",
ROLE_SPA_ACCESS = "ROLE_SPA_ACCESS",
SPA_APPROVER = "ROLE_SPA_APPROVER",
- SPA_OFFICIAL_IN_CHARGE = "ROLE_SPA_OFFICIAL_IN_CHARGE"
+ SPA_OFFICIAL_IN_CHARGE = "ROLE_SPA_OFFICIAL_IN_CHARGE",
+ SPA_ADMIN = "ROLE_SPA_ADMIN"
}
export const ALL_NON_TRIVIAL_USER_ROLES = [
EAPIUserRoles.DIVISION_MEMBER,
EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE,
- EAPIUserRoles.SPA_APPROVER
+ EAPIUserRoles.SPA_APPROVER,
+ EAPIUserRoles.SPA_ADMIN
];
diff --git a/src/app/core/api/index.ts b/src/app/core/api/index.ts
index 5a17d1f..ccbbf5f 100644
--- a/src/app/core/api/index.ts
+++ b/src/app/core/api/index.ts
@@ -14,6 +14,7 @@
export * from "./attachments";
export * from "./contacts";
export * from "./core";
+export * from "./mail";
export * from "./process";
export * from "./settings";
export * from "./shared";
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/core/api/mail/IAPIEmailAttachmentModel.ts
similarity index 84%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/core/api/mail/IAPIEmailAttachmentModel.ts
index a3980e1..eeb188c 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/core/api/mail/IAPIEmailAttachmentModel.ts
@@ -11,4 +11,8 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export interface IAPIEmailAttachmentModel {
+ name: string;
+ size: string;
+ type: string;
+}
diff --git a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss b/src/app/core/api/mail/IAPIEmailModel.ts
similarity index 66%
copy from src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss
copy to src/app/core/api/mail/IAPIEmailModel.ts
index ac39661..a087714 100644
--- a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss
+++ b/src/app/core/api/mail/IAPIEmailModel.ts
@@ -11,19 +11,14 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-@import "openk.styles";
+import {IAPIEmailAttachmentModel} from "./IAPIEmailAttachmentModel";
-.dashboard-item-header-actions {
- display: inline-flex;
- margin-left: auto;
-
- & > * {
- margin-left: 0.5em;
- }
-}
-
-.dashboard-item-body {
- padding: 1em;
- display: flex;
- flex-flow: column;
+export interface IAPIEmailModel {
+ identifier: string;
+ subject: string;
+ date: string;
+ from: string;
+ textPlain: string;
+ textHtml: string;
+ attachments: IAPIEmailAttachmentModel[];
}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/core/api/mail/index.ts
similarity index 82%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/core/api/mail/index.ts
index a3980e1..54cdf02 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/core/api/mail/index.ts
@@ -11,4 +11,6 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./mail-api.service";
+export * from "./IAPIEmailAttachmentModel";
+export * from "./IAPIEmailModel";
diff --git a/src/app/core/api/mail/mail-api.service.ts b/src/app/core/api/mail/mail-api.service.ts
new file mode 100644
index 0000000..90962d3
--- /dev/null
+++ b/src/app/core/api/mail/mail-api.service.ts
@@ -0,0 +1,81 @@
+/********************************************************************************
+ * 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 {HttpClient} from "@angular/common/http";
+import {Inject, Injectable} from "@angular/core";
+import {Observable} from "rxjs";
+import {urlJoin} from "../../../util/http";
+import {SPA_BACKEND_ROUTE} from "../../external-routes";
+import {IAPIAttachmentModel} from "../attachments";
+import {IAPIEmailModel} from "./IAPIEmailModel";
+
+@Injectable({providedIn: "root"})
+export class MailApiService {
+
+ public constructor(
+ protected readonly httpClient: HttpClient,
+ @Inject(SPA_BACKEND_ROUTE) protected readonly baseUrl: string
+ ) {
+
+ }
+
+ /**
+ * Fetches a list of all emails in the module's email inbox.
+ */
+ public getInbox() {
+ const endPoint = `/mail/inbox`;
+ return this.httpClient.get<IAPIEmailModel[]>(urlJoin(this.baseUrl, endPoint));
+ }
+
+ /**
+ * Deletes a specific email from the module's email inbox.
+ */
+ public deleteInboxEmail(mailId: string) {
+ const endPoint = `/mail/inbox/${mailId}`;
+ return this.httpClient.delete(urlJoin(this.baseUrl, endPoint));
+ }
+
+ /**
+ * Fetches a specific email from the module's email inbox.
+ */
+ public getEmail(mailId: string) {
+ const endPoint = `/mail/identifier/${mailId}`;
+ return this.httpClient.get<IAPIEmailModel>(urlJoin(this.baseUrl, endPoint));
+ }
+
+ /**
+ * Transfers the linked email's body of a statement to its attachments.
+ */
+ public transferMailText(statementId: number, taskId: string) {
+ const endPoint = `/process/statements/${statementId}/task/${taskId}/transfermailtext`;
+ return this.httpClient.post<IAPIAttachmentModel>(urlJoin(this.baseUrl, endPoint), null);
+ }
+
+ /**
+ * Transfers a list of email attachments for a statement to its attachments.
+ */
+ public transferMailAttachment(statementId: number, taskId: string, body: Array<{ name: string, tagIds: string[] }>)
+ : Observable<IAPIAttachmentModel[]> {
+ const endPoint = `/process/statements/${statementId}/task/${taskId}/transfermailattachments`;
+ return this.httpClient.post<IAPIAttachmentModel[]>(urlJoin(this.baseUrl, endPoint), body);
+ }
+
+ /**
+ * Re-sends the outgoing email for a statement.
+ */
+ public dispatchStatement(statementId: number, taskId: string) {
+ const endPoint = `/process/statements/${statementId}/task/${taskId}/maildispatch`;
+ return this.httpClient.post(urlJoin(this.baseUrl, endPoint), null);
+ }
+
+}
diff --git a/src/app/core/api/process/IAPIProcessTask.ts b/src/app/core/api/process/IAPIProcessTask.ts
index bc72f41..a81b9d9 100644
--- a/src/app/core/api/process/IAPIProcessTask.ts
+++ b/src/app/core/api/process/IAPIProcessTask.ts
@@ -28,6 +28,10 @@
assignee: string;
+ authorized: boolean;
+
+ name?: string;
+
requiredVariables: {
[key: string]: string
};
diff --git a/src/app/core/api/statements/IAPIDashboardStatementModel.ts b/src/app/core/api/statements/IAPIDashboardStatementModel.ts
new file mode 100644
index 0000000..4110c3e
--- /dev/null
+++ b/src/app/core/api/statements/IAPIDashboardStatementModel.ts
@@ -0,0 +1,32 @@
+/********************************************************************************
+ * 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 {IAPIProcessTask} from "../process";
+import {IAPIStatementModel} from "./IAPIStatementModel";
+
+export interface IAPIDashboardStatementModel {
+
+ info: IAPIStatementModel;
+
+ tasks: IAPIProcessTask[];
+
+ editedByMe: boolean;
+
+ mandatoryDepartmentsCount: number;
+
+ mandatoryContributionsCount: number;
+
+ optionalForMyDepartment: boolean;
+
+ completedForMyDepartment: boolean;
+
+}
diff --git a/src/app/core/api/statements/IAPIStatementModel.ts b/src/app/core/api/statements/IAPIStatementModel.ts
index 5a6a571..3272aa8 100644
--- a/src/app/core/api/statements/IAPIStatementModel.ts
+++ b/src/app/core/api/statements/IAPIStatementModel.ts
@@ -38,4 +38,10 @@
contactId: string;
+ sourceMailId: string;
+
+ creationDate: string;
+
+ customerReference?: string;
+
}
diff --git a/src/app/core/api/statements/statements-api.service.ts b/src/app/core/api/statements/statements-api.service.ts
index a8e89ff..05cb28d 100644
--- a/src/app/core/api/statements/statements-api.service.ts
+++ b/src/app/core/api/statements/statements-api.service.ts
@@ -18,6 +18,7 @@
import {IAPIDepartmentGroups} from "../settings";
import {IAPIPaginationResponse, IAPISearchOptions} from "../shared";
import {IAPICommentModel} from "./IAPICommentModel";
+import {IAPIDashboardStatementModel} from "./IAPIDashboardStatementModel";
import {IAPISectorsModel} from "./IAPISectorsModel";
import {IAPIPartialStatementModel, IAPIStatementModel} from "./IAPIStatementModel";
import {IAPIWorkflowData} from "./IAPIWorkflowData";
@@ -167,4 +168,12 @@
return this.httpClient.patch(urlJoin(this.baseUrl, endPoint), null);
}
+ /**
+ * Fetches the list of statements to display in the dashboard.
+ */
+ public getDashboardStatements() {
+ const endPoint = `/dashboard/statements`;
+ return this.httpClient.get<IAPIDashboardStatementModel[]>(urlJoin(this.baseUrl, endPoint));
+ }
+
}
diff --git a/src/app/core/api/text/IAPITextBlockConfigurationModel.ts b/src/app/core/api/text/IAPITextBlockConfigurationModel.ts
index 6833b83..2d96137 100644
--- a/src/app/core/api/text/IAPITextBlockConfigurationModel.ts
+++ b/src/app/core/api/text/IAPITextBlockConfigurationModel.ts
@@ -30,4 +30,9 @@
*/
groups: IAPITextBlockGroupModel[];
+ /**
+ * List of all available text block template groups for a negative response.
+ */
+ negativeGroups: IAPITextBlockGroupModel[];
+
}
diff --git a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.html b/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.html
deleted file mode 100644
index 03a1b07..0000000
--- a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.html
+++ /dev/null
@@ -1,34 +0,0 @@
-<!-------------------------------------------------------------------------------
- * 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
- -------------------------------------------------------------------------------->
-
-<app-card *ngIf="appItem"
- [appCardHeader]="headerActions"
- [appTitle]="appItem?.id + ' ' + appItem?.title">
-
- <ng-template #headerActions>
- <span class="dashboard-item-header-actions">
- <a [queryParams]="{id: appItem?.id}" class="openk-button openk-button-rounded openk-info" routerLink="/details">
- <mat-icon>find_in_page</mat-icon>
- </a>
-
- <button class="openk-button openk-button-rounded openk-info" disabled>
- <mat-icon>create</mat-icon>
- </button>
- </span>
- </ng-template>
-
- <span *ngFor="let obj of appItem | objToArray">
- {{obj.key}}: {{obj.value}}
- </span>
-
-</app-card>
diff --git a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.ts b/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.ts
deleted file mode 100644
index 7f29325..0000000
--- a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/********************************************************************************
- * 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 {Component, Input} from "@angular/core";
-
-@Component({
- selector: "app-dashboard-item",
- templateUrl: "./dashboard-item.component.html",
- styleUrls: ["./dashboard-item.component.scss"]
-})
-export class DashboardItemComponent {
-
- @Input()
- public appItem: any;
-
-}
diff --git a/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.html b/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.html
index a412f89..b8e3b73 100644
--- a/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.html
+++ b/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.html
@@ -11,14 +11,20 @@
* SPDX-License-Identifier: EPL-2.0
-------------------------------------------------------------------------------->
-<div class="dashboard-list-title">
- <span class="dashboard-list-title-text">
- {{appTitle}}
- </span>
-</div>
+<span *ngIf="appCaption" class="dashboard-list--caption">
+ {{appCaption}}
+</span>
-<app-dashboard-item
- *ngFor="let item of appItems"
- [appItem]="item"
- class="dashboard-list-item">
-</app-dashboard-item>
+<app-statement-table
+ [appColumns]="(large$ | async)?.matches ? columns : columnsShort"
+ [appEntries]="appEntries"
+ [appShowAlert]="true"
+ [appShowContributionStatusForMyDepartment]="appShowContributionStatusForMyDepartment"
+ [appStatementTypeOptions]="appStatementTypeOptions"
+ class="openk-table---last-row-without-border dashboard-list--table">
+</app-statement-table>
+
+<span *ngIf="appShowSubCaption"
+ class="dashboard-list--sub-caption">
+ <ng-content></ng-content>
+</span>
diff --git a/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.scss b/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.scss
index 896dc1e..474a58e 100644
--- a/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.scss
+++ b/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.scss
@@ -11,23 +11,28 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
+@import "openk.styles";
+
:host {
display: flex;
flex-flow: column;
}
-.dashboard-list-title {
- margin: 0 auto 0.5em 0;
+.dashboard-list--caption {
+ margin-left: 1em;
+ font-style: italic;
+ font-weight: 600;
+ font-size: small;
}
-.dashboard-list-title-text {
- font-size: large;
+.dashboard-list--sub-caption {
+ font-style: italic;
+ font-size: small;
+ margin-right: 1em;
+ margin-left: auto;
}
-.dashboard-list-item {
- width: 100%;
-
- &:not(:last-child) {
- margin-bottom: 1em;
- }
+.dashboard-list--table {
+ min-height: 5.3125em;
+ background: get-color($openk-default-palette);
}
diff --git a/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.spec.ts b/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.spec.ts
index 8e5afe7..d1041aa 100644
--- a/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.spec.ts
+++ b/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.spec.ts
@@ -12,6 +12,9 @@
********************************************************************************/
import {async, ComponentFixture, TestBed} from "@angular/core/testing";
+import {RouterTestingModule} from "@angular/router/testing";
+import {I18nModule} from "../../../../core/i18n";
+import {DashboardModule} from "../../dashboard.module";
import {DashboardListComponent} from "./dashboard-list.component";
describe("DashboardListComponent", () => {
@@ -20,9 +23,12 @@
beforeEach(async(() => {
TestBed.configureTestingModule({
- declarations: [DashboardListComponent]
- })
- .compileComponents();
+ imports: [
+ DashboardModule,
+ RouterTestingModule,
+ I18nModule
+ ]
+ }).compileComponents();
}));
beforeEach(() => {
@@ -34,4 +40,5 @@
it("should create", () => {
expect(component).toBeTruthy();
});
+
});
diff --git a/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.ts b/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.ts
index 9e6ea03..2095594 100644
--- a/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.ts
+++ b/src/app/features/dashboard/components/dashboard-list/dashboard-list.component.ts
@@ -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 @@
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) {
+
+ }
}
diff --git a/src/app/features/dashboard/components/dashboard/dashboard.component.html b/src/app/features/dashboard/components/dashboard/dashboard.component.html
index fff4a03..da61b15 100644
--- a/src/app/features/dashboard/components/dashboard/dashboard.component.html
+++ b/src/app/features/dashboard/components/dashboard/dashboard.component.html
@@ -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>
- <app-dashboard-list
- [appItems]="finishedStatements$ | async"
- [appTitle]="'Abgeschlossene Vorgänge'"
- class="dashboard-list">
+ <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>
- </app-dashboard-list>
-</div>
+</ng-template>
+
+
diff --git a/src/app/features/dashboard/components/dashboard/dashboard.component.scss b/src/app/features/dashboard/components/dashboard/dashboard.component.scss
index 46acc2e..c83a4d8 100644
--- a/src/app/features/dashboard/components/dashboard/dashboard.component.scss
+++ b/src/app/features/dashboard/components/dashboard/dashboard.component.scss
@@ -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;
-
- .openk-button {
- margin: 0.25em;
- }
-}
-
-.dashboard {
- width: 100%;
- display: flex;
- flex-flow: row;
- margin-top: 1em;
-}
-
.dashboard-list {
- margin: 0 0.5em;
- flex: 1 1 50%;
+ margin-top: 2.5em;
+
+ &:first-of-type {
+ margin-top: 1em;
+ }
}
-@media (max-width: 50em) {
-
- .dashboard {
- flex-flow: row wrap;
- }
-
- .dashboard-list {
- margin-bottom: 1em;
-
- &:last-child {
- margin-bottom: 0;
- }
- }
+.loading {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
}
diff --git a/src/app/features/dashboard/components/dashboard/dashboard.component.spec.ts b/src/app/features/dashboard/components/dashboard/dashboard.component.spec.ts
index 9134bc7..17f9bda 100644
--- a/src/app/features/dashboard/components/dashboard/dashboard.component.spec.ts
+++ b/src/app/features/dashboard/components/dashboard/dashboard.component.spec.ts
@@ -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));
+ });
+
});
diff --git a/src/app/features/dashboard/components/dashboard/dashboard.component.ts b/src/app/features/dashboard/components/dashboard/dashboard.component.ts
index 0f397ff..a45fb13 100644
--- a/src/app/features/dashboard/components/dashboard/dashboard.component.ts
+++ b/src/app/features/dashboard/components/dashboard/dashboard.component.ts
@@ -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 @@
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 readonly unfinishedStatements$ = this.store.pipe(select(unfinishedStatementListSelector));
+ 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 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()));
}
}
diff --git a/src/app/features/dashboard/components/index.ts b/src/app/features/dashboard/components/index.ts
index 65758b0..9fe5d1e 100644
--- a/src/app/features/dashboard/components/index.ts
+++ b/src/app/features/dashboard/components/index.ts
@@ -12,5 +12,4 @@
********************************************************************************/
export * from "./dashboard";
-export * from "./dashboard-item";
export * from "./dashboard-list";
diff --git a/src/app/features/dashboard/dashboard.module.ts b/src/app/features/dashboard/dashboard.module.ts
index faee66a..ad0b684 100644
--- a/src/app/features/dashboard/dashboard.module.ts
+++ b/src/app/features/dashboard/dashboard.module.ts
@@ -15,27 +15,34 @@
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 {
diff --git a/src/app/features/dashboard/pipe/get-dashboard-entries.pipe.spec.ts b/src/app/features/dashboard/pipe/get-dashboard-entries.pipe.spec.ts
new file mode 100644
index 0000000..e28c152
--- /dev/null
+++ b/src/app/features/dashboard/pipe/get-dashboard-entries.pipe.spec.ts
@@ -0,0 +1,73 @@
+/********************************************************************************
+ * 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);
+ });
+
+});
diff --git a/src/app/features/dashboard/pipe/get-dashboard-entries.pipe.ts b/src/app/features/dashboard/pipe/get-dashboard-entries.pipe.ts
new file mode 100644
index 0000000..74ceb3d
--- /dev/null
+++ b/src/app/features/dashboard/pipe/get-dashboard-entries.pipe.ts
@@ -0,0 +1,55 @@
+/********************************************************************************
+ * 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
+ };
+ });
+ }
+
+}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/features/dashboard/pipe/index.ts
similarity index 92%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/features/dashboard/pipe/index.ts
index a3980e1..c11c324 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/features/dashboard/pipe/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./get-dashboard-entries.pipe";
diff --git a/src/app/features/details/components/process-information/process-history/process-history.component.scss b/src/app/features/details/components/process-information/process-history/process-history.component.scss
index e0142d1..c9f0ba7 100644
--- a/src/app/features/details/components/process-information/process-history/process-history.component.scss
+++ b/src/app/features/details/components/process-information/process-history/process-history.component.scss
@@ -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 {
diff --git a/src/app/features/details/components/side-menu/statement-details-side-menu.component.html b/src/app/features/details/components/side-menu/statement-details-side-menu.component.html
index 9484272..a2b7ca4 100644
--- a/src/app/features/details/components/side-menu/statement-details-side-menu.component.html
+++ b/src/app/features/details/components/side-menu/statement-details-side-menu.component.html
@@ -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>
+</ng-container>
- <ng-container *appSideMenu="'top'; title: 'details.sideMenu.title' | translate">
- <app-action-button
- [appIcon]="'home'"
- [appRouterLink]="'/'"
- [appDisabled]="appLoading"
- class="side-menu-button openk-info">
- {{'details.sideMenu.backToDashboard' | translate}}
- </app-action-button>
- </ng-container>
+<ng-container *appSideMenu="'bottom'">
-
- <ng-container *appSideMenu="'bottom'">
-
- <app-action-button
- (appClick)="button.emit(button.task)"
- *ngFor="let button of buttonLayout"
- [appDisabled]="appLoading"
- [appIcon]="button.icon"
- [ngClass]="button.cssClass"
- class="side-menu-button">
- {{button.label | translate}}
- </app-action-button>
-
- </ng-container>
+ <app-action-button
+ (appClick)="button.emit(button.task)"
+ *ngFor="let button of buttonLayout"
+ [appDisabled]="appLoading || button?.task?.assignee != null && appUserName !== button?.task?.assignee"
+ [appIcon]="button.icon"
+ [ngClass]="button.cssClass"
+ class="side-menu-button">
+ {{button.label | translate}}
+ </app-action-button>
</ng-container>
-<ng-container *ngIf="appLoading">
- <app-side-menu-status
- *appSideMenu="'center'"
- [appLoadingMessage]="'core.loading' | translate"
- [appLoading]="appLoading">
- </app-side-menu-status>
-</ng-container>
+<app-side-menu-status
+ *appSideMenu="'center'"
+ [appErrorMessage]="appErrorMessage"
+ [appLoadingMessage]="'core.loading' | translate"
+ [appLoading]="appLoading">
+
+ <span *ngIf="appErrorMessage == null && infoMessage != null" class="info-message">
+ {{infoMessage | translate}}
+ </span>
+
+</app-side-menu-status>
diff --git a/src/app/features/details/components/side-menu/statement-details-side-menu.component.scss b/src/app/features/details/components/side-menu/statement-details-side-menu.component.scss
index 3796710..d765276 100644
--- a/src/app/features/details/components/side-menu/statement-details-side-menu.component.scss
+++ b/src/app/features/details/components/side-menu/statement-details-side-menu.component.scss
@@ -22,3 +22,7 @@
margin-top: 1em;
}
}
+
+.info-message {
+ font-style: italic;
+}
diff --git a/src/app/features/details/components/side-menu/statement-details-side-menu.component.spec.ts b/src/app/features/details/components/side-menu/statement-details-side-menu.component.spec.ts
index 7369cca..1a5afd1 100644
--- a/src/app/features/details/components/side-menu/statement-details-side-menu.component.spec.ts
+++ b/src/app/features/details/components/side-menu/statement-details-side-menu.component.spec.ts
@@ -13,7 +13,8 @@
import {SimpleChange} from "@angular/core";
import {async, ComponentFixture, TestBed} from "@angular/core/testing";
-import {EAPIProcessTaskDefinitionKey, EAPIUserRoles, IAPIProcessTask} from "../../../../core";
+import {EAPIProcessTaskDefinitionKey, EAPIUserRoles, I18nModule, IAPIProcessTask} from "../../../../core";
+import {EErrorCode} from "../../../../store/root/model";
import {StatementDetailsModule} from "../../statement-details.module";
import {StatementDetailsSideMenuComponent} from "./statement-details-side-menu.component";
@@ -23,7 +24,7 @@
beforeEach(async(() => {
TestBed.configureTestingModule({
- imports: [StatementDetailsModule]
+ imports: [StatementDetailsModule, I18nModule]
}).compileComponents();
}));
@@ -39,7 +40,7 @@
it("should call update on input changes", () => {
const updateSpy = spyOn(component, "update");
- const keys: Array<keyof StatementDetailsSideMenuComponent> = ["appUserRoles", "appTasks"];
+ const keys: Array<keyof StatementDetailsSideMenuComponent> = ["appUserRoles", "appTasks", "appUserName"];
updateSpy.calls.reset();
component.ngOnChanges({});
@@ -61,9 +62,43 @@
...{} as IAPIProcessTask,
statementId: 19,
taskId: "abcde",
+ authorized: true,
taskDefinitionKey: EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA
}];
component.update();
expect(component.buttonLayout.length).toEqual(2);
+
+ component.appUserRoles = [EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE];
+ component.appTasks = [{
+ ...{} as IAPIProcessTask,
+ statementId: 19,
+ taskId: "abcde",
+ authorized: false,
+ taskDefinitionKey: EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA
+ }];
+ component.update();
+ expect(component.buttonLayout.length).toEqual(0);
+ });
+
+ it("should update info message based upon current user name and tasks", () => {
+ component.update();
+ expect(component.buttonLayout).toEqual([]);
+
+ component.appUserName = "hugo";
+ component.appUserRoles = [EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE];
+ component.appTasks = [{
+ ...{} as IAPIProcessTask,
+ statementId: 19,
+ taskId: "abcde",
+ taskDefinitionKey: EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA,
+ authorized: true,
+ assignee: "hugo"
+ }];
+ component.update();
+ expect(component.infoMessage).not.toBeDefined();
+
+ component.appUserName = "noAssignee";
+ component.update();
+ expect(component.infoMessage).toEqual(EErrorCode.CLAIMED_BY_OTHER_USER);
});
});
diff --git a/src/app/features/details/components/side-menu/statement-details-side-menu.component.ts b/src/app/features/details/components/side-menu/statement-details-side-menu.component.ts
index 76611a1..d5fcb0e 100644
--- a/src/app/features/details/components/side-menu/statement-details-side-menu.component.ts
+++ b/src/app/features/details/components/side-menu/statement-details-side-menu.component.ts
@@ -20,7 +20,7 @@
IAPIProcessTask,
TCompleteTaskVariable
} from "../../../../core";
-import {claimAndCompleteTask, claimTaskAction, sendStatementViaMailAction} from "../../../../store";
+import {claimAndCompleteTask, claimTaskAction, EErrorCode, sendStatementViaMailAction} from "../../../../store";
import {arrayJoin, filterDistinctValues} from "../../../../util/store";
export interface IStatementDetailsSideMenuActionButton {
@@ -58,16 +58,24 @@
appLoading: boolean;
@Input()
+ public appUserName: string;
+
+ @Input()
public appUserRoles: EAPIUserRoles[];
@Input()
public appTasks: IAPIProcessTask[];
+ @Input()
+ public appErrorMessage: string;
+
@Output()
public appDispatch = new EventEmitter<Action>();
public buttonLayout: IStatementDetailsSideMenuActionButton[] = [];
+ public infoMessage: string;
+
private taskUserLayoutMap: ITaskUserLayoutMap<IStatementDetailsSideMenuActionButton> = {
[EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE]: {
[EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA]: [
@@ -89,7 +97,10 @@
],
[EAPIProcessTaskDefinitionKey.CREATE_NEGATIVE_RESPONSE]: [
{
- emit: this.emitClaimAndCompleteFactory({response_created: {type: "Boolean", value: false}}),
+ emit: this.emitClaimAndCompleteFactory(
+ {response_created: {type: "Boolean", value: false}},
+ EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA
+ ),
label: "details.sideMenu.backToInfoData",
icon: "subject",
cssClass: "openk-info"
@@ -180,7 +191,7 @@
};
public ngOnChanges(changes: SimpleChanges) {
- const keys: Array<keyof StatementDetailsSideMenuComponent> = ["appUserRoles", "appTasks"];
+ const keys: Array<keyof StatementDetailsSideMenuComponent> = ["appUserRoles", "appTasks", "appUserName"];
if (keys.some((_) => changes[_] != null)) {
this.update();
}
@@ -191,9 +202,12 @@
roles = ALL_NON_TRIVIAL_USER_ROLES.filter((_) => roles.indexOf(_) > -1);
const tasks = filterDistinctValues(this.appTasks);
const actionsForRoles = filterDistinctValues(roles).map((role) => {
- return tasks.map((task) => this.getLayoutForRoleAndTask(role, task));
+ return tasks
+ .filter((task) => task.authorized)
+ .map((task) => this.getLayoutForRoleAndTask(role, task));
});
this.buttonLayout = arrayJoin(...arrayJoin(...actionsForRoles));
+ this.infoMessage = this.getInfoMessage();
}
private emitClaimAndCompleteFactory(variables: TCompleteTaskVariable, claimNext?: EAPIProcessTaskDefinitionKey) {
@@ -217,4 +231,16 @@
return userActions == null ? [] : arrayJoin(userActions[task.taskDefinitionKey]).map((_) => ({..._, task}));
}
+ private getInfoMessage(): string {
+ const isTaskClaimedByOtherUser = arrayJoin(this.appTasks)
+ .map((task) => task.assignee)
+ .some((assignee) => assignee != null && assignee !== this.appUserName);
+
+ if (isTaskClaimedByOtherUser) {
+ return EErrorCode.CLAIMED_BY_OTHER_USER;
+ }
+
+ return;
+ }
+
}
diff --git a/src/app/features/details/components/statement-details/statement-details.component.html b/src/app/features/details/components/statement-details/statement-details.component.html
index f9d245f..0a0b6d9 100644
--- a/src/app/features/details/components/statement-details/statement-details.component.html
+++ b/src/app/features/details/components/statement-details/statement-details.component.html
@@ -21,8 +21,10 @@
<app-statement-details-side-menu
(appDispatch)="store.dispatch($event)"
- [appLoading]="loading$ | async"
+ [appUserName]="userName$ | async"
+ [appLoading]="isStatementLoading$ | async"
[appTasks]="tasks$ | async"
+ [appErrorMessage]="(appError$ | async)?.errorMessage"
[appUserRoles]="userRoles$ | async">
</app-statement-details-side-menu>
diff --git a/src/app/features/details/components/statement-details/statement-details.component.ts b/src/app/features/details/components/statement-details/statement-details.component.ts
index 8c589c3..322432a 100644
--- a/src/app/features/details/components/statement-details/statement-details.component.ts
+++ b/src/app/features/details/components/statement-details/statement-details.component.ts
@@ -13,7 +13,7 @@
import {Component, OnDestroy, OnInit} from "@angular/core";
import {select, Store} from "@ngrx/store";
-import {Subscription} from "rxjs";
+import {Observable, Subscription} from "rxjs";
import {filter, take} from "rxjs/operators";
import {IAPIProcessTask} from "../../../../core/api/process";
import {
@@ -22,15 +22,19 @@
currentActivityIds,
deleteCommentAction,
fetchStatementDetailsAction,
+ getStatementErrorSelector,
historySelector,
+ IStatementErrorEntity,
processDiagramSelector,
processNameSelector,
processVersionSelector,
queryParamsIdSelector,
+ setErrorAction,
statementLoadingSelector,
statementSelector,
statementTasksSelector,
statementTitleSelector,
+ userNameSelector,
userRolesSelector
} from "../../../../store";
@@ -59,9 +63,13 @@
public userRoles$ = this.store.pipe(select(userRolesSelector));
+ public userName$ = this.store.pipe(select(userNameSelector));
+
public tasks$ = this.store.pipe(select(statementTasksSelector));
- public loading$ = this.store.pipe(select(statementLoadingSelector));
+ public isStatementLoading$ = this.store.pipe(select(statementLoadingSelector));
+
+ public appError$: Observable<IStatementErrorEntity> = this.store.pipe(select(getStatementErrorSelector));
private subscription: Subscription;
@@ -75,11 +83,12 @@
.subscribe((statementId) => this.store.dispatch(fetchStatementDetailsAction({statementId})));
}
- public ngOnDestroy(): void {
+ public async ngOnDestroy() {
if (this.subscription != null) {
this.subscription.unsubscribe();
this.subscription = null;
}
+ await this.clearErrors();
}
public editTask(task: IAPIProcessTask, options?: any) {
@@ -100,4 +109,12 @@
this.store.dispatch(deleteCommentAction({statementId, commentId}));
}
+ private async clearErrors() {
+ const statementId = await this.statementId$.pipe(take(1)).toPromise();
+ const loading = await this.isStatementLoading$.pipe(take(1)).toPromise();
+ if (statementId != null && !loading) {
+ this.store.dispatch(setErrorAction({statementId, error: null}));
+ }
+ }
+
}
diff --git a/src/app/features/forms/attachments/attachments-form.module.ts b/src/app/features/forms/attachments/attachments-form.module.ts
index 523a67b..3d0cd18 100644
--- a/src/app/features/forms/attachments/attachments-form.module.ts
+++ b/src/app/features/forms/attachments/attachments-form.module.ts
@@ -13,9 +13,10 @@
import {CommonModule} from "@angular/common";
import {NgModule} from "@angular/core";
-import {ReactiveFormsModule} from "@angular/forms";
+import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {MatIconModule} from "@angular/material/icon";
import {TranslateModule} from "@ngx-translate/core";
+import {ActionButtonModule} from "../../../shared/layout/action-button";
import {CollapsibleModule} from "../../../shared/layout/collapsible";
import {FileDropModule} from "../../../shared/layout/file-drop";
import {SharedPipesModule} from "../../../shared/pipes";
@@ -30,7 +31,9 @@
TranslateModule,
CollapsibleModule,
FileDropModule,
- SharedPipesModule
+ SharedPipesModule,
+ FormsModule,
+ ActionButtonModule,
],
declarations: [
AttachmentsFormGroupComponent,
diff --git a/src/app/features/forms/attachments/components/attachment-control/attachment-control.component.html b/src/app/features/forms/attachments/components/attachment-control/attachment-control.component.html
index e3b4dab..dae9ae5 100644
--- a/src/app/features/forms/attachments/components/attachment-control/attachment-control.component.html
+++ b/src/app/features/forms/attachments/components/attachment-control/attachment-control.component.html
@@ -39,7 +39,7 @@
{{appValue?.name}}
</label>
- <button (click)="appDownloadAttachment.emit(appValue?.id)"
+ <button (click)="appValue?.id ? appDownloadAttachment.emit(appValue?.id) : appDownloadAttachment.emit(appValue?.name)"
*ngIf="appIsDownloadable"
[disabled]="appDisabled"
class="openk-button openk-button-rounded"
diff --git a/src/app/features/forms/attachments/components/attachment-control/attachment-control.component.ts b/src/app/features/forms/attachments/components/attachment-control/attachment-control.component.ts
index 75b687e..dbd4e7b 100644
--- a/src/app/features/forms/attachments/components/attachment-control/attachment-control.component.ts
+++ b/src/app/features/forms/attachments/components/attachment-control/attachment-control.component.ts
@@ -53,7 +53,7 @@
public appCancel = new EventEmitter<void>();
@Output()
- public appDownloadAttachment = new EventEmitter<number>();
+ public appDownloadAttachment = new EventEmitter<number | string>();
@Input()
public appHideNotUsedTags: boolean;
diff --git a/src/app/features/forms/attachments/components/attachment-list-form/attachment-list-form.component.html b/src/app/features/forms/attachments/components/attachment-list-form/attachment-list-form.component.html
index c2676f6..fcfb143 100644
--- a/src/app/features/forms/attachments/components/attachment-list-form/attachment-list-form.component.html
+++ b/src/app/features/forms/attachments/components/attachment-list-form/attachment-list-form.component.html
@@ -21,7 +21,7 @@
<app-attachment-control
(appDownloadAttachment)="appDownloadAttachment.emit($event)"
*ngFor="let control of (appFormGroup | getFormArray : appFormArrayName)?.controls; let i = index;"
- [appHideNotUsedTags]="true"
+ [appHideNotUsedTags]="appHideNotUsedTags"
[appIsDownloadable]="true"
[appIsSelectable]="true"
[appTagList]="appTagList"
diff --git a/src/app/features/forms/attachments/components/attachment-list-form/attachment-list-form.component.ts b/src/app/features/forms/attachments/components/attachment-list-form/attachment-list-form.component.ts
index 65a0d1b..b42600d 100644
--- a/src/app/features/forms/attachments/components/attachment-list-form/attachment-list-form.component.ts
+++ b/src/app/features/forms/attachments/components/attachment-list-form/attachment-list-form.component.ts
@@ -35,8 +35,11 @@
@Input()
public appTitle: string;
+ @Input()
+ public appHideNotUsedTags = true;
+
@Output()
- public appDownloadAttachment = new EventEmitter<number>();
+ public appDownloadAttachment = new EventEmitter<number | string>();
}
diff --git a/src/app/features/forms/attachments/components/attachments-form-group.component.html b/src/app/features/forms/attachments/components/attachments-form-group.component.html
index d53947f..c3c0aae 100644
--- a/src/app/features/forms/attachments/components/attachments-form-group.component.html
+++ b/src/app/features/forms/attachments/components/attachments-form-group.component.html
@@ -13,23 +13,60 @@
<div [formGroup]="appFormGroup" class="attachments">
- <app-attachment-list-form *ngIf="(attachments$ | async)?.length > 0"
- (appDownloadAttachment)="downloadAttachment($event)"
- [appFormArrayName]="'edit'"
- [appFormGroup]="appFormGroup"
- [appTagList]="appWithoutTagControl ? null : (tagList$ | async)"
- [appTitle]="'attachments.edit' | translate"
- class="attachments--container">
- </app-attachment-list-form>
+ <div *ngIf="appMailId != null && mail != null" class="attachments--email">
- <app-attachment-file-drop-form
- [appAutoTagIds]="appAutoTagIds"
- [appFormArrayName]="'add'"
- [appFormGroup]="appFormGroup"
- [appTagList]="appWithoutTagControl ? null : (tagList$ | async)"
- [appTitle]="'attachments.add' | translate"
- class="attachments--container">
+ <div>
+ <span>{{"attachments.email" | translate}}</span>
+ <div class="attachments--container--email-text">
- </app-attachment-file-drop-form>
+ <input #inputElement
+ (keydown.enter)="inputElement.click()"
+ [class.cursor-pointer]="!appFormGroup.disabled"
+ [formControlName]="'transferMailText'"
+ [id]="'email-text'"
+ class="attachments--email-select"
+ type="checkbox">
+
+ <label [class.cursor-pointer]="!appFormGroup.disabled"
+ [for]="'email-text'"
+ class="attachments--container--email-summary">
+ <span>{{mail.subject}}</span><br>
+ <span class="attachments--container--email-summary--from">von: {{mail.from}}</span>
+ </label>
+
+ </div>
+ </div>
+
+ <app-attachment-list-form (appDownloadAttachment)="downloadEmailAttachment($event)"
+ *ngIf="appFormGroup?.value?.email?.length > 0"
+ [appFormArrayName]="'email'"
+ [appFormGroup]="appFormGroup"
+ [appHideNotUsedTags]="false"
+ [appTagList]="appWithoutTagControl ? null : (tagList$ | async)"
+ [appTitle]="'attachments.addEmailAttachments' | translate">
+ </app-attachment-list-form>
+
+ </div>
+
+ <div [class.attachments--files---full-display]="appMailId == null"
+ class="attachments--files">
+ <app-attachment-list-form (appDownloadAttachment)="downloadAttachment($event)"
+ *ngIf="appFormGroup?.value?.edit?.length > 0"
+ [appFormArrayName]="'edit'"
+ [appFormGroup]="appFormGroup"
+ [appTagList]="appWithoutTagControl ? null : (tagList$ | async)"
+ [appTitle]="'attachments.edit' | translate"
+ class="attachments---half-size">
+ </app-attachment-list-form>
+
+ <app-attachment-file-drop-form
+ [appAutoTagIds]="appAutoTagIds"
+ [appFormArrayName]="'add'"
+ [appFormGroup]="appFormGroup"
+ [appTagList]="appWithoutTagControl ? null : (tagList$ | async)"
+ [appTitle]="'attachments.add' | translate"
+ class="attachments--container">
+ </app-attachment-file-drop-form>
+ </div>
</div>
diff --git a/src/app/features/forms/attachments/components/attachments-form-group.component.scss b/src/app/features/forms/attachments/components/attachments-form-group.component.scss
index 800c806..6713a69 100644
--- a/src/app/features/forms/attachments/components/attachments-form-group.component.scss
+++ b/src/app/features/forms/attachments/components/attachments-form-group.component.scss
@@ -13,6 +13,8 @@
$break-point: 16em;
+@import "openk.styles";
+
:host {
display: block;
width: 100%;
@@ -20,16 +22,62 @@
.attachments {
display: flex;
- flex-flow: row wrap;
- min-height: 15em;
+ flex-direction: row;
+}
+
+.attachments--files {
padding: 0.5em;
box-sizing: border-box;
overflow: auto;
+ flex: 1;
+}
+
+.attachments--email {
+ padding: 0.5em;
+ box-sizing: border-box;
+ flex: 1;
+}
+
+.attachments---half-size {
+ flex: 1;
}
.attachments--container {
- flex: 1 1 max(calc(50% - 2em), #{$break-point});
+ min-height: 15em;
+ flex: 1;
+}
+
+.attachments--container--email-text {
display: flex;
- flex-flow: column;
- margin: 0.5em;
+ flex-direction: row;
+ align-items: center;
+ align-self: flex-start;
+ margin-bottom: 0.5em;
+ padding: 0 0.2em;
+
+ &:hover {
+ background-color: $openk-background-highlight;
+ }
+}
+
+.attachments--container--email-summary {
+ flex: 1;
+ margin-left: 0.2em;
+ line-height: 1;
+}
+
+.attachments--container--email-summary--from {
+ margin-left: 0.2em;
+ font-style: italic;
+ font-size: small;
+}
+
+.attachments--email-select {
+ font-size: 1em;
+ width: 1em;
+ height: 1em;
+}
+
+.attachments--files---full-display {
+ display: flex;
}
diff --git a/src/app/features/forms/attachments/components/attachments-form-group.component.ts b/src/app/features/forms/attachments/components/attachments-form-group.component.ts
index 50db04b..69753f1 100644
--- a/src/app/features/forms/attachments/components/attachments-form-group.component.ts
+++ b/src/app/features/forms/attachments/components/attachments-form-group.component.ts
@@ -13,21 +13,25 @@
import {Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges} from "@angular/core";
import {select, Store} from "@ngrx/store";
-import {BehaviorSubject} from "rxjs";
-import {delay, switchMap, take, takeUntil} from "rxjs/operators";
-import {AUTO_SELECTED_TAGS} from "../../../../core/api/attachments";
+import {BehaviorSubject, combineLatest} from "rxjs";
+import {delay, filter, switchMap, take, takeUntil} from "rxjs/operators";
+import {AUTO_SELECTED_TAGS, IAPIAttachmentModel} from "../../../../core/api/attachments";
+import {IAPIEmailAttachmentModel, IAPIEmailModel} from "../../../../core/api/mail";
import {
clearAttachmentCacheAction,
createAttachmentForm,
fetchAttachmentTagsAction,
+ getAllStatementAttachments,
getAttachmentControlValueSelector,
getFilteredAttachmentTagsSelector,
getStatementAttachmentCacheSelector,
- IAttachmentControlValue,
IAttachmentFormValue,
queryParamsIdSelector,
startAttachmentDownloadAction
} from "../../../../store";
+import {downloadEmailAttachmentAction} from "../../../../store/mail/actions";
+import {getSelectedEmailSelector, getStatementMailSelector} from "../../../../store/mail/selectors";
+import {arrayJoin} from "../../../../util/store";
import {AbstractReactiveFormComponent} from "../../abstract";
@Component({
@@ -57,12 +61,27 @@
@Input()
public appFormGroup = createAttachmentForm();
- public attachments: IAttachmentControlValue[] = [];
+ @Input()
+ public appForNewStatement: boolean;
+
+ @Input()
+ public appMailId: string;
+
+ @Input()
+ public appDisabled: boolean;
+
+ public mail: IAPIEmailModel;
public statementId$ = this.store.pipe(select(queryParamsIdSelector));
+ public selectedMail$ = this.store.pipe(select(getSelectedEmailSelector));
+
+ public statementMail$ = this.store.pipe(select(getStatementMailSelector));
+
public attachments$ = this.store.pipe(select(getAttachmentControlValueSelector, {}));
+ public allAttachments$ = this.store.pipe(select(getAllStatementAttachments));
+
public fileCache$ = this.store.pipe(select(getStatementAttachmentCacheSelector));
public tagList$ = this.store.pipe(select(getFilteredAttachmentTagsSelector, {without: AUTO_SELECTED_TAGS}));
@@ -82,8 +101,21 @@
select(getAttachmentControlValueSelector, props)
))
);
+
this.attachments$.pipe(delay(0), takeUntil(this.destroy$))
.subscribe((values) => this.setValueForArray(values, "edit"));
+
+ combineLatest([this.selectedMail$, this.statementMail$, this.allAttachments$]).pipe(
+ filter(([_, m, a]) => (_ != null || m != null) && a != null),
+ delay(0),
+ takeUntil(this.destroy$)
+ ).subscribe(async ([selectedMail, statementMail, attachments]) => {
+ this.setMailTextAttachmentValue(attachments, this.appForNewStatement);
+ this.mail = selectedMail ? selectedMail : statementMail;
+ this.setMailAttachmentValues(attachments, arrayJoin(this.mail?.attachments));
+ });
+
+
this.fileCache$.pipe(delay(0), takeUntil(this.destroy$))
.subscribe((values) => this.setValueForArray(values, "add"));
this.store.dispatch(fetchAttachmentTagsAction());
@@ -110,4 +142,38 @@
this.store.dispatch(startAttachmentDownloadAction({statementId, attachmentId}));
}
+ public async downloadEmailAttachment(attachmentId: string | number) {
+ if (typeof (attachmentId) === "number") {
+ this.downloadAttachment(attachmentId);
+ } else {
+ this.store.dispatch(downloadEmailAttachmentAction({mailId: this.appMailId, name: attachmentId}));
+ }
+ }
+
+ public setMailTextAttachmentValue(attachments: IAPIAttachmentModel[], isNewStatement: boolean) {
+ const mailTextAttachmentId = attachments.find((_) =>
+ _.name === "mailText.txt" && _.tagIds && _.tagIds.length === 2
+ && _.tagIds[0] === "email" && _.tagIds[1] === "email-text")?.id;
+
+ this.appFormGroup.patchValue({mailTextAttachmentId, transferMailText: mailTextAttachmentId != null || isNewStatement});
+ }
+
+ public setMailAttachmentValues(attachments: IAPIAttachmentModel[], emailAttachments: IAPIEmailAttachmentModel[]) {
+ const mergedAttachmentLists = emailAttachments.map((emailAttachment) => {
+ const emailAttachmentFromAttachmentsArray = attachments.find((attachment) =>
+ attachment.name === emailAttachment.name && attachment.tagIds.find((_) => _ === "email") != null);
+
+ const isSelected = emailAttachmentFromAttachmentsArray != null || this.appForNewStatement;
+
+ const tagIds = emailAttachmentFromAttachmentsArray?.tagIds;
+
+ return {
+ ...(emailAttachmentFromAttachmentsArray ? emailAttachmentFromAttachmentsArray : emailAttachment),
+ isSelected,
+ tagIds: arrayJoin(tagIds)
+ };
+ });
+ this.setValueForArray(mergedAttachmentLists, "email");
+ }
+
}
diff --git a/src/app/features/forms/statement-editor/components/side-menu/statement-editor-side-menu.component.html b/src/app/features/forms/statement-editor/components/side-menu/statement-editor-side-menu.component.html
index f610571..48c0fb7 100644
--- a/src/app/features/forms/statement-editor/components/side-menu/statement-editor-side-menu.component.html
+++ b/src/app/features/forms/statement-editor/components/side-menu/statement-editor-side-menu.component.html
@@ -51,6 +51,7 @@
<app-side-menu-status
*appSideMenu="'center'"
[appLoadingMessage]="'core.submitting' | translate"
+ [appErrorMessage]="appErrorMessage"
[appLoading]="appLoading">
</app-side-menu-status>
diff --git a/src/app/features/forms/statement-editor/components/side-menu/statement-editor-side-menu.component.ts b/src/app/features/forms/statement-editor/components/side-menu/statement-editor-side-menu.component.ts
index f7b3de1..8e58a93 100644
--- a/src/app/features/forms/statement-editor/components/side-menu/statement-editor-side-menu.component.ts
+++ b/src/app/features/forms/statement-editor/components/side-menu/statement-editor-side-menu.component.ts
@@ -43,6 +43,9 @@
export class StatementEditorSideMenuComponent implements OnChanges {
@Input()
+ public appErrorMessage: string;
+
+ @Input()
public appForFinalization: boolean;
@Input()
@@ -149,7 +152,7 @@
}),
label: "statementEditorForm.sideMenu.backToInfoData",
icon: "subject",
- cssClass: "openk-info"
+ cssClass: "openk-danger"
},
{
emit: emitFactory(this.appSaveAndFinalize),
diff --git a/src/app/features/forms/statement-editor/components/statement-editor-form/statement-editor-form.component.html b/src/app/features/forms/statement-editor/components/statement-editor-form/statement-editor-form.component.html
index 061b58c..754c9a8 100644
--- a/src/app/features/forms/statement-editor/components/statement-editor-form/statement-editor-form.component.html
+++ b/src/app/features/forms/statement-editor/components/statement-editor-form/statement-editor-form.component.html
@@ -26,10 +26,10 @@
(appSaveAndFinalize)="submit({ compile: true })"
(appValidate)="validate()"
[appForFinalization]="(file$ | async) != null"
- [appLoading]="isLoading$ | async"
+ [appLoading]="isStatementLoading$ | async"
[appTask]="task$ | async"
+ [appErrorMessage]="(error$ | async)?.errorMessage"
[appUserRoles]="userRoles$ | async">
-
</app-statement-editor-side-menu>
<app-collapsible
diff --git a/src/app/features/forms/statement-editor/components/statement-editor-form/statement-editor-form.component.spec.ts b/src/app/features/forms/statement-editor/components/statement-editor-form/statement-editor-form.component.spec.ts
index f5842d9..c8222b5 100644
--- a/src/app/features/forms/statement-editor/components/statement-editor-form/statement-editor-form.component.spec.ts
+++ b/src/app/features/forms/statement-editor/components/statement-editor-form/statement-editor-form.component.spec.ts
@@ -90,7 +90,10 @@
expect(dispatchSpy).toHaveBeenCalledWith(submitStatementEditorFormAction({
statementId,
taskId,
- value: {arrangement: [], attachments: {edit: [], add: []}, contributions: null},
+ value: {
+ arrangement: [],
+ attachments: {edit: [], add: [], email: [], transferMailText: false, mailTextAttachmentId: null}, contributions: null
+ },
options: undefined
}));
@@ -102,7 +105,11 @@
expect(dispatchSpy).toHaveBeenCalledWith(submitStatementEditorFormAction({
statementId,
taskId,
- value: {arrangement: [], attachments: {edit: [], add: []}, contributions: {selected: [], indeterminate: []}},
+ value: {
+ arrangement: [],
+ attachments: {edit: [], add: [], email: [], transferMailText: false, mailTextAttachmentId: null},
+ contributions: {selected: [], indeterminate: []}
+ },
options: undefined
}));
});
diff --git a/src/app/features/forms/statement-editor/components/statement-editor-form/statement-editor-form.component.ts b/src/app/features/forms/statement-editor/components/statement-editor-form/statement-editor-form.component.ts
index 73b2d7d..8919b3a 100644
--- a/src/app/features/forms/statement-editor/components/statement-editor-form/statement-editor-form.component.ts
+++ b/src/app/features/forms/statement-editor/components/statement-editor-form/statement-editor-form.component.ts
@@ -28,14 +28,17 @@
fetchStatementTextArrangementAction,
getContributionsSelector,
getStatementArrangementErrorSelector,
+ getStatementArrangementForCurrentTaskSelector,
getStatementEditorControlConfigurationSelector,
+ getStatementErrorSelector,
getStatementStaticTextReplacementsSelector,
- getStatementTextBlockGroups,
+ getStatementTextBlockGroupsForCurrentTaskSelector,
IStatementEditorFormValue,
+ IStatementErrorEntity,
queryParamsIdSelector,
requiredContributionsGroupsSelector,
requiredContributionsOptionsSelector,
- statementArrangementSelector,
+ setErrorAction,
statementFileSelector,
statementLoadingSelector,
submitStatementEditorFormAction,
@@ -101,29 +104,32 @@
map((value) => filterDistinctValues(value.arrangement.map((item) => item.textblockId)))
);
- public arrangement$ = this.store.pipe(select(statementArrangementSelector));
+ public textBlockGroups$ = this.store.pipe(select(getStatementTextBlockGroupsForCurrentTaskSelector));
+
+ public arrangement$ = this.store.pipe(select(getStatementArrangementForCurrentTaskSelector));
public file$ = this.store.pipe(select(statementFileSelector));
- public textBlockGroups$ = this.store.pipe(select(getStatementTextBlockGroups));
-
public arrangementError$ = this.store.pipe(select(getStatementArrangementErrorSelector));
- public isLoading$ = this.store.pipe(select(statementLoadingSelector));
+ public isStatementLoading$ = this.store.pipe(select(statementLoadingSelector));
+
+ public error$: Observable<IStatementErrorEntity> = this.store.pipe(select(getStatementErrorSelector));
public constructor(public store: Store) {
super();
}
- public ngOnInit() {
+ public async ngOnInit() {
this.updateForm();
this.fetchTextArrangement();
- this.deleteStatementFile();
+ await this.deleteStatementFile();
}
- public ngOnDestroy() {
+ public async ngOnDestroy() {
super.ngOnDestroy();
- this.deleteStatementFile();
+ await this.deleteStatementFile();
+ await this.clearErrors();
}
public setArrangementErrors(errors: IAPITextArrangementErrorModel[]) {
@@ -137,6 +143,7 @@
}
public async validate() {
+ await this.clearErrors();
const task = await this.task$.pipe(take(1)).toPromise();
this.store.dispatch(validateStatementArrangementAction({
statementId: task.statementId,
@@ -146,6 +153,7 @@
}
public async compile() {
+ await this.clearErrors();
const task = await this.task$.pipe(take(1)).toPromise();
this.store.dispatch(compileStatementArrangementAction({
statementId: task.statementId,
@@ -161,6 +169,7 @@
contribute?: boolean,
file?: File
}) {
+ await this.clearErrors();
const task = await this.task$.pipe(take(1)).toPromise();
const value = this.getValue();
this.store.dispatch(submitStatementEditorFormAction({
@@ -175,6 +184,7 @@
}
public async finalize(complete?: boolean) {
+ await this.clearErrors();
if (complete) {
const file = await this.file$.pipe(take(1)).toPromise();
return this.submit({
@@ -203,12 +213,14 @@
}
private updateForm() {
- this.isLoading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => this.disable(loading));
- this.arrangement$.pipe(takeUntil(this.destroy$)).subscribe((arrangement) => {
- this.setValueForArray(arrangement, "arrangement");
- });
+ this.isStatementLoading$.pipe(takeUntil(this.destroy$))
+ .subscribe((loading) => this.disable(loading));
+ this.arrangement$.pipe(takeUntil(this.destroy$))
+ .subscribe((arrangement) => this.setValueForArray(arrangement, "arrangement"));
+ this.contributions$.pipe(takeUntil(this.destroy$))
+ .subscribe((contributions) => this.patchValue({contributions}));
this.arrangementError$.pipe(
- skip(1), // The first value is skipped when the use enters the site.
+ skip(1), // The first value is skipped when the user enters the site.
switchMap((errors) => {
// Errors are only displayed when the form is ready to use.
return this.appFormGroup.statusChanges.pipe(
@@ -219,9 +231,14 @@
}),
takeUntil(this.destroy$),
).subscribe((errors) => this.setArrangementErrors(errors));
- this.contributions$.pipe(takeUntil(this.destroy$)).subscribe((contributions) => {
- this.patchValue({contributions});
- });
+ }
+
+ private async clearErrors() {
+ const statementId = await this.statementId$.pipe(take(1)).toPromise();
+ const loading = await this.isStatementLoading$.pipe(take(1)).toPromise();
+ if (statementId != null && !loading) {
+ this.store.dispatch(setErrorAction({statementId, error: null}));
+ }
}
}
diff --git a/src/app/features/forms/statement-information/components/general-information-form-group/general-information-form-group.component.html b/src/app/features/forms/statement-information/components/general-information-form-group/general-information-form-group.component.html
index c7d6b4a..b1c2361 100644
--- a/src/app/features/forms/statement-information/components/general-information-form-group/general-information-form-group.component.html
+++ b/src/app/features/forms/statement-information/components/general-information-form-group/general-information-form-group.component.html
@@ -23,8 +23,25 @@
[id]="appId + '-title'"
appFormControlStatus
autocomplete="off"
- class="openk-input openk-info form-group-container--input"
- required>
+ class="openk-input openk-info form-group-container--input">
+
+ <div class="form-group-container--label">
+ <label [for]="appId + '-type'">
+ {{"statementInformationForm.controls.typeId" | translate}}
+ </label>
+ </div>
+
+ <div>
+ <app-select #selectComponent
+ [appDisabled]="appStatementTypeOptions?.length == null || appStatementTypeOptions.length === 0"
+ [appId]="appId + '-type'"
+ [appOptions]="appStatementTypeOptions"
+ [appPlaceholder]="selectComponent.appDisabled ? 'Keine Daten verfügbar...' : ''"
+ [formControlName]="'typeId'"
+ appFormControlStatus
+ class="openk-info form-group-container--select">
+ </app-select>
+ </div>
<div class="form-group-container--label">
<label [for]="appId + '-city'">
@@ -36,8 +53,7 @@
[id]="appId + '-city'"
appFormControlStatus
autocomplete="off"
- class="openk-input openk-info form-group-container--input"
- required>
+ class="openk-input openk-info form-group-container--input">
<div class="form-group-container--label">
<label [for]="appId + '-district'">
@@ -49,8 +65,7 @@
[id]="appId + '-district'"
appFormControlStatus
autocomplete="off"
- class="openk-input openk-info form-group-container--input"
- required>
+ class="openk-input openk-info form-group-container--input">
<div class="form-group-container"></div>
@@ -70,28 +85,22 @@
</span>
</div>
-
</div>
<div [formGroup]="appFormGroup" class="form-group-container">
<div class="form-group-container--label">
- <label [for]="appId + '-type'">
- {{"statementInformationForm.controls.typeId" | translate}}
+ <label [for]="appId + '-creation-date'">
+ {{"statementInformationForm.controls.creationDate" | translate}}
</label>
</div>
<div>
- <app-select #selectComponent
- [appDisabled]="appStatementTypeOptions?.length == null || appStatementTypeOptions.length === 0"
- [appId]="appId + '-type'"
- [appOptions]="appStatementTypeOptions"
- [appPlaceholder]="selectComponent.appDisabled ? 'Keine Daten verfügbar...' : ''"
- [formControlName]="'typeId'"
- appFormControlStatus
- class="openk-info form-group-container--select"
- required>
- </app-select>
+ <app-date-control [appId]="appId + '-creation-date'"
+ [formControlName]="'creationDate'"
+ appFormControlStatus
+ class="openk-info form-group-container--date">
+ </app-date-control>
</div>
<div class="form-group-container--label">
@@ -104,8 +113,7 @@
<app-date-control [appId]="appId + '-receipt-date'"
[formControlName]="'receiptDate'"
appFormControlStatus
- class="openk-info"
- required>
+ class="openk-info form-group-container--date">
</app-date-control>
</div>
@@ -119,9 +127,19 @@
<app-date-control [appId]="appId + '-due-date'"
[formControlName]="'dueDate'"
appFormControlStatus
- class="openk-info"
- required>
+ class="openk-info form-group-container--date">
</app-date-control>
</div>
+ <div class="form-group-container--label">
+ <label [for]="appId + '-customer-reference'">
+ {{"statementInformationForm.controls.customerReference" | translate}}
+ </label>
+ </div>
+
+ <input [formControlName]="'customerReference'"
+ [id]="appId + '-customer-reference'"
+ autocomplete="off"
+ class="openk-input form-group-container--input">
+
</div>
diff --git a/src/app/features/forms/statement-information/components/general-information-form-group/general-information-form-group.component.scss b/src/app/features/forms/statement-information/components/general-information-form-group/general-information-form-group.component.scss
index e869db7..2842cf1 100644
--- a/src/app/features/forms/statement-information/components/general-information-form-group/general-information-form-group.component.scss
+++ b/src/app/features/forms/statement-information/components/general-information-form-group/general-information-form-group.component.scss
@@ -22,7 +22,7 @@
}
.form-group-container {
- flex: 0 1 29em;
+ flex: 0 1 calc(29em + 6px);
display: grid;
box-sizing: border-box;
padding: 0 0.5em 0.5em 0.5em;
@@ -50,7 +50,7 @@
}
.form-group-container---fill {
- flex: 10 1 25em;
+ flex: 10 1 30em;
}
.form-group-container--label {
@@ -61,3 +61,7 @@
.form-group-container--select {
width: 100%;
}
+
+.form-group-container--date {
+ width: 100%;
+}
diff --git a/src/app/features/forms/statement-information/components/side-menu/statement-information-side-menu.component.html b/src/app/features/forms/statement-information/components/side-menu/statement-information-side-menu.component.html
index 62f1285..750405b 100644
--- a/src/app/features/forms/statement-information/components/side-menu/statement-information-side-menu.component.html
+++ b/src/app/features/forms/statement-information/components/side-menu/statement-information-side-menu.component.html
@@ -14,15 +14,6 @@
<ng-container *appSideMenu="'top'; title: (appForNewStatement ? titleNew : title) | translate">
<app-action-button
- *ngIf="appForNewStatement"
- [appDisabled]="appDisabled"
- [appIcon]="'email'"
- [appRouterLink]="'/mail'"
- class="openk-info side-menu-button">
- {{ "statementInformationForm.sideMenu.backToInbox" | translate }}
- </app-action-button>
-
- <app-action-button
*ngIf="!appForNewStatement"
[appDisabled]="appDisabled"
[appIcon]="'arrow_back'"
@@ -33,6 +24,16 @@
</app-action-button>
<app-action-button
+ *ngIf="appForNewStatement || appMailId != null"
+ [appDisabled]="appDisabled"
+ [appIcon]="'email'"
+ [appMailId]="appMailId"
+ [appRouterLink]="'/mail'"
+ class="openk-info side-menu-button">
+ {{ "statementInformationForm.sideMenu.backToInbox" | translate }}
+ </app-action-button>
+
+ <app-action-button
(appClick)="appSubmit.emit()"
*ngIf="!appForNewStatement"
[appDisabled]="appDisabled"
@@ -46,6 +47,7 @@
<app-side-menu-status
*appSideMenu="'center'"
[appLoadingMessage]="'core.submitting' | translate"
+ [appErrorMessage]="appErrorMessage"
[appLoading]="appLoading">
</app-side-menu-status>
diff --git a/src/app/features/forms/statement-information/components/side-menu/statement-information-side-menu.component.scss b/src/app/features/forms/statement-information/components/side-menu/statement-information-side-menu.component.scss
index 3796710..69f59f2 100644
--- a/src/app/features/forms/statement-information/components/side-menu/statement-information-side-menu.component.scss
+++ b/src/app/features/forms/statement-information/components/side-menu/statement-information-side-menu.component.scss
@@ -11,6 +11,8 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
+@import "openk.styles";
+
:host {
display: none;
}
@@ -22,3 +24,7 @@
margin-top: 1em;
}
}
+
+.error-message {
+ color: $openk-error-color;
+}
diff --git a/src/app/features/forms/statement-information/components/side-menu/statement-information-side-menu.component.ts b/src/app/features/forms/statement-information/components/side-menu/statement-information-side-menu.component.ts
index 49f03c8..ae0d15b 100644
--- a/src/app/features/forms/statement-information/components/side-menu/statement-information-side-menu.component.ts
+++ b/src/app/features/forms/statement-information/components/side-menu/statement-information-side-menu.component.ts
@@ -32,6 +32,12 @@
@Input()
public appDisabled: boolean;
+ @Input()
+ public appErrorMessage: string;
+
+ @Input()
+ public appMailId: string;
+
@Output()
public appSubmit = new EventEmitter<void | boolean>();
diff --git a/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.html b/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.html
index 0e06839..a9dc6a3 100644
--- a/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.html
+++ b/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.html
@@ -15,9 +15,10 @@
(appSubmit)="submit($event)"
[appDisabled]="appFormGroup.disabled"
[appForNewStatement]="appForNewStatement"
- [appLoading]="statementLoading$ | async"
+ [appLoading]="isStatementLoading$ | async"
+ [appErrorMessage]="(appError$ | async)?.errorMessage"
+ [appMailId]="mailId"
[appStatementId]="(task$ | async)?.statementId">
-
</app-statement-information-side-menu>
<div [formGroup]="appFormGroup" class="info-form">
@@ -42,11 +43,12 @@
(appSearchChange)="search($event)"
[appDetails]="selectedContact$ | async"
[appEntries]="contactSearchContent$ | async"
- [appIsLoading]="(contactLoading$ | async)?.searching"
+ [appIsLoading]="(contactLoading$ | async)?.searching || (contactLoading$ | async)?.fetching"
[appMessage]="'contacts.selectContact' | translate"
[appPageSize]="(contactSearch$ | async)?.totalPages"
[appPage]="(contactSearch$ | async)?.number"
[formControlName]="'contactId'"
+ [appSearch]="initialSearchText"
class="form-control">
</app-contact-select>
@@ -57,7 +59,9 @@
<app-attachments-form-group
[appForbiddenTagIds]="appForbiddenTagIds"
- [appFormGroup]="appFormGroup | getFormGroup: 'attachments'">
+ [appForNewStatement]="appForNewStatement"
+ [appFormGroup]="appFormGroup | getFormGroup: 'attachments'"
+ [appMailId]="mailId">
</app-attachments-form-group>
</app-collapsible>
diff --git a/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.scss b/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.scss
index 48301e9..b999e74 100644
--- a/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.scss
+++ b/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.scss
@@ -49,8 +49,3 @@
flex-flow: column;
margin: 0.5em;
}
-
-.attachments--container--control {
- flex: 1 1 100%;
- margin: 0.25em 0 0.5em 0;
-}
diff --git a/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.spec.ts b/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.spec.ts
index f9aef9c..b4429f0 100644
--- a/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.spec.ts
+++ b/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.spec.ts
@@ -14,18 +14,22 @@
import {EventEmitter} from "@angular/core";
import {async, ComponentFixture, TestBed} from "@angular/core/testing";
import {MockStore, provideMockStore} from "@ngrx/store/testing";
+import {of, timer} from "rxjs";
import {I18nModule, IAPIProcessTask, IAPISearchOptions} from "../../../../../core";
import {
+ EErrorCode,
+ fetchContactDetailsAction,
fetchSettingsAction,
getStatementLoadingSelector,
IStatementInformationFormValue,
openContactDataBaseAction,
+ setErrorAction,
+ startContactSearchAction,
statementInformationFormValueSelector,
statementTypesSelector,
- submitStatementInformationFormAction
+ submitStatementInformationFormAction,
+ taskSelector
} from "../../../../../store";
-import {fetchContactDetailsAction, startContactSearchAction} from "../../../../../store/contacts/actions";
-import {taskSelector} from "../../../../../store/process/selectors";
import {createSelectOptionsMock} from "../../../../../test";
import {StatementInformationFormModule} from "../../statement-information-form.module";
import {StatementInformationFormComponent} from "./statement-information-form.component";
@@ -64,19 +68,10 @@
it("should initialize form for existing statements", async () => {
const statementInformationFormValueSelectorMock = mockStore.overrideSelector(statementInformationFormValueSelector, {});
- const value: IStatementInformationFormValue = {
+ const value: IStatementInformationFormValue = createStatementInfoFormValue({
title: "Title",
- dueDate: null,
- receiptDate: null,
- typeId: 19,
- city: null,
- district: null,
- contactId: null,
- attachments: {
- add: [],
- edit: []
- }
- };
+ typeId: 19
+ });
expect(component).toBeDefined();
fixture.detectChanges();
await fixture.whenStable();
@@ -89,19 +84,12 @@
it("should initialize for new statements", async () => {
const statementInformationFormValueSelectorMock = mockStore.overrideSelector(statementInformationFormValueSelector, {});
- const value: IStatementInformationFormValue = {
- title: null,
+ const value = createStatementInfoFormValue({
dueDate: today,
receiptDate: today,
- typeId: 0,
- city: null,
- district: null,
- contactId: null,
- attachments: {
- add: [],
- edit: []
- }
- };
+ creationDate: today,
+ typeId: 0
+ });
component.appForNewStatement = true;
expect(component).toBeDefined();
fixture.detectChanges();
@@ -116,19 +104,12 @@
it("should initialize for new statements without statement types", async () => {
const statementInformationFormValueSelectorMock = mockStore.overrideSelector(statementInformationFormValueSelector, {});
mockStore.overrideSelector(statementTypesSelector, null);
- const value: IStatementInformationFormValue = {
- title: null,
+ const value = createStatementInfoFormValue({
+ typeId: undefined,
dueDate: today,
receiptDate: today,
- typeId: undefined,
- city: null,
- district: null,
- contactId: null,
- attachments: {
- add: [],
- edit: []
- }
- };
+ creationDate: today
+ });
component.appForNewStatement = true;
expect(component).toBeDefined();
fixture.detectChanges();
@@ -165,24 +146,27 @@
const dispatchSpy = spyOn(component.store, "dispatch");
component.appForNewStatement = true;
fixture.detectChanges();
+ await fixture.whenStable();
expect(dispatchSpy).toHaveBeenCalledWith(fetchSettingsAction());
});
it("should fetch contact details on value changes", async () => {
- const dispatchSpy = spyOn(component.store, "dispatch");
const formValueChangesMock = new EventEmitter<any>();
(component.appFormGroup as any).valueChanges = formValueChangesMock;
+ component.task$ = of({statementId: undefined} as IAPIProcessTask);
fixture.detectChanges();
await fixture.whenStable();
const value: Partial<IStatementInformationFormValue> = {
contactId: "19191919"
};
-
+ const dispatchSpy = spyOn(component.store, "dispatch");
component.appFormGroup.patchValue(value);
formValueChangesMock.next(value);
- expect(dispatchSpy).toHaveBeenCalledWith(fetchContactDetailsAction({contactId: value.contactId}));
+ await timer(0).toPromise();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(fetchContactDetailsAction({contactId: value.contactId, statementId: undefined}));
});
it("should open contact data base module", async () => {
@@ -221,25 +205,26 @@
expect(component.appFormGroup.touched).toBeFalse();
expect(component.appFormGroup.invalid).toBeTrue();
- component.submit();
+ await component.submit();
expect(component.appFormGroup.touched).toBeTrue();
- expect(dispatchSpy).not.toHaveBeenCalled();
+ expect(dispatchSpy).toHaveBeenCalledWith(setErrorAction({
+ statementId: "new",
+ error: EErrorCode.MISSING_FORM_DATA
+ }));
});
it("should submit information for a new statement", async () => {
- const value: IStatementInformationFormValue = {
+ const value = createStatementInfoFormValue({
title: "Title",
+ creationDate: today,
dueDate: today,
receiptDate: today,
typeId: 3,
city: "city",
district: "district",
contactId: "contactId",
- attachments: {
- add: [],
- edit: []
- }
- };
+ customerReference: ""
+ });
component.appForNewStatement = true;
fixture.detectChanges();
@@ -273,19 +258,16 @@
taskId: "19191919",
statementId: 19
};
- const value: IStatementInformationFormValue = {
+ const value = createStatementInfoFormValue({
title: "Title",
dueDate: today,
receiptDate: today,
+ creationDate: today,
typeId: 3,
city: "city",
district: "district",
- contactId: "contactId",
- attachments: {
- add: [],
- edit: []
- }
- };
+ contactId: "contactId"
+ });
mockStore.overrideSelector(taskSelector, task as IAPIProcessTask);
@@ -317,3 +299,26 @@
}));
});
});
+
+function createStatementInfoFormValue(value: Partial<IStatementInformationFormValue>): IStatementInformationFormValue {
+ return {
+ title: null,
+ dueDate: null,
+ receiptDate: null,
+ creationDate: null,
+ typeId: null,
+ city: null,
+ district: null,
+ contactId: null,
+ customerReference: null,
+ attachments: {
+ add: [],
+ edit: [],
+ email: [],
+ transferMailText: false,
+ mailTextAttachmentId: null
+ },
+ sourceMailId: undefined,
+ ...value,
+ };
+}
diff --git a/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.stories.ts b/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.stories.ts
index 6e218f6..3b09614 100644
--- a/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.stories.ts
+++ b/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.stories.ts
@@ -16,9 +16,9 @@
import {action} from "@storybook/addon-actions";
import {boolean, withKnobs} from "@storybook/addon-knobs";
import {moduleMetadata, storiesOf} from "@storybook/angular";
-import {I18nModule} from "../../../../../core/i18n";
+import {I18nModule} from "../../../../../core";
import {statementInformationFormValueSelector, statementTypesSelector} from "../../../../../store";
-import {createSelectOptionsMock} from "../../../../../test/create-select-options.spec";
+import {createSelectOptionsMock} from "../../../../../test";
import {StatementInformationFormModule} from "../../statement-information-form.module";
storiesOf("Features / Forms", module)
diff --git a/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.ts b/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.ts
index 05fbe60..34c88c6 100644
--- a/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.ts
+++ b/src/app/features/forms/statement-information/components/statement-information-form/statement-information-form.component.ts
@@ -11,30 +11,40 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-import {Component, Input, OnInit} from "@angular/core";
+import {Component, Input, OnDestroy, OnInit} from "@angular/core";
import {select, Store} from "@ngrx/store";
-import {concat, defer, of} from "rxjs";
-import {distinctUntilChanged, filter, map, switchMap, take, takeUntil} from "rxjs/operators";
+import {combineLatest, concat, defer, Observable, of} from "rxjs";
+import {distinctUntilChanged, filter, ignoreElements, map, switchMap, take, takeUntil, withLatestFrom} from "rxjs/operators";
import {AUTO_SELECTED_TAGS, IAPISearchOptions} from "../../../../../core";
import {
createStatementInformationForm,
+ EErrorCode,
fetchContactDetailsAction,
fetchSettingsAction,
getContactDetailsSelector,
getContactLoadingSelector,
getContactSearchContentSelector,
getContactSearchSelector,
+ getStatementErrorForNewSelector,
+ getStatementErrorSelector,
getStatementSectorsSelector,
+ IStatementErrorEntity,
IStatementInformationFormValue,
openContactDataBaseAction,
+ queryParamsMailIdSelector,
+ setErrorAction,
startContactSearchAction,
statementInformationFormValueSelector,
statementLoadingSelector,
+ statementMailIdSelector,
statementTypesSelector,
submitStatementInformationFormAction,
taskSelector
} from "../../../../../store";
+import {fetchEmailAction} from "../../../../../store/mail/actions";
+import {getEmailLoadingSelector, getSelectedEmailSelector, getStatementMailSelector} from "../../../../../store/mail/selectors";
import {arrayJoin} from "../../../../../util";
+import {ExtractMailAddressPipe} from "../../../../mail/pipes/extract-mail-address.pipe";
import {AbstractReactiveFormComponent} from "../../../abstract";
@Component({
@@ -42,7 +52,8 @@
templateUrl: "./statement-information-form.component.html",
styleUrls: ["./statement-information-form.component.scss"]
})
-export class StatementInformationFormComponent extends AbstractReactiveFormComponent<IStatementInformationFormValue> implements OnInit {
+export class StatementInformationFormComponent
+ extends AbstractReactiveFormComponent<IStatementInformationFormValue> implements OnInit, OnDestroy {
@Input()
public appForbiddenTagIds = AUTO_SELECTED_TAGS;
@@ -50,7 +61,7 @@
@Input()
public appForNewStatement: boolean;
- public statementLoading$ = this.store.pipe(select(statementLoadingSelector));
+ public isStatementLoading$ = this.store.pipe(select(statementLoadingSelector));
public task$ = this.store.pipe(select(taskSelector));
@@ -64,18 +75,34 @@
public sectors$ = this.store.pipe(select(getStatementSectorsSelector));
- public selectedContact$ = defer(() => this.selectedContactId$).pipe(
- switchMap((id) => this.store.pipe(select(getContactDetailsSelector, {id})))
- );
-
public searchText: string;
+ public initialSearchText: string;
+
public appFormGroup = createStatementInformationForm();
public selectedContactId$ = defer(() => concat(of(null), this.appFormGroup.valueChanges)).pipe(
map(() => this.getValue().contactId)
);
+ public selectedContact$ = defer(() => this.selectedContactId$).pipe(
+ switchMap((id) => this.store.pipe(select(getContactDetailsSelector, {id})))
+ );
+
+ public appError$: Observable<IStatementErrorEntity>;
+
+ public queryParamsMailId$ = this.store.pipe(select(queryParamsMailIdSelector));
+
+ public statementMailId$ = this.store.pipe(select(statementMailIdSelector));
+
+ public selectedMail$ = this.store.pipe(select(getSelectedEmailSelector));
+
+ public statementMail$ = this.store.pipe(select(getStatementMailSelector));
+
+ public emailFetching$ = this.store.pipe(select(getEmailLoadingSelector));
+
+ public mailId: string;
+
private form$ = this.store.pipe(select(statementInformationFormValueSelector));
private searchSize = 10;
@@ -84,17 +111,45 @@
super();
}
- public ngOnInit() {
+ public async ngOnInit() {
+
+ this.appError$ = this.store.pipe(select(this.appForNewStatement ? getStatementErrorForNewSelector : getStatementErrorSelector));
+
+ let mailId = await this.queryParamsMailId$.pipe(take(1)).toPromise();
+ if (!mailId) {
+ mailId = await this.statementMailId$.pipe(take(1)).toPromise();
+ }
+ this.mailId = mailId;
+
if (this.appForNewStatement) {
- this.setInitialValue();
+ await this.clearErrors();
+ await this.setInitialValue();
this.store.dispatch(fetchSettingsAction());
} else {
this.appFormGroup.markAllAsTouched();
}
+ if (this.mailId) {
+ await this.setEmailValues(mailId);
+ } else {
+ this.search("");
+ }
+
this.updateForm();
this.fetchContactDetails();
- this.search("");
+
+ this.value$.pipe(takeUntil(this.destroy$)).subscribe(async () => {
+ const errorMessage = await this.appError$.pipe(take(1)).toPromise();
+ if (this.appFormGroup.valid && errorMessage === EErrorCode.MISSING_FORM_DATA) {
+ return this.clearErrors();
+ }
+ });
+ }
+
+ public async ngOnDestroy() {
+ if (!await this.isStatementLoading$.pipe(take(1)).toPromise()) {
+ await this.clearErrors();
+ }
}
public openContactDataBaseModule() {
@@ -115,47 +170,134 @@
this.store.dispatch(startContactSearchAction({options}));
}
- public async submit(responsible?: boolean) {
- if (this.appFormGroup.invalid) {
- this.appFormGroup.markAllAsTouched();
- return;
- }
+ public async clearErrors() {
+ const task = await this.task$.pipe(take(1)).toPromise();
+ this.store.dispatch(setErrorAction({
+ statementId: this.appForNewStatement ? "new" : task?.statementId,
+ error: null
+ }));
+ }
- if (this.appForNewStatement) {
- this.store.dispatch(submitStatementInformationFormAction({
- new: true,
- value: this.getValue(),
- responsible
+ public async submit(responsible?: boolean) {
+ const task = await this.task$.pipe(take(1)).toPromise();
+ this.appFormGroup.markAllAsTouched();
+
+ if (this.appFormGroup.invalid) {
+ return this.store.dispatch(setErrorAction({
+ statementId: this.appForNewStatement ? "new" : task.statementId,
+ error: EErrorCode.MISSING_FORM_DATA
}));
} else {
- const task = await this.task$.pipe(take(1)).toPromise();
- this.store.dispatch(submitStatementInformationFormAction({
- statementId: task.statementId,
- taskId: task.taskId,
- value: this.getValue(),
- responsible
- }));
+ await this.clearErrors();
+ return this.store.dispatch(submitStatementInformationFormAction(
+ this.appForNewStatement ? {
+ new: true,
+ value: this.getValue(),
+ responsible
+ } : {
+ statementId: task.statementId,
+ taskId: task.taskId,
+ value: this.getValue(),
+ responsible
+ }
+ ));
}
}
private async setInitialValue() {
+
const statementTypeOptions = await this.statementTypeOptions$.pipe(take(1)).toPromise();
const typeId = arrayJoin(statementTypeOptions)[0]?.value;
const today = new Date().toISOString().slice(0, 10);
- this.patchValue({typeId, dueDate: today, receiptDate: today});
+
+ this.patchValue({typeId, dueDate: today, receiptDate: today, creationDate: today, sourceMailId: this.mailId});
+ }
+
+ private async setEmailValues(mailId: string) {
+ const task = await this.task$.pipe(take(1)).toPromise();
+ this.store.dispatch(fetchEmailAction({mailId, statementId: this.appForNewStatement ? "new" : task?.statementId}));
+
+ combineLatest([this.selectedMail$, this.statementMail$]).pipe(
+ filter(([mail, statementMail]) => mail != null || statementMail != null),
+ take(1),
+ takeUntil(this.destroy$)
+ ).subscribe(async ([selectedMail, statementMail]) => {
+
+ const mail = selectedMail ? selectedMail : statementMail;
+
+ const subject = mail?.subject;
+ const emailAddress = new ExtractMailAddressPipe().transform(mail?.from);
+
+ const emailDate = new Date(mail?.date).toISOString().slice(0, 10);
+
+ this.patchValue({
+ receiptDate: emailDate ? emailDate : this.getValue().receiptDate,
+ creationDate: emailDate ? emailDate : this.getValue().creationDate,
+ dueDate: emailDate ? emailDate : this.getValue().dueDate,
+ title: subject
+ });
+
+ this.selectContactForEmail(emailAddress);
+ });
+ }
+
+ private selectContactForEmail(emailAddress: string) {
+
+ if (!emailAddress) {
+ return;
+ }
+
+ const waitUntilSearchStarted = this.contactLoading$.pipe(
+ takeUntil(this.contactLoading$.pipe(filter(_ => _.searching))),
+ ignoreElements()
+ );
+
+ const waitUntilSearchFinished = this.contactLoading$.pipe(
+ takeUntil(this.contactLoading$.pipe(filter(_ => !_.searching))),
+ ignoreElements()
+ );
+
+ concat(
+ waitUntilSearchStarted,
+ waitUntilSearchFinished,
+ this.contactSearchContent$.pipe(take(1))
+ ).pipe(
+ takeUntil(this.destroy$)
+ ).subscribe((_) => {
+ const contactId = _[0]?.id;
+ if (contactId) {
+ this.patchValue({contactId});
+ }
+ });
+
+ this.initialSearchText = emailAddress;
+ this.search(emailAddress);
}
private fetchContactDetails() {
- this.selectedContactId$.pipe(distinctUntilChanged(), takeUntil(this.destroy$))
- .subscribe((contactId) => this.store.dispatch(fetchContactDetailsAction({contactId})));
+ this.selectedContactId$.pipe(
+ distinctUntilChanged(),
+ withLatestFrom(this.task$),
+ takeUntil(this.destroy$)
+ ).subscribe(async ([contactId, task]) => {
+ const errorMessage = (await this.appError$.pipe(take(1)).toPromise())?.errorMessage;
+ if (errorMessage === EErrorCode.FAILED_LOADING_CONTACT) {
+ await this.clearErrors();
+ }
+ const statementId = this.appForNewStatement ? "new" : task?.statementId;
+ this.store.dispatch(fetchContactDetailsAction({contactId, statementId}));
+ });
+
}
private updateForm() {
this.form$.pipe(takeUntil(this.destroy$), filter(() => !this.appForNewStatement)).subscribe((value) => {
this.patchValue(value);
});
- this.statementLoading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => {
- loading ? this.appFormGroup.disable() : this.appFormGroup.enable();
+ combineLatest([this.isStatementLoading$, this.emailFetching$]).pipe(
+ takeUntil(this.destroy$)
+ ).subscribe(([loading, emailLoading]) => {
+ loading || emailLoading?.fetching ? this.appFormGroup.disable() : this.appFormGroup.enable();
});
}
diff --git a/src/app/features/forms/workflow-data/components/side-menu/workflow-data-side-menu.component.html b/src/app/features/forms/workflow-data/components/side-menu/workflow-data-side-menu.component.html
index 41972c8..4d70c87 100644
--- a/src/app/features/forms/workflow-data/components/side-menu/workflow-data-side-menu.component.html
+++ b/src/app/features/forms/workflow-data/components/side-menu/workflow-data-side-menu.component.html
@@ -35,6 +35,7 @@
<app-side-menu-status
*appSideMenu="'center'"
[appLoadingMessage]="'core.submitting' | translate"
+ [appErrorMessage]="appErrorMessage"
[appLoading]="appLoading">
</app-side-menu-status>
diff --git a/src/app/features/forms/workflow-data/components/side-menu/workflow-data-side-menu.component.ts b/src/app/features/forms/workflow-data/components/side-menu/workflow-data-side-menu.component.ts
index 649cfb5..57e4a64 100644
--- a/src/app/features/forms/workflow-data/components/side-menu/workflow-data-side-menu.component.ts
+++ b/src/app/features/forms/workflow-data/components/side-menu/workflow-data-side-menu.component.ts
@@ -29,6 +29,9 @@
@Input()
public appLoading: boolean;
+ @Input()
+ public appErrorMessage: string;
+
@Output()
public appSubmit = new EventEmitter<boolean>();
diff --git a/src/app/features/forms/workflow-data/components/workflow-data-form.component.html b/src/app/features/forms/workflow-data/components/workflow-data-form.component.html
index 89c6a94..657543a 100644
--- a/src/app/features/forms/workflow-data/components/workflow-data-form.component.html
+++ b/src/app/features/forms/workflow-data/components/workflow-data-form.component.html
@@ -15,8 +15,8 @@
(appSubmit)="submit($event)"
[appDisabled]="appFormGroup.disabled"
[appLoading]="isStatementLoading$ | async"
+ [appErrorMessage]="(appErrorMessage$ | async)?.errorMessage"
[appStatementId]="(task$ | async)?.statementId">
-
</app-workflow-data-side-menu>
@@ -39,10 +39,14 @@
</app-collapsible>
<app-collapsible
- [appCollapsed]="true"
[appTitle]="'workflowDataForm.container.geographicPosition' | translate">
- <div style="padding: 1em;"> Not yet implemented.</div>
+ <app-map-select
+ [appActionButtonLabel]="'shared.map.openGIS' | translate"
+ [appCenter]="'leaflet.defaultView' | translate"
+ [formControlName]="'geographicPosition'"
+ class="geographic-position">
+ </app-map-select>
</app-collapsible>
@@ -60,7 +64,6 @@
</app-collapsible>
<app-collapsible
- [appCollapsed]="true"
[appTitle]="('workflowDataForm.container.linkedIssues' | translate) + ' (' + appFormGroup.value.parentIds?.length + ')'">
<app-statement-select
diff --git a/src/app/features/forms/workflow-data/components/workflow-data-form.component.scss b/src/app/features/forms/workflow-data/components/workflow-data-form.component.scss
index 2d75d77..467dba9 100644
--- a/src/app/features/forms/workflow-data/components/workflow-data-form.component.scss
+++ b/src/app/features/forms/workflow-data/components/workflow-data-form.component.scss
@@ -31,8 +31,8 @@
}
.geographic-position {
- box-sizing: border-box;
- height: 3em;
+ padding: 1em;
+ height: 30em;
}
.departments {
diff --git a/src/app/features/forms/workflow-data/components/workflow-data-form.component.ts b/src/app/features/forms/workflow-data/components/workflow-data-form.component.ts
index 6a39dd9..f119b47 100644
--- a/src/app/features/forms/workflow-data/components/workflow-data-form.component.ts
+++ b/src/app/features/forms/workflow-data/components/workflow-data-form.component.ts
@@ -11,8 +11,9 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-import {Component, OnInit} from "@angular/core";
+import {Component, OnDestroy, OnInit} from "@angular/core";
import {select, Store} from "@ngrx/store";
+import {Observable} from "rxjs";
import {distinctUntilChanged, take, takeUntil} from "rxjs/operators";
import {IAPISearchOptions} from "../../../../core/api";
import {
@@ -20,7 +21,11 @@
departmentGroupsSelector,
departmentOptionsSelector,
getSearchContentStatementsSelector,
+ getStatementErrorSelector,
+ IStatementErrorEntity,
IWorkflowFormValue,
+ queryParamsIdSelector,
+ setErrorAction,
startStatementSearchAction,
statementLoadingSelector,
statementTypesSelector,
@@ -35,10 +40,12 @@
templateUrl: "./workflow-data-form.component.html",
styleUrls: ["./workflow-data-form.component.scss"]
})
-export class WorkflowDataFormComponent extends AbstractReactiveFormComponent<IWorkflowFormValue> implements OnInit {
+export class WorkflowDataFormComponent extends AbstractReactiveFormComponent<IWorkflowFormValue> implements OnInit, OnDestroy {
public task$ = this.store.pipe(select(taskSelector));
+ public statementId$ = this.store.pipe(select(queryParamsIdSelector));
+
public statementTypes$ = this.store.pipe(select(statementTypesSelector));
public searchContent$ = this.store.pipe(select(getSearchContentStatementsSelector));
@@ -51,25 +58,26 @@
public appFormGroup = createWorkflowForm();
+ public appErrorMessage$: Observable<IStatementErrorEntity> = this.store.pipe(select(getStatementErrorSelector));
+
private form$ = this.store.pipe(select(workflowFormValueSelector));
public constructor(public store: Store) {
super();
}
- public ngOnInit() {
- this.patchValue({geographicPosition: "", departments: {selected: [], indeterminate: []}, parentIds: []});
- this.isStatementLoading$.pipe(takeUntil(this.destroy$), distinctUntilChanged())
- .subscribe((loading) => loading ? this.appFormGroup.disable() : this.appFormGroup.enable());
- this.form$.pipe(takeUntil(this.destroy$))
- .subscribe((value) => this.patchValue(value));
- this.task$.pipe(takeUntil(this.destroy$))
- .subscribe(() => {
- this.search({q: ""});
- });
+ public async ngOnInit() {
+ this.updateForm();
+ this.task$.pipe(takeUntil(this.destroy$)).subscribe(() => this.search({q: ""}));
+ }
+
+ public async ngOnDestroy() {
+ super.ngOnDestroy();
+ return this.clearErrors();
}
public async submit(completeTask?: boolean) {
+ await this.clearErrors();
const task = await this.task$.pipe(take(1)).toPromise();
this.store.dispatch(submitWorkflowDataFormAction({
statementId: task.statementId,
@@ -83,4 +91,19 @@
this.store.dispatch(startStatementSearchAction({options}));
}
+ private async clearErrors() {
+ const statementId = await this.statementId$.pipe(take(1)).toPromise();
+ const loading = await this.isStatementLoading$.pipe(take(1)).toPromise();
+ if (statementId != null && !loading) {
+ this.store.dispatch(setErrorAction({statementId, error: null}));
+ }
+ }
+
+ private updateForm() {
+ this.isStatementLoading$.pipe(takeUntil(this.destroy$), distinctUntilChanged())
+ .subscribe((loading) => loading ? this.appFormGroup.disable() : this.appFormGroup.enable());
+ this.form$.pipe(takeUntil(this.destroy$)).subscribe((value) => this.patchValue(value));
+ }
+
+
}
diff --git a/src/app/features/forms/workflow-data/workflow-data-form.module.ts b/src/app/features/forms/workflow-data/workflow-data-form.module.ts
index 44ec32f..d9c9f43 100644
--- a/src/app/features/forms/workflow-data/workflow-data-form.module.ts
+++ b/src/app/features/forms/workflow-data/workflow-data-form.module.ts
@@ -16,6 +16,7 @@
import {ReactiveFormsModule} from "@angular/forms";
import {MatIconModule} from "@angular/material/icon";
import {TranslateModule} from "@ngx-translate/core";
+import {MapSelectModule} from "../../../shared/controls/map-select/map-select.module";
import {SelectModule} from "../../../shared/controls/select";
import {StatementSelectModule} from "../../../shared/controls/statement-select";
import {ActionButtonModule} from "../../../shared/layout/action-button";
@@ -34,7 +35,8 @@
SelectModule,
StatementSelectModule,
SideMenuModule,
- ActionButtonModule
+ ActionButtonModule,
+ MapSelectModule
],
declarations: [
WorkflowDataFormComponent,
diff --git a/src/app/features/mail/components/index.ts b/src/app/features/mail/components/index.ts
index 710dc8a..892094d 100644
--- a/src/app/features/mail/components/index.ts
+++ b/src/app/features/mail/components/index.ts
@@ -12,3 +12,5 @@
********************************************************************************/
export * from "./mail";
+export * from "./mail-details";
+export * from "./mail-inbox";
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/features/mail/components/mail-details/index.ts
similarity index 92%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/features/mail/components/mail-details/index.ts
index a3980e1..244b5b2 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/features/mail/components/mail-details/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./mail-details.component";
diff --git a/src/app/features/mail/components/mail-details/mail-details.component.html b/src/app/features/mail/components/mail-details/mail-details.component.html
new file mode 100644
index 0000000..c95e178
--- /dev/null
+++ b/src/app/features/mail/components/mail-details/mail-details.component.html
@@ -0,0 +1,88 @@
+<!-------------------------------------------------------------------------------
+ * 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
+ -------------------------------------------------------------------------------->
+
+<app-collapsible *ngIf="appEmail"
+ [appCollapsed]="false"
+ [appHeaderTemplateRef]="emailHeaderRef"
+ [appHideButton]="true"
+ [appTitle]="appEmail?.subject"
+ class="email">
+ <div class="email--content">
+
+ <div class="email--content--info">
+ <div class="email--content--info--text">
+ <span class="email--content--info--text---bold">{{"mails.sender" | translate}}</span>
+ <span class="email--content--info--text---bold">{{"mails.date" | translate}}</span>
+ </div>
+ <div class="email--content--info--text">
+ <a [href]="'mailto:' + appEmail.from" rel="noreferrer noopener" target="_blank">{{appEmail.from}}</a>
+ <span>{{(appEmail.date | appMomentPipe).format(dateFormat)}}</span>
+ </div>
+ </div>
+
+ <div class="email--content--body">
+ <div *ngFor="let text of (appEmail.textPlain | appEmailTextToArray), let i = index;">
+ {{text}} <br *ngIf="(appEmail.textPlain | appEmailTextToArray).length !== i">
+ </div>
+ <div *ngIf="appEmail?.textPlain === '\r\n'"
+ class="email--content--body--placeholder">{{"mails.noContent" | translate}}
+ </div>
+
+ </div>
+
+ <app-collapsible [appCollapsed]="false"
+ [appSimpleCollapsible]="true"
+ [appTitle]="('mails.attachments' | translate) + ' (' + (appEmail?.attachments ? appEmail.attachments.length.toString() : '0') +')'"
+ class="email--content--attachments">
+
+ <div *ngIf="appEmail?.attachments?.length > 0" class="email--content--attachments--row">
+ <button (click)="appDownloadAttachment.emit({ mailId: appEmail.identifier, name: attachment?.name })"
+ *ngFor="let attachment of appEmail?.attachments"
+ class="openk-button email--content--attachments--btn"
+ type="button">
+ {{attachment?.name}}
+ <mat-icon class="email--content--attachments--icon">get_app</mat-icon>
+ </button>
+ </div>
+ </app-collapsible>
+
+ </div>
+
+</app-collapsible>
+
+<div *ngIf="!appHideControls" class="email-controls">
+ <app-action-button
+ [appIcon]="'note_add'"
+ [appMailId]="appEmail?.identifier"
+ [appRouterLink]="'/new'"
+ class="openk-success">
+ {{"core.actions.createStatementFromEmail" | translate}}
+ </app-action-button>
+</div>
+
+<ng-template #emailHeaderRef>
+ <div class="email--header">
+
+ <app-progress-spinner
+ [class.progress-spinner---hidden]="!appDeleting">
+ </app-progress-spinner>
+
+ <button (click)="appRemoveFromInbox.emit(appEmail.identifier)"
+ *ngIf="!appHideControls && !appDeleting"
+ class="openk-button openk-button-rounded email--content--attachments--btn"
+ type="button">
+ <mat-icon>delete_forever</mat-icon>
+ </button>
+
+ </div>
+</ng-template>
diff --git a/src/app/features/mail/components/mail-details/mail-details.component.scss b/src/app/features/mail/components/mail-details/mail-details.component.scss
new file mode 100644
index 0000000..4485712
--- /dev/null
+++ b/src/app/features/mail/components/mail-details/mail-details.component.scss
@@ -0,0 +1,129 @@
+/********************************************************************************
+ * 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 "openk.styles";
+
+:host {
+ width: 100%;
+ height: 100%;
+ padding: 1em;
+ box-sizing: border-box;
+
+ display: flex;
+ flex-flow: column;
+}
+
+.email-controls {
+ width: 100%;
+ margin-top: 1em;
+ display: flex;
+ justify-content: flex-end;
+
+ & > * {
+ width: initial;
+ margin-left: 0.5em;
+ }
+}
+
+.email--header {
+ display: flex;
+ justify-content: flex-end;
+ align-content: center;
+ margin-right: 0.125em;
+}
+
+.email--content--attachments--btn {
+ border: 0;
+ padding: 0.1em 0 0.1em 0.5em;
+
+ &:not(.openk-info) {
+ background-color: transparent;
+ }
+
+ &:not(.openk-info):active,
+ &:not(.openk-info):focus,
+ &:not(.openk-info):hover {
+ background-color: $openk-background-highlight;
+ }
+}
+
+.email--content--attachments--icon {
+ color: get-color($openk-info-palette);
+ font-size: 1em;
+ padding: 0;
+}
+
+.email--content--attachments {
+ border: initial;
+ background: initial;
+ font-size: smaller;
+}
+
+.email--content--attachments--row {
+ margin: 0 0.5em 0.5em 0.5em;
+ font-size: medium;
+ display: inline-block;
+ border-radius: 0.5em;
+}
+
+.email {
+ max-height: calc(100vh - 50px - 7.25em);
+}
+
+.email--content {
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ height: 100%;
+}
+
+.email--content--info {
+ display: flex;
+ padding: 0.5em;
+ border-bottom: 1px solid $openk-form-border;
+}
+
+.email--content--info--text {
+ display: flex;
+ flex-direction: column;
+
+ & > * {
+ margin-left: 0.25em;
+ }
+}
+
+.email--content--info--text---bold {
+ font-weight: 600;
+}
+
+.email--content--body {
+ overflow: auto;
+ padding: 0.5em 1em;
+ border-bottom: 1px solid $openk-form-border;
+ min-height: 5em;
+}
+
+.email--content--body--placeholder {
+ font-weight: 600;
+ width: 100%;
+ margin-top: 1.5em;
+ text-align: center;
+}
+
+.progress-spinner---hidden {
+ display: block;
+ font-size: 0;
+ height: 0;
+ width: 0;
+ overflow: hidden;
+}
+
diff --git a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.spec.ts b/src/app/features/mail/components/mail-details/mail-details.component.spec.ts
similarity index 67%
copy from src/app/features/dashboard/components/dashboard-item/dashboard-item.component.spec.ts
copy to src/app/features/mail/components/mail-details/mail-details.component.spec.ts
index 1998914..fdaaa21 100644
--- a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.spec.ts
+++ b/src/app/features/mail/components/mail-details/mail-details.component.spec.ts
@@ -13,22 +13,22 @@
import {async, ComponentFixture, TestBed} from "@angular/core/testing";
import {RouterTestingModule} from "@angular/router/testing";
-import {DashboardItemComponent} from "./dashboard-item.component";
+import {I18nModule} from "../../../../core/i18n";
+import {MailModule} from "../../mail.module";
+import {MailDetailsComponent} from "./mail-details.component";
-describe("DashboardItemComponent", () => {
- let component: DashboardItemComponent;
- let fixture: ComponentFixture<DashboardItemComponent>;
+describe("MailDetailsComponent", () => {
+ let component: MailDetailsComponent;
+ let fixture: ComponentFixture<MailDetailsComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
- declarations: [DashboardItemComponent],
- imports: [RouterTestingModule]
- })
- .compileComponents();
+ imports: [MailModule, RouterTestingModule, I18nModule]
+ }).compileComponents();
}));
beforeEach(() => {
- fixture = TestBed.createComponent(DashboardItemComponent);
+ fixture = TestBed.createComponent(MailDetailsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
diff --git a/src/app/features/mail/components/mail-details/mail-details.component.ts b/src/app/features/mail/components/mail-details/mail-details.component.ts
new file mode 100644
index 0000000..196f752
--- /dev/null
+++ b/src/app/features/mail/components/mail-details/mail-details.component.ts
@@ -0,0 +1,42 @@
+/********************************************************************************
+ * 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 {Component, EventEmitter, Input, Output} from "@angular/core";
+import {IAPIEmailModel} from "../../../../core/api/mail";
+import {momentFormatDisplayFullDateAndTime} from "../../../../util/moment";
+
+@Component({
+ selector: "app-mail-details",
+ templateUrl: "./mail-details.component.html",
+ styleUrls: ["./mail-details.component.scss"]
+})
+export class MailDetailsComponent {
+
+ @Input()
+ public appEmail: IAPIEmailModel;
+
+ @Input()
+ public appHideControls: boolean;
+
+ @Input()
+ public appDeleting: boolean;
+
+ @Output()
+ public appDownloadAttachment = new EventEmitter<{ mailId: string, name: string }>();
+
+ @Output()
+ public appRemoveFromInbox = new EventEmitter<string>();
+
+ public dateFormat = momentFormatDisplayFullDateAndTime;
+
+}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/features/mail/components/mail-inbox/index.ts
similarity index 92%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/features/mail/components/mail-inbox/index.ts
index a3980e1..a53510c 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/features/mail/components/mail-inbox/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./mail-inbox.component";
diff --git a/src/app/features/mail/components/mail-inbox/mail-inbox.component.html b/src/app/features/mail/components/mail-inbox/mail-inbox.component.html
new file mode 100644
index 0000000..54fad24
--- /dev/null
+++ b/src/app/features/mail/components/mail-inbox/mail-inbox.component.html
@@ -0,0 +1,58 @@
+<!-------------------------------------------------------------------------------
+ * 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
+ -------------------------------------------------------------------------------->
+
+<div class="email-inbox--title">
+ <span class="email-inbox--title--text">
+ {{"mails.inbox" | translate}} {{"(" + (appMails?.length ? appMails.length : 0) + ")"}}
+ </span>
+
+ <app-progress-spinner
+ [class.progress-spinner---hidden]="!appLoading">
+ </app-progress-spinner>
+
+ <button (click)="appFetch.emit()"
+ *ngIf="!appLoading"
+ [disabled]="appLoading"
+ class="openk-button openk-button-rounded openk-info email-inbox--refresh-button">
+ <mat-icon>refresh</mat-icon>
+ </button>
+</div>
+
+<div class="email-inbox--list">
+
+ <a *ngFor="let item of appMails"
+ [class.email-inbox--list--element---active]="item.identifier === appSelectedMailId"
+ [queryParams]="{ mailId: item.identifier }"
+ [routerLink]="'/mail'"
+ class="email-inbox--list--element">
+ <span class="email-inbox--list--element--title">{{ item.subject }}</span>
+ <br>
+ <span class="email-inbox--list--element--date">
+ {{"mails.at" | translate}} {{(item.date | appMomentPipe).format(dateFormat)}}
+ </span>
+ <br>
+ <div class="email-inbox--list--element--sender">
+ <span class="email-inbox--list--element--sender--text">
+ {{"mails.from" | translate}}
+ </span>
+ <div class="email-inbox--list--element--sender--column">
+ <span *ngFor="let contact of (item.from | appSenderSplitNameMail)"
+ class="email-inbox--list--element--sender--text">
+ {{contact}}
+ </span>
+ </div>
+ </div>
+
+ </a>
+</div>
+
diff --git a/src/app/features/mail/components/mail-inbox/mail-inbox.component.scss b/src/app/features/mail/components/mail-inbox/mail-inbox.component.scss
new file mode 100644
index 0000000..0c5aecc
--- /dev/null
+++ b/src/app/features/mail/components/mail-inbox/mail-inbox.component.scss
@@ -0,0 +1,102 @@
+/********************************************************************************
+ * 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 "openk.styles";
+
+:host {
+ display: flex;
+ flex-flow: column;
+ height: 100%;
+ width: 100%;
+}
+
+.email-inbox--title {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.5em 1em 0.5em 1em;
+ border-bottom: 1px solid $openk-form-border;
+ align-items: center;
+ height: 2em;
+}
+
+.email-inbox--title--text {
+ font-size: larger;
+ font-weight: 600;
+}
+
+.email-inbox--refresh-button {
+ font-size: 0.875em;
+}
+
+.email-inbox--list {
+ overflow: auto;
+}
+
+.email-inbox--list--element {
+ display: block;
+ width: 100%;
+ padding: 0.25em 0.5em;
+ border-bottom: 1px solid $openk-form-border;
+ box-sizing: border-box;
+ cursor: pointer;
+ color: inherit;
+ text-decoration: none;
+ line-height: 1.25;
+
+ &:nth-of-type(odd) {
+ background: $openk-background-card;
+ }
+
+ &:hover {
+ background: get-color($openk-info-palette, 100);
+ }
+}
+
+.email-inbox--list--element--title {
+ // font-size: large;
+ font-weight: 600;
+}
+
+.email-inbox--list--element--date {
+ font-size: small;
+ margin: 0.25em 0.5em 0.25em 0.5em;
+}
+
+.email-inbox--list--element--sender {
+ display: inline-flex;
+ margin: 0.25em 0.5em 0.25em 0.5em;
+}
+
+.email-inbox--list--element--sender--text {
+ font-size: small;
+ margin-right: 0.25em;
+}
+
+.email-inbox--list--element--sender--column {
+ display: flex;
+ flex-direction: column;
+}
+
+.email-inbox--list--element---active {
+ background-color: get-color($openk-info-palette, 500) !important;
+ color: get-color($openk-info-palette, 500, contrast) !important;
+}
+
+.progress-spinner---hidden {
+ display: block;
+ font-size: 0;
+ height: 0;
+ width: 0;
+ overflow: hidden;
+}
+
diff --git a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.spec.ts b/src/app/features/mail/components/mail-inbox/mail-inbox.component.spec.ts
similarity index 68%
rename from src/app/features/dashboard/components/dashboard-item/dashboard-item.component.spec.ts
rename to src/app/features/mail/components/mail-inbox/mail-inbox.component.spec.ts
index 1998914..34b86de 100644
--- a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.spec.ts
+++ b/src/app/features/mail/components/mail-inbox/mail-inbox.component.spec.ts
@@ -13,22 +13,22 @@
import {async, ComponentFixture, TestBed} from "@angular/core/testing";
import {RouterTestingModule} from "@angular/router/testing";
-import {DashboardItemComponent} from "./dashboard-item.component";
+import {I18nModule} from "../../../../core/i18n";
+import {MailModule} from "../../mail.module";
+import {MailInboxComponent} from "./mail-inbox.component";
-describe("DashboardItemComponent", () => {
- let component: DashboardItemComponent;
- let fixture: ComponentFixture<DashboardItemComponent>;
+describe("MailInboxComponent", () => {
+ let component: MailInboxComponent;
+ let fixture: ComponentFixture<MailInboxComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
- declarations: [DashboardItemComponent],
- imports: [RouterTestingModule]
- })
- .compileComponents();
+ imports: [MailModule, RouterTestingModule, I18nModule]
+ }).compileComponents();
}));
beforeEach(() => {
- fixture = TestBed.createComponent(DashboardItemComponent);
+ fixture = TestBed.createComponent(MailInboxComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
diff --git a/src/app/features/mail/components/mail-inbox/mail-inbox.component.ts b/src/app/features/mail/components/mail-inbox/mail-inbox.component.ts
new file mode 100644
index 0000000..5eb55ce
--- /dev/null
+++ b/src/app/features/mail/components/mail-inbox/mail-inbox.component.ts
@@ -0,0 +1,39 @@
+/********************************************************************************
+ * 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 {Component, EventEmitter, Input, Output} from "@angular/core";
+import {IAPIEmailModel} from "../../../../core/api/mail";
+import {momentFormatDisplayNumeric} from "../../../../util/moment";
+
+@Component({
+ selector: "app-mail-inbox",
+ templateUrl: "./mail-inbox.component.html",
+ styleUrls: ["./mail-inbox.component.scss"]
+})
+export class MailInboxComponent {
+
+ @Input()
+ public appMails: IAPIEmailModel[];
+
+ @Input()
+ public appLoading: boolean;
+
+ @Input()
+ public appSelectedMailId: string;
+
+ @Output()
+ public appFetch = new EventEmitter();
+
+ public dateFormat = momentFormatDisplayNumeric;
+
+}
diff --git a/src/app/features/mail/components/mail/mail.component.html b/src/app/features/mail/components/mail/mail.component.html
index 8982b11..0735fcf 100644
--- a/src/app/features/mail/components/mail/mail.component.html
+++ b/src/app/features/mail/components/mail/mail.component.html
@@ -11,8 +11,18 @@
* SPDX-License-Identifier: EPL-2.0
-------------------------------------------------------------------------------->
-<div *appSideMenu="'top'; left: true; title: 'Posteingang'">
- <div>
- Not yet implemented.
- </div>
-</div>
+<app-mail-inbox
+ (appFetch)="fetchInbox()"
+ *appSideMenu="'top'; left: true; style: { padding: '0' }"
+ [appLoading]="(loading$ | async)?.fetchingInbox"
+ [appMails]="(emailInbox$ | async)"
+ [appSelectedMailId]="(selectedEmailId$ | async)">
+</app-mail-inbox>
+
+<app-mail-details
+ (appDownloadAttachment)="downloadAttachment($event?.mailId, $event?.name)"
+ (appRemoveFromInbox)="remove($event)"
+ [appEmail]="selectedEmail$ | async"
+ [appDeleting]="(loading$ | async)?.deleting"
+ [appHideControls]="(loading$ | async) != null || (emailInbox$ | async)?.indexOf(selectedEmail$ | async) === -1">
+</app-mail-details>
diff --git a/src/app/features/mail/components/mail/mail.component.scss b/src/app/features/mail/components/mail/mail.component.scss
index 06db89a..e399c87 100644
--- a/src/app/features/mail/components/mail/mail.component.scss
+++ b/src/app/features/mail/components/mail/mail.component.scss
@@ -11,3 +11,8 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
+:host {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
diff --git a/src/app/features/mail/components/mail/mail.component.spec.ts b/src/app/features/mail/components/mail/mail.component.spec.ts
index b8f4c49..377a3cb 100644
--- a/src/app/features/mail/components/mail/mail.component.spec.ts
+++ b/src/app/features/mail/components/mail/mail.component.spec.ts
@@ -12,17 +12,25 @@
********************************************************************************/
import {async, ComponentFixture, TestBed} from "@angular/core/testing";
-import {SideMenuModule} from "../../../../shared/layout/side-menu";
+import {RouterTestingModule} from "@angular/router/testing";
+import {MockStore, provideMockStore} from "@ngrx/store/testing";
+import {MailModule} from "../../mail.module";
import {MailComponent} from "./mail.component";
describe("MailComponent", () => {
+ let store: MockStore;
let component: MailComponent;
let fixture: ComponentFixture<MailComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
- declarations: [MailComponent],
- imports: [SideMenuModule]
+ imports: [
+ MailModule,
+ RouterTestingModule
+ ],
+ providers: [
+ provideMockStore()
+ ]
}).compileComponents();
}));
@@ -30,6 +38,7 @@
fixture = TestBed.createComponent(MailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
+ store = TestBed.inject(MockStore);
});
it("should create", () => {
diff --git a/src/app/features/mail/components/mail/mail.component.ts b/src/app/features/mail/components/mail/mail.component.ts
index 7f248f6..edd300e 100644
--- a/src/app/features/mail/components/mail/mail.component.ts
+++ b/src/app/features/mail/components/mail/mail.component.ts
@@ -11,13 +11,98 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-import {Component} from "@angular/core";
+import {Component, OnDestroy, OnInit} from "@angular/core";
+import {Router} from "@angular/router";
+import {select, Store} from "@ngrx/store";
+import {Subject} from "rxjs";
+import {concatMap, filter, map, switchMap, take, takeUntil} from "rxjs/operators";
+import {
+ deleteEmailFromInboxAction,
+ downloadEmailAttachmentAction,
+ fetchEmailAction,
+ fetchEmailInboxAction
+} from "../../../../store/mail/actions";
+import {getEmailInboxSelector, getEmailLoadingSelector, getSelectedEmailSelector} from "../../../../store/mail/selectors";
+import {queryParamsMailIdSelector} from "../../../../store/root/selectors";
+import {arrayJoin} from "../../../../util/store";
@Component({
selector: "app-mail",
templateUrl: "./mail.component.html",
styleUrls: ["./mail.component.scss"]
})
-export class MailComponent {
+export class MailComponent implements OnInit, OnDestroy {
+
+ public loading$ = this.store.pipe(select(getEmailLoadingSelector));
+
+ public emailInbox$ = this.store.pipe(select(getEmailInboxSelector));
+
+ public selectedEmailId$ = this.store.pipe(select(queryParamsMailIdSelector));
+
+ public selectedEmail$ = this.store.pipe(select(getSelectedEmailSelector));
+
+ private destroy$ = new Subject();
+
+ public constructor(
+ public store: Store,
+ public readonly router: Router
+ ) {
+
+ }
+
+ public ngOnInit() {
+ this.fetchInbox();
+ this.selectedEmailId$.pipe(
+ concatMap((id) => {
+ return this.emailInbox$.pipe(
+ filter((_) => arrayJoin(_).length <= 0),
+ take(1),
+ map((mails) => {
+ return mails.find((_) => _.identifier === id) == null ? id : null;
+ })
+ );
+ }),
+ takeUntil(this.destroy$)
+ ).subscribe((mailId) => this.fetch(mailId));
+
+ this.selectedEmailId$.pipe(
+ takeUntil(this.destroy$),
+ filter((id) => id == null),
+ switchMap(() => {
+ return this.emailInbox$.pipe(
+ map((mails) => {
+ return mails[0]?.identifier;
+ }),
+ filter((id) => id != null),
+ take(1)
+ );
+ },
+ )
+ ).subscribe((id) => {
+ this.router.navigate(["mail"], {queryParams: {mailId: id}});
+ });
+
+ }
+
+ public ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ public async fetchInbox() {
+ this.store.dispatch(fetchEmailInboxAction());
+ }
+
+ public fetch(mailId: string) {
+ this.store.dispatch(fetchEmailAction({mailId}));
+ }
+
+ public downloadAttachment(mailId: string, name: string) {
+ this.store.dispatch(downloadEmailAttachmentAction({mailId, name}));
+ }
+
+ public remove(mailId: string) {
+ this.store.dispatch(deleteEmailFromInboxAction({mailId, navigateTo: "mail"}));
+ }
}
diff --git a/src/app/features/mail/mail-routing.module.ts b/src/app/features/mail/mail-routing.module.ts
index 4b28b15..c506e27 100644
--- a/src/app/features/mail/mail-routing.module.ts
+++ b/src/app/features/mail/mail-routing.module.ts
@@ -13,6 +13,7 @@
import {NgModule} from "@angular/core";
import {RouterModule, Routes} from "@angular/router";
+import {OfficialInChargeRouteGuardService} from "../../store/root/services";
import {MailComponent} from "./components";
import {MailModule} from "./mail.module";
@@ -20,7 +21,8 @@
{
path: "",
pathMatch: "full",
- component: MailComponent
+ component: MailComponent,
+ canActivate: [OfficialInChargeRouteGuardService]
}
];
diff --git a/src/app/features/mail/mail.module.ts b/src/app/features/mail/mail.module.ts
index b20203d..d03b4bc 100644
--- a/src/app/features/mail/mail.module.ts
+++ b/src/app/features/mail/mail.module.ts
@@ -13,19 +13,45 @@
import {CommonModule} from "@angular/common";
import {NgModule} from "@angular/core";
+import {MatIconModule} from "@angular/material/icon";
+import {RouterModule} from "@angular/router";
+import {TranslateModule} from "@ngx-translate/core";
+import {DateControlModule} from "../../shared/controls/date-control";
+import {ActionButtonModule} from "../../shared/layout/action-button";
+import {CollapsibleModule} from "../../shared/layout/collapsible";
import {SideMenuModule} from "../../shared/layout/side-menu";
-import {MailComponent} from "./components";
+import {SharedPipesModule} from "../../shared/pipes";
+import {ProgressSpinnerModule} from "../../shared/progress-spinner";
+import {MailComponent, MailDetailsComponent, MailInboxComponent} from "./components";
+import {EmailTextToArrayPipe} from "./pipes/email-text.pipe";
+import {ExtractMailAddressPipe} from "./pipes/extract-mail-address.pipe";
+import {SenderSplitNameMailPipe} from "./pipes/sender-split-name-mail.pipe";
@NgModule({
imports: [
CommonModule,
- SideMenuModule
+ SideMenuModule,
+ RouterModule,
+ SharedPipesModule,
+ ActionButtonModule,
+ MatIconModule,
+ ProgressSpinnerModule,
+ DateControlModule,
+ CollapsibleModule,
+ TranslateModule
],
declarations: [
- MailComponent
+ MailComponent,
+ MailInboxComponent,
+ MailDetailsComponent,
+ EmailTextToArrayPipe,
+ SenderSplitNameMailPipe,
+ ExtractMailAddressPipe
],
exports: [
- MailComponent
+ MailComponent,
+ MailInboxComponent,
+ MailDetailsComponent
]
})
export class MailModule {
diff --git a/src/app/features/mail/pipes/email-text.pipe.spec.ts b/src/app/features/mail/pipes/email-text.pipe.spec.ts
new file mode 100644
index 0000000..1ef71e3
--- /dev/null
+++ b/src/app/features/mail/pipes/email-text.pipe.spec.ts
@@ -0,0 +1,38 @@
+/********************************************************************************
+ * 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 {EmailTextToArrayPipe} from "./email-text.pipe";
+
+describe("EmailTextToArrayPipe", () => {
+
+ const pipe = new EmailTextToArrayPipe();
+
+ describe("transform", () => {
+
+ it("should convert the input text to an array of the text divided by the newlines", () => {
+ const inputText = `This is a test text \n\n and this is another row \n last row.`;
+ const result: string[] = pipe.transform(inputText);
+ expect(result.length).toBe(4);
+ expect(result).toEqual(
+ [
+ "This is a test text ",
+ "",
+ " and this is another row ",
+ " last row."
+ ]
+ );
+ });
+ });
+});
+
diff --git a/src/app/features/mail/pipes/email-text.pipe.ts b/src/app/features/mail/pipes/email-text.pipe.ts
new file mode 100644
index 0000000..f96f557
--- /dev/null
+++ b/src/app/features/mail/pipes/email-text.pipe.ts
@@ -0,0 +1,26 @@
+/********************************************************************************
+ * 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";
+
+@Pipe({
+ name: "appEmailTextToArray"
+})
+export class EmailTextToArrayPipe implements PipeTransform {
+
+ public transform(text: string): Array<string> {
+ const textAsArray = text.replace("\r", "").split("\n");
+ return textAsArray.filter((el, index) => el.trim() !== "" || (index > 0 && index < textAsArray.length - 1));
+ }
+
+}
diff --git a/src/app/features/mail/pipes/extract-mail-address.pipe.spec.ts b/src/app/features/mail/pipes/extract-mail-address.pipe.spec.ts
new file mode 100644
index 0000000..cf7e06a
--- /dev/null
+++ b/src/app/features/mail/pipes/extract-mail-address.pipe.spec.ts
@@ -0,0 +1,35 @@
+/********************************************************************************
+ * 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 {ExtractMailAddressPipe} from "./extract-mail-address.pipe";
+
+describe("ExtractMailAddressPipe", () => {
+
+ const pipe = new ExtractMailAddressPipe();
+
+ describe("transform", () => {
+
+ it("should extract the email from between the <> tags", () => {
+ const inputText = "Max Mustermann <max@mustermann.muster>";
+ const result = pipe.transform(inputText);
+ expect(result).toBe("max@mustermann.muster");
+ });
+
+ it("should return the input text unchanged if no <> tags are present", () => {
+ const inputText = "Max Mustermann max@mustermann.muster";
+ const result = pipe.transform(inputText);
+ expect(result).toBe("Max Mustermann max@mustermann.muster");
+ });
+ });
+});
+
diff --git a/src/app/features/mail/pipes/extract-mail-address.pipe.ts b/src/app/features/mail/pipes/extract-mail-address.pipe.ts
new file mode 100644
index 0000000..07031e0
--- /dev/null
+++ b/src/app/features/mail/pipes/extract-mail-address.pipe.ts
@@ -0,0 +1,26 @@
+/********************************************************************************
+ * 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 {arrayJoin} from "../../../util/store";
+
+@Pipe({
+ name: "appExtractMailAddress"
+})
+export class ExtractMailAddressPipe implements PipeTransform {
+
+ public transform(text: string): string {
+ const splitText = text?.split("<");
+ return arrayJoin(splitText)[1] ? arrayJoin(splitText)[1].replace(">", "") : text;
+ }
+}
diff --git a/src/app/features/mail/pipes/sender-split-name-mail.pipe.spec.ts b/src/app/features/mail/pipes/sender-split-name-mail.pipe.spec.ts
new file mode 100644
index 0000000..e40668b
--- /dev/null
+++ b/src/app/features/mail/pipes/sender-split-name-mail.pipe.spec.ts
@@ -0,0 +1,48 @@
+/********************************************************************************
+ * 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 {SenderSplitNameMailPipe} from "./sender-split-name-mail.pipe";
+
+describe("SenderSplitNameMailPipe", () => {
+
+ const pipe = new SenderSplitNameMailPipe();
+
+ describe("transform", () => {
+
+ it("should split the input text at the first occurance of <", () => {
+ const inputText = "Max Mustermann <max@mustermann.muster> <example@example.example>";
+ const result: string[] = pipe.transform(inputText);
+ expect(result).toEqual([
+ "Max Mustermann ",
+ "<max@mustermann.muster> <example@example.example>"
+ ]);
+ });
+
+ it("should return the text unchanged if no < delimter is present", () => {
+ const inputText = "Max Mustermann max@mustermann.muster example@example.example";
+ const result: string[] = pipe.transform(inputText);
+ console.log(result);
+ expect(result).toEqual([
+ "Max Mustermann max@mustermann.muster example@example.example"
+ ]);
+ });
+
+ it("should return empty array for no value supplied", () => {
+ const inputText = null;
+ const result: string[] = pipe.transform(inputText);
+ expect(result).toEqual([]);
+ });
+ });
+});
+
diff --git a/src/app/features/mail/pipes/sender-split-name-mail.pipe.ts b/src/app/features/mail/pipes/sender-split-name-mail.pipe.ts
new file mode 100644
index 0000000..3cfb33d
--- /dev/null
+++ b/src/app/features/mail/pipes/sender-split-name-mail.pipe.ts
@@ -0,0 +1,27 @@
+/********************************************************************************
+ * 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 {arrayJoin} from "../../../util/store";
+
+@Pipe({
+ name: "appSenderSplitNameMail"
+})
+export class SenderSplitNameMailPipe implements PipeTransform {
+
+ public transform(text: string): Array<string> {
+ const textAsArray = arrayJoin(text?.split(/(?=<)/g));
+ return [textAsArray[0], textAsArray.slice(1).join("")].filter(x => x);
+ // return text.split(/(?=<)(.+)/);
+ }
+}
diff --git a/src/app/features/navigation/app-navigation-frame.module.ts b/src/app/features/navigation/app-navigation-frame.module.ts
index ff5691e..f15c058 100644
--- a/src/app/features/navigation/app-navigation-frame.module.ts
+++ b/src/app/features/navigation/app-navigation-frame.module.ts
@@ -17,6 +17,8 @@
import {MatIconModule} from "@angular/material/icon";
import {RouterModule} from "@angular/router";
import {TranslateModule} from "@ngx-translate/core";
+import {ButtonModule} from "primeng/button";
+import {ToastModule} from "primeng/toast";
import {DropDownModule} from "../../shared/layout/drop-down";
import {SideMenuModule} from "../../shared/layout/side-menu";
import {ExitPageComponent, NavDropDownComponent, NavFrameComponent, NavHeaderComponent, NavigationComponent} from "./components";
@@ -30,7 +32,9 @@
DropDownModule,
ScrollingModule,
- SideMenuModule
+ SideMenuModule,
+ ToastModule,
+ ButtonModule
],
declarations: [
ExitPageComponent,
diff --git a/src/app/features/navigation/components/nav-drop-down/nav-drop-down.component.html b/src/app/features/navigation/components/nav-drop-down/nav-drop-down.component.html
index 73f9916..775f318 100644
--- a/src/app/features/navigation/components/nav-drop-down/nav-drop-down.component.html
+++ b/src/app/features/navigation/components/nav-drop-down/nav-drop-down.component.html
@@ -13,8 +13,11 @@
<button #appDropDown="appDropDown"
(click)="appDropDown.toggle()"
+ (appClose)="isOpen = false"
[appDropDown]="dropDownTemplate"
- [class.nav-drop-down-button-opened]="appDropDown.isOpen"
+ (appOpen)="isOpen = true"
+ [appConnectedPositions]="connectedPositions"
+ [class.nav-drop-down-button-opened]="isOpen"
class="nav-drop-down-button nav-drop-down-toggle cursor-pointer user-select-none">
<span class="nav-drop-down-button-label">
diff --git a/src/app/features/navigation/components/nav-drop-down/nav-drop-down.component.scss b/src/app/features/navigation/components/nav-drop-down/nav-drop-down.component.scss
index e4c9ae2..4431daf 100644
--- a/src/app/features/navigation/components/nav-drop-down/nav-drop-down.component.scss
+++ b/src/app/features/navigation/components/nav-drop-down/nav-drop-down.component.scss
@@ -97,6 +97,7 @@
display: flex;
flex-flow: column;
width: 100%;
+ box-sizing: border-box;
background-color: $nav-drop-down-menu-background;
color: $nav-drop-down-menu-contrast;
border: 1px solid $nav-drop-down-menu-toggle-border;
diff --git a/src/app/features/navigation/components/nav-drop-down/nav-drop-down.component.ts b/src/app/features/navigation/components/nav-drop-down/nav-drop-down.component.ts
index edcda0e..ac9158b 100644
--- a/src/app/features/navigation/components/nav-drop-down/nav-drop-down.component.ts
+++ b/src/app/features/navigation/components/nav-drop-down/nav-drop-down.component.ts
@@ -11,6 +11,7 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
+import {ConnectedPosition} from "@angular/cdk/overlay";
import {Component, EventEmitter, Output} from "@angular/core";
@Component({
@@ -20,6 +21,18 @@
})
export class NavDropDownComponent {
+ public isOpen: boolean;
+
+ public connectedPositions: ConnectedPosition[] = [
+ {
+ originX: "center",
+ originY: "bottom",
+ overlayX: "center",
+ overlayY: "top",
+ panelClass: "bottom"
+ }
+ ];
+
@Output()
public appLogOut = new EventEmitter<void>();
diff --git a/src/app/features/navigation/components/nav-frame/nav-frame.component.html b/src/app/features/navigation/components/nav-frame/nav-frame.component.html
index 0c66802..184e6fc 100644
--- a/src/app/features/navigation/components/nav-frame/nav-frame.component.html
+++ b/src/app/features/navigation/components/nav-frame/nav-frame.component.html
@@ -19,7 +19,10 @@
[appUserRoles]="appUserRoles">
</app-nav-header>
-<app-side-menu-container cdk-scrollable class="nav-frame-content">
+<app-side-menu-container
+ [appHideSideMenu]="appExitCode != null"
+ cdk-scrollable
+ class="nav-frame-content">
<div class="nav-frame-content-main">
diff --git a/src/app/features/navigation/components/nav-header/nav-header.component.html b/src/app/features/navigation/components/nav-header/nav-header.component.html
index 819c91d..00c9237 100644
--- a/src/app/features/navigation/components/nav-header/nav-header.component.html
+++ b/src/app/features/navigation/components/nav-header/nav-header.component.html
@@ -39,6 +39,7 @@
[class.openk-primary]="isLinkActive(route.link, route.exact)"
[routerLink]="route.link"
[target]="route.target"
+ [title]="route.tooltip?.length > 0 ? (route.tooltip | translate) : ''"
class="openk-button openk-button-rounded nav-header-menu-anchor">
<mat-icon>{{route.icon}}</mat-icon>
</a>
diff --git a/src/app/features/navigation/components/nav-header/nav-header.component.spec.ts b/src/app/features/navigation/components/nav-header/nav-header.component.spec.ts
index 1dcc190..55555b3 100644
--- a/src/app/features/navigation/components/nav-header/nav-header.component.spec.ts
+++ b/src/app/features/navigation/components/nav-header/nav-header.component.spec.ts
@@ -13,7 +13,7 @@
import {async, ComponentFixture, TestBed} from "@angular/core/testing";
import {RouterTestingModule} from "@angular/router/testing";
-import {EAPIUserRoles} from "../../../../core";
+import {EAPIUserRoles, I18nModule} from "../../../../core";
import {AppNavigationFrameModule} from "../../app-navigation-frame.module";
import {NavHeaderComponent} from "./nav-header.component";
@@ -23,9 +23,12 @@
beforeEach(async(() => {
TestBed.configureTestingModule({
- imports: [RouterTestingModule, AppNavigationFrameModule]
- })
- .compileComponents();
+ imports: [
+ RouterTestingModule,
+ AppNavigationFrameModule,
+ I18nModule
+ ]
+ }).compileComponents();
}));
beforeEach(() => {
diff --git a/src/app/features/navigation/components/nav-header/nav-header.component.ts b/src/app/features/navigation/components/nav-header/nav-header.component.ts
index 7b90b36..b6c7282 100644
--- a/src/app/features/navigation/components/nav-header/nav-header.component.ts
+++ b/src/app/features/navigation/components/nav-header/nav-header.component.ts
@@ -18,6 +18,7 @@
export interface INavHeaderRoute {
icon: string;
link: string;
+ tooltip?: string;
exact?: boolean;
roles?: EAPIUserRoles[];
target?: string;
@@ -51,30 +52,36 @@
{
icon: "home",
link: "/",
+ tooltip: "core.header.home",
exact: true
},
{
icon: "find_in_page",
- link: "/search"
+ link: "/search",
+ tooltip: "core.header.search"
},
{
icon: "email",
link: "/mail",
+ tooltip: "core.header.mail",
roles: [EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE]
},
{
icon: "note_add",
link: "/new",
+ tooltip: "core.header.new",
roles: [EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE]
},
{
icon: "settings",
link: "/settings",
- roles: [EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE]
+ tooltip: "core.header.settings",
+ roles: [EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE, EAPIUserRoles.SPA_ADMIN]
},
{
icon: "help_outline",
link: "/help",
+ tooltip: "core.header.help",
target: "_blank",
largeIcon: true
}
diff --git a/src/app/features/navigation/components/navigation/navigation.component.html b/src/app/features/navigation/components/navigation/navigation.component.html
index abe945e..80e1edd 100644
--- a/src/app/features/navigation/components/navigation/navigation.component.html
+++ b/src/app/features/navigation/components/navigation/navigation.component.html
@@ -11,6 +11,8 @@
* SPDX-License-Identifier: EPL-2.0
-------------------------------------------------------------------------------->
+<p-toast *ngIf="(exitCode$ | async) == null" [preventOpenDuplicates]="true"></p-toast>
+
<app-nav-frame
(appCloseWindow)="closeWindow()"
(appLogout)="logOut()"
diff --git a/src/app/features/navigation/components/navigation/navigation.component.spec.ts b/src/app/features/navigation/components/navigation/navigation.component.spec.ts
index f99e0e7..cec3bf1 100644
--- a/src/app/features/navigation/components/navigation/navigation.component.spec.ts
+++ b/src/app/features/navigation/components/navigation/navigation.component.spec.ts
@@ -15,6 +15,8 @@
import {RouterTestingModule} from "@angular/router/testing";
import {StoreModule} from "@ngrx/store";
import {MockStore, provideMockStore} from "@ngrx/store/testing";
+import {MessageService} from "primeng/api";
+import {ToastModule} from "primeng/toast";
import {I18nModule, WINDOW} from "../../../../core";
import {logOutAction} from "../../../../store";
import {AppNavigationFrameModule} from "../../app-navigation-frame.module";
@@ -22,7 +24,9 @@
class WindowMock {
public opener: any;
- public close() {}
+
+ public close() {
+ }
}
describe("NavigationComponent", () => {
@@ -38,12 +42,14 @@
I18nModule,
RouterTestingModule,
StoreModule,
+ ToastModule
],
providers:
[
provideMockStore({initialState}),
- {provide: WINDOW, useClass: WindowMock}
- ]
+ {provide: WINDOW, useClass: WindowMock},
+ MessageService
+ ],
}).compileComponents();
});
beforeEach(() => {
diff --git a/src/app/features/new/new-statement-routing.module.ts b/src/app/features/new/new-statement-routing.module.ts
index 96b36bb..cb77fab 100644
--- a/src/app/features/new/new-statement-routing.module.ts
+++ b/src/app/features/new/new-statement-routing.module.ts
@@ -13,16 +13,16 @@
import {NgModule} from "@angular/core";
import {RouterModule, Routes} from "@angular/router";
+import {OfficialInChargeRouteGuardService} from "../../store/root/services";
import {NewStatementComponent} from "./components";
import {NewStatementModule} from "./new-statement.module";
-import {NewStatementRouteGuardService} from "./services/new-statement-route-guard.service";
const routes: Routes = [
{
path: "",
pathMatch: "full",
component: NewStatementComponent,
- canActivate: [NewStatementRouteGuardService]
+ canActivate: [OfficialInChargeRouteGuardService]
}
];
diff --git a/src/app/features/new/services/new-statement-route-guard.service.spec.ts b/src/app/features/new/services/new-statement-route-guard.service.spec.ts
deleted file mode 100644
index 2556c0d..0000000
--- a/src/app/features/new/services/new-statement-route-guard.service.spec.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-/********************************************************************************
- * 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 {Location} from "@angular/common";
-import {NgZone} from "@angular/core";
-import {async, TestBed} from "@angular/core/testing";
-import {Router} from "@angular/router";
-import {RouterTestingModule} from "@angular/router/testing";
-import {MemoizedSelector} from "@ngrx/store";
-import {MockStore, provideMockStore} from "@ngrx/store/testing";
-import {appRoutes} from "../../../app-routing.module";
-import {EAPIUserRoles} from "../../../core";
-import {isLoadingSelector, userRolesSelector} from "../../../store";
-
-describe("NewStatementRouteGuard", () => {
- let router: Router;
- let location: Location;
- let userRolesSelectorMock: MemoizedSelector<any, string[]>;
-
- function callInZone<T>(fn: () => T | Promise<T>): Promise<T> {
- const ngZone = TestBed.inject(NgZone);
- return new Promise<T>((res, rej) => {
- ngZone.run(() => Promise.resolve().then(() => fn()).then(res).catch(rej));
- });
- }
-
- beforeEach(async(() => {
- TestBed.configureTestingModule({
- imports: [
- RouterTestingModule.withRoutes(appRoutes),
- ],
- providers: [
- provideMockStore()
- ]
- }).compileComponents();
- router = TestBed.inject(Router);
- location = TestBed.inject(Location);
- const mockStore = TestBed.inject(MockStore);
- userRolesSelectorMock = mockStore.overrideSelector(userRolesSelector, []);
- const isLoadingSelectorMock = mockStore.overrideSelector(isLoadingSelector, true);
- isLoadingSelectorMock.setResult(false);
- }));
-
- it("should allow access to /new if user is official in charge ", async () => {
- userRolesSelectorMock.setResult([EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE]);
- const isRoutingSuccessful = await callInZone(() => router.navigate(["new"]));
- expect(isRoutingSuccessful).toBeTruthy();
- expect(location.path()).toBe("/new");
- });
-
- it("should prevent access to /new if user is no official in charge ", async () => {
- const isRoutingSuccessful = await callInZone(() => router.navigate(["new"]));
- expect(isRoutingSuccessful).toBeTruthy();
- expect(location.path()).toBe("/");
- });
-});
diff --git a/src/app/features/new/services/new-statement-route-guard.service.ts b/src/app/features/new/services/new-statement-route-guard.service.ts
deleted file mode 100644
index 48be3ea..0000000
--- a/src/app/features/new/services/new-statement-route-guard.service.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-/********************************************************************************
- * 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 {Injectable} from "@angular/core";
-import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from "@angular/router";
-import {select, Store} from "@ngrx/store";
-import {Observable} from "rxjs";
-import {filter, map, switchMap} from "rxjs/operators";
-import {isLoadingSelector, isOfficialInChargeSelector} from "../../../store";
-
-@Injectable({providedIn: "root"})
-export class NewStatementRouteGuardService implements CanActivate {
-
- public readonly isInitialized$ = this.store.pipe(
- select(isLoadingSelector),
- filter((isLoading) => !isLoading)
- );
-
- public readonly isAllowed$ = this.store.pipe(
- select(isOfficialInChargeSelector)
- );
-
- private readonly redirectUrlTree = this.router.createUrlTree(["/"]);
-
- public constructor(private readonly store: Store, private readonly router: Router) {
- }
-
- public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
- return this.isInitialized$.pipe(
- switchMap(() => this.isAllowed$),
- map((isAllowed) => isAllowed ? isAllowed : this.redirectUrlTree)
- );
- }
-
-}
diff --git a/src/app/features/settings/settings-routing.module.ts b/src/app/features/settings/settings-routing.module.ts
index ad6e152..62871ef 100644
--- a/src/app/features/settings/settings-routing.module.ts
+++ b/src/app/features/settings/settings-routing.module.ts
@@ -13,6 +13,7 @@
import {NgModule} from "@angular/core";
import {RouterModule, Routes} from "@angular/router";
+import {OfficialInChargeOrAdminRouteGuardService} from "../../store/root/services";
import {SettingsComponent} from "./components";
import {SettingsModule} from "./settings.module";
@@ -20,7 +21,8 @@
{
path: "",
pathMatch: "full",
- component: SettingsComponent
+ component: SettingsComponent,
+ canActivate: [OfficialInChargeOrAdminRouteGuardService]
}
];
diff --git a/src/app/shared/controls/contact-select/contact-select.component.scss b/src/app/shared/controls/contact-select/contact-select.component.scss
index c44d826..a28ef31 100644
--- a/src/app/shared/controls/contact-select/contact-select.component.scss
+++ b/src/app/shared/controls/contact-select/contact-select.component.scss
@@ -78,7 +78,7 @@
}
.contacts--address--message {
- color: get-color($openk-danger-palette, A200);
+ color: $openk-error-color;
display: block;
width: 100%;
text-align: center;
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/shared/controls/map-select/components/index.ts
similarity index 92%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/shared/controls/map-select/components/index.ts
index a3980e1..514e892 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/shared/controls/map-select/components/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./map-select.component";
diff --git a/src/app/shared/controls/map-select/components/map-select.component.html b/src/app/shared/controls/map-select/components/map-select.component.html
new file mode 100644
index 0000000..0b9644a
--- /dev/null
+++ b/src/app/shared/controls/map-select/components/map-select.component.html
@@ -0,0 +1,39 @@
+<!-------------------------------------------------------------------------------
+ * 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
+ -------------------------------------------------------------------------------->
+
+<div class="map">
+ <div #appLeaflet="appLeaflet"
+ (appLatLngZoomChange)="select($event)"
+ [appDisabled]="appDisabled"
+ [appView]="appValue | stringToLatLngZoom"
+ appLeaflet
+ class="map--leaflet">
+
+ <ng-container appLeafletCenterMarker>
+ </ng-container>
+
+ </div>
+
+ <app-action-button
+ (appClick)="appActionButtonClick.emit(appLeaflet.getBounds())"
+ *ngIf="appActionButtonLabel"
+ [appDisabled]="appDisabled"
+ [appIcon]="'my_location'"
+ class="map--action-button openk-info">
+ {{appActionButtonLabel}}
+ </app-action-button>
+</div>
+
+<label *ngIf="appSubCaption" class="sub-caption">
+ {{appSubCaption}}
+</label>
diff --git a/src/app/shared/controls/map-select/components/map-select.component.scss b/src/app/shared/controls/map-select/components/map-select.component.scss
new file mode 100644
index 0000000..d45e610
--- /dev/null
+++ b/src/app/shared/controls/map-select/components/map-select.component.scss
@@ -0,0 +1,55 @@
+/********************************************************************************
+ * 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 "openk.styles";
+
+:host {
+ width: 100%;
+ height: 100%;
+ box-sizing: border-box;
+
+ display: flex;
+ flex-flow: column;
+}
+
+.map {
+ flex: 1 1 100%;
+ width: 100%;
+ box-sizing: border-box;
+ border: 1px solid $openk-form-border;
+ position: relative;
+ overflow: hidden;
+}
+
+.map--leaflet {
+ width: 100%;
+ height: 100%;
+}
+
+.map--action-button {
+ display: block;
+ width: fit-content;
+ height: fit-content;
+ position: absolute;
+ bottom: 10px;
+ left: 10px;
+ z-index: 1000;
+}
+
+.sub-caption {
+ color: $openk-form-border;
+ margin-top: 0.5em;
+ margin-left: auto;
+ font-size: smaller;
+ font-style: italic;
+}
diff --git a/src/app/shared/controls/map-select/components/map-select.component.spec.ts b/src/app/shared/controls/map-select/components/map-select.component.spec.ts
new file mode 100644
index 0000000..f57df21
--- /dev/null
+++ b/src/app/shared/controls/map-select/components/map-select.component.spec.ts
@@ -0,0 +1,47 @@
+/********************************************************************************
+ * 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 {async, ComponentFixture, TestBed} from "@angular/core/testing";
+import {MapSelectModule} from "../map-select.module";
+import {MapSelectComponent} from "./map-select.component";
+
+describe("MapSelectComponent", () => {
+ let component: MapSelectComponent;
+ let fixture: ComponentFixture<MapSelectComponent>;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [MapSelectModule]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MapSelectComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("should select a new value", () => {
+ const value = "52.520008,13.404954,11";
+ const valueEmitSpy = spyOn(component.appValueChange, "emit");
+ const onChangeSpy = spyOn(component, "onChange");
+ const onTouchSpy = spyOn(component, "onTouch");
+
+ component.select(value);
+
+ expect(valueEmitSpy).toHaveBeenCalledWith(value);
+ expect(onChangeSpy).toHaveBeenCalledWith(value);
+ expect(onTouchSpy).toHaveBeenCalledWith();
+ });
+
+});
diff --git a/src/app/shared/controls/map-select/components/map-select.component.ts b/src/app/shared/controls/map-select/components/map-select.component.ts
new file mode 100644
index 0000000..f408006
--- /dev/null
+++ b/src/app/shared/controls/map-select/components/map-select.component.ts
@@ -0,0 +1,53 @@
+/********************************************************************************
+ * 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 {Component, EventEmitter, forwardRef, Input, Output} from "@angular/core";
+import {NG_VALUE_ACCESSOR} from "@angular/forms";
+import {ILeafletBounds} from "../../../layout/leaflet";
+import {AbstractControlValueAccessorComponent} from "../../common";
+
+@Component({
+ selector: "app-map-select",
+ templateUrl: "./map-select.component.html",
+ styleUrls: ["./map-select.component.scss"],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => MapSelectComponent),
+ multi: true
+ }
+ ]
+})
+export class MapSelectComponent extends AbstractControlValueAccessorComponent<string> {
+
+ @Input()
+ public appCenter: string;
+
+ @Input()
+ public appSubCaption: string;
+
+ @Input()
+ public appActionButtonLabel: string;
+
+ @Output()
+ public appActionButtonClick = new EventEmitter<ILeafletBounds>();
+
+ public select(value: string) {
+ // Note that this.appValue should not be changed here:
+ // Changing it here can create an infinite loop in which the map position changes without any user input.
+ this.onChange(value);
+ this.onTouch();
+ this.appValueChange.emit(value);
+ }
+
+}
diff --git a/src/app/shared/controls/map-select/map-select.module.ts b/src/app/shared/controls/map-select/map-select.module.ts
new file mode 100644
index 0000000..8a22296
--- /dev/null
+++ b/src/app/shared/controls/map-select/map-select.module.ts
@@ -0,0 +1,35 @@
+/********************************************************************************
+ * 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 {CommonModule} from "@angular/common";
+import {NgModule} from "@angular/core";
+import {ActionButtonModule} from "../../layout/action-button";
+import {LeafletModule} from "../../layout/leaflet";
+import {MapSelectComponent} from "./components";
+
+@NgModule({
+ imports: [
+ CommonModule,
+ LeafletModule,
+ ActionButtonModule
+ ],
+ declarations: [
+ MapSelectComponent
+ ],
+ exports: [
+ MapSelectComponent
+ ]
+})
+export class MapSelectModule {
+
+}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/shared/controls/statement-select/components/index.ts
similarity index 92%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/shared/controls/statement-select/components/index.ts
index a3980e1..a3433e9 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/shared/controls/statement-select/components/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./statement-select.component";
diff --git a/src/app/shared/controls/statement-select/statement-select.component.html b/src/app/shared/controls/statement-select/components/statement-select.component.html
similarity index 79%
rename from src/app/shared/controls/statement-select/statement-select.component.html
rename to src/app/shared/controls/statement-select/components/statement-select.component.html
index bfc979f..f824a8d 100644
--- a/src/app/shared/controls/statement-select/statement-select.component.html
+++ b/src/app/shared/controls/statement-select/components/statement-select.component.html
@@ -19,11 +19,13 @@
</app-searchbar>
<app-statement-table
- (appToggleSelect)="toggle($event.id, $event.value)"
*ngIf="appSearchContent?.length > 0"
- [appEntries]="getEntries()"
+ (appSelect)="toggle($event.id, $event.value)"
+ [appColumns]="columns"
+ [appEntries]="appSearchContent | getStatementEntriesForSelect: appValue"
+ [appOpenStatementInNewTab]="true"
[appStatementTypeOptions]="appStatementTypeOptions"
- class="statements-table">
+ class="statements-table openk-table---last-row-without-border">
</app-statement-table>
<button (click)="writeValue([], true)" class="openk-button" type="button">
diff --git a/src/app/shared/controls/statement-select/statement-select.component.scss b/src/app/shared/controls/statement-select/components/statement-select.component.scss
similarity index 97%
rename from src/app/shared/controls/statement-select/statement-select.component.scss
rename to src/app/shared/controls/statement-select/components/statement-select.component.scss
index ed13cd6..998f296 100644
--- a/src/app/shared/controls/statement-select/statement-select.component.scss
+++ b/src/app/shared/controls/statement-select/components/statement-select.component.scss
@@ -21,7 +21,6 @@
}
.statements-table {
- height: 100%;
max-height: 25em;
margin-bottom: 0.75em;
}
diff --git a/src/app/shared/controls/statement-select/components/statement-select.component.spec.ts b/src/app/shared/controls/statement-select/components/statement-select.component.spec.ts
new file mode 100644
index 0000000..33b99da
--- /dev/null
+++ b/src/app/shared/controls/statement-select/components/statement-select.component.spec.ts
@@ -0,0 +1,54 @@
+/********************************************************************************
+ * 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 {ComponentFixture, TestBed} from "@angular/core/testing";
+import {RouterTestingModule} from "@angular/router/testing";
+import {I18nModule} from "../../../../core";
+import {StatementSelectModule} from "../statement-select.module";
+import {StatementSelectComponent} from "./statement-select.component";
+
+describe("StatementSelectComponent", () => {
+ let component: StatementSelectComponent;
+ let fixture: ComponentFixture<StatementSelectComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ StatementSelectModule,
+ I18nModule,
+ RouterTestingModule
+ ]
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(StatementSelectComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("should toggle statements", () => {
+ component.appValue = [19];
+
+ component.toggle(1919, true);
+ expect(component.appValue).toEqual([19, 1919]);
+
+ component.toggle(1919, false);
+ expect(component.appValue).toEqual([19]);
+ });
+
+});
diff --git a/src/app/shared/controls/statement-select/statement-select.component.stories.ts b/src/app/shared/controls/statement-select/components/statement-select.component.stories.ts
similarity index 87%
rename from src/app/shared/controls/statement-select/statement-select.component.stories.ts
rename to src/app/shared/controls/statement-select/components/statement-select.component.stories.ts
index ac279d0..0f678af 100644
--- a/src/app/shared/controls/statement-select/statement-select.component.stories.ts
+++ b/src/app/shared/controls/statement-select/components/statement-select.component.stories.ts
@@ -15,10 +15,9 @@
import {action} from "@storybook/addon-actions";
import {withKnobs} from "@storybook/addon-knobs";
import {moduleMetadata, storiesOf} from "@storybook/angular";
-import {I18nModule} from "../../../core/i18n";
-import {createStatementModelMock} from "../../../test";
-import {createSelectOptionsMock} from "../../../test/create-select-options.spec";
-import {StatementSelectModule} from "./statement-select.module";
+import {I18nModule} from "../../../../core/i18n";
+import {createSelectOptionsMock, createStatementModelMock} from "../../../../test";
+import {StatementSelectModule} from "../statement-select.module";
storiesOf("Shared / Controls", module)
.addDecorator(withKnobs)
diff --git a/src/app/shared/controls/statement-select/statement-select.component.ts b/src/app/shared/controls/statement-select/components/statement-select.component.ts
similarity index 60%
rename from src/app/shared/controls/statement-select/statement-select.component.ts
rename to src/app/shared/controls/statement-select/components/statement-select.component.ts
index a076d62..2f9c82b 100644
--- a/src/app/shared/controls/statement-select/statement-select.component.ts
+++ b/src/app/shared/controls/statement-select/components/statement-select.component.ts
@@ -11,19 +11,18 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, forwardRef, Input, Output} from "@angular/core";
+import {Component, EventEmitter, forwardRef, Input, Output} from "@angular/core";
import {NG_VALUE_ACCESSOR} from "@angular/forms";
-import {IAPISearchOptions, IAPIStatementModel} from "../../../core";
-import {arrayJoin, filterDistinctValues} from "../../../util/store";
-import {IStatementTableEntry} from "../../layout/statement-table";
-import {AbstractControlValueAccessorComponent} from "../common";
-import {ISelectOption} from "../select/model";
+import {IAPISearchOptions, IAPIStatementModel} from "../../../../core";
+import {arrayJoin, filterDistinctValues} from "../../../../util/store";
+import {StatementTableComponent} from "../../../layout/statement-table";
+import {AbstractControlValueAccessorComponent} from "../../common";
+import {ISelectOption} from "../../select";
@Component({
selector: "app-statement-select",
templateUrl: "statement-select.component.html",
styleUrls: ["statement-select.component.scss"],
- changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
@@ -49,20 +48,7 @@
@Output()
public appSearch: EventEmitter<IAPISearchOptions> = new EventEmitter();
- public constructor(private readonly changeDetectorRef: ChangeDetectorRef) {
- super();
- }
-
- public getEntries(onlySelected?: boolean): IStatementTableEntry[] {
- const entries = arrayJoin(this.appSearchContent);
- const value = arrayJoin(this.appValue);
- return entries.map((statement) => {
- return {
- ...statement,
- isSelected: value.indexOf(statement.id) !== -1
- };
- }).filter((entry) => !onlySelected || entry.isSelected);
- }
+ public columns = [...StatementTableComponent.STATEMENT_SELECT_COLUMNS];
public toggle(id: number, isSelected: boolean) {
if (isSelected) {
@@ -76,9 +62,4 @@
}
}
- public writeValue(obj: number[], emit?: boolean) {
- super.writeValue(obj, emit);
- this.changeDetectorRef.markForCheck();
- }
-
}
diff --git a/src/app/shared/controls/statement-select/index.ts b/src/app/shared/controls/statement-select/index.ts
index 1d0a651..14fbed9 100644
--- a/src/app/shared/controls/statement-select/index.ts
+++ b/src/app/shared/controls/statement-select/index.ts
@@ -11,4 +11,6 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
+export * from "./components";
+export * from "./pipes";
export * from "./statement-select.module";
diff --git a/src/app/shared/controls/statement-select/pipes/get-statement-entries-for-select.pipe.spec.ts b/src/app/shared/controls/statement-select/pipes/get-statement-entries-for-select.pipe.spec.ts
new file mode 100644
index 0000000..3e9bb0f
--- /dev/null
+++ b/src/app/shared/controls/statement-select/pipes/get-statement-entries-for-select.pipe.spec.ts
@@ -0,0 +1,30 @@
+/********************************************************************************
+ * 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 {createStatementModelMock} from "../../../../test";
+import {IStatementTableEntry} from "../../../layout/statement-table/model";
+import {GetStatementEntriesForSelectPipe} from "./get-statement-entries-for-select.pipe";
+
+describe("GetStatementEntriesForSelectPipe", () => {
+
+ const pipe = new GetStatementEntriesForSelectPipe();
+
+ it("should transform statement models to table entries", () => {
+ const statements = Array(100).fill(0).map((_, id) => createStatementModelMock(id));
+ const value = [19];
+ const results: IStatementTableEntry[] = [...statements]
+ .map((_, id) => ({..._, isSelected: id === 19}));
+ expect(pipe.transform(statements, value)).toEqual(results);
+ });
+
+});
diff --git a/src/app/shared/controls/statement-select/pipes/get-statement-entries-for-select.pipe.ts b/src/app/shared/controls/statement-select/pipes/get-statement-entries-for-select.pipe.ts
new file mode 100644
index 0000000..fe550a8
--- /dev/null
+++ b/src/app/shared/controls/statement-select/pipes/get-statement-entries-for-select.pipe.ts
@@ -0,0 +1,32 @@
+/********************************************************************************
+ * 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 {IAPIStatementModel} from "../../../../core";
+import {arrayJoin} from "../../../../util/store";
+import {IStatementTableEntry} from "../../../layout/statement-table";
+
+@Pipe({name: "getStatementEntriesForSelect"})
+export class GetStatementEntriesForSelectPipe implements PipeTransform {
+
+ public transform(statements: IAPIStatementModel[], value: number[]): IStatementTableEntry[] {
+ value = arrayJoin(value);
+ return arrayJoin(statements).map((statement) => {
+ return {
+ ...statement,
+ isSelected: value.indexOf(statement.id) !== -1
+ };
+ });
+ }
+
+}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/shared/controls/statement-select/pipes/index.ts
similarity index 90%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/shared/controls/statement-select/pipes/index.ts
index a3980e1..a68fc53 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/shared/controls/statement-select/pipes/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./get-statement-entries-for-select.pipe";
diff --git a/src/app/shared/controls/statement-select/statement-select.module.ts b/src/app/shared/controls/statement-select/statement-select.module.ts
index 5f250e1..ab7a3c9 100644
--- a/src/app/shared/controls/statement-select/statement-select.module.ts
+++ b/src/app/shared/controls/statement-select/statement-select.module.ts
@@ -16,7 +16,8 @@
import {TranslateModule} from "@ngx-translate/core";
import {SearchbarModule} from "../../layout/searchbar";
import {StatementTableModule} from "../../layout/statement-table";
-import {StatementSelectComponent} from "./statement-select.component";
+import {StatementSelectComponent} from "./components";
+import {GetStatementEntriesForSelectPipe} from "./pipes";
@NgModule({
imports: [
@@ -27,10 +28,12 @@
SearchbarModule
],
declarations: [
- StatementSelectComponent
+ StatementSelectComponent,
+ GetStatementEntriesForSelectPipe
],
exports: [
- StatementSelectComponent
+ StatementSelectComponent,
+ GetStatementEntriesForSelectPipe
]
})
export class StatementSelectModule {
diff --git a/src/app/shared/layout/action-button/components/action-button.component.html b/src/app/shared/layout/action-button/components/action-button.component.html
index 603230e..dbdcab5 100644
--- a/src/app/shared/layout/action-button/components/action-button.component.html
+++ b/src/app/shared/layout/action-button/components/action-button.component.html
@@ -26,7 +26,7 @@
<ng-template #anchorRef>
<a (click)="appClick.emit($event)"
- [queryParams]="appStatementId != null ? { id: appStatementId } : undefined"
+ [queryParams]="appStatementId != null || appMailId != null ? { id: appStatementId, mailId: appMailId } : undefined"
[routerLink]="appRouterLink"
[type]="appType"
class="action-button openk-button">
diff --git a/src/app/shared/layout/action-button/components/action-button.component.ts b/src/app/shared/layout/action-button/components/action-button.component.ts
index a01c386..d047af7 100644
--- a/src/app/shared/layout/action-button/components/action-button.component.ts
+++ b/src/app/shared/layout/action-button/components/action-button.component.ts
@@ -35,6 +35,9 @@
@Input()
public appStatementId: number;
+ @Input()
+ public appMailId: string;
+
@Output()
public appClick = new EventEmitter<MouseEvent>();
diff --git a/src/app/shared/layout/collapsible/collapsible.component.html b/src/app/shared/layout/collapsible/collapsible.component.html
index 6c5152f..6f6e9de 100644
--- a/src/app/shared/layout/collapsible/collapsible.component.html
+++ b/src/app/shared/layout/collapsible/collapsible.component.html
@@ -15,6 +15,7 @@
#header [class.collapsible-header---no-color]="appSimpleCollapsible" class="collapsible-header">
<button (click)="toggle()"
+ *ngIf="!appHideButton"
[class.not-allowed]="appDisabled"
type="button"
[class.collapsible-header--toggle---small]="appSimpleCollapsible"
@@ -30,6 +31,12 @@
</span>
</button>
+ <div *ngIf="appHideButton" class="collapsible-header--toggle">
+ <span *ngIf="appTitle" class="collapsible-header--title" style="margin-left: 0.25em;">
+ {{appTitle}}
+ </span>
+ </div>
+
<ng-container *ngTemplateOutlet="appHeaderTemplateRef"></ng-container>
</div>
diff --git a/src/app/shared/layout/collapsible/collapsible.component.ts b/src/app/shared/layout/collapsible/collapsible.component.ts
index 74d7a8c..6e3e19d 100644
--- a/src/app/shared/layout/collapsible/collapsible.component.ts
+++ b/src/app/shared/layout/collapsible/collapsible.component.ts
@@ -63,6 +63,9 @@
@Input()
public appDisabled: boolean;
+ @Input()
+ public appHideButton: boolean;
+
@Output()
public readonly appCollapsedChange = new EventEmitter<boolean>();
diff --git a/src/app/shared/layout/contact-table/contact-table.component.scss b/src/app/shared/layout/contact-table/contact-table.component.scss
index 76c78e9..83bf638 100644
--- a/src/app/shared/layout/contact-table/contact-table.component.scss
+++ b/src/app/shared/layout/contact-table/contact-table.component.scss
@@ -66,10 +66,6 @@
width: 24px;
}
-.contact-list--table--cell--icon---red {
- color: get-color($openk-danger-palette, 300);
-}
-
.mat-header-cell:first-of-type {
padding-left: 0.6em;
}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/shared/layout/leaflet/directives/center-marker/index.ts
similarity index 91%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/shared/layout/leaflet/directives/center-marker/index.ts
index a3980e1..a8d18a6 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/shared/layout/leaflet/directives/center-marker/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./leaflet-center-marker.directive";
diff --git a/src/app/shared/layout/leaflet/directives/center-marker/leaflet-center-marker.directive.spec.ts b/src/app/shared/layout/leaflet/directives/center-marker/leaflet-center-marker.directive.spec.ts
new file mode 100644
index 0000000..1da4d1b
--- /dev/null
+++ b/src/app/shared/layout/leaflet/directives/center-marker/leaflet-center-marker.directive.spec.ts
@@ -0,0 +1,73 @@
+/********************************************************************************
+ * 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 {Component, ViewChild} from "@angular/core";
+import {async, ComponentFixture, TestBed} from "@angular/core/testing";
+import {LatLng, LatLngLiteral} from "leaflet";
+import {Subject} from "rxjs";
+import {LeafletModule} from "../../leaflet.module";
+import {LeafletCenterMarkerDirective} from "./leaflet-center-marker.directive";
+
+describe("LeafletCenterMarkerDirective", () => {
+
+ const latLng: LatLngLiteral = {
+ lat: 52.520008,
+ lng: 13.404954
+ };
+
+ let component: LeafletCenterMarkerSpecComponent;
+ let fixture: ComponentFixture<LeafletCenterMarkerSpecComponent>;
+ let directive: LeafletCenterMarkerDirective;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [LeafletModule],
+ declarations: [LeafletCenterMarkerSpecComponent]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(LeafletCenterMarkerSpecComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ directive = component.directive;
+ });
+
+ it("should update marker coordinates on move events", () => {
+ const center = new LatLng(latLng.lat, latLng.lng);
+ const move$ = new Subject<any>();
+ const setLatLngSpy = spyOn(directive.marker, "setLatLng").and.callThrough();
+ spyOn(directive.leafletDirective.leaflet, "getCenter").and.returnValue(center);
+ spyOn(directive, "on").and.returnValue(move$.asObservable());
+ directive.ngOnInit();
+ move$.next();
+ expect(setLatLngSpy).toHaveBeenCalledWith(center);
+ });
+
+});
+
+@Component({
+ selector: "app-leaflet-center-marker-spec",
+ template: `
+ <div appLeaflet>
+ <ng-container appLeafletCenterMarker>
+ </ng-container>
+ </div>
+ `
+})
+class LeafletCenterMarkerSpecComponent {
+
+ @ViewChild(LeafletCenterMarkerDirective, {static: true})
+ public directive: LeafletCenterMarkerDirective;
+
+}
diff --git a/src/app/shared/layout/leaflet/directives/center-marker/leaflet-center-marker.directive.ts b/src/app/shared/layout/leaflet/directives/center-marker/leaflet-center-marker.directive.ts
new file mode 100644
index 0000000..9fa22aa
--- /dev/null
+++ b/src/app/shared/layout/leaflet/directives/center-marker/leaflet-center-marker.directive.ts
@@ -0,0 +1,50 @@
+/********************************************************************************
+ * 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 {Directive, Inject, NgZone, OnDestroy, OnInit} from "@angular/core";
+import {merge, of, Subject} from "rxjs";
+import {takeUntil} from "rxjs/operators";
+import {runOutsideZone} from "../../../../../util/rxjs";
+import {ILeafletConfiguration, LEAFLET_CONFIGURATION_TOKEN} from "../../leaflet-configuration.token";
+import {LeafletDirective} from "../leaflet";
+import {AbstractLeafletMarkerDirective} from "../marker/abstract-leaflet-marker.directive";
+
+@Directive({
+ selector: "[appLeafletCenterMarker]",
+ exportAs: "appLeafletCenterMarker"
+})
+export class LeafletCenterMarkerDirective extends AbstractLeafletMarkerDirective implements OnInit, OnDestroy {
+
+ protected destroy$ = new Subject();
+
+ public constructor(
+ ngZone: NgZone,
+ leafletDirective: LeafletDirective,
+ @Inject(LEAFLET_CONFIGURATION_TOKEN) configuration: ILeafletConfiguration,
+ ) {
+ super(ngZone, leafletDirective, configuration);
+ }
+
+ public ngOnInit() {
+ merge(of(null), this.leafletDirective.on("move", true))
+ .pipe(takeUntil(this.destroy$), runOutsideZone(this.ngZone))
+ .subscribe(() => this.setLatLng(this.leafletDirective.leaflet.getCenter()));
+ }
+
+ public ngOnDestroy() {
+ super.ngOnDestroy();
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/shared/layout/leaflet/directives/index.ts
similarity index 86%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/shared/layout/leaflet/directives/index.ts
index a3980e1..34d7752 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/shared/layout/leaflet/directives/index.ts
@@ -11,4 +11,6 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./center-marker";
+export * from "./leaflet";
+export * from "./marker";
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/shared/layout/leaflet/directives/leaflet/index.ts
similarity index 92%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/shared/layout/leaflet/directives/leaflet/index.ts
index a3980e1..6ecc261 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/shared/layout/leaflet/directives/leaflet/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./leaflet.directive";
diff --git a/src/app/shared/layout/leaflet/directives/leaflet/leaflet.directive.spec.ts b/src/app/shared/layout/leaflet/directives/leaflet/leaflet.directive.spec.ts
new file mode 100644
index 0000000..3e85b17
--- /dev/null
+++ b/src/app/shared/layout/leaflet/directives/leaflet/leaflet.directive.spec.ts
@@ -0,0 +1,117 @@
+/********************************************************************************
+ * 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 {Component, ViewChild} from "@angular/core";
+import {async, ComponentFixture, fakeAsync, TestBed, tick} from "@angular/core/testing";
+import {LatLngLiteral} from "leaflet";
+import {Subject, Subscription} from "rxjs";
+import {LEAFLET_RESIZE_TOKEN} from "../../leaflet-configuration.token";
+import {LeafletModule} from "../../leaflet.module";
+import {LeafletDirective} from "./leaflet.directive";
+
+describe("LeafletDirective", () => {
+ const resize$ = new Subject();
+ let component: LeafletSpecComponent;
+ let fixture: ComponentFixture<LeafletSpecComponent>;
+ let directive: LeafletDirective;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [LeafletModule],
+ declarations: [LeafletSpecComponent],
+ providers: [
+ {
+ provide: LEAFLET_RESIZE_TOKEN,
+ useValue: resize$
+ }
+ ]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(LeafletSpecComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ directive = component.directive;
+ });
+
+ it("should", () => {
+ const latLngZoom: LatLngLiteral & { zoom: number } = {
+ lat: 52.520008,
+ lng: 13.404954,
+ zoom: 13
+ };
+
+ directive.appView = null;
+ directive.appView = latLngZoom;
+ expect(directive.leaflet.getCenter().lat).toBe(latLngZoom.lat);
+ expect(directive.leaflet.getCenter().lng).toBe(latLngZoom.lng);
+ expect(directive.leaflet.getZoom()).toBe(latLngZoom.zoom);
+ });
+
+ it("should invalidate size on resize", () => {
+ spyOn(directive, "invalidateSize").and.callThrough();
+ resize$.next();
+ expect(directive.invalidateSize).toHaveBeenCalled();
+ });
+
+ it("should extract bounds", () => {
+ const bounds = directive.getBounds();
+ expect(bounds).toBeDefined();
+ expect(bounds.center).toBeDefined();
+ expect(bounds.zoom).toBeDefined();
+ });
+
+ it("should generate event observables for leaflet map", () => {
+ let subscription: Subscription = null;
+ expect(() => subscription = directive.on("move", true).subscribe()).not.toThrow();
+ subscription.unsubscribe();
+ });
+
+ it("should pass through leaflet events as output", fakeAsync(() => {
+ const event$ = new Subject<any>();
+ const onSpy = spyOn(directive, "on").and.returnValue(event$.asObservable());
+ let subscription: Subscription;
+
+ onSpy.calls.reset();
+ subscription = directive.appLatLngZoomChange.subscribe();
+ expect(onSpy).toHaveBeenCalledWith("move zoom", true);
+ event$.next();
+ tick(10);
+ subscription.unsubscribe();
+
+ onSpy.calls.reset();
+ subscription = directive.appClick.subscribe();
+ expect(onSpy).toHaveBeenCalledWith("click");
+ subscription.unsubscribe();
+
+ onSpy.calls.reset();
+ subscription = directive.appUnload$.subscribe();
+ expect(onSpy).toHaveBeenCalledWith("unload");
+ subscription.unsubscribe();
+ }));
+
+});
+
+@Component({
+ selector: "app-leaflet-spec",
+ template: `
+ <div style="width: 1em; height: 1em;" appLeaflet></div>
+ `
+})
+class LeafletSpecComponent {
+
+ @ViewChild(LeafletDirective, {static: true})
+ public directive: LeafletDirective;
+
+}
diff --git a/src/app/shared/layout/leaflet/directives/leaflet/leaflet.directive.ts b/src/app/shared/layout/leaflet/directives/leaflet/leaflet.directive.ts
new file mode 100644
index 0000000..62fcaae
--- /dev/null
+++ b/src/app/shared/layout/leaflet/directives/leaflet/leaflet.directive.ts
@@ -0,0 +1,124 @@
+/********************************************************************************
+ * 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 {AfterViewInit, Directive, ElementRef, HostBinding, Inject, Input, NgZone, OnDestroy, OnInit, Optional, Output} from "@angular/core";
+import {InvalidateSizeOptions, LatLngLiteral, LeafletEvent, LeafletMouseEvent, Map, TileLayer} from "leaflet";
+import {defer, Observable, of, pipe} from "rxjs";
+import {debounceTime, distinctUntilChanged, map, switchMap, takeUntil, throttleTime} from "rxjs/operators";
+import {runInZone, runOutsideZone} from "../../../../../util/rxjs";
+import {ILeafletConfiguration, LEAFLET_CONFIGURATION_TOKEN, LEAFLET_RESIZE_TOKEN} from "../../leaflet-configuration.token";
+import {fromLeafletEvent, latLngZoomToString} from "../../util";
+
+export interface ILeafletBounds {
+ northWest: LatLngLiteral;
+ northEast: LatLngLiteral;
+ southEast: LatLngLiteral;
+ southWest: LatLngLiteral;
+ center: LatLngLiteral;
+ zoom: number;
+}
+
+@Directive({
+ selector: "[appLeaflet]",
+ exportAs: "appLeaflet"
+})
+export class LeafletDirective implements OnInit, OnDestroy, AfterViewInit {
+
+ @HostBinding("class.no-pointer-events")
+ @HostBinding("class.disable")
+ @Input()
+ public appDisabled: boolean;
+
+ @Output()
+ public appLatLngZoomChange = defer(() => this.on("move zoom", true)).pipe(
+ debounceTime(10),
+ map(() => latLngZoomToString(this.leaflet.getCenter(), this.leaflet.getZoom())),
+ distinctUntilChanged(),
+ runInZone(this.ngZone)
+ );
+
+ @Output()
+ public appClick = defer(() => this.on<LeafletMouseEvent>("click"));
+
+ @Output()
+ public appUnload$ = defer(() => this.on("unload"));
+
+ public readonly leaflet: Map;
+
+ public constructor(
+ public readonly elementRef: ElementRef<HTMLElement>,
+ public readonly ngZone: NgZone,
+ @Inject(LEAFLET_CONFIGURATION_TOKEN) public readonly configuration: ILeafletConfiguration,
+ @Optional() @Inject(LEAFLET_RESIZE_TOKEN) public resize$: Observable<any>
+ ) {
+ this.leaflet = this.ngZone.runOutsideAngular(() => {
+ const tileLayer = new TileLayer(this.configuration.urlTemplate, {
+ attribution: this.configuration.attribution
+ });
+ const result = new Map(this.elementRef.nativeElement, {layers: [tileLayer]});
+ result.setView({lat: configuration.lat, lng: configuration.lng}, configuration.zoom);
+ return result;
+ });
+ }
+
+ @Input()
+ public set appView(value: { lat: number, lng: number, zoom: number }) {
+ if (value != null) {
+ this.ngZone.runOutsideAngular(() => this.leaflet.setView(value, value.zoom));
+ }
+ }
+
+ public ngOnInit() {
+ if (this.resize$ instanceof Observable) {
+ this.resize$.pipe(throttleTime(10), takeUntil(this.appUnload$))
+ .subscribe((_) => this.invalidateSize());
+ }
+ }
+
+ public ngAfterViewInit() {
+ setTimeout(() => this.invalidateSize());
+ }
+
+ public ngOnDestroy() {
+ this.ngZone.runOutsideAngular(() => this.leaflet.remove());
+ }
+
+ public getBounds(): ILeafletBounds {
+ return this.ngZone.runOutsideAngular(() => {
+ const bounds = this.leaflet.getBounds();
+ const zoom = this.leaflet.getZoom();
+ const result = {
+ northWest: bounds.getNorthWest(),
+ northEast: bounds.getNorthEast(),
+ southEast: bounds.getSouthEast(),
+ southWest: bounds.getSouthWest(),
+ center: bounds.getCenter(),
+ zoom
+ };
+ return this.ngZone.run(() => result);
+ });
+ }
+
+ public invalidateSize(options?: boolean | InvalidateSizeOptions) {
+ this.ngZone.runOutsideAngular(() => this.leaflet.invalidateSize(options));
+ }
+
+ public on<T extends LeafletEvent>(type: string, outsideZone?: boolean): Observable<T> {
+ return of(type).pipe(
+ runOutsideZone(this.ngZone),
+ switchMap((_) => fromLeafletEvent<T>(this.leaflet, type)),
+ outsideZone ? pipe() : runInZone(this.ngZone)
+ );
+ }
+
+}
diff --git a/src/app/shared/layout/leaflet/directives/marker/abstract-leaflet-marker.directive.ts b/src/app/shared/layout/leaflet/directives/marker/abstract-leaflet-marker.directive.ts
new file mode 100644
index 0000000..e51a197
--- /dev/null
+++ b/src/app/shared/layout/leaflet/directives/marker/abstract-leaflet-marker.directive.ts
@@ -0,0 +1,69 @@
+/********************************************************************************
+ * 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 {Directive, Inject, NgZone, OnDestroy, Output} from "@angular/core";
+import {DivIcon, LatLngLiteral, LeafletEvent, LeafletMouseEvent, Marker} from "leaflet";
+import {defer, Observable, of, pipe} from "rxjs";
+import {switchMap} from "rxjs/operators";
+import {runInZone, runOutsideZone} from "../../../../../util/rxjs";
+import {ILeafletConfiguration, LEAFLET_CONFIGURATION_TOKEN} from "../../leaflet-configuration.token";
+import {fromLeafletEvent} from "../../util";
+import {LeafletDirective} from "../leaflet";
+
+
+@Directive({})
+export abstract class AbstractLeafletMarkerDirective implements OnDestroy {
+
+ @Output()
+ public appClick = defer(() => this.on<LeafletMouseEvent>("click"));
+
+ public readonly marker: Marker = new Marker(this.configuration, {
+ icon: new DivIcon({className: "openk-leaflet-marker", iconSize: [30, 30]})
+ });
+
+ public constructor(
+ public readonly ngZone: NgZone,
+ public readonly leafletDirective: LeafletDirective,
+ @Inject(LEAFLET_CONFIGURATION_TOKEN) public readonly configuration: ILeafletConfiguration,
+ ) {
+
+ }
+
+ public ngOnDestroy() {
+ this.remove();
+ }
+
+ public on<T extends LeafletEvent>(type: string, outsideZone?: boolean): Observable<T> {
+ return of(type).pipe(
+ runOutsideZone(this.ngZone),
+ switchMap((_) => fromLeafletEvent<T>(this.marker, type)),
+ outsideZone ? pipe() : runInZone(this.ngZone)
+ );
+ }
+
+ protected setLatLng(value?: LatLngLiteral): boolean {
+ return this.ngZone.runOutsideAngular(() => {
+ if (Number.isFinite(value?.lat) && Number.isFinite(value?.lng)) {
+ this.marker.setLatLng(value);
+ this.marker.addTo(this.leafletDirective.leaflet);
+ return true;
+ }
+ return false;
+ });
+ }
+
+ protected remove() {
+ this.ngZone.runOutsideAngular(() => this.marker.remove());
+ }
+
+}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/shared/layout/leaflet/directives/marker/index.ts
similarity index 92%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/shared/layout/leaflet/directives/marker/index.ts
index a3980e1..11544de 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/shared/layout/leaflet/directives/marker/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./leaflet-marker.directive";
diff --git a/src/app/shared/layout/leaflet/directives/marker/leaflet-marker.directive.spec.ts b/src/app/shared/layout/leaflet/directives/marker/leaflet-marker.directive.spec.ts
new file mode 100644
index 0000000..c5b474c
--- /dev/null
+++ b/src/app/shared/layout/leaflet/directives/marker/leaflet-marker.directive.spec.ts
@@ -0,0 +1,96 @@
+/********************************************************************************
+ * 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 {Component, ViewChild} from "@angular/core";
+import {async, ComponentFixture, TestBed} from "@angular/core/testing";
+import {LatLngLiteral} from "leaflet";
+import {Subject, Subscription} from "rxjs";
+import {LeafletModule} from "../../leaflet.module";
+import {LeafletMarkerDirective} from "./leaflet-marker.directive";
+
+describe("LeafletMarkerDirective", () => {
+
+ const latLng: LatLngLiteral = {
+ lat: 52.520008,
+ lng: 13.404954
+ };
+
+ let component: LeafletMarkerSpecComponent;
+ let fixture: ComponentFixture<LeafletMarkerSpecComponent>;
+ let directive: LeafletMarkerDirective;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [LeafletModule],
+ declarations: [LeafletMarkerSpecComponent]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(LeafletMarkerSpecComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ directive = component.directive;
+ });
+
+ it("should set coordinates for marker", () => {
+ const removeSpy = spyOn(directive.marker, "remove").and.callThrough();
+
+ directive.appLeafletMarker = latLng;
+ expect(directive.marker.getLatLng().lat).toEqual(latLng.lat);
+ expect(directive.marker.getLatLng().lng).toEqual(latLng.lng);
+
+ directive.appLeafletMarker = {lat: 1, lng: null};
+ expect(removeSpy).toHaveBeenCalled();
+ directive.appLeafletMarker = {lat: null, lng: 2};
+ expect(removeSpy).toHaveBeenCalled();
+ directive.appLeafletMarker = null;
+ expect(removeSpy).toHaveBeenCalled();
+ });
+
+ it("should pass through leaflet events as output", () => {
+ const event$ = new Subject<any>();
+ const onSpy = spyOn(directive, "on").and.returnValue(event$.asObservable());
+ let subscription: Subscription;
+
+ onSpy.calls.reset();
+ subscription = directive.appClick.subscribe();
+ expect(onSpy).toHaveBeenCalledWith("click");
+ subscription.unsubscribe();
+ });
+
+ it("should generate event observables for leaflet map", () => {
+ let subscription: Subscription = null;
+ expect(() => subscription = directive.on("click", true).subscribe()).not.toThrow();
+ subscription.unsubscribe();
+ });
+
+});
+
+@Component({
+ selector: "app-leaflet-spec",
+ template: `
+ <div appLeaflet>
+ <ng-container [appLeafletMarker]="coordinates">
+ </ng-container>
+ </div>
+ `
+})
+class LeafletMarkerSpecComponent {
+
+ public coordinates: LatLngLiteral;
+
+ @ViewChild(LeafletMarkerDirective, {static: true})
+ public directive: LeafletMarkerDirective;
+
+}
diff --git a/src/app/shared/layout/leaflet/directives/marker/leaflet-marker.directive.ts b/src/app/shared/layout/leaflet/directives/marker/leaflet-marker.directive.ts
new file mode 100644
index 0000000..9ea0814
--- /dev/null
+++ b/src/app/shared/layout/leaflet/directives/marker/leaflet-marker.directive.ts
@@ -0,0 +1,42 @@
+/********************************************************************************
+ * 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 {Directive, Inject, Input, NgZone} from "@angular/core";
+import {LatLngLiteral} from "leaflet";
+import {ILeafletConfiguration, LEAFLET_CONFIGURATION_TOKEN} from "../../leaflet-configuration.token";
+import {LeafletDirective} from "../leaflet";
+import {AbstractLeafletMarkerDirective} from "./abstract-leaflet-marker.directive";
+
+@Directive({
+ selector: "[appLeafletMarker]",
+ exportAs: "appLeafletMarker"
+})
+export class LeafletMarkerDirective extends AbstractLeafletMarkerDirective {
+
+ public constructor(
+ ngZone: NgZone,
+ leafletDirective: LeafletDirective,
+ @Inject(LEAFLET_CONFIGURATION_TOKEN) configuration: ILeafletConfiguration,
+ ) {
+ super(ngZone, leafletDirective, configuration);
+ }
+
+ @Input()
+ public set appLeafletMarker(value: LatLngLiteral) {
+ const isSet = this.setLatLng(value);
+ if (!isSet) {
+ this.remove();
+ }
+ }
+
+}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/shared/layout/leaflet/index.ts
similarity index 77%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/shared/layout/leaflet/index.ts
index a3980e1..849c149 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/shared/layout/leaflet/index.ts
@@ -11,4 +11,9 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./directives";
+export * from "./pipes";
+export * from "./util";
+
+export * from "./leaflet.module";
+export * from "./leaflet-configuration.token";
diff --git a/src/app/shared/layout/leaflet/leaflet-configuration.token.ts b/src/app/shared/layout/leaflet/leaflet-configuration.token.ts
new file mode 100644
index 0000000..ea60f4b
--- /dev/null
+++ b/src/app/shared/layout/leaflet/leaflet-configuration.token.ts
@@ -0,0 +1,39 @@
+/********************************************************************************
+ * 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 {InjectionToken} from "@angular/core";
+import {Observable} from "rxjs";
+import {environment} from "../../../../environments/environment";
+
+export interface ILeafletConfiguration {
+
+ urlTemplate: string;
+
+ attribution?: string;
+
+ gis: string;
+
+ lat: number;
+
+ lng: number;
+
+ zoom: number;
+
+}
+
+export const LEAFLET_RESIZE_TOKEN = new InjectionToken<Observable<any>>("Leaflet resize observable");
+
+export const LEAFLET_CONFIGURATION_TOKEN = new InjectionToken<ILeafletConfiguration>("Leaflet configuration", {
+ providedIn: "root",
+ factory: () => environment.leaflet
+});
diff --git a/src/app/shared/layout/leaflet/leaflet.module.ts b/src/app/shared/layout/leaflet/leaflet.module.ts
new file mode 100644
index 0000000..f817b70
--- /dev/null
+++ b/src/app/shared/layout/leaflet/leaflet.module.ts
@@ -0,0 +1,53 @@
+/********************************************************************************
+ * 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 {CommonModule} from "@angular/common";
+import {ModuleWithProviders, NgModule, Type} from "@angular/core";
+import {Observable} from "rxjs";
+import {LeafletCenterMarkerDirective, LeafletDirective, LeafletMarkerDirective} from "./directives";
+import {LEAFLET_RESIZE_TOKEN} from "./leaflet-configuration.token";
+import {StringToLatLngZoomPipe} from "./pipes";
+
+@NgModule({
+ imports: [
+ CommonModule
+ ],
+ declarations: [
+ LeafletCenterMarkerDirective,
+ LeafletDirective,
+ LeafletMarkerDirective,
+ StringToLatLngZoomPipe
+ ],
+ exports: [
+ LeafletCenterMarkerDirective,
+ LeafletDirective,
+ LeafletMarkerDirective,
+ StringToLatLngZoomPipe
+ ]
+})
+export class LeafletModule {
+
+ public static for<T extends { resize$: Observable<any> }>(resizeService: Type<T>): ModuleWithProviders {
+ return {
+ ngModule: LeafletModule,
+ providers: [
+ {
+ provide: LEAFLET_RESIZE_TOKEN,
+ useFactory: (service: T) => service.resize$,
+ deps: [resizeService]
+ }
+ ]
+ };
+ }
+
+}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/shared/layout/leaflet/pipes/index.ts
similarity index 91%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/shared/layout/leaflet/pipes/index.ts
index a3980e1..23f1589 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/shared/layout/leaflet/pipes/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./string-to-lat-lng-zoom.pipe";
diff --git a/src/app/shared/layout/statement-table/IStatementTableEntry.ts b/src/app/shared/layout/leaflet/pipes/string-to-lat-lng-zoom.pipe.spec.ts
similarity index 62%
copy from src/app/shared/layout/statement-table/IStatementTableEntry.ts
copy to src/app/shared/layout/leaflet/pipes/string-to-lat-lng-zoom.pipe.spec.ts
index a18cce9..1ca143b 100644
--- a/src/app/shared/layout/statement-table/IStatementTableEntry.ts
+++ b/src/app/shared/layout/leaflet/pipes/string-to-lat-lng-zoom.pipe.spec.ts
@@ -11,8 +11,13 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-import {IAPIStatementModel} from "../../../core/api/statements";
+import {StringToLatLngZoomPipe} from "./string-to-lat-lng-zoom.pipe";
-export interface IStatementTableEntry extends IAPIStatementModel {
- isSelected?: boolean;
-}
+describe("StringToLatLngZoomPipe", () => {
+
+ it("should transform lat/lng/zoom strings to objects", () => {
+ const pipe: StringToLatLngZoomPipe = new StringToLatLngZoomPipe();
+ expect(pipe.transform("1,2,3")).toEqual({lat: 1, lng: 2, zoom: 3});
+ });
+
+});
diff --git a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss b/src/app/shared/layout/leaflet/pipes/string-to-lat-lng-zoom.pipe.ts
similarity index 60%
copy from src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss
copy to src/app/shared/layout/leaflet/pipes/string-to-lat-lng-zoom.pipe.ts
index ac39661..489cfb2 100644
--- a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss
+++ b/src/app/shared/layout/leaflet/pipes/string-to-lat-lng-zoom.pipe.ts
@@ -11,19 +11,17 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-@import "openk.styles";
+import {Pipe, PipeTransform} from "@angular/core";
+import {stringToLatLngZoom} from "../util";
-.dashboard-item-header-actions {
- display: inline-flex;
- margin-left: auto;
+/**
+ * Parses a string as comma separated list of coordination/zoom values.
+ */
+@Pipe({name: "stringToLatLngZoom"})
+export class StringToLatLngZoomPipe implements PipeTransform {
- & > * {
- margin-left: 0.5em;
- }
-}
+ public transform(value: string) {
+ return stringToLatLngZoom(value);
+ }
-.dashboard-item-body {
- padding: 1em;
- display: flex;
- flex-flow: column;
}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/shared/layout/leaflet/util/index.ts
similarity index 92%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/shared/layout/leaflet/util/index.ts
index a3980e1..4d2fee5 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/shared/layout/leaflet/util/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./leaflet.util";
diff --git a/src/app/shared/layout/leaflet/util/leaflet.util.spec.ts b/src/app/shared/layout/leaflet/util/leaflet.util.spec.ts
new file mode 100644
index 0000000..2e884cf
--- /dev/null
+++ b/src/app/shared/layout/leaflet/util/leaflet.util.spec.ts
@@ -0,0 +1,79 @@
+/********************************************************************************
+ * 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 {fakeAsync} from "@angular/core/testing";
+import {LeafletEventHandlerFn, Map} from "leaflet";
+import {fromLeafletEvent, latLngZoomToString, stringToLatLngZoom} from "./leaflet.util";
+
+describe("LeafletUtil", () => {
+
+ it("fromLeafletEvent", fakeAsync(() => {
+ const onItems: Array<{ type: string, fn: LeafletEventHandlerFn }> = [];
+ const offItems: Array<{ type: string, fn: LeafletEventHandlerFn }> = [];
+
+ const leafletMock = new LeafletMapMock(onItems, offItems) as any as Map;
+
+ const subscription = fromLeafletEvent(leafletMock, "move").subscribe();
+
+ expect(onItems.length).toBe(1);
+ expect(offItems.length).toBe(0);
+ expect(onItems[0].type).toBe("move");
+
+ subscription.unsubscribe();
+
+ expect(onItems.length).toBe(1);
+ expect(offItems.length).toBe(1);
+ expect(offItems[0].type).toBe("move");
+ expect(offItems[0].fn).toBe(onItems[0].fn);
+
+ expect(fromLeafletEvent(null, "move").subscribe().closed).toBe(true);
+ }));
+
+ it("latLngZoomToString", () => {
+ expect(latLngZoomToString({lat: 1, lng: 2}, 3)).toEqual("1,2,3");
+ expect(latLngZoomToString({lat: null, lng: 2}, 3)).not.toBeDefined();
+ expect(latLngZoomToString({lat: 1, lng: null}, 3)).not.toBeDefined();
+ expect(latLngZoomToString({lat: 1, lng: 2}, null)).not.toBeDefined();
+ expect(latLngZoomToString(null, 3)).not.toBeDefined();
+ });
+
+ it("stringToLatLngZoom", () => {
+ expect(stringToLatLngZoom("1,2,3")).toEqual({lat: 1, lng: 2, zoom: 3});
+ expect(stringToLatLngZoom("1,2,3,4,5,6")).not.toBeDefined();
+ expect(stringToLatLngZoom("1;2;3")).not.toBeDefined();
+ expect(stringToLatLngZoom(null)).not.toBeDefined();
+ expect(stringToLatLngZoom("1,2")).not.toBeDefined();
+ });
+
+});
+
+class LeafletMapMock {
+
+ public constructor(
+ private onItems: Array<{ type: string, fn: LeafletEventHandlerFn }> = [],
+ private offItems: Array<{ type: string, fn: LeafletEventHandlerFn }> = []
+ ) {
+
+ }
+
+ on(type: string, fn: LeafletEventHandlerFn, context?: any): this {
+ this.onItems.push({type, fn});
+ return this;
+ }
+
+ off(type: string, fn: LeafletEventHandlerFn, context?: any): this {
+ this.offItems.push({type, fn});
+ return this;
+ }
+
+}
diff --git a/src/app/shared/layout/leaflet/util/leaflet.util.ts b/src/app/shared/layout/leaflet/util/leaflet.util.ts
new file mode 100644
index 0000000..2df3ef7
--- /dev/null
+++ b/src/app/shared/layout/leaflet/util/leaflet.util.ts
@@ -0,0 +1,61 @@
+/********************************************************************************
+ * 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 {Evented, LatLngLiteral, LeafletEvent} from "leaflet";
+import {Observable} from "rxjs";
+
+/**
+ * Creates an observable for specific leaflet map events.
+ */
+export function fromLeafletEvent<T extends LeafletEvent>(leafletMap: Evented, type: string): Observable<T> {
+ return new Observable<T>((subscriber) => {
+ if (leafletMap == null) {
+ subscriber.complete();
+ return;
+ }
+ const next = (e) => subscriber.next(e);
+ leafletMap.on(type, next);
+ subscriber.add(() => {
+ leafletMap.off(type, next);
+ });
+ });
+}
+
+/**
+ * Reduces the given variables to a comma separated string.
+ */
+export function latLngZoomToString(latLng: LatLngLiteral, zoom: number): string {
+ const values = [
+ latLng?.lat,
+ latLng?.lng,
+ zoom
+ ];
+
+ return values.some((_) => !Number.isFinite(_)) ?
+ undefined :
+ values.reduce((_, v) => _ + "," + v, "").slice(1);
+}
+
+/**
+ * Parses a string as comma separated list of coordination/zoom values.
+ */
+export function stringToLatLngZoom(value: string): LatLngLiteral & { zoom: number } {
+ const split = typeof value !== "string" ? [] : value.split(",")
+ .map((v) => parseFloat(v.replace(",", "")));
+
+ return split.length !== 3 || split.some((_) => !Number.isFinite(_)) ? undefined : {
+ lat: split[0],
+ lng: split[1],
+ zoom: split[2]
+ };
+}
diff --git a/src/app/shared/layout/side-menu/components/side-menu-container/side-menu-container.component.html b/src/app/shared/layout/side-menu/components/side-menu-container/side-menu-container.component.html
index efe06cd..98df1f0 100644
--- a/src/app/shared/layout/side-menu/components/side-menu-container/side-menu-container.component.html
+++ b/src/app/shared/layout/side-menu/components/side-menu-container/side-menu-container.component.html
@@ -12,13 +12,16 @@
-------------------------------------------------------------------------------->
<ng-template #sideMenuTemplateRef>
- <div *ngIf="hasContent$ | async"
+ <div *ngIf="!appHideSideMenu && (hasContent$ | async)"
[class.side-menu---collapsed]="!isOpen"
[class.side-menu---left]="left$ | async"
+ (transitionend)="registrationService.resize$.next()"
class="side-menu">
- <div class="side-menu--content">
+ <div [class.side-menu--content---left]="left$ | async"
+ [ngStyle]="style$ | async"
+ class="side-menu--content">
<ng-container *ngIf="(title$ | async)?.trim().length > 0">
<div class="side-menu--content--title">
@@ -26,11 +29,17 @@
</div>
</ng-container>
- <ng-container *ngFor="let directive of (content$ | async); let first = first;">
- <ng-container *ngIf="directive?.templateRef"
- [ngTemplateOutlet]="directive?.templateRef">
+ <ng-container *ngFor="let directive of (content$ | async); let first = first; let last = last;">
+ <ng-container *ngIf="directive?.templateRef">
+
+ <div *ngIf="!first" class="side-menu--content--spacing"></div>
+
+ <ng-container [ngTemplateOutlet]="directive?.templateRef"></ng-container>
+
+ <div *ngIf="!last" class="side-menu--content--spacing"></div>
+
</ng-container>
- <div class="side-menu--content--spacing"></div>
+
</ng-container>
</div>
diff --git a/src/app/shared/layout/side-menu/components/side-menu-container/side-menu-container.component.scss b/src/app/shared/layout/side-menu/components/side-menu-container/side-menu-container.component.scss
index b187757..e801994 100644
--- a/src/app/shared/layout/side-menu/components/side-menu-container/side-menu-container.component.scss
+++ b/src/app/shared/layout/side-menu/components/side-menu-container/side-menu-container.component.scss
@@ -14,6 +14,10 @@
@import "openk.styles";
:host {
+ --side-menu-width: 17em;
+ --side-menu-width-collapsed: 1em;
+ --side-menu-border-width-highlighted: 3px;
+
display: flex;
flex-flow: row;
overflow: hidden;
@@ -22,7 +26,7 @@
}
.main-content {
- flex: 1 1 100%;
+ flex: 1 1 calc(100% - var(--side-menu-width));
width: 100%;
height: 100%;
@@ -38,16 +42,9 @@
}
.side-menu {
- --side-menu-width: 17em;
- --side-menu-width-collapsed: 1em;
- --side-menu-border-width-highlighted: 3px;
-
position: relative;
- display: flex;
- flex: 1 1 var(--side-menu-width-collapsed);
width: var(--side-menu-width);
- box-sizing: border-box;
border-left: 1px solid $openk-form-border;
height: 100%;
@@ -66,10 +63,14 @@
justify-content: flex-end;
}
+.side-menu--content---left {
+ position: absolute;
+ right: 0;
+}
.side-menu--content {
height: 100%;
- min-width: var(--side-menu-width);
+ width: var(--side-menu-width);
overflow: auto;
display: flex;
@@ -94,7 +95,7 @@
min-height: 1em;
& + & {
- min-height: initial;
+ display: none;
}
&:last-of-type {
diff --git a/src/app/shared/layout/side-menu/components/side-menu-container/side-menu-container.component.ts b/src/app/shared/layout/side-menu/components/side-menu-container/side-menu-container.component.ts
index b6dbac3..ba2dc92 100644
--- a/src/app/shared/layout/side-menu/components/side-menu-container/side-menu-container.component.ts
+++ b/src/app/shared/layout/side-menu/components/side-menu-container/side-menu-container.component.ts
@@ -11,7 +11,7 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-import {Component} from "@angular/core";
+import {Component, Input} from "@angular/core";
import {SideMenuRegistrationService} from "../../services";
@Component({
@@ -21,6 +21,9 @@
})
export class SideMenuContainerComponent {
+ @Input()
+ public appHideSideMenu: boolean;
+
public content$ = this.registrationService.content$;
public left$ = this.registrationService.left$;
@@ -29,6 +32,8 @@
public title$ = this.registrationService.title$;
+ public style$ = this.registrationService.style$;
+
public isOpen = true;
constructor(public registrationService: SideMenuRegistrationService) {
diff --git a/src/app/shared/layout/side-menu/components/side-menu-status/side-menu-status.component.html b/src/app/shared/layout/side-menu/components/side-menu-status/side-menu-status.component.html
index d5ab4a9..ac2773e 100644
--- a/src/app/shared/layout/side-menu/components/side-menu-status/side-menu-status.component.html
+++ b/src/app/shared/layout/side-menu/components/side-menu-status/side-menu-status.component.html
@@ -20,5 +20,9 @@
</div>
<ng-container *ngIf="!appLoading">
+ <div *ngIf="appErrorMessage" class="error-message">
+ <mat-icon class="error-message---icon">error</mat-icon>
+ <span class="error-message---text">{{appErrorMessage | translate}}</span>
+ </div>
<ng-content></ng-content>
</ng-container>
diff --git a/src/app/shared/layout/side-menu/components/side-menu-status/side-menu-status.component.scss b/src/app/shared/layout/side-menu/components/side-menu-status/side-menu-status.component.scss
index f54bba3..cf50641 100644
--- a/src/app/shared/layout/side-menu/components/side-menu-status/side-menu-status.component.scss
+++ b/src/app/shared/layout/side-menu/components/side-menu-status/side-menu-status.component.scss
@@ -11,6 +11,8 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
+@import "openk.styles";
+
:host {
width: 100%;
font-size: smaller;
@@ -37,3 +39,20 @@
.loading-message {
font-style: italic;
}
+
+.error-message {
+ color: $openk-error-color;
+ display: inline-flex;
+ align-items: center;
+}
+
+.error-message---icon {
+ margin-right: 0.25em;
+}
+
+.error-message---text {
+ color: $openk-error-color;
+ font-weight: 600;
+ text-align: left;
+ line-height: 1.25;
+}
diff --git a/src/app/shared/layout/side-menu/components/side-menu-status/side-menu-status.component.ts b/src/app/shared/layout/side-menu/components/side-menu-status/side-menu-status.component.ts
index 3153bc2..94ea08e 100644
--- a/src/app/shared/layout/side-menu/components/side-menu-status/side-menu-status.component.ts
+++ b/src/app/shared/layout/side-menu/components/side-menu-status/side-menu-status.component.ts
@@ -24,6 +24,9 @@
public appLoading: boolean;
@Input()
- public appLoadingMessage;
+ public appLoadingMessage: string;
+
+ @Input()
+ public appErrorMessage: string;
}
diff --git a/src/app/shared/layout/side-menu/directives/side-menu.directive.spec.ts b/src/app/shared/layout/side-menu/directives/side-menu.directive.spec.ts
index 16704a6..beabf2e 100644
--- a/src/app/shared/layout/side-menu/directives/side-menu.directive.spec.ts
+++ b/src/app/shared/layout/side-menu/directives/side-menu.directive.spec.ts
@@ -44,14 +44,17 @@
});
it("should re-register on changes", () => {
- resetSpyCalls();
expect(registration.register).not.toHaveBeenCalled();
- ["appSideMenu", "appSideMenuLeft", "appSideMenuTitle"]
+ ["appSideMenu", "appSideMenuLeft", "appSideMenuTitle", "appSideMenuStyle"]
.map<SimpleChanges>((_) => ({[_]: new SimpleChange(0, 1, false)}))
.forEach((changes) => {
+ resetSpyCalls();
directive.ngOnChanges(changes);
expect(registration.register).toHaveBeenCalledWith(directive);
});
+ resetSpyCalls();
+ directive.ngOnChanges({});
+ expect(registration.register).not.toHaveBeenCalled();
});
it("should deregister on destroy", () => {
diff --git a/src/app/shared/layout/side-menu/directives/side-menu.directive.ts b/src/app/shared/layout/side-menu/directives/side-menu.directive.ts
index f7842f7..d251811 100644
--- a/src/app/shared/layout/side-menu/directives/side-menu.directive.ts
+++ b/src/app/shared/layout/side-menu/directives/side-menu.directive.ts
@@ -29,6 +29,9 @@
@Input()
public appSideMenuTitle: string;
+ @Input()
+ public appSideMenuStyle: { [styleProperty: string]: any; };
+
public test: boolean;
public constructor(
@@ -39,7 +42,7 @@
}
public ngOnChanges(changes: SimpleChanges) {
- const keys: Array<keyof SideMenuDirective> = ["appSideMenu", "appSideMenuLeft", "appSideMenuTitle"];
+ const keys: Array<keyof SideMenuDirective> = ["appSideMenu", "appSideMenuLeft", "appSideMenuTitle", "appSideMenuStyle"];
if (keys.filter((_) => changes[_] != null).length > 0) {
this.sideMenuRegistrationService.register(this);
}
diff --git a/src/app/shared/layout/side-menu/services/side-menu-registration.service.spec.ts b/src/app/shared/layout/side-menu/services/side-menu-registration.service.spec.ts
index 5cdac56..fbc7178 100644
--- a/src/app/shared/layout/side-menu/services/side-menu-registration.service.spec.ts
+++ b/src/app/shared/layout/side-menu/services/side-menu-registration.service.spec.ts
@@ -61,6 +61,14 @@
await expectAsync(getLeft$.toPromise()).toBeResolvedTo(false);
});
+ it("should return a style object for the side menu", async () => {
+ const getStyle$ = registration.style$.pipe(take(1));
+ await expectAsync(getStyle$.toPromise()).toBeResolvedTo(undefined);
+ directive.appSideMenuStyle = {padding: "0"};
+ registration.register(directive);
+ await expectAsync(getStyle$.toPromise()).toBeResolvedTo({padding: "0"});
+ });
+
it("should return the title of the side menu", async () => {
const getTitle$ = registration.title$.pipe(take(1));
await expectAsync(getTitle$.toPromise()).toBeResolvedTo("Side Menu Spec");
diff --git a/src/app/shared/layout/side-menu/services/side-menu-registration.service.ts b/src/app/shared/layout/side-menu/services/side-menu-registration.service.ts
index 4e75543..1719f61 100644
--- a/src/app/shared/layout/side-menu/services/side-menu-registration.service.ts
+++ b/src/app/shared/layout/side-menu/services/side-menu-registration.service.ts
@@ -12,7 +12,7 @@
********************************************************************************/
import {Injectable} from "@angular/core";
-import {BehaviorSubject, defer} from "rxjs";
+import {BehaviorSubject, defer, Subject} from "rxjs";
import {distinctUntilChanged, map} from "rxjs/operators";
import {filterDistinctValues} from "../../../../util/store";
import {SideMenuDirective} from "../directives";
@@ -43,6 +43,12 @@
distinctUntilChanged()
);
+ public readonly resize$ = new Subject<any>();
+
+ public style$ = defer(() => this.directive$).pipe(
+ map(() => this.getStyle())
+ );
+
private readonly directivesSubject = new BehaviorSubject<SideMenuDirective[]>([]);
private get directive$() {
@@ -59,7 +65,7 @@
private getLeft(): boolean {
return this.directivesSubject.getValue()
- .reduce((left, _) => left || _.appSideMenuLeft, false) === true;
+ .some((_) => _.appSideMenuLeft);
}
private getTitle(): string {
@@ -73,4 +79,11 @@
.find((_) => _.appSideMenu === position);
}
+ private getStyle() {
+ return this.directivesSubject.getValue()
+ .reverse()
+ .map((_) => _.appSideMenuStyle)
+ .find((_) => _ != null);
+ }
+
}
diff --git a/src/app/shared/layout/side-menu/side-menu.module.ts b/src/app/shared/layout/side-menu/side-menu.module.ts
index 681028c..778b5aa 100644
--- a/src/app/shared/layout/side-menu/side-menu.module.ts
+++ b/src/app/shared/layout/side-menu/side-menu.module.ts
@@ -13,6 +13,8 @@
import {CommonModule} from "@angular/common";
import {NgModule} from "@angular/core";
+import {MatIconModule} from "@angular/material/icon";
+import {TranslateModule} from "@ngx-translate/core";
import {ProgressSpinnerModule} from "../../progress-spinner";
import {SideMenuContainerComponent, SideMenuStatusComponent} from "./components";
import {SideMenuDirective} from "./directives";
@@ -20,7 +22,9 @@
@NgModule({
imports: [
CommonModule,
- ProgressSpinnerModule
+ ProgressSpinnerModule,
+ MatIconModule,
+ TranslateModule
],
declarations: [
SideMenuContainerComponent,
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/shared/layout/statement-table/components/index.ts
similarity index 92%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/shared/layout/statement-table/components/index.ts
index a3980e1..dd145ea 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/shared/layout/statement-table/components/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./statement-table.component";
diff --git a/src/app/shared/layout/statement-table/components/statement-table.component.html b/src/app/shared/layout/statement-table/components/statement-table.component.html
new file mode 100644
index 0000000..6e3b9bb
--- /dev/null
+++ b/src/app/shared/layout/statement-table/components/statement-table.component.html
@@ -0,0 +1,211 @@
+<!-------------------------------------------------------------------------------
+ * 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
+ -------------------------------------------------------------------------------->
+
+<table [dataSource]="appEntries" [trackBy]="trackBy" cdk-table
+ class="openk-table statement-table openk-table---without-last-border">
+
+ <caption hidden>{{ "shared.statementTable.caption" | translate}}</caption>
+
+ <tr *cdkHeaderRowDef="appColumns; sticky: true" cdk-header-row>
+ </tr>
+
+ <tr *cdkRowDef="let myRowData; columns: appColumns" cdk-row
+ class="statement-table--row">
+ </tr>
+
+
+ <ng-container cdkColumnDef="select">
+ <th *cdkHeaderCellDef="let statementHeader"
+ cdk-header-cell
+ class="statement-table--select"
+ scope="col">
+ </th>
+ <td *cdkCellDef="let statement"
+ cdk-cell
+ class="statement-table--select">
+ <div class="statement-table--select--container">
+ <input #inputElement
+ (keydown.enter)="inputElement.click()"
+ (ngModelChange)="$event ? select(statement.id) : deselect(statement.id)"
+ [class.cursor-pointer]="!appDisabled"
+ [disabled]="appDisabled"
+ [ngModel]="statement?.isSelected"
+ class="statement-table--select--input"
+ type="checkbox">
+ </div>
+ </td>
+ </ng-container>
+
+ <ng-container cdkColumnDef="id">
+ <th *cdkHeaderCellDef="let statementHeader"
+ cdk-header-cell
+ class="statement-table--id"
+ scope="col">
+ {{"shared.statementTable.id" | translate}}
+ </th>
+ <td *cdkCellDef="let statement" cdk-cell
+ class="openk-no-whitespace-wrap statement-table--id">
+ <div class="openk-no-whitespace-wrap openk-right statement-table--id--container">
+ <mat-icon *ngIf="appShowAlert && (statement | statementTableAlert)"
+ class="statement-table--icon statement-table--id--icon openk-warning">
+ priority_high
+ </mat-icon>
+ {{statement?.id}}
+ </div>
+ </td>
+ </ng-container>
+
+ <ng-container cdkColumnDef="title">
+ <th *cdkHeaderCellDef="let statementHeader"
+ cdk-header-cell
+ class="statement-table--title"
+ scope="col">
+ {{"shared.statementTable.title" | translate}}
+ </th>
+ <td *cdkCellDef="let statement" cdk-cell
+ class="statement-table--title">
+ {{statement?.title}}
+ </td>
+ </ng-container>
+
+ <ng-container cdkColumnDef="city">
+ <th *cdkHeaderCellDef="let statementHeader"
+ cdk-header-cell
+ class="statement-table--city"
+ scope="col">
+ {{"shared.statementTable.city" | translate}}
+ </th>
+ <td *cdkCellDef="let statement" cdk-cell
+ class="statement-table--city">
+ {{statement?.city}}
+ </td>
+ </ng-container>
+
+ <ng-container cdkColumnDef="district">
+ <th *cdkHeaderCellDef="let statementHeader"
+ cdk-header-cell
+ class="statement-table--district"
+ scope="col">
+ {{"shared.statementTable.district" | translate}}
+ </th>
+ <td *cdkCellDef="let statement" cdk-cell
+ class="statement-table--district">
+ {{statement?.district}}
+ </td>
+ </ng-container>
+
+ <ng-container cdkColumnDef="type">
+ <th *cdkHeaderCellDef="let statementHeader"
+ cdk-header-cell
+ class="statement-table--type"
+ scope="col">
+ {{"shared.statementTable.statementType" | translate}}
+ </th>
+ <td *cdkCellDef="let statement" cdk-cell
+ class="openk-no-whitespace-wrap statement-table--type">
+ {{ (appStatementTypeOptions | selected : statement?.typeId)?.label }}
+ </td>
+ </ng-container>
+
+ <ng-container cdkColumnDef="creationDate">
+ <th *cdkHeaderCellDef="let statementHeader"
+ cdk-header-cell
+ class="statement-table--date"
+ scope="col">
+ {{"shared.statementTable.creationDate" | translate}}
+ </th>
+ <td *cdkCellDef="let statement" cdk-cell
+ class="openk-no-whitespace-wrap statement-table--date">
+ {{statement?.creationDate ? (statement.creationDate | appMomentPipe).format(appTimeDisplayFormat) : ""}}
+ </td>
+ </ng-container>
+
+ <ng-container cdkColumnDef="receiptDate">
+ <th *cdkHeaderCellDef="let statementHeader"
+ cdk-header-cell
+ class="statement-table--date"
+ scope="col">
+ {{"shared.statementTable.receiptDate" | translate}}
+ </th>
+ <td *cdkCellDef="let statement" cdk-cell
+ class="openk-no-whitespace-wrap statement-table--date">
+ {{statement?.receiptDate ? (statement.receiptDate | appMomentPipe).format(appTimeDisplayFormat) : ""}}
+ </td>
+ </ng-container>
+
+ <ng-container cdkColumnDef="dueDate">
+ <th *cdkHeaderCellDef="let statementHeader"
+ cdk-header-cell
+ class="statement-table--date"
+ scope="col">
+ {{"shared.statementTable.dueDate" | translate}}
+ </th>
+ <td *cdkCellDef="let statement" cdk-cell
+ class="openk-no-whitespace-wrap statement-table--date">
+ {{statement?.dueDate ? (statement.dueDate | appMomentPipe).format(appTimeDisplayFormat) : ""}}
+ </td>
+ </ng-container>
+
+ <ng-container cdkColumnDef="currentTaskName">
+ <th *cdkHeaderCellDef="let statementHeader"
+ cdk-header-cell
+ class="statement-table--tasks"
+ scope="col">
+ {{"shared.statementTable.currentTaskName" | translate}}
+ </th>
+ <td *cdkCellDef="let statement" cdk-cell
+ class="statement-table--tasks">
+ {{statement?.currentTaskName}}
+ </td>
+ </ng-container>
+
+ <ng-container cdkColumnDef="contributions">
+ <th *cdkHeaderCellDef="let statementHeader"
+ cdk-header-cell
+ class="statement-table--contributions"
+ scope="col">
+ {{"shared.statementTable.contributions" | translate}}
+ </th>
+ <td *cdkCellDef="let statement" cdk-cell
+ class="statement-table--contributions">
+ <span class="openk-no-whitespace-wrap">
+ {{statement?.contributionStatus}}
+ <mat-icon *ngIf="appShowContributionStatusForMyDepartment"
+ [class.openk-success]="statement?.contributionStatusForMyDepartment"
+ [class.statement-table--contributions--icon---hidden]="statement?.contributionStatusForMyDepartment === false"
+ class="statement-table--icon statement-table--contributions--icon">
+ check
+ </mat-icon>
+ </span>
+ </td>
+ </ng-container>
+
+ <ng-container cdkColumnDef="anchor">
+ <th *cdkHeaderCellDef="let statementHeader"
+ cdk-header-cell
+ class="statement-table--anchor"
+ scope="col">
+ </th>
+ <td *cdkCellDef="let statement" cdk-cell
+ class="statement-table--anchor">
+ <mat-icon *ngIf="statement?.id === 111">warning</mat-icon>
+ <a [queryParams]="{id: statement.id}"
+ [routerLink]="'/details'"
+ [target]="appOpenStatementInNewTab ? '_blank' : undefined"
+ class="openk-button openk-button-rounded openk-info statement-table--button">
+ <mat-icon>call_made</mat-icon>
+ </a>
+ </td>
+ </ng-container>
+
+</table>
diff --git a/src/app/shared/layout/statement-table/components/statement-table.component.scss b/src/app/shared/layout/statement-table/components/statement-table.component.scss
new file mode 100644
index 0000000..9304f8d
--- /dev/null
+++ b/src/app/shared/layout/statement-table/components/statement-table.component.scss
@@ -0,0 +1,134 @@
+/********************************************************************************
+ * 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 "src/styles/openk.styles";
+
+:host {
+ display: flex;
+ flex-flow: column;
+ width: 100%;
+ position: relative;
+ overflow: auto;
+ box-sizing: border-box;
+ border: 1px solid $openk-form-border;
+ border-radius: 4px;
+ background: $openk-background-card;
+ padding-bottom: 2px;
+}
+
+.openk-table---last-row-without-border:host {
+ padding-bottom: 0;
+}
+
+.statement-table--icon--container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.statement-table--icon {
+ width: initial;
+ height: initial;
+ font-size: 1em;
+ color: $openk-form-border;
+ padding-right: 0.2em;
+ padding-left: 0.2em;
+ font-weight: 1000;
+
+ &.openk-success {
+ color: get-color($openk-success-palette);
+ }
+
+ &.openk-warning {
+ color: get-color($openk-warning-palette);
+ }
+}
+
+.statement-table--select {
+ width: 1em;
+ vertical-align: center;
+ text-align: center;
+}
+
+.statement-table--select--container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.statement-table--select--input {
+ font-size: 1em;
+ width: 1em;
+ height: 1em;
+}
+
+.statement-table--id {
+ width: 1em;
+ text-align: right;
+}
+
+.statement-table--id--container {
+ width: 100%;
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+}
+
+.statement-table--id--icon {
+ margin-right: auto;
+}
+
+.statement-table--title {
+ width: 10em;
+ text-align: left;
+}
+
+.statement-table--city,
+.statement-table--district,
+.statement-table--type {
+ width: 6em;
+ text-align: left;
+}
+
+.statement-table--date {
+ width: 6em;
+ text-align: left;
+}
+
+.statement-table--tasks {
+ width: 12em;
+ text-align: left;
+}
+
+.statement-table--contributions {
+ width: 7em;
+ text-align: center;
+}
+
+.statement-table--contributions--icon {
+ transform: translateY(0.1em) scale(1.25);
+}
+
+.statement-table--contributions--icon---hidden {
+ opacity: 0;
+}
+
+.statement-table--anchor {
+ width: 1em;
+ text-align: right;
+ padding-right: 0.75em !important;
+}
+
+.statement-table--button {
+ font-size: 0.7em;
+}
diff --git a/src/app/shared/layout/statement-table/components/statement-table.component.spec.ts b/src/app/shared/layout/statement-table/components/statement-table.component.spec.ts
new file mode 100644
index 0000000..8e675ec
--- /dev/null
+++ b/src/app/shared/layout/statement-table/components/statement-table.component.spec.ts
@@ -0,0 +1,57 @@
+/********************************************************************************
+ * 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 {ComponentFixture, TestBed} from "@angular/core/testing";
+import {RouterTestingModule} from "@angular/router/testing";
+import {I18nModule} from "../../../../core/i18n";
+import {IStatementTableEntry} from "../model";
+import {StatementTableModule} from "../statement-table.module";
+import {StatementTableComponent} from "./statement-table.component";
+
+describe("StatementTableComponent", () => {
+ let component: StatementTableComponent;
+ let fixture: ComponentFixture<StatementTableComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ StatementTableModule,
+ I18nModule,
+ RouterTestingModule
+ ]
+ }).compileComponents();
+ });
+ beforeEach(() => {
+ fixture = TestBed.createComponent(StatementTableComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("should emit appToggleSelect with the id and corresponding checked value", () => {
+ const selectSpy = spyOn(component.appSelect, "emit").and.callThrough();
+ component.select(1);
+ expect(selectSpy).toHaveBeenCalledWith({id: 1, value: true});
+ component.deselect(1);
+ expect(selectSpy).toHaveBeenCalledWith({id: 1, value: true});
+ });
+
+ it("should track rows by id", () => {
+ expect(component.trackBy(1919, null)).not.toBeDefined();
+ expect(component.trackBy(1919, {...{} as IStatementTableEntry})).not.toBeDefined();
+ expect(component.trackBy(1919, {...{} as IStatementTableEntry, id: 19})).toBe(19);
+ });
+});
diff --git a/src/app/shared/layout/statement-table/statement-table.component.stories.ts b/src/app/shared/layout/statement-table/components/statement-table.component.stories.ts
similarity index 79%
rename from src/app/shared/layout/statement-table/statement-table.component.stories.ts
rename to src/app/shared/layout/statement-table/components/statement-table.component.stories.ts
index 168ad74..555f3e2 100644
--- a/src/app/shared/layout/statement-table/statement-table.component.stories.ts
+++ b/src/app/shared/layout/statement-table/components/statement-table.component.stories.ts
@@ -15,10 +15,9 @@
import {action} from "@storybook/addon-actions";
import {boolean, text, withKnobs} from "@storybook/addon-knobs";
import {moduleMetadata, storiesOf} from "@storybook/angular";
-import {I18nModule} from "../../../core/i18n";
-import {createStatementModelMock} from "../../../test";
-import {createSelectOptionsMock} from "../../../test/create-select-options.spec";
-import {StatementTableModule} from "./statement-table.module";
+import {I18nModule} from "../../../../core/i18n";
+import {createSelectOptionsMock, createStatementModelMock} from "../../../../test";
+import {StatementTableModule} from "../statement-table.module";
storiesOf("Shared / Layout", module)
.addDecorator(withKnobs)
@@ -32,13 +31,12 @@
.add("StatementTableComponent", () => ({
template: `
<div style="padding: 1em; height: 100%; box-sizing: border-box;">
- <app-statement-table style="width: 100%; height: 100%;"
- [appColumnsToDisplay]="restricted ? columnsRestricted : columnsAll"
+ <app-statement-table class="openk-table---without-last-border" style="width: 100%; height: 100%;"
[appDisabled]="appDisabled"
[appEntries]="appEntries"
[appStatementTypeOptions]="appStatementTypeOptions"
[style.maxHeight]="maxHeight"
- (appToggleSelect)="appToggleSelect($event)">
+ (appSelect)="appToggleSelect($event)">
</app-statement-table>
</div>
`,
@@ -48,7 +46,7 @@
appDisabled: boolean("appDisabled", false),
appStatementTypeOptions: createSelectOptionsMock(5, "StatementType"),
appToggleSelect: action("appToggleSelect"),
- appEntries: Array(20).fill(0)
+ appEntries: Array(19).fill(0)
.map((_, id) => createStatementModelMock(id, id % 5)),
columnsAll: ["select", "id", "title", "type", "date", "city", "district", "link"],
columnsRestricted: ["id", "title", "type", "date", "city", "district", "link"],
diff --git a/src/app/shared/layout/statement-table/components/statement-table.component.ts b/src/app/shared/layout/statement-table/components/statement-table.component.ts
new file mode 100644
index 0000000..c569bd4
--- /dev/null
+++ b/src/app/shared/layout/statement-table/components/statement-table.component.ts
@@ -0,0 +1,119 @@
+/********************************************************************************
+ * 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 {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from "@angular/core";
+import {momentFormatDisplayNumeric} from "../../../../util/moment";
+import {ISelectOption} from "../../../controls/select/model";
+import {IStatementTableEntry} from "../model";
+
+export type TStatementTableColumns = "select" | "id" | "title" | "type" | "city" | "district"
+ | "creationDate" | "receiptDate" | "dueDate" | "currentTaskName" | "contributions" | "anchor";
+
+@Component({
+ selector: "app-statement-table",
+ templateUrl: "./statement-table.component.html",
+ styleUrls: ["./statement-table.component.scss"],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class StatementTableComponent {
+
+ public static readonly SEARCH_COLUMNS: TStatementTableColumns[] = [
+ "id",
+ "title",
+ "city",
+ "district",
+ "type",
+ "creationDate",
+ "receiptDate",
+ "dueDate",
+ "anchor"
+ ];
+
+ public static readonly DASHBOARD_COLUMNS: TStatementTableColumns[] = [
+ "id",
+ "title",
+ "city",
+ "district",
+ "type",
+ "creationDate",
+ "receiptDate",
+ "dueDate",
+ "currentTaskName",
+ "contributions",
+ "anchor"
+ ];
+
+ public static readonly DASHBOARD_COLUMNS_SHORT: TStatementTableColumns[] = [
+ "id",
+ "title",
+ "city",
+ "district",
+ "type",
+ "dueDate",
+ "currentTaskName",
+ "contributions",
+ "anchor"
+ ];
+
+ public static readonly STATEMENT_SELECT_COLUMNS: TStatementTableColumns[] = [
+ "select",
+ "id",
+ "title",
+ "city",
+ "district",
+ "type",
+ "creationDate",
+ "receiptDate",
+ "anchor"
+ ];
+
+ @Input()
+ public appColumns: TStatementTableColumns[] = StatementTableComponent.STATEMENT_SELECT_COLUMNS;
+
+ @Input()
+ public appDisabled: boolean;
+
+ @Input()
+ public appEntries: IStatementTableEntry[];
+
+ @Input()
+ public appOpenStatementInNewTab: boolean;
+
+ @Input()
+ public appShowAlert: boolean;
+
+ @Input()
+ public appShowContributionStatusForMyDepartment: boolean;
+
+ @Input()
+ public appStatementTypeOptions: ISelectOption<number>[] = [];
+
+ @Input()
+ public appTimeDisplayFormat: string = momentFormatDisplayNumeric;
+
+ @Output()
+ public appSelect: EventEmitter<{ id: number, value: boolean }> = new EventEmitter();
+
+ public trackBy(index: number, entry: IStatementTableEntry) {
+ return entry?.id;
+ }
+
+ public select(id: number) {
+ this.appSelect.emit({id, value: true});
+ }
+
+ public deselect(id: number) {
+ this.appSelect.emit({id, value: false});
+ }
+
+}
diff --git a/src/app/shared/layout/statement-table/index.ts b/src/app/shared/layout/statement-table/index.ts
index 62e8b86..a9fbe25 100644
--- a/src/app/shared/layout/statement-table/index.ts
+++ b/src/app/shared/layout/statement-table/index.ts
@@ -11,6 +11,8 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./IStatementTableEntry";
-export * from "./statement-table.component";
+export * from "./components";
+export * from "./model";
+export * from "./pipes";
+
export * from "./statement-table.module";
diff --git a/src/app/shared/layout/statement-table/IStatementTableEntry.ts b/src/app/shared/layout/statement-table/model/IStatementTableEntry.ts
similarity index 80%
rename from src/app/shared/layout/statement-table/IStatementTableEntry.ts
rename to src/app/shared/layout/statement-table/model/IStatementTableEntry.ts
index a18cce9..31708ed 100644
--- a/src/app/shared/layout/statement-table/IStatementTableEntry.ts
+++ b/src/app/shared/layout/statement-table/model/IStatementTableEntry.ts
@@ -11,8 +11,12 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-import {IAPIStatementModel} from "../../../core/api/statements";
+import {IAPIStatementModel} from "../../../../core";
export interface IStatementTableEntry extends IAPIStatementModel {
isSelected?: boolean;
+
+ currentTaskName?: string;
+ contributionStatus?: string;
+ contributionStatusForMyDepartment?: boolean;
}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/shared/layout/statement-table/model/index.ts
similarity index 92%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/shared/layout/statement-table/model/index.ts
index a3980e1..84fb599 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/shared/layout/statement-table/model/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./IStatementTableEntry";
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/shared/layout/statement-table/pipes/index.ts
similarity index 92%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/shared/layout/statement-table/pipes/index.ts
index a3980e1..ec85594 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/shared/layout/statement-table/pipes/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./statement-table-alert.pipe";
diff --git a/src/app/shared/layout/statement-table/pipes/statement-table-alert.pipe.spec.ts b/src/app/shared/layout/statement-table/pipes/statement-table-alert.pipe.spec.ts
new file mode 100644
index 0000000..6c105f3
--- /dev/null
+++ b/src/app/shared/layout/statement-table/pipes/statement-table-alert.pipe.spec.ts
@@ -0,0 +1,37 @@
+/********************************************************************************
+ * 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 {IStatementTableEntry} from "../model";
+import {StatementTableAlertPipe} from "./statement-table-alert.pipe";
+
+describe("StatementTableAlertPipe", () => {
+
+ const pipe = new StatementTableAlertPipe();
+
+ it("should transform entries to boolean", () => {
+ const entry: IStatementTableEntry = {} as IStatementTableEntry;
+ expect(pipe.transform(null)).toBe(false);
+ expect(pipe.transform(entry)).toBe(false);
+
+ entry.dueDate = "2019-01-01";
+ expect(pipe.transform(entry)).toBe(true);
+ });
+
+ it("should check if a given time span is due", () => {
+ expect(pipe.isDue(null)).toBe(false);
+ expect(pipe.isDue(pipe.dueTimeInMs)).toBe(false);
+ expect(pipe.isDue(pipe.dueTimeInMs - 1)).toBe(true);
+ expect(pipe.isDue(0)).toBe(true);
+ });
+
+});
diff --git a/src/app/shared/layout/statement-table/pipes/statement-table-alert.pipe.ts b/src/app/shared/layout/statement-table/pipes/statement-table-alert.pipe.ts
new file mode 100644
index 0000000..59c7a3c
--- /dev/null
+++ b/src/app/shared/layout/statement-table/pipes/statement-table-alert.pipe.ts
@@ -0,0 +1,34 @@
+/********************************************************************************
+ * 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 {momentDiff} from "../../../../util/moment";
+import {IStatementTableEntry} from "../model";
+
+@Pipe({name: "statementTableAlert"})
+export class StatementTableAlertPipe implements PipeTransform {
+
+ public readonly dueTimeInMs = 5 * (1000 * 60 * 60 * 24);
+
+ public transform(value: IStatementTableEntry): boolean {
+ if (value?.dueDate == null) {
+ return false;
+ }
+ return this.isDue(momentDiff(value.dueDate, new Date()));
+ }
+
+ public isDue(diffInMs: number): boolean {
+ return Number.isFinite(diffInMs) ? diffInMs < this.dueTimeInMs : false;
+ }
+
+}
diff --git a/src/app/shared/layout/statement-table/statement-table.component.html b/src/app/shared/layout/statement-table/statement-table.component.html
deleted file mode 100644
index 894f5d9..0000000
--- a/src/app/shared/layout/statement-table/statement-table.component.html
+++ /dev/null
@@ -1,102 +0,0 @@
-<!-------------------------------------------------------------------------------
- * 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
- -------------------------------------------------------------------------------->
-
-<table [dataSource]="appEntries" class="predecessors--table" mat-table>
-
- <caption hidden>{{ "shared.statementTable.caption" | translate}}</caption>
-
- <ng-container matColumnDef="select">
- <th *matHeaderCellDef
- class="predecessors--table--cell predecessors--table--cell---border predecessors--table--cell---bold"
- mat-header-cell scope="col"></th>
- <td *matCellDef="let statement" class="predecessors--table--cell predecessors--table--cell---border"
- mat-cell>
- <div class="predecessors--table--cell--checkbox">
- <input #inputElement (keydown.enter)="inputElement.click()"
- (ngModelChange)="$event ? select(statement.id) : deselect(statement.id)"
- [class.cursor-pointer]="!appDisabled"
- [disabled]="appDisabled"
- [ngModel]="statement?.isSelected"
- class="predecessors--table--cell--checkbox---size"
- type="checkbox">
- </div>
- </td>
- </ng-container>
-
- <ng-container matColumnDef="id">
- <th *matHeaderCellDef
- class="predecessors--table--cell predecessors--table--cell---border predecessors--table--cell---bold predecessors--table--cell---center"
- mat-header-cell scope="col">{{"shared.statementTable.id" | translate}}</th>
- <td *matCellDef="let statement"
- class="predecessors--table--cell predecessors--table--cell---border predecessors--table--cell---center"
- mat-cell>{{statement?.id}}</td>
- </ng-container>
-
- <ng-container matColumnDef="title">
- <th *matHeaderCellDef
- class="predecessors--table--cell predecessors--table--cell---border predecessors--table--cell---bold"
- mat-header-cell scope="col">{{"shared.statementTable.title" | translate}}</th>
- <td *matCellDef="let statement" class="predecessors--table--cell predecessors--table--cell---border"
- mat-cell>{{statement?.title}}</td>
- </ng-container>
-
- <ng-container matColumnDef="type">
- <th *matHeaderCellDef
- class="predecessors--table--cell predecessors--table--cell---border predecessors--table--cell---bold"
- mat-header-cell scope="col">{{"shared.statementTable.statementType" | translate}}</th>
- <td *matCellDef="let statement" class="predecessors--table--cell predecessors--table--cell---border"
- mat-cell>{{ (appStatementTypeOptions | selected : statement?.typeId)?.label }}</td>
- </ng-container>
-
- <ng-container matColumnDef="date">
- <th *matHeaderCellDef
- class="predecessors--table--cell predecessors--table--cell---border predecessors--table--cell---bold"
- mat-header-cell scope="col">{{"shared.statementTable.receiptDate" | translate}}</th>
- <td *matCellDef="let statement" class="predecessors--table--cell predecessors--table--cell---border"
- mat-cell>{{statement?.receiptDate ? (statement.receiptDate | appMomentPipe).format(appTimeDisplayFormat) : ""}} </td>
- </ng-container>
-
- <ng-container matColumnDef="city">
- <th *matHeaderCellDef
- class="predecessors--table--cell predecessors--table--cell---border predecessors--table--cell---bold"
- mat-header-cell scope="col">{{"shared.statementTable.city" | translate}}</th>
- <td *matCellDef="let statement" class="predecessors--table--cell predecessors--table--cell---border"
- mat-cell>{{statement?.city}}</td>
- </ng-container>
-
- <ng-container matColumnDef="district">
- <th *matHeaderCellDef
- class="predecessors--table--cell predecessors--table--cell---border predecessors--table--cell---bold"
- mat-header-cell scope="col">{{"shared.statementTable.district" | translate}}</th>
- <td *matCellDef="let statement" class="predecessors--table--cell predecessors--table--cell---border"
- mat-cell>{{statement?.district}}</td>
- </ng-container>
-
- <ng-container matColumnDef="link">
- <th *matHeaderCellDef
- class="predecessors--table--cell predecessors--table--cell---border predecessors--table--cell---bold"
- mat-header-cell scope="col"></th>
- <td *matCellDef="let statement" class="predecessors--table--cell predecessors--table--cell---border"
- mat-cell>
- <a [queryParams]="{id: statement.id}"
- class="openk-button openk-button-rounded openk-info predecessors--table--cell--btn" routerLink="/details"
- target="_blank">
- <mat-icon>call_made</mat-icon>
- </a>
- </td>
- </ng-container>
-
- <tr *matHeaderRowDef="appColumnsToDisplay; sticky: true" class="predecessors--table--row" mat-header-row></tr>
- <tr *matRowDef="let myRowData; columns: appColumnsToDisplay"
- class="predecessors--table--row predecessors--table--row---alternating-color" mat-row></tr>
-</table>
diff --git a/src/app/shared/layout/statement-table/statement-table.component.scss b/src/app/shared/layout/statement-table/statement-table.component.scss
deleted file mode 100644
index 3f9fdec..0000000
--- a/src/app/shared/layout/statement-table/statement-table.component.scss
+++ /dev/null
@@ -1,119 +0,0 @@
-/********************************************************************************
- * 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 "openk.styles";
-
-:host {
- display: flex;
- flex-flow: column;
- width: 100%;
- overflow: auto;
- box-sizing: border-box;
- border: 1px solid $openk-form-border;
- border-radius: 4px;
-}
-
-.predecessors {
- width: 100%;
- overflow: auto;
- display: flex;
- box-sizing: border-box;
- height: 100%;
-}
-
-.predecessors---border {
- border: 1px solid $openk-form-border;
- border-radius: 4px;
-}
-
-.predecessors--table {
- flex: 1;
- width: 100%;
-}
-
-.predecessors--table--row {
- background: $openk-background-card;
- height: auto;
-}
-
-.predecessors--table--row---alternating-color {
-
- &:nth-child(odd) {
- background: $openk-background-highlight;
- }
-}
-
-.predecessors--table--cell {
- padding: 0.25em 0.75em;
-}
-
-.predecessors--table--cell---border {
- border-bottom: 1px solid $openk-form-border;
-}
-
-.predecessors--table--cell---bold {
- font-weight: bold;
- font-size: 14px;
-}
-
-.predecessors--table--cell--checkbox {
- display: flex;
- flex-direction: row;
- align-items: center;
-}
-
-.predecessors--table--cell---center {
- text-align: center;
-}
-
-.predecessors--table--cell---fill {
- width: 100%;
- line-break: anywhere;
-}
-
-.predecessors--table--cell--checkbox---size {
- font-size: 1em;
- width: 1em;
- height: 1em;
-}
-
-.predecessors--table--cell--btn {
- transform: scale(0.7);
-}
-
-.predecessors--table--cell--icon---red {
- color: get-color($openk-danger-palette, 300);
-}
-
-.mat-header-cell:first-of-type {
- padding-left: 0.6em;
-}
-
-.mat-cell:first-of-type {
- padding-left: 0.6em;
-}
-
-.mat-header-cell:last-of-type {
- padding-right: 0.6em;
-}
-
-.mat-cell:last-of-type {
- padding-right: 0.6em;
-}
-
-.mat-row:last-of-type {
-
- .mat-cell {
- border-bottom: 0;
- }
-}
diff --git a/src/app/shared/layout/statement-table/statement-table.component.spec.ts b/src/app/shared/layout/statement-table/statement-table.component.spec.ts
deleted file mode 100644
index 0cfd30c..0000000
--- a/src/app/shared/layout/statement-table/statement-table.component.spec.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-/********************************************************************************
- * 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 {Component, ElementRef, ViewChild} from "@angular/core";
-import {ComponentFixture, TestBed} from "@angular/core/testing";
-import {MatIconModule} from "@angular/material/icon";
-import {By} from "@angular/platform-browser";
-import {RouterTestingModule} from "@angular/router/testing";
-import {I18nModule} from "../../../core/i18n";
-import {IStatementTableEntry} from "./IStatementTableEntry";
-import {StatementTableComponent} from "./statement-table.component";
-import {StatementTableModule} from "./statement-table.module";
-
-@Component({
- selector: `app-host-component`,
- template: `
- <div style="height: 200px;">
- <app-statement-table #predecessors [appEntries]="appData"></app-statement-table>
- </div>
- `
-})
-class TestHostComponent {
- public appData: Array<IStatementTableEntry>;
- @ViewChild("history", {read: ElementRef}) history: ElementRef;
-
- public constructor() {
- this.appData = (new Array(40)).fill({
- isPredecessor: true,
- id: 1,
- title: "ein Titel",
- type: "Bauvorhaben",
- receiptDate: "2007-08-31T16:47+00:00",
- city: "Stadt",
- district: "Ortsteil"
- });
- }
-}
-
-describe("StatementTableComponent", () => {
- let component: TestHostComponent;
- let fixture: ComponentFixture<TestHostComponent>;
- let childComponent: StatementTableComponent;
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- declarations: [StatementTableComponent, TestHostComponent],
- imports: [
- StatementTableModule,
- I18nModule,
- MatIconModule,
- RouterTestingModule
- ],
- }).compileComponents();
- });
- beforeEach(() => {
- fixture = TestBed.createComponent(TestHostComponent);
- component = fixture.componentInstance;
- const childDebugElement = fixture.debugElement.query(By.directive(StatementTableComponent));
- childComponent = childDebugElement.componentInstance;
- fixture.detectChanges();
- });
-
- it("should create", () => {
- expect(component).toBeTruthy();
- });
-
- it("should emit appToggleSelect with the id and corresponding checked value", () => {
- spyOn(childComponent.appToggleSelect, "emit").and.callThrough();
- childComponent.select(1);
- expect(childComponent.appToggleSelect.emit).toHaveBeenCalledWith({id: 1, value: true});
- childComponent.deselect(1);
- expect(childComponent.appToggleSelect.emit).toHaveBeenCalledWith({id: 1, value: false});
- });
-});
diff --git a/src/app/shared/layout/statement-table/statement-table.component.ts b/src/app/shared/layout/statement-table/statement-table.component.ts
deleted file mode 100644
index 3e472b3..0000000
--- a/src/app/shared/layout/statement-table/statement-table.component.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/********************************************************************************
- * 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 {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from "@angular/core";
-import {momentFormatDisplayNumeric} from "../../../util/moment";
-import {ISelectOption} from "../../controls/select/model";
-import {IStatementTableEntry} from "./IStatementTableEntry";
-
-export type TStatementTableColumns = "select" | "id" | "title" | "type" | "date" | "city" | "district" | "link";
-
-@Component({
- selector: "app-statement-table",
- templateUrl: "./statement-table.component.html",
- styleUrls: ["./statement-table.component.scss"],
- changeDetection: ChangeDetectionStrategy.OnPush
-})
-export class StatementTableComponent {
-
- @Input()
- public appColumnsToDisplay: TStatementTableColumns[] = ["select", "id", "title", "type", "date", "city", "district", "link"];
-
- @Input()
- public appDisabled: boolean;
-
- @Input()
- public appEntries: IStatementTableEntry[];
-
- @Input()
- public appStatementTypeOptions: ISelectOption<number>[] = [];
-
- @Input()
- public appTimeDisplayFormat: string = momentFormatDisplayNumeric;
-
- @Output()
- public appToggleSelect: EventEmitter<{ id: number, value: boolean }> = new EventEmitter();
-
- public select(id: number) {
- this.appToggleSelect.emit({id, value: true});
- }
-
- public deselect(id: number) {
- this.appToggleSelect.emit({id, value: false});
- }
-}
diff --git a/src/app/shared/layout/statement-table/statement-table.module.ts b/src/app/shared/layout/statement-table/statement-table.module.ts
index 2f1cf08..b1c256b 100644
--- a/src/app/shared/layout/statement-table/statement-table.module.ts
+++ b/src/app/shared/layout/statement-table/statement-table.module.ts
@@ -11,33 +11,37 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
+import {CdkTableModule} from "@angular/cdk/table";
import {CommonModule} from "@angular/common";
import {NgModule} from "@angular/core";
import {FormsModule} from "@angular/forms";
import {MatIconModule} from "@angular/material/icon";
-import {MatTableModule} from "@angular/material/table";
import {RouterModule} from "@angular/router";
import {TranslateModule} from "@ngx-translate/core";
import {DateControlModule} from "../../controls/date-control";
import {SelectModule} from "../../controls/select";
-import {StatementTableComponent} from "./statement-table.component";
+import {StatementTableComponent} from "./components";
+import {StatementTableAlertPipe} from "./pipes";
@NgModule({
imports: [
CommonModule,
FormsModule,
- MatIconModule,
- MatTableModule,
- TranslateModule,
- DateControlModule,
RouterModule,
- SelectModule
+ CdkTableModule,
+ MatIconModule,
+
+ TranslateModule,
+ SelectModule,
+ DateControlModule
],
declarations: [
StatementTableComponent,
+ StatementTableAlertPipe
],
exports: [
StatementTableComponent,
+ StatementTableAlertPipe
]
})
export class StatementTableModule {
diff --git a/src/app/shared/linked-statements/linked-statements/linked-statements.component.html b/src/app/shared/linked-statements/linked-statements/linked-statements.component.html
index 076d351..680e9c6 100644
--- a/src/app/shared/linked-statements/linked-statements/linked-statements.component.html
+++ b/src/app/shared/linked-statements/linked-statements/linked-statements.component.html
@@ -14,18 +14,22 @@
<div *ngIf="appPredecessors?.length > 0" class="statements">
<span class="statements--titlebar">{{"shared.linkedStatements.precedingStatements" | translate}}</span>
<app-statement-table
- [appColumnsToDisplay]="columnsToDisplay"
+ [appColumns]="columns"
[appEntries]="appSuccessors"
- [appStatementTypeOptions]="appStatementTypeOptions">
+ [appOpenStatementInNewTab]="true"
+ [appStatementTypeOptions]="appStatementTypeOptions"
+ class="openk-table---last-row-without-border">
</app-statement-table>
</div>
<div *ngIf="appSuccessors?.length > 0" class="statements">
<span class="statements--titlebar">{{"shared.linkedStatements.successiveStatements" | translate}}</span>
<app-statement-table
- [appColumnsToDisplay]="columnsToDisplay"
+ [appColumns]="columns"
[appEntries]="appSuccessors"
- [appStatementTypeOptions]="appStatementTypeOptions">
+ [appOpenStatementInNewTab]="true"
+ [appStatementTypeOptions]="appStatementTypeOptions"
+ class="openk-table---last-row-without-border">
</app-statement-table>
</div>
diff --git a/src/app/shared/linked-statements/linked-statements/linked-statements.component.ts b/src/app/shared/linked-statements/linked-statements/linked-statements.component.ts
index 83fbc69..e46b191 100644
--- a/src/app/shared/linked-statements/linked-statements/linked-statements.component.ts
+++ b/src/app/shared/linked-statements/linked-statements/linked-statements.component.ts
@@ -13,7 +13,7 @@
import {Component, Input} from "@angular/core";
import {ISelectOption} from "../../controls/select/model";
-import {IStatementTableEntry, TStatementTableColumns} from "../../layout/statement-table";
+import {IStatementTableEntry, StatementTableComponent, TStatementTableColumns} from "../../layout/statement-table";
@Component({
selector: "app-linked-statements",
@@ -22,8 +22,6 @@
})
export class LinkedStatementsComponent {
- public columnsToDisplay: TStatementTableColumns[] = ["id", "title", "type", "date", "city", "district", "link"];
-
@Input()
public appPredecessors: Array<IStatementTableEntry>;
@@ -32,4 +30,7 @@
@Input()
public appStatementTypeOptions: ISelectOption<number>[];
+
+ public columns: TStatementTableColumns[] = [...StatementTableComponent.SEARCH_COLUMNS];
+
}
diff --git a/src/app/shared/text-block/components/text-block/text-block.component.scss b/src/app/shared/text-block/components/text-block/text-block.component.scss
index 31362c5..42249fc 100644
--- a/src/app/shared/text-block/components/text-block/text-block.component.scss
+++ b/src/app/shared/text-block/components/text-block/text-block.component.scss
@@ -27,7 +27,7 @@
}
.text-block---error {
- border-color: get-color($openk-danger-palette, 200);
+ border-color: $openk-error-color;
}
.title-bar {
@@ -67,7 +67,7 @@
.title-bar--block-error {
float: right;
- color: get-color($openk-danger-palette, 200);
+ color: $openk-error-color;
}
.highlight-text {
@@ -98,7 +98,7 @@
}
.error {
- color: get-color($openk-danger-palette, A200);
+ color: $openk-error-color;
display: inline-flex;
}
diff --git a/src/app/store/app-store.module.ts b/src/app/store/app-store.module.ts
index 3f7599d..013dafb 100644
--- a/src/app/store/app-store.module.ts
+++ b/src/app/store/app-store.module.ts
@@ -12,8 +12,12 @@
********************************************************************************/
import {NgModule, Optional, SkipSelf} from "@angular/core";
+import {MessageService} from "primeng/api";
+import {ButtonModule} from "primeng/button";
+import {ToastModule} from "primeng/toast";
import {AttachmentsStoreModule} from "./attachments";
import {ContactsStoreModule} from "./contacts";
+import {MailStoreModule} from "./mail";
import {ProcessStoreModule} from "./process";
import {RootStoreModule} from "./root";
import {SettingsStoreModule} from "./settings";
@@ -26,7 +30,15 @@
SettingsStoreModule,
ProcessStoreModule,
StatementsStoreModule,
- ContactsStoreModule
+ ContactsStoreModule,
+ ToastModule,
+ ButtonModule,
+ MailStoreModule
+ ],
+ providers: [
+ MessageService,
+ ContactsStoreModule,
+ MailStoreModule
]
})
export class AppStoreModule {
diff --git a/src/app/store/attachments/effects/download/attachment-download.effect.ts b/src/app/store/attachments/effects/download/attachment-download.effect.ts
index f1d0d7c..7449336 100644
--- a/src/app/store/attachments/effects/download/attachment-download.effect.ts
+++ b/src/app/store/attachments/effects/download/attachment-download.effect.ts
@@ -13,9 +13,14 @@
import {Inject, Injectable} from "@angular/core";
import {Actions, createEffect, ofType} from "@ngrx/effects";
-import {filter, mergeMap} from "rxjs/operators";
+import {Action} from "@ngrx/store";
+import {defer, Observable} from "rxjs";
+import {filter, ignoreElements, mergeMap} from "rxjs/operators";
import {AuthService, DownloadService, SPA_BACKEND_ROUTE} from "../../../../core";
import {urlJoin} from "../../../../util/http";
+import {catchErrorTo} from "../../../../util/rxjs";
+import {setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
import {startAttachmentDownloadAction} from "../../actions";
@Injectable({providedIn: "root"})
@@ -24,8 +29,8 @@
public startAttachmentDownload$ = createEffect(() => this.actions.pipe(
ofType(startAttachmentDownloadAction),
filter((action) => action.statementId != null && action.attachmentId != null),
- mergeMap(async (action) => this.startAttachmentDownload(action.statementId, action.attachmentId))
- ), {dispatch: false});
+ mergeMap((action) => this.startAttachmentDownload(action.statementId, action.attachmentId))
+ ));
public constructor(
public actions: Actions,
@@ -36,9 +41,14 @@
}
- public startAttachmentDownload(statementId: number, attachmentId: number) {
- const endPoint = `/statements/${statementId}/attachments/${attachmentId}/file`;
- return this.downloadService.startDownload(urlJoin(this.spaBackendRoute, endPoint), this.authService.token);
+ public startAttachmentDownload(statementId: number, attachmentId: number): Observable<Action> {
+ return defer(() => {
+ const endPoint = `/statements/${statementId}/attachments/${attachmentId}/file`;
+ return this.downloadService.startDownload(urlJoin(this.spaBackendRoute, endPoint), this.authService.token);
+ }).pipe(
+ ignoreElements(),
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED}))
+ );
}
}
diff --git a/src/app/store/attachments/effects/fetch/fetch-attachments.effect.ts b/src/app/store/attachments/effects/fetch/fetch-attachments.effect.ts
index f4c7276..e6dbfe4 100644
--- a/src/app/store/attachments/effects/fetch/fetch-attachments.effect.ts
+++ b/src/app/store/attachments/effects/fetch/fetch-attachments.effect.ts
@@ -16,8 +16,10 @@
import {Action} from "@ngrx/store";
import {merge, Observable} from "rxjs";
import {filter, map, retry, switchMap} from "rxjs/operators";
-import {AttachmentsApiService} from "../../../../core/api/attachments";
-import {completeInitializationAction} from "../../../root/actions";
+import {AttachmentsApiService} from "../../../../core";
+import {catchErrorTo} from "../../../../util/rxjs";
+import {completeInitializationAction, setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
import {fetchAttachmentsAction, fetchAttachmentTagsAction, setAttachmentsAction, setAttachmentTagsAction} from "../../actions";
@Injectable({providedIn: "root"})
@@ -48,14 +50,16 @@
public fetchAttachments(statementId: number): Observable<Action> {
return this.attachmentsApiService.getAttachments(statementId).pipe(
map((entities) => setAttachmentsAction({statementId, entities})),
- retry(2)
+ retry(2),
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED}))
);
}
public fetchAttachmentTags(): Observable<Action> {
return this.attachmentsApiService.getTagList().pipe(
map((tags) => setAttachmentTagsAction({tags})),
- retry(2)
+ retry(2),
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED}))
);
}
diff --git a/src/app/store/attachments/effects/submit/submit-attachments.effect.spec.ts b/src/app/store/attachments/effects/submit/submit-attachments.effect.spec.ts
index ab634c8..543ff23 100644
--- a/src/app/store/attachments/effects/submit/submit-attachments.effect.spec.ts
+++ b/src/app/store/attachments/effects/submit/submit-attachments.effect.spec.ts
@@ -81,7 +81,7 @@
});
it("should throw an error if a single request fails on submit", async () => {
- const attachmentError: IAttachmentError = {statementId, attachment: null, error: new Error("Test")};
+ const attachmentError: IAttachmentError = {statementId, attachment: null, error: new Error("Test"), message: "Message"};
effect.addAttachments = (
_: number,
__: string,
@@ -95,7 +95,7 @@
spyOn(effect.fetchAttachmentsEffect, "fetchAttachments").and.returnValue(EMPTY);
await expectAsync(effect.submit(statementId, taskId, {add: [], edit: []}).toPromise())
- .toBeRejectedWith([attachmentError]);
+ .toBeRejectedWith(attachmentError.error);
});
it("should add attachments", async () => {
@@ -156,10 +156,10 @@
},
null
];
- const add$ = effect.editAttachments(statementId, taskId, values, errors);
+ const edit$ = effect.editAttachments(statementId, taskId, values, errors);
const results: Action[] = [];
- subscription = add$.subscribe((_) => results.push(_));
+ subscription = edit$.subscribe((_) => results.push(_));
expectDeleteRequest(18, true);
expectDeleteRequest(19, false);
expect(results).toEqual([
@@ -170,6 +170,7 @@
expect(errors[0].statementId).toBe(statementId);
expect(errors[0].attachment).toBe(values[0]);
expect(errors[0].error).toBeDefined();
+ expect(errors[0].message).toBeDefined();
httpTestingController.verify();
});
@@ -198,11 +199,14 @@
subscription = add$.subscribe((_) => results.push(_));
expectPostAttachmentTags(values[0].id, values[0].tagIds, true);
expectPostAttachmentTags(values[1].id, values[1].tagIds, false);
- expect(results).toEqual([updateAttachmentTagsAction({items: values})]);
+ expect(results).toEqual([
+ updateAttachmentTagsAction({items: values})
+ ]);
expect(errors.length).toBe(1);
expect(errors[0].statementId).toBe(statementId);
expect(errors[0].attachment).toBe(values[0]);
expect(errors[0].error).toBeDefined();
+ expect(errors[0].message).toBeDefined();
httpTestingController.verify();
});
diff --git a/src/app/store/attachments/effects/submit/submit-attachments.effect.ts b/src/app/store/attachments/effects/submit/submit-attachments.effect.ts
index 39d8366..329a94e 100644
--- a/src/app/store/attachments/effects/submit/submit-attachments.effect.ts
+++ b/src/app/store/attachments/effects/submit/submit-attachments.effect.ts
@@ -17,7 +17,10 @@
import {concat, EMPTY, Observable, of, throwError} from "rxjs";
import {catchError, filter, ignoreElements, map, mergeMap, startWith, switchMap} from "rxjs/operators";
import {AttachmentsApiService} from "../../../../core/api/attachments";
+import {MailApiService} from "../../../../core/api/mail";
import {arrayJoin, endWithObservable, ignoreError} from "../../../../util";
+import {setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
import {
addAttachmentEntityAction,
deleteAttachmentsAction,
@@ -43,7 +46,8 @@
public constructor(
public readonly actions: Actions,
public readonly attachmentsApiService: AttachmentsApiService,
- public readonly fetchAttachmentsEffect: FetchAttachmentsEffect
+ public readonly fetchAttachmentsEffect: FetchAttachmentsEffect,
+ public readonly mailApiService: MailApiService
) {
}
@@ -54,15 +58,71 @@
value: IAttachmentFormValue
): Observable<Action> {
const errors: IAttachmentError[] = [];
+ const edit = arrayJoin(value?.edit, value?.email).filter((_) => _?.id);
return concat(
- this.editAttachments(statementId, taskId, value?.edit, errors),
+ this.editAttachments(statementId, taskId, edit, errors),
this.addAttachments(statementId, taskId, value?.add, errors),
+ this.transferEmailAttachments(statementId, taskId, value?.email, errors),
+ this.transferMailText(statementId, taskId, value?.transferMailText, value?.mailTextAttachmentId, errors),
this.fetchAttachmentsEffect.fetchAttachments(statementId)
).pipe(
- endWithObservable(() => errors.length > 0 ? throwError(errors) : EMPTY)
+ endWithObservable(() => {
+ const lastError = errors.reverse()[0];
+ return lastError == null ? EMPTY : concat(
+ of(setErrorAction({statementId, error: lastError.message})),
+ throwError(lastError.error)
+ );
+ })
);
}
+ public transferMailText(
+ statementId: number,
+ taskId: string,
+ shouldBeAdded?: boolean,
+ mailTextAttachmentId?: number,
+ errors?: IAttachmentError[]
+ ) {
+ const mailTextIsAlreadyAdded = mailTextAttachmentId == null;
+ if (shouldBeAdded && mailTextIsAlreadyAdded) {
+ return this.mailApiService.transferMailText(statementId, taskId).pipe(
+ map((entity) => addAttachmentEntityAction({statementId, entity})),
+ catchError((error) => {
+ errors.push({statementId, attachment: null, error, message: EErrorCode.FAILED_MAIL_TRANSFER});
+ return EMPTY;
+ })
+ );
+ }
+ if (!shouldBeAdded && !mailTextIsAlreadyAdded) {
+ return this.attachmentsApiService.deleteAttachment(statementId, taskId, mailTextAttachmentId).pipe(
+ map(() => deleteAttachmentsAction({statementId, entityIds: [mailTextAttachmentId]})),
+ catchError((error) => {
+ errors.push({statementId, attachment: null, error, message: EErrorCode.UNEXPECTED});
+ return EMPTY;
+ })
+ );
+ }
+ return EMPTY;
+ }
+
+ public transferEmailAttachments(statementId: number,
+ taskId: string,
+ emailAttachments: IAttachmentControlValue[],
+ errors: IAttachmentError[] = []): Observable<Action> {
+ const attachmentsToTransfer = arrayJoin(emailAttachments)
+ .filter((_) => _.isSelected && _.id == null)
+ .map((_) => ({name: _.name, tagIds: _.tagIds}));
+
+ return attachmentsToTransfer.length === 0 ? EMPTY :
+ this.mailApiService.transferMailAttachment(statementId, taskId, attachmentsToTransfer).pipe(
+ ignoreElements(),
+ catchError((error) => {
+ errors.push({statementId, attachment: null, error, message: EErrorCode.FAILED_MAIL_TRANSFER});
+ return EMPTY;
+ })
+ );
+ }
+
public editAttachments(
statementId: number,
taskId: string,
@@ -74,7 +134,7 @@
mergeMap((item) => {
return this.editSingleAttachment(statementId, taskId, item.id, item.tagIds, !item.isSelected).pipe(
catchError((error) => {
- errors.push({statementId, attachment: item, error});
+ errors.push({statementId, attachment: item, error, message: EErrorCode.UNEXPECTED});
return EMPTY;
})
);
@@ -96,7 +156,7 @@
return this.addSingleAttachment(statementId, taskId, item.file, item.tagIds).pipe(
catchError((error) => {
items.push(item);
- errors.push({statementId, attachment: item, error});
+ errors.push({statementId, attachment: item, error, message: EErrorCode.FAILED_FILE_UPLOAD});
return EMPTY;
})
);
diff --git a/src/app/store/attachments/model/IAttachmentError.ts b/src/app/store/attachments/model/IAttachmentError.ts
index 60084e6..3ba1d2e 100644
--- a/src/app/store/attachments/model/IAttachmentError.ts
+++ b/src/app/store/attachments/model/IAttachmentError.ts
@@ -16,5 +16,6 @@
export interface IAttachmentError {
statementId: number;
attachment: IAttachmentControlValue;
+ message: string;
error: any;
}
diff --git a/src/app/store/attachments/model/IAttachmentFormValue.ts b/src/app/store/attachments/model/IAttachmentFormValue.ts
index 74ec430..00dcb11 100644
--- a/src/app/store/attachments/model/IAttachmentFormValue.ts
+++ b/src/app/store/attachments/model/IAttachmentFormValue.ts
@@ -11,7 +11,7 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-import {FormArray} from "@angular/forms";
+import {FormArray, FormControl} from "@angular/forms";
import {createFormGroup} from "../../../util/forms";
import {IAttachmentControlValue} from "./IAttachmentControlValue";
@@ -21,11 +21,20 @@
edit?: IAttachmentControlValue[];
+ email?: IAttachmentControlValue[];
+
+ transferMailText?: boolean;
+
+ mailTextAttachmentId?: number;
+
}
export function createAttachmentForm() {
return createFormGroup<IAttachmentFormValue>({
add: new FormArray([]),
- edit: new FormArray([])
+ edit: new FormArray([]),
+ email: new FormArray([]),
+ transferMailText: new FormControl(false),
+ mailTextAttachmentId: new FormControl(undefined)
});
}
diff --git a/src/app/store/attachments/selectors/attachments.selectors.ts b/src/app/store/attachments/selectors/attachments.selectors.ts
index 3035e85..59ae2dc 100644
--- a/src/app/store/attachments/selectors/attachments.selectors.ts
+++ b/src/app/store/attachments/selectors/attachments.selectors.ts
@@ -83,6 +83,16 @@
}
);
+export const getAllStatementAttachments = createSelector(
+ getStatementAttachmentIds,
+ attachmentsEntitiesSelector,
+ (attachmentIds: number[],
+ attachmentEntities: TStoreEntities<IAPIAttachmentModel>) => {
+ return arrayJoin(attachmentIds)
+ .map((attachmentId) => attachmentEntities[attachmentId]);
+ }
+);
+
export const getStatementAttachmentCacheSelector = createSelector(
getStatementAttachmentCacheEntitiesSelector,
queryParamsIdSelector,
diff --git a/src/app/store/contacts/actions/contact.actions.ts b/src/app/store/contacts/actions/contact.actions.ts
index 7939f8d..8c3eb21 100644
--- a/src/app/store/contacts/actions/contact.actions.ts
+++ b/src/app/store/contacts/actions/contact.actions.ts
@@ -22,7 +22,7 @@
export const fetchContactDetailsAction = createAction(
"[Edit] Fetch contact details",
- props<{ contactId: string }>()
+ props<{ contactId: string, statementId: number | "new" }>()
);
export const setContactEntityAction = createAction(
diff --git a/src/app/store/contacts/effects/fetch-details/fetch-contact-details.effect.spec.ts b/src/app/store/contacts/effects/fetch-details/fetch-contact-details.effect.spec.ts
index 0705992..b6504fe 100644
--- a/src/app/store/contacts/effects/fetch-details/fetch-contact-details.effect.spec.ts
+++ b/src/app/store/contacts/effects/fetch-details/fetch-contact-details.effect.spec.ts
@@ -77,7 +77,7 @@
];
const results: Action[] = [];
- actions$ = of(fetchContactDetailsAction({contactId}));
+ actions$ = of(fetchContactDetailsAction({contactId, statementId: undefined}));
subscription = effect.fetch$.subscribe((action) => results.push(action));
expectFetchContactDetails(contactId, fetchResult);
diff --git a/src/app/store/contacts/effects/fetch-details/fetch-contact-details.effect.ts b/src/app/store/contacts/effects/fetch-details/fetch-contact-details.effect.ts
index 55c5487..2706d80 100644
--- a/src/app/store/contacts/effects/fetch-details/fetch-contact-details.effect.ts
+++ b/src/app/store/contacts/effects/fetch-details/fetch-contact-details.effect.ts
@@ -17,7 +17,10 @@
import {Observable} from "rxjs";
import {endWith, filter, map, retry, startWith, switchMap} from "rxjs/operators";
import {ContactsApiService} from "../../../../core/api/contacts";
-import {ignoreError} from "../../../../util/rxjs";
+import {catchHttpErrorTo, EHttpStatusCodes} from "../../../../util/http";
+import {catchErrorTo} from "../../../../util/rxjs";
+import {setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
import {fetchContactDetailsAction, setContactEntityAction, setContactsLoadingState} from "../../actions";
@Injectable({providedIn: "root"})
@@ -26,14 +29,14 @@
public fetch$ = createEffect(() => this.actions.pipe(
ofType(fetchContactDetailsAction),
filter((action) => action.contactId != null),
- switchMap((action) => this.fetch(action.contactId))
+ switchMap((action) => this.fetch(action.contactId, action.statementId))
));
public constructor(public actions: Actions, public contactsApiService: ContactsApiService) {
}
- public fetch(contactId: string): Observable<Action> {
+ public fetch(contactId: string, statementId?: number | "new"): Observable<Action> {
return this.contactsApiService.getContactDetails(contactId).pipe(
map((result) => {
return setContactEntityAction({
@@ -43,8 +46,12 @@
}
});
}),
+ catchHttpErrorTo(
+ setErrorAction({statementId, error: EErrorCode.CONTACT_MODULE_NO_ACCESS}),
+ EHttpStatusCodes.FORBIDDEN
+ ),
retry(2),
- ignoreError(),
+ catchErrorTo(setErrorAction({statementId, error: EErrorCode.FAILED_LOADING_CONTACT})),
startWith(setContactsLoadingState({state: {fetching: true}})),
endWith(setContactsLoadingState({state: {fetching: false}}))
);
diff --git a/src/app/store/contacts/effects/search/search-contacts-effect.service.ts b/src/app/store/contacts/effects/search/search-contacts-effect.service.ts
index 71969f2..d9a77a6 100644
--- a/src/app/store/contacts/effects/search/search-contacts-effect.service.ts
+++ b/src/app/store/contacts/effects/search/search-contacts-effect.service.ts
@@ -17,7 +17,9 @@
import {asyncScheduler, Observable} from "rxjs";
import {concatMap, endWith, filter, map, startWith, throttleTime} from "rxjs/operators";
import {ContactsApiService, IAPISearchOptions} from "../../../../core";
-import {ignoreError} from "../../../../util";
+import {catchErrorTo, catchHttpErrorTo, EHttpStatusCodes} from "../../../../util";
+import {setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
import {setContactsLoadingState, setContactsSearchAction, startContactSearchAction} from "../../actions";
@Injectable({providedIn: "root"})
@@ -37,7 +39,8 @@
public search(options: IAPISearchOptions): Observable<Action> {
return this.contactsApiService.getContacts(options).pipe(
map((results) => setContactsSearchAction({results})),
- ignoreError(),
+ catchHttpErrorTo(setErrorAction({error: EErrorCode.CONTACT_MODULE_NO_ACCESS}), EHttpStatusCodes.FORBIDDEN),
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
startWith(setContactsLoadingState({state: {searching: true}})),
endWith(setContactsLoadingState({state: {searching: false}}))
);
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/store/mail/actions/index.ts
similarity index 92%
rename from src/app/features/dashboard/components/dashboard-item/index.ts
rename to src/app/store/mail/actions/index.ts
index a3980e1..3b9c7d9 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/store/mail/actions/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./mail.actions";
diff --git a/src/app/store/mail/actions/mail.actions.ts b/src/app/store/mail/actions/mail.actions.ts
new file mode 100644
index 0000000..8bde645
--- /dev/null
+++ b/src/app/store/mail/actions/mail.actions.ts
@@ -0,0 +1,56 @@
+/********************************************************************************
+ * 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 {createAction, props} from "@ngrx/store";
+import {IAPIEmailModel} from "../../../core/api/mail";
+import {IMailLoadingState} from "../model";
+
+export const fetchEmailAction = createAction(
+ "[Email/Edit] Fetch email",
+ props<{ mailId: string, statementId?: "new" | number }>()
+);
+
+export const fetchEmailInboxAction = createAction(
+ "[Email] Fetch inbox"
+);
+
+export const deleteEmailFromInboxAction = createAction(
+ "[Email] Delete email from inbox",
+ props<{ mailId: string, navigateTo?: string }>()
+);
+
+export const downloadEmailAttachmentAction = createAction(
+ "[Email] Download email attachment",
+ props<{ mailId: string, name: string; }>()
+);
+
+
+export const setEmailInboxAction = createAction(
+ "[API] Set inbox",
+ props<{ entities: IAPIEmailModel[] }>()
+);
+
+export const setEmailEntitiesAction = createAction(
+ "[API] Set email entity",
+ props<{ entities: IAPIEmailModel[] }>()
+);
+
+export const deleteEmailEntityAction = createAction(
+ "[API] Delete email entity",
+ props<{ mailId: string }>()
+);
+
+export const setEmailLoadingStateAction = createAction(
+ "[API] Set mail loading state",
+ props<{ loading?: IMailLoadingState }>()
+);
diff --git a/src/app/store/mail/effects/delete/delete-email-from-inbox.effect.spec.ts b/src/app/store/mail/effects/delete/delete-email-from-inbox.effect.spec.ts
new file mode 100644
index 0000000..6abb45d
--- /dev/null
+++ b/src/app/store/mail/effects/delete/delete-email-from-inbox.effect.spec.ts
@@ -0,0 +1,96 @@
+/********************************************************************************
+ * 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 {HttpClientTestingModule, HttpTestingController} from "@angular/common/http/testing";
+import {TestBed} from "@angular/core/testing";
+import {RouterTestingModule} from "@angular/router/testing";
+import {provideMockActions} from "@ngrx/effects/testing";
+import {Action} from "@ngrx/store";
+import {EMPTY, Observable, Subject, Subscription} from "rxjs";
+import {SPA_BACKEND_ROUTE} from "../../../../core";
+import {deleteEmailEntityAction, deleteEmailFromInboxAction, setEmailLoadingStateAction} from "../../actions";
+import {DeleteEmailFromInboxEffect} from "./delete-email-from-inbox.effect";
+
+describe("DeleteEmailFromInboxEffect", () => {
+
+ let actions$: Observable<Action>;
+ let httpTestingController: HttpTestingController;
+ let effect: DeleteEmailFromInboxEffect;
+ let subscription: Subscription;
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({
+ imports: [
+ HttpClientTestingModule,
+ RouterTestingModule
+ ],
+ providers: [
+ provideMockActions(() => actions$),
+ {
+ provide: SPA_BACKEND_ROUTE,
+ useValue: "/"
+ }
+ ]
+ });
+ effect = TestBed.inject(DeleteEmailFromInboxEffect);
+ httpTestingController = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ if (subscription != null) {
+ subscription.unsubscribe();
+ }
+ });
+
+ it("should delete emails on deleteEmailFromInboxAction", () => {
+ const mailId = "<Mail19>";
+ const results: Action[] = [];
+ const spy = spyOn(effect, "delete").and.returnValue(EMPTY);
+ const actionSubject = new Subject<Action>();
+ actions$ = actionSubject;
+ subscription = effect.delete$.subscribe((_) => results.push(_));
+
+ actionSubject.next(deleteEmailFromInboxAction({mailId}));
+ expect(spy).toHaveBeenCalledWith(mailId, undefined);
+ spy.calls.reset();
+
+ actionSubject.next(deleteEmailFromInboxAction({mailId: null}));
+ expect(spy).not.toHaveBeenCalled();
+
+ expect(results).toEqual([]);
+ });
+
+ it("should delete emails from inbox", async () => {
+ const mailId = "<Mail19>";
+ const results: Action[] = [];
+
+ subscription = effect.delete(mailId).subscribe((_) => results.push(_));
+ expectDeleteMailFromInboxRequest(mailId);
+
+ expect(subscription.closed).toBeTrue();
+ expect(results).toEqual([
+ setEmailLoadingStateAction({loading: {deleting: true}}),
+ deleteEmailEntityAction({mailId}),
+ setEmailLoadingStateAction({loading: {deleting: false}})
+ ]);
+ });
+
+ function expectDeleteMailFromInboxRequest(mailId: string) {
+ const url = `/mail/inbox/${mailId}`;
+ const request = httpTestingController.expectOne(url);
+ expect(request.request.method).toBe("DELETE");
+ request.flush(200);
+ }
+
+});
+
diff --git a/src/app/store/mail/effects/delete/delete-email-from-inbox.effect.ts b/src/app/store/mail/effects/delete/delete-email-from-inbox.effect.ts
new file mode 100644
index 0000000..2b937b3
--- /dev/null
+++ b/src/app/store/mail/effects/delete/delete-email-from-inbox.effect.ts
@@ -0,0 +1,61 @@
+/********************************************************************************
+ * 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 {Injectable} from "@angular/core";
+import {Router} from "@angular/router";
+import {Actions, createEffect, ofType} from "@ngrx/effects";
+import {Action} from "@ngrx/store";
+import {EMPTY, Observable} from "rxjs";
+import {catchError, endWith, filter, map, mergeMap, startWith} from "rxjs/operators";
+import {MailApiService} from "../../../../core";
+import {endWithObservable, ignoreError} from "../../../../util/rxjs";
+import {deleteEmailEntityAction, deleteEmailFromInboxAction, setEmailLoadingStateAction} from "../../actions";
+
+@Injectable({providedIn: "root"})
+export class DeleteEmailFromInboxEffect {
+
+ public delete$ = createEffect(() => this.actions.pipe(
+ ofType(deleteEmailFromInboxAction),
+ filter((action) => action.mailId != null),
+ mergeMap((action) => this.delete(action.mailId, action.navigateTo))
+ ));
+
+ public constructor(
+ public readonly actions: Actions,
+ public readonly mailApiService: MailApiService,
+ public readonly router: Router
+ ) {
+
+ }
+
+ public delete(mailId: string, navigateTo?: string): Observable<Action> {
+ let error = false;
+ return this.mailApiService.deleteInboxEmail(mailId).pipe(
+ map(() => deleteEmailEntityAction({mailId})),
+ startWith(setEmailLoadingStateAction({loading: {deleting: true}})),
+ catchError(() => {
+ error = true;
+ return EMPTY;
+ }),
+ ignoreError(),
+ endWith((setEmailLoadingStateAction({loading: {deleting: false}}))),
+ endWithObservable(() => {
+ if (navigateTo && !error) {
+ this.router.navigate(["mail"]);
+ }
+ return EMPTY;
+ })
+ );
+ }
+
+}
diff --git a/src/app/store/mail/effects/download/download-email-attachment.effect.spec.ts b/src/app/store/mail/effects/download/download-email-attachment.effect.spec.ts
new file mode 100644
index 0000000..f3d6b2d
--- /dev/null
+++ b/src/app/store/mail/effects/download/download-email-attachment.effect.spec.ts
@@ -0,0 +1,94 @@
+/********************************************************************************
+ * 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 {HttpClientTestingModule, HttpTestingController} from "@angular/common/http/testing";
+import {TestBed} from "@angular/core/testing";
+import {provideMockActions} from "@ngrx/effects/testing";
+import {Action} from "@ngrx/store";
+import {EMPTY, Observable, Subject, Subscription} from "rxjs";
+import {AuthService, SPA_BACKEND_ROUTE} from "../../../../core";
+import {downloadEmailAttachmentAction} from "../../actions";
+import {DownloadEmailAttachmentEffect} from "./download-email-attachment.effect";
+
+describe("DownloadEmailAttachmentEffect", () => {
+
+ const token = "<TOKEN>";
+ let actions$: Observable<Action>;
+ let httpTestingController: HttpTestingController;
+ let effect: DownloadEmailAttachmentEffect;
+ let subscription: Subscription;
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({
+ imports: [
+ HttpClientTestingModule
+ ],
+ providers: [
+ provideMockActions(() => actions$),
+ {
+ provide: SPA_BACKEND_ROUTE,
+ useValue: "/"
+ },
+ {
+ provide: AuthService,
+ useValue: {token}
+ }
+ ]
+ });
+ effect = TestBed.inject(DownloadEmailAttachmentEffect);
+ httpTestingController = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ if (subscription != null) {
+ subscription.unsubscribe();
+ }
+ });
+
+ it("should download email attachment on downloadEmailAttachmentAction", () => {
+ const mailId = "<Mail19>";
+ const name = "attachment.pdf";
+ const results: Action[] = [];
+ const spy = spyOn(effect, "download").and.returnValue(EMPTY);
+ const actionSubject = new Subject<Action>();
+ actions$ = actionSubject;
+ subscription = effect.download$.subscribe((_) => results.push(_));
+
+ actionSubject.next(downloadEmailAttachmentAction({mailId, name}));
+ expect(spy).toHaveBeenCalledWith(mailId, name);
+ spy.calls.reset();
+
+ actionSubject.next(downloadEmailAttachmentAction({mailId: null, name}));
+ expect(spy).not.toHaveBeenCalled();
+
+ actionSubject.next(downloadEmailAttachmentAction({mailId, name: null}));
+ expect(spy).not.toHaveBeenCalled();
+
+ expect(results).toEqual([]);
+ });
+
+ it("should download email attachments", () => {
+ const mailId = "<Mail19>";
+ const name = "attachment.pdf";
+ const results: Action[] = [];
+ const spy = spyOn(effect.downloadService, "startDownload");
+
+ subscription = effect.download(mailId, name).subscribe((_) => results.push(_));
+
+ expect(subscription.closed).toBeTrue();
+ expect(spy).toHaveBeenCalledWith(`/mail/identifier/${mailId}/${name}`, token);
+ expect(results).toEqual([]);
+ });
+
+});
+
diff --git a/src/app/store/mail/effects/download/download-email-attachment.effect.ts b/src/app/store/mail/effects/download/download-email-attachment.effect.ts
new file mode 100644
index 0000000..db89594
--- /dev/null
+++ b/src/app/store/mail/effects/download/download-email-attachment.effect.ts
@@ -0,0 +1,47 @@
+/********************************************************************************
+ * 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 {Inject, Injectable} from "@angular/core";
+import {Actions, createEffect, ofType} from "@ngrx/effects";
+import {Action} from "@ngrx/store";
+import {EMPTY, Observable} from "rxjs";
+import {filter, mergeMap} from "rxjs/operators";
+import {AuthService, DownloadService, SPA_BACKEND_ROUTE} from "../../../../core";
+import {urlJoin} from "../../../../util/http";
+import {downloadEmailAttachmentAction} from "../../actions";
+
+@Injectable({providedIn: "root"})
+export class DownloadEmailAttachmentEffect {
+
+ public download$ = createEffect(() => this.actions.pipe(
+ ofType(downloadEmailAttachmentAction),
+ filter((action) => action.mailId != null && action.name != null),
+ mergeMap((action) => this.download(action.mailId, action.name))
+ ));
+
+ public constructor(
+ public readonly actions: Actions,
+ public readonly downloadService: DownloadService,
+ public readonly authService: AuthService,
+ @Inject(SPA_BACKEND_ROUTE) public readonly spaBackendRoute: string
+ ) {
+
+ }
+
+ public download(mailId: string, name: string): Observable<Action> {
+ const endPoint = `/mail/identifier/${mailId}/${name}`;
+ this.downloadService.startDownload(urlJoin(this.spaBackendRoute, endPoint), this.authService.token);
+ return EMPTY;
+ }
+
+}
diff --git a/src/app/store/mail/effects/fetch/fetch-emails.effect.spec.ts b/src/app/store/mail/effects/fetch/fetch-emails.effect.spec.ts
new file mode 100644
index 0000000..1c02f9b
--- /dev/null
+++ b/src/app/store/mail/effects/fetch/fetch-emails.effect.spec.ts
@@ -0,0 +1,135 @@
+/********************************************************************************
+ * 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 {HttpClientTestingModule, HttpTestingController} from "@angular/common/http/testing";
+import {TestBed} from "@angular/core/testing";
+import {provideMockActions} from "@ngrx/effects/testing";
+import {Action} from "@ngrx/store";
+import {EMPTY, Observable, Subject, Subscription} from "rxjs";
+import {IAPIEmailModel, SPA_BACKEND_ROUTE} from "../../../../core";
+import {createEmailModelMock} from "../../../../test";
+import {
+ fetchEmailAction,
+ fetchEmailInboxAction,
+ setEmailEntitiesAction,
+ setEmailInboxAction,
+ setEmailLoadingStateAction
+} from "../../actions";
+import {FetchEmailsEffect} from "./fetch-emails.effect";
+
+describe("FetchEmailsEffect", () => {
+ let actions$: Observable<Action>;
+ let httpTestingController: HttpTestingController;
+ let effect: FetchEmailsEffect;
+ let subscription: Subscription;
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({
+ imports: [
+ HttpClientTestingModule
+ ],
+ providers: [
+ provideMockActions(() => actions$),
+ {
+ provide: SPA_BACKEND_ROUTE,
+ useValue: "/"
+ }
+ ]
+ });
+ effect = TestBed.inject(FetchEmailsEffect);
+ httpTestingController = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ if (subscription != null) {
+ subscription.unsubscribe();
+ }
+ });
+
+ it("should fetch emails on fetchEmailInboxAction", () => {
+ const results: Action[] = [];
+ const spy = spyOn(effect, "fetchEmailInbox").and.returnValue(EMPTY);
+ const actionSubject = new Subject<Action>();
+ actions$ = actionSubject;
+ subscription = effect.fetchInbox$.subscribe((_) => results.push(_));
+
+ actionSubject.next(fetchEmailInboxAction());
+ expect(spy).toHaveBeenCalledWith();
+ expect(results).toEqual([]);
+ });
+
+ it("should fetch email on fetchEmailAction", () => {
+ const mailId = "<Mail19>";
+ const results: Action[] = [];
+ const spy = spyOn(effect, "fetchEmail").and.returnValue(EMPTY);
+ const actionSubject = new Subject<Action>();
+ actions$ = actionSubject;
+ subscription = effect.fetchEmail$.subscribe((_) => results.push(_));
+
+ actionSubject.next(fetchEmailAction({mailId}));
+ expect(spy).toHaveBeenCalledWith(mailId, undefined);
+ spy.calls.reset();
+
+ actionSubject.next(fetchEmailAction({mailId: null}));
+ expect(spy).not.toHaveBeenCalled();
+
+ expect(results).toEqual([]);
+ });
+
+ it("should fetch email inbox", () => {
+ const results: Action[] = [];
+ const entities = ["<Mail19>", "<Mail1919>"].map((_) => createEmailModelMock(_));
+
+ subscription = effect.fetchEmailInbox().subscribe((_) => results.push(_));
+ expectFetchEmailInboxRequest(entities);
+
+ expect(subscription.closed).toBeTrue();
+ expect(results).toEqual([
+ setEmailLoadingStateAction({loading: {fetchingInbox: true}}),
+ setEmailInboxAction({entities}),
+ setEmailLoadingStateAction({loading: {fetchingInbox: false}})
+ ]);
+ });
+
+ it("should fetch emails", () => {
+ const mailId = "<Mail19>";
+ const entity = createEmailModelMock(mailId);
+ const results: Action[] = [];
+
+ subscription = effect.fetchEmail(mailId).subscribe((_) => results.push(_));
+ expectFetchEmailRequest(entity);
+
+ expect(subscription.closed).toBeTrue();
+ expect(results).toEqual([
+ setEmailLoadingStateAction({loading: {fetching: true}}),
+ setEmailEntitiesAction({entities: [entity]}),
+ setEmailLoadingStateAction({loading: {fetching: false}})
+ ]);
+ });
+
+ function expectFetchEmailInboxRequest(result: IAPIEmailModel[]) {
+ const url = `/mail/inbox`;
+ const request = httpTestingController.expectOne(url);
+ expect(request.request.method).toBe("GET");
+ request.flush(result);
+ }
+
+ function expectFetchEmailRequest(result: IAPIEmailModel) {
+ const url = `/mail/identifier/${result.identifier}`;
+ const request = httpTestingController.expectOne(url);
+ expect(request.request.method).toBe("GET");
+ request.flush(result);
+ }
+
+});
+
diff --git a/src/app/store/mail/effects/fetch/fetch-emails.effect.ts b/src/app/store/mail/effects/fetch/fetch-emails.effect.ts
new file mode 100644
index 0000000..f66ce41
--- /dev/null
+++ b/src/app/store/mail/effects/fetch/fetch-emails.effect.ts
@@ -0,0 +1,85 @@
+/********************************************************************************
+ * 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 {Injectable} from "@angular/core";
+import {Actions, createEffect, ofType} from "@ngrx/effects";
+import {Action} from "@ngrx/store";
+import {concat, Observable, of} from "rxjs";
+import {endWith, exhaustMap, filter, map, retry, startWith, switchMap} from "rxjs/operators";
+import {MailApiService} from "../../../../core/api/mail";
+import {catchHttpError, EHttpStatusCodes} from "../../../../util/http";
+import {catchErrorTo, ignoreError} from "../../../../util/rxjs";
+import {setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
+import {setStatementErrorAction} from "../../../statements/actions";
+import {
+ deleteEmailEntityAction,
+ fetchEmailAction,
+ fetchEmailInboxAction,
+ setEmailEntitiesAction,
+ setEmailInboxAction,
+ setEmailLoadingStateAction
+} from "../../actions";
+
+@Injectable({providedIn: "root"})
+export class FetchEmailsEffect {
+
+ public fetchInbox$ = createEffect(() => this.actions.pipe(
+ ofType((fetchEmailInboxAction)),
+ exhaustMap(() => this.fetchEmailInbox())
+ ));
+
+ public fetchEmail$ = createEffect(() => this.actions.pipe(
+ ofType(fetchEmailAction),
+ filter((action) => action.mailId != null),
+ switchMap((action) => this.fetchEmail(action.mailId, action.statementId))
+ ));
+
+ public constructor(
+ public readonly actions: Actions,
+ public readonly emailApiService: MailApiService
+ ) {
+
+ }
+
+ public fetchEmailInbox(): Observable<Action> {
+ return this.emailApiService.getInbox().pipe(
+ map((entities) => setEmailInboxAction({entities})),
+ startWith(setEmailLoadingStateAction({loading: {fetchingInbox: true}})),
+ ignoreError(),
+ endWith(setEmailLoadingStateAction({loading: {fetchingInbox: false}}))
+ );
+ }
+
+ public fetchEmail(mailId: string, statementId?: number | "new"): Observable<Action> {
+ return this.emailApiService.getEmail(mailId).pipe(
+ map((entity) => setEmailEntitiesAction({entities: [entity]})),
+ retry(2),
+ catchHttpError(() => {
+ return concat(
+ of(deleteEmailEntityAction({mailId})),
+ of(statementId ?
+ setStatementErrorAction({statementId, error: {errorMessage: EErrorCode.COULD_NOT_LOAD_MAIL_DATA}}) :
+ setErrorAction({error: EErrorCode.COULD_NOT_LOAD_MAIL_DATA}))
+ );
+ }, EHttpStatusCodes.NOT_FOUND),
+ catchErrorTo(statementId ?
+ setStatementErrorAction({statementId, error: {errorMessage: EErrorCode.COULD_NOT_LOAD_MAIL_DATA}}) :
+ setErrorAction({error: EErrorCode.COULD_NOT_LOAD_MAIL_DATA})),
+ startWith(setEmailLoadingStateAction({loading: {fetching: true}})),
+ ignoreError(),
+ endWith(setEmailLoadingStateAction({loading: {fetching: false}}))
+ );
+ }
+
+}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/store/mail/effects/index.ts
similarity index 77%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/store/mail/effects/index.ts
index a3980e1..c284f7c 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/store/mail/effects/index.ts
@@ -11,4 +11,6 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./delete/delete-email-from-inbox.effect";
+export * from "./download/download-email-attachment.effect";
+export * from "./fetch/fetch-emails.effect";
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/store/mail/index.ts
similarity index 82%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/store/mail/index.ts
index a3980e1..6e07101 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/store/mail/index.ts
@@ -11,4 +11,8 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./actions";
+export * from "./effects";
+export * from "./selectors";
+
+export * from "./mail-store.module";
diff --git a/src/app/store/mail/mail-reducers.token.ts b/src/app/store/mail/mail-reducers.token.ts
new file mode 100644
index 0000000..ca637d9
--- /dev/null
+++ b/src/app/store/mail/mail-reducers.token.ts
@@ -0,0 +1,28 @@
+/********************************************************************************
+ * 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 {InjectionToken} from "@angular/core";
+import {ActionReducerMap} from "@ngrx/store";
+import {IMailStoreState} from "./model";
+import {mailEntitiesReducer, mailInboxReducer, mailLoadingReducer} from "./reducers";
+
+export const EMAIL_NAME = "email";
+
+export const EMAIL_REDCUERS = new InjectionToken<ActionReducerMap<IMailStoreState>>("Email store reducer", {
+ providedIn: "root",
+ factory: () => ({
+ entities: mailEntitiesReducer,
+ inboxIds: mailInboxReducer,
+ loading: mailLoadingReducer
+ })
+});
diff --git a/src/app/store/mail/mail-store.module.ts b/src/app/store/mail/mail-store.module.ts
new file mode 100644
index 0000000..9237a65
--- /dev/null
+++ b/src/app/store/mail/mail-store.module.ts
@@ -0,0 +1,32 @@
+/********************************************************************************
+ * 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 {NgModule} from "@angular/core";
+import {EffectsModule} from "@ngrx/effects";
+import {StoreModule} from "@ngrx/store";
+import {DeleteEmailFromInboxEffect, DownloadEmailAttachmentEffect, FetchEmailsEffect} from "./effects";
+import {EMAIL_NAME, EMAIL_REDCUERS} from "./mail-reducers.token";
+
+@NgModule({
+ imports: [
+ StoreModule.forFeature(EMAIL_NAME, EMAIL_REDCUERS),
+ EffectsModule.forFeature([
+ DeleteEmailFromInboxEffect,
+ DownloadEmailAttachmentEffect,
+ FetchEmailsEffect
+ ])
+ ]
+})
+export class MailStoreModule {
+
+}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/store/mail/model/IMailLoadingState.ts
similarity index 82%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/store/mail/model/IMailLoadingState.ts
index a3980e1..c72e6d9 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/store/mail/model/IMailLoadingState.ts
@@ -11,4 +11,8 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export interface IMailLoadingState {
+ fetchingInbox?: boolean;
+ fetching?: boolean;
+ deleting?: boolean;
+}
diff --git a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss b/src/app/store/mail/model/IMailStoreState.ts
similarity index 64%
copy from src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss
copy to src/app/store/mail/model/IMailStoreState.ts
index ac39661..f63b6f6 100644
--- a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss
+++ b/src/app/store/mail/model/IMailStoreState.ts
@@ -11,19 +11,16 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-@import "openk.styles";
+import {IAPIEmailModel} from "../../../core/api/mail";
+import {TStoreEntities} from "../../../util/store";
+import {IMailLoadingState} from "./IMailLoadingState";
-.dashboard-item-header-actions {
- display: inline-flex;
- margin-left: auto;
+export interface IMailStoreState {
- & > * {
- margin-left: 0.5em;
- }
-}
+ entities?: TStoreEntities<IAPIEmailModel>;
-.dashboard-item-body {
- padding: 1em;
- display: flex;
- flex-flow: column;
+ inboxIds?: string[];
+
+ loading?: IMailLoadingState;
+
}
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/store/mail/model/index.ts
similarity index 88%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/store/mail/model/index.ts
index a3980e1..087c81f 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/store/mail/model/index.ts
@@ -11,4 +11,5 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./IMailLoadingState";
+export * from "./IMailStoreState";
diff --git a/src/app/store/mail/reducers/entities/mail-entities.reducer.spec.ts b/src/app/store/mail/reducers/entities/mail-entities.reducer.spec.ts
new file mode 100644
index 0000000..8af95d2
--- /dev/null
+++ b/src/app/store/mail/reducers/entities/mail-entities.reducer.spec.ts
@@ -0,0 +1,75 @@
+/********************************************************************************
+ * 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 {Action} from "@ngrx/store";
+import {IAPIEmailModel} from "../../../../core/api/mail";
+import {createEmailModelMock} from "../../../../test";
+import {TStoreEntities} from "../../../../util/store";
+import {deleteEmailEntityAction, setEmailEntitiesAction, setEmailInboxAction} from "../../actions";
+import {mailEntitiesReducer} from "./mail-entities.reducer";
+
+describe("mailEntitiesReducer", () => {
+ const entities: IAPIEmailModel[] = [
+ createEmailModelMock("<Mail19>"),
+ createEmailModelMock("<Mail1919>")
+ ];
+ let initialState: TStoreEntities<IAPIEmailModel> = {};
+ let state: TStoreEntities<IAPIEmailModel> = {};
+ let action: Action;
+
+ it("should update store entities on setEmailEntitiesAction", () => {
+ initialState = {};
+ action = setEmailEntitiesAction({entities: [null]});
+ state = mailEntitiesReducer(initialState, action);
+ expect(state).toEqual(initialState);
+
+ action = setEmailEntitiesAction({entities});
+ state = mailEntitiesReducer(initialState, action);
+ expect(state).toEqual({
+ [entities[0].identifier]: entities[0],
+ [entities[1].identifier]: entities[1],
+ });
+ });
+
+ it("should update store entities on setEmailInboxAction", () => {
+ initialState = {};
+ action = setEmailInboxAction({entities: [null]});
+ state = mailEntitiesReducer(initialState, action);
+ expect(state).toEqual(initialState);
+
+ action = setEmailInboxAction({entities});
+ state = mailEntitiesReducer(initialState, action);
+ expect(state).toEqual({
+ [entities[0].identifier]: entities[0],
+ [entities[1].identifier]: entities[1],
+ });
+ });
+
+ it("should delete store entities on deleteEmailEntityAction", () => {
+ initialState = {
+ [entities[0].identifier]: entities[0],
+ [entities[1].identifier]: entities[1],
+ };
+
+ action = deleteEmailEntityAction({mailId: null});
+ state = mailEntitiesReducer(initialState, action);
+ expect(state).toEqual(initialState);
+
+ action = deleteEmailEntityAction({mailId: entities[0].identifier});
+ state = mailEntitiesReducer(initialState, action);
+ expect(state).toEqual({
+ [entities[1].identifier]: entities[1],
+ });
+ });
+
+});
diff --git a/src/app/store/mail/reducers/entities/mail-entities.reducer.ts b/src/app/store/mail/reducers/entities/mail-entities.reducer.ts
new file mode 100644
index 0000000..4f7fb04
--- /dev/null
+++ b/src/app/store/mail/reducers/entities/mail-entities.reducer.ts
@@ -0,0 +1,30 @@
+/********************************************************************************
+ * 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 {createReducer, on} from "@ngrx/store";
+import {IAPIEmailModel} from "../../../../core";
+import {deleteEntities, setEntitiesObject, TStoreEntities} from "../../../../util/store";
+import {deleteEmailEntityAction, setEmailEntitiesAction, setEmailInboxAction} from "../../actions";
+
+export const mailEntitiesReducer = createReducer<TStoreEntities<IAPIEmailModel>>(
+ {},
+ on(setEmailInboxAction, (state, payload) => {
+ return setEntitiesObject(state, payload.entities, (_) => _.identifier);
+ }),
+ on(setEmailEntitiesAction, (state, payload) => {
+ return setEntitiesObject(state, payload.entities, (_) => _.identifier);
+ }),
+ on(deleteEmailEntityAction, (state, payload) => {
+ return deleteEntities(state, [payload.mailId]);
+ })
+);
diff --git a/src/app/store/mail/reducers/inbox/mail-inbox.reducer.spec.ts b/src/app/store/mail/reducers/inbox/mail-inbox.reducer.spec.ts
new file mode 100644
index 0000000..88c98ae
--- /dev/null
+++ b/src/app/store/mail/reducers/inbox/mail-inbox.reducer.spec.ts
@@ -0,0 +1,58 @@
+/********************************************************************************
+ * 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 {Action} from "@ngrx/store";
+import {IAPIEmailModel} from "../../../../core";
+import {createEmailModelMock} from "../../../../test";
+import {deleteEmailEntityAction, setEmailInboxAction} from "../../actions";
+import {mailInboxReducer} from "./mail-inbox.reducer";
+
+describe("mailInboxReducer", () => {
+
+ let initialState: string[] = [];
+ let state: string[] = [];
+ let action: Action;
+
+ it("should update inbox ids on setEmailInboxAction", () => {
+ initialState = [];
+ const entities: IAPIEmailModel[] = [
+ createEmailModelMock("<Mail1>"),
+ createEmailModelMock("<Mail2>")
+ ];
+
+ action = setEmailInboxAction({entities: [null]});
+ state = mailInboxReducer(initialState, action);
+ expect(state).toEqual([]);
+
+ action = setEmailInboxAction({entities});
+ state = mailInboxReducer(initialState, action);
+ expect(state).toEqual([entities[0].identifier, entities[1].identifier]);
+ });
+
+ it("should delete inbox ids on deleteEmailEntityAction", () => {
+ const entities: IAPIEmailModel[] = [
+ createEmailModelMock("<Mail1>"),
+ createEmailModelMock("<Mail2>")
+ ];
+ initialState = entities.map((_) => _.identifier);
+
+ action = deleteEmailEntityAction({mailId: null});
+ state = mailInboxReducer(initialState, action);
+ expect(state).toEqual(initialState);
+
+ action = deleteEmailEntityAction({mailId: entities[0].identifier});
+ state = mailInboxReducer(initialState, action);
+ expect(state).toEqual([entities[1].identifier]);
+ });
+
+});
diff --git a/src/app/store/mail/reducers/inbox/mail-inbox.reducer.ts b/src/app/store/mail/reducers/inbox/mail-inbox.reducer.ts
new file mode 100644
index 0000000..a64930e
--- /dev/null
+++ b/src/app/store/mail/reducers/inbox/mail-inbox.reducer.ts
@@ -0,0 +1,26 @@
+/********************************************************************************
+ * 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 {createReducer, on} from "@ngrx/store";
+import {arrayJoin, filterDistinctValues} from "../../../../util/store";
+import {deleteEmailEntityAction, setEmailInboxAction} from "../../actions";
+
+export const mailInboxReducer = createReducer<string[]>(
+ [],
+ on(setEmailInboxAction, (state, payload) => {
+ return filterDistinctValues(arrayJoin(payload.entities).map((_) => _?.identifier));
+ }),
+ on(deleteEmailEntityAction, (state, payload) => {
+ return arrayJoin(state).filter((_) => _ !== payload.mailId);
+ })
+);
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/store/mail/reducers/index.ts
similarity index 79%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/store/mail/reducers/index.ts
index a3980e1..48e63a2 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/store/mail/reducers/index.ts
@@ -11,4 +11,6 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./entities/mail-entities.reducer";
+export * from "./inbox/mail-inbox.reducer";
+export * from "./loading/mail-loading.reducer";
diff --git a/src/app/store/mail/reducers/loading/mail-loading.reducer.spec.ts b/src/app/store/mail/reducers/loading/mail-loading.reducer.spec.ts
new file mode 100644
index 0000000..2483bd3
--- /dev/null
+++ b/src/app/store/mail/reducers/loading/mail-loading.reducer.spec.ts
@@ -0,0 +1,44 @@
+/********************************************************************************
+ * 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 {Action} from "@ngrx/store";
+import {setEmailLoadingStateAction} from "../../actions";
+import {IMailLoadingState} from "../../model";
+import {mailLoadingReducer} from "./mail-loading.reducer";
+
+describe("mailLoadingReducer", () => {
+
+ let initialState: IMailLoadingState;
+ let state: IMailLoadingState;
+ let action: Action;
+
+ it("should update loading state on setEmailInboxAction", () => {
+ initialState = undefined;
+ action = setEmailLoadingStateAction(null);
+ state = mailLoadingReducer(initialState, action);
+ expect(state).toEqual(undefined);
+
+ action = setEmailLoadingStateAction({loading: null});
+ state = mailLoadingReducer(initialState, action);
+ expect(state).toEqual(undefined);
+
+ action = setEmailLoadingStateAction({loading: {fetching: false}});
+ state = mailLoadingReducer(initialState, action);
+ expect(state).toEqual(undefined);
+
+ action = setEmailLoadingStateAction({loading: {fetching: true}});
+ state = mailLoadingReducer(initialState, action);
+ expect(state).toEqual({fetching: true});
+ });
+
+});
diff --git a/src/app/store/mail/reducers/loading/mail-loading.reducer.ts b/src/app/store/mail/reducers/loading/mail-loading.reducer.ts
new file mode 100644
index 0000000..d3c6f5d
--- /dev/null
+++ b/src/app/store/mail/reducers/loading/mail-loading.reducer.ts
@@ -0,0 +1,24 @@
+/********************************************************************************
+ * 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 {createReducer, on} from "@ngrx/store";
+import {setEmailLoadingStateAction} from "../../actions";
+import {IMailLoadingState} from "../../model";
+
+export const mailLoadingReducer = createReducer<IMailLoadingState>(
+ undefined,
+ on(setEmailLoadingStateAction, (state, payload) => {
+ const result = {...state, ...payload.loading};
+ return Object.values(result).some((_) => _) ? result : undefined;
+ })
+);
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/store/mail/selectors/index.ts
similarity index 92%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/store/mail/selectors/index.ts
index a3980e1..b02137c 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/store/mail/selectors/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./mail.selectors";
diff --git a/src/app/store/mail/selectors/mail.selectors.spec.ts b/src/app/store/mail/selectors/mail.selectors.spec.ts
new file mode 100644
index 0000000..5caf062
--- /dev/null
+++ b/src/app/store/mail/selectors/mail.selectors.spec.ts
@@ -0,0 +1,38 @@
+/********************************************************************************
+ * 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 {IAPIEmailModel} from "../../../core/api/mail";
+import {createEmailModelMock} from "../../../test";
+import {TStoreEntities} from "../../../util/store";
+import {getEmailInboxSelector} from "./mail.selectors";
+
+describe("mailSelectors", () => {
+
+ it("getEmailInboxSelector", () => {
+ const projector = getEmailInboxSelector.projector;
+ const inbox: IAPIEmailModel[] = [
+ createEmailModelMock("<Mail1>"),
+ createEmailModelMock("<Mail2>")
+ ];
+ const entities: TStoreEntities<IAPIEmailModel> = {
+ [inbox[0].identifier]: inbox[0],
+ [inbox[1].identifier]: inbox[1]
+ };
+ const inboxIds: string[] = inbox.map((_) => _.identifier);
+
+ expect(projector({}, inboxIds)).toEqual([]);
+ expect(projector(entities, [])).toEqual([]);
+ expect(projector(entities, inboxIds)).toEqual(inbox.reverse());
+ });
+
+});
diff --git a/src/app/store/mail/selectors/mail.selectors.ts b/src/app/store/mail/selectors/mail.selectors.ts
new file mode 100644
index 0000000..e0ec432
--- /dev/null
+++ b/src/app/store/mail/selectors/mail.selectors.ts
@@ -0,0 +1,67 @@
+/********************************************************************************
+ * 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 {createFeatureSelector, createSelector} from "@ngrx/store";
+import {
+ arrayJoin,
+ filterDistinctValues,
+ selectArrayProjector,
+ selectEntityWithIdProjector,
+ selectPropertyProjector
+} from "../../../util/store";
+import {queryParamsMailIdSelector} from "../../root/selectors";
+import {statementMailIdSelector} from "../../statements/selectors";
+import {EMAIL_NAME} from "../mail-reducers.token";
+import {IMailStoreState} from "../model";
+
+export const emailStateSelector = createFeatureSelector<IMailStoreState>(EMAIL_NAME);
+
+const emailEntitiesSelector = createSelector(
+ emailStateSelector,
+ selectPropertyProjector("entities", {})
+);
+
+const emailInboxIdsSelector = createSelector(
+ emailStateSelector,
+ selectArrayProjector("inboxIds", [])
+);
+
+export const getIsEmailInInboxSelector = createSelector(
+ emailInboxIdsSelector,
+ (inbox) => arrayJoin(inbox).length > 0
+);
+
+export const getEmailInboxSelector = createSelector(
+ emailEntitiesSelector,
+ emailInboxIdsSelector,
+ (entities, inboxIds) => {
+ return filterDistinctValues(arrayJoin(inboxIds).map((mailId) => entities[mailId])).reverse();
+ }
+);
+
+export const getSelectedEmailSelector = createSelector(
+ emailEntitiesSelector,
+ queryParamsMailIdSelector,
+ selectEntityWithIdProjector()
+);
+
+export const getStatementMailSelector = createSelector(
+ emailEntitiesSelector,
+ statementMailIdSelector,
+ selectEntityWithIdProjector()
+);
+
+export const getEmailLoadingSelector = createSelector(
+ emailStateSelector,
+ selectPropertyProjector("loading")
+);
diff --git a/src/app/store/process/actions/process.actions.ts b/src/app/store/process/actions/process.actions.ts
index 0508382..538ff93 100644
--- a/src/app/store/process/actions/process.actions.ts
+++ b/src/app/store/process/actions/process.actions.ts
@@ -43,21 +43,21 @@
);
-export const setTasksAction = createAction(
- "[API] Set tasks",
+export const setStatementTasksAction = createAction(
+ "[API] Set statement tasks",
props<{ statementId: number, tasks: IAPIProcessTask[] }>()
);
+export const setTaskEntityAction = createAction(
+ "[API] Set task entity",
+ props<{ task: IAPIProcessTask }>()
+);
+
export const deleteTaskAction = createAction(
"[API] Delete task",
props<{ statementId: number; taskId?: string }>()
);
-export const updateTaskAction = createAction(
- "[API] Set task",
- props<{ task: IAPIProcessTask }>()
-);
-
export const setHistoryAction = createAction(
"[API] Set process history",
props<{ statementId: number, history: IAPIStatementHistory }>()
diff --git a/src/app/store/process/effects/process-task.effect.spec.ts b/src/app/store/process/effects/process-task.effect.spec.ts
index 14f1b90..262f791 100644
--- a/src/app/store/process/effects/process-task.effect.spec.ts
+++ b/src/app/store/process/effects/process-task.effect.spec.ts
@@ -27,7 +27,8 @@
completeTaskAction,
deleteTaskAction,
setProcessLoadingAction,
- setTasksAction,
+ setStatementTasksAction,
+ setTaskEntityAction,
unclaimAllTasksAction
} from "../actions";
import {ProcessTaskEffect} from "./process-task.effect";
@@ -74,8 +75,7 @@
const expectedResult = [
setProcessLoadingAction({statementId, loading: true}),
- deleteTaskAction({statementId}),
- setTasksAction({statementId, tasks: [task]}),
+ setTaskEntityAction({task}),
setProcessLoadingAction({statementId, loading: false})
];
const results: Action[] = [];
@@ -83,7 +83,6 @@
subscription = effect.claim$.subscribe((action) => results.push(action));
expectClaimTask(statementId, taskId, task);
- expectGetStatementTasks(statementId, [task]);
expect(results).toEqual(expectedResult);
expect(navigateSpy).toHaveBeenCalledWith(statementId, taskId);
@@ -94,9 +93,9 @@
it("should claim and complete a task", async () => {
const statementId = 19;
const taskId = "191919";
- const task: IAPIProcessTask = {...{} as IAPIProcessTask, taskId};
const variables = {};
actions$ = of(claimAndCompleteTask({statementId, taskId, variables, claimNext: true}));
+ const claimTaskSpy = spyOn(effect, "claimTask").and.returnValue(EMPTY);
const completeTaskSpy = spyOn(effect, "completeTask").and.returnValue(EMPTY);
const expectedResult = [
@@ -107,9 +106,8 @@
subscription = effect.claimAndComplete$.subscribe((action) => results.push(action));
- expectClaimTask(statementId, taskId, task);
-
expect(results).toEqual(expectedResult);
+ expect(claimTaskSpy).toHaveBeenCalledWith(statementId, taskId);
expect(completeTaskSpy).toHaveBeenCalledWith(statementId, taskId, variables, true);
httpTestingController.verify();
@@ -126,8 +124,10 @@
const expectedResult = [
setProcessLoadingAction({statementId, loading: true}),
+ deleteTaskAction({statementId, taskId}),
+ setTaskEntityAction({task: nextTask}),
deleteTaskAction({statementId}),
- setTasksAction({statementId, tasks: [nextTask]}),
+ setStatementTasksAction({statementId, tasks: [nextTask]}),
setProcessLoadingAction({statementId, loading: false})
];
const results: Action[] = [];
@@ -149,23 +149,21 @@
const statementId = 19;
const assignee = "assignee";
const tasks: IAPIProcessTask[] = [
- {...{} as IAPIProcessTask, taskId: "19", assignee},
- {...{} as IAPIProcessTask, taskId: "20"}
+ {...{} as IAPIProcessTask, statementId, taskId: "19", assignee},
+ {...{} as IAPIProcessTask, statementId, taskId: "20"}
];
actions$ = of(unclaimAllTasksAction({statementId, assignee}));
const expectedResult = [
- deleteTaskAction({statementId}),
- setTasksAction({statementId, tasks}),
+ setTaskEntityAction({task: {...tasks[0], assignee: null}})
];
const results: Action[] = [];
subscription = effect.unclaimAll$.subscribe((action) => results.push(action));
expectGetStatementTasks(statementId, tasks);
- expectUnclaimTask(statementId, tasks[0].taskId);
- expectGetStatementTasks(statementId, tasks);
+ expectUnclaimTask(tasks[0]);
expect(results).toEqual(expectedResult);
@@ -206,11 +204,12 @@
request.flush(returnValue);
}
- function expectUnclaimTask(statementId: number, taskId: string) {
- const url = `/process/statements/${statementId}/task/${taskId}/unclaim`;
+ function expectUnclaimTask(task: IAPIProcessTask) {
+ const url = `/process/statements/${task.statementId}/task/${task.taskId}/unclaim`;
const request = httpTestingController.expectOne(url);
expect(request.request.method).toBe("POST");
- request.flush({statementId, taskId});
+ const result: IAPIProcessTask = {...task, assignee: null};
+ request.flush(result);
}
});
diff --git a/src/app/store/process/effects/process-task.effect.ts b/src/app/store/process/effects/process-task.effect.ts
index 458e130..2078a99 100644
--- a/src/app/store/process/effects/process-task.effect.ts
+++ b/src/app/store/process/effects/process-task.effect.ts
@@ -15,18 +15,22 @@
import {Router} from "@angular/router";
import {Actions, createEffect, ofType} from "@ngrx/effects";
import {Action} from "@ngrx/store";
-import {EMPTY, from, Observable, of} from "rxjs";
-import {catchError, endWith, exhaustMap, filter, ignoreElements, map, mergeMap, retry, startWith, switchMap, tap} from "rxjs/operators";
-import {EAPIProcessTaskDefinitionKey, ProcessApiService, TCompleteTaskVariable} from "../../../core/api/process";
-import {emitOnComplete, endWithObservable, ignoreError} from "../../../util/rxjs";
+import {concat, EMPTY, from, Observable, of, pipe} from "rxjs";
+import {catchError, endWith, exhaustMap, filter, ignoreElements, map, mergeMap, retry, startWith, switchMap} from "rxjs/operators";
+import {EAPIProcessTaskDefinitionKey, IAPIProcessTask, ProcessApiService, TCompleteTaskVariable} from "../../../core/api/process";
+import {catchHttpError, catchHttpErrorTo, EHttpStatusCodes} from "../../../util/http";
+import {catchErrorTo, emitOnComplete, endWithObservable, ignoreError, throwAfterActionType} from "../../../util/rxjs";
import {arrayJoin} from "../../../util/store";
+import {setErrorAction} from "../../root/actions";
+import {EErrorCode} from "../../root/model";
import {
claimAndCompleteTask,
claimTaskAction,
completeTaskAction,
deleteTaskAction,
setProcessLoadingAction,
- setTasksAction,
+ setStatementTasksAction,
+ setTaskEntityAction,
unclaimAllTasksAction
} from "../actions";
@@ -35,7 +39,8 @@
public claim$ = createEffect(() => this.actions.pipe(
ofType(claimTaskAction),
- switchMap((action) => this.claimTask(action.statementId, action.taskId).pipe(
+ switchMap((action) => this.claimTask(action.statementId, action.taskId, true).pipe(
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
startWith(setProcessLoadingAction({statementId: action.statementId, loading: true})),
endWith(setProcessLoadingAction({statementId: action.statementId, loading: false}))
))
@@ -44,6 +49,7 @@
public claimAndComplete$ = createEffect(() => this.actions.pipe(
ofType(claimAndCompleteTask),
switchMap((action) => this.claimAndCompleteTask(action.statementId, action.taskId, action.variables, action.claimNext).pipe(
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
startWith(setProcessLoadingAction({statementId: action.statementId, loading: true})),
endWith(setProcessLoadingAction({statementId: action.statementId, loading: false}))
))
@@ -52,6 +58,7 @@
public completeTask$ = createEffect(() => this.actions.pipe(
ofType(completeTaskAction),
exhaustMap((action) => this.completeTask(action.statementId, action.taskId, action.variables, action.claimNext).pipe(
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
startWith(setProcessLoadingAction({statementId: action.statementId, loading: true})),
endWith(setProcessLoadingAction({statementId: action.statementId, loading: false}))
))
@@ -70,14 +77,16 @@
}
- public claimTask(statementId: number, taskId: string): Observable<Action> {
- let nextTaskId: string = null;
+ public claimTask(statementId: number, taskId: string, navigate?: boolean): Observable<Action> {
return this.processApiService.claimStatementTask(statementId, taskId).pipe(
- tap((_) => nextTaskId = _.taskId),
- ignoreElements(),
- ignoreError(),
- endWithObservable(() => this.fetchTasks(statementId)),
- endWithObservable(() => nextTaskId == null ? EMPTY : this.navigateTo(statementId, nextTaskId))
+ map((task) => setTaskEntityAction({task})),
+ endWithObservable(() => navigate ? this.navigateTo(statementId, taskId) : EMPTY),
+ catchHttpErrorTo(setErrorAction({
+ statementId,
+ error: EErrorCode.CLAIMED_BY_OTHER_USER
+ }), EHttpStatusCodes.BAD_REQUEST),
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
+ this.fetchTasksAfterError(statementId)
);
}
@@ -87,9 +96,10 @@
variable: TCompleteTaskVariable,
claimNext?: boolean | EAPIProcessTaskDefinitionKey
): Observable<Action> {
- return this.processApiService.claimStatementTask(statementId, taskId).pipe(
- switchMap(() => this.completeTask(statementId, taskId, variable, claimNext)),
- catchError(() => this.fetchTasks(statementId))
+ return this.claimTask(statementId, taskId).pipe(
+ throwAfterActionType(setErrorAction),
+ endWithObservable(() => this.completeTask(statementId, taskId, variable, claimNext)),
+ ignoreError()
);
}
@@ -99,57 +109,97 @@
variable: TCompleteTaskVariable,
claimNext?: boolean | EAPIProcessTaskDefinitionKey
): Observable<Action> {
- let nextTaskId: string = null;
- return this.processApiService.completeStatementTask(statementId, taskId, variable).pipe(
- switchMap(() => claimNext == null ? EMPTY : this.claimNext(statementId, claimNext).pipe(
- tap((_) => nextTaskId = _)
- )),
- ignoreError(),
- ignoreElements(),
- endWithObservable(() => this.fetchTasks(statementId)),
+ let nextTaskId = taskId;
+ return concat(
+ this.processApiService.completeStatementTask(statementId, taskId, variable).pipe(
+ map(() => {
+ nextTaskId = null;
+ return deleteTaskAction({statementId, taskId});
+ }),
+ catchHttpError(() => {
+ nextTaskId = null;
+ return of(
+ deleteTaskAction({statementId, taskId}),
+ setErrorAction({statementId, error: EErrorCode.TASK_TO_COMPLETE_NOT_FOUND})
+ );
+ }, EHttpStatusCodes.NOT_FOUND)
+ ),
+ this.claimNext(statementId, claimNext, false).pipe(
+ map((task) => {
+ nextTaskId = task.taskId;
+ return setTaskEntityAction({task});
+ }),
+ catchHttpErrorTo(setErrorAction({
+ statementId,
+ error: EErrorCode.CLAIMED_BY_OTHER_USER
+ }), EHttpStatusCodes.BAD_REQUEST)
+ ),
+ this.fetchTasks(statementId)
+ ).pipe(
+ this.fetchTasksAfterError(statementId),
endWithObservable(() => this.navigateTo(statementId, nextTaskId))
);
}
- public claimNext(statementId: number, claimNext: boolean | EAPIProcessTaskDefinitionKey): Observable<string> {
- return this.processApiService.getStatementTasks(statementId).pipe(
- switchMap((tasks) => {
- const nextTaskId = arrayJoin(tasks)
- .find((_) => claimNext === true || _.taskDefinitionKey === claimNext)?.taskId;
- return nextTaskId == null ? EMPTY : this.processApiService.claimStatementTask(statementId, nextTaskId).pipe(
- map(() => nextTaskId)
- );
- })
- );
- }
-
public unclaim(statementId: number, assignee: string): Observable<Action> {
return this.processApiService.getStatementTasks(statementId).pipe(
switchMap((tasks) => of(...tasks)),
filter((task) => task?.assignee === assignee),
- mergeMap((task) => this.processApiService.unclaimStatementTask(statementId, task.taskId).pipe(ignoreError())),
- ignoreElements(),
- ignoreError(),
- endWithObservable(() => this.fetchTasks(statementId))
+ mergeMap((task) => this.processApiService.unclaimStatementTask(statementId, task.taskId).pipe(
+ map((unclaimedTask) => setTaskEntityAction({task: unclaimedTask})),
+ catchHttpErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED}))
+ )),
+ this.fetchTasksAfterError(statementId)
);
}
-
public fetchTasks(statementId: number): Observable<Action> {
return this.processApiService.getStatementTasks(statementId).pipe(
retry(2),
- map((tasks) => setTasksAction({statementId, tasks})),
- ignoreError(),
+ map((tasks) => setStatementTasksAction({statementId, tasks})),
startWith(deleteTaskAction({statementId})),
- emitOnComplete()
+ emitOnComplete(),
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED}))
);
}
- public navigateTo(statementId: number, taskId?: string) {
+
+ public claimNext(
+ statementId: number,
+ claimNext: boolean | EAPIProcessTaskDefinitionKey,
+ navigate?: boolean
+ ): Observable<IAPIProcessTask> {
+ let taskId: string;
+ return claimNext == null || claimNext === false ? EMPTY : this.getNextTask(statementId, claimNext).pipe(
+ switchMap((_taskId) => this.processApiService.claimStatementTask(statementId, taskId = _taskId)),
+ endWithObservable(() => navigate ? this.navigateTo(statementId, taskId) : EMPTY)
+ );
+ }
+
+ public getNextTask(statementId: number, next?: boolean | EAPIProcessTaskDefinitionKey): Observable<string> {
+ return this.processApiService.getStatementTasks(statementId).pipe(
+ map((tasks) => {
+ return arrayJoin(tasks).find((_) => {
+ return next === true || _.taskDefinitionKey === next;
+ })?.taskId;
+ })
+ );
+ }
+
+ public navigateTo(statementId: number, taskId?: string): Observable<never> {
return from(taskId == null ?
this.router.navigate(["details"], {queryParams: {id: statementId}}) :
this.router.navigate(["edit"], {queryParams: {id: statementId, taskId}})
).pipe(ignoreElements());
}
+ public fetchTasksAfterError(statementId: number) {
+ return pipe(
+ catchErrorTo(setErrorAction({statementId, error: EErrorCode.UNEXPECTED})),
+ throwAfterActionType(setErrorAction),
+ catchError(() => this.fetchTasks(statementId)),
+ ignoreError()
+ );
+ }
+
}
diff --git a/src/app/store/process/reducers/statement-tasks.reducer.spec.ts b/src/app/store/process/reducers/statement-tasks.reducer.spec.ts
index 775052a..7777825 100644
--- a/src/app/store/process/reducers/statement-tasks.reducer.spec.ts
+++ b/src/app/store/process/reducers/statement-tasks.reducer.spec.ts
@@ -13,20 +13,20 @@
import {IAPIProcessTask} from "../../../core/api/process";
import {TStoreEntities} from "../../../util/store";
-import {deleteTaskAction, setTasksAction} from "../actions";
+import {deleteTaskAction, setStatementTasksAction, setTaskEntityAction} from "../actions";
import {statementTaskReducer} from "./statement-tasks.reducer";
describe("statementTaskReducer", () => {
- it("should set the taskids to the given statementid to the state", () => {
+ it("should set taskids to the given statementid to the state", () => {
const actionPayload = {statementId: "1"} as unknown as { statementId: number, tasks: IAPIProcessTask[] };
let initialState: TStoreEntities<string[]> = {};
- let action = setTasksAction(actionPayload);
+ let action = setStatementTasksAction(actionPayload);
let state = statementTaskReducer(initialState, action);
expect(state).toEqual(initialState);
- action = setTasksAction(undefined);
+ action = setStatementTasksAction(undefined);
state = statementTaskReducer(initialState, action);
expect(state).toEqual(initialState);
@@ -41,25 +41,25 @@
taskId: "taskId2"
}
] as IAPIProcessTask[];
- action = setTasksAction(actionPayload);
+ action = setStatementTasksAction(actionPayload);
state = statementTaskReducer(initialState, action);
expect(state).toEqual({1: ["taskId"]});
initialState = state;
actionPayload.statementId = 2;
- action = setTasksAction(actionPayload);
+ action = setStatementTasksAction(actionPayload);
state = statementTaskReducer(initialState, action);
expect(state).toEqual({1: ["taskId"], 2: ["taskId2"]});
initialState = {};
actionPayload.statementId = 1;
actionPayload.tasks = {} as IAPIProcessTask[];
- action = setTasksAction(actionPayload);
+ action = setStatementTasksAction(actionPayload);
state = statementTaskReducer(initialState, action);
expect(state).toEqual({1: undefined});
});
- it("should delete the taskid for the given statementid from the state", () => {
+ it("should delete a taskid for the given statementid from the state", () => {
const actionPayload = {statementId: "1", taskId: "taskId"} as unknown as { statementId: number, taskId: string };
let initialState: TStoreEntities<string[]> = {};
@@ -103,4 +103,15 @@
expect(state).toEqual({...initialState, 1: undefined});
});
+ it("should update the task ids of a statement for a given task id", () => {
+ const task: IAPIProcessTask = {
+ ...{} as IAPIProcessTask,
+ statementId: 1,
+ taskId: "taskId3"
+ };
+ const initialState = {1: ["taskId1", "taskId2"], 2: ["taskId2"]};
+ const state = statementTaskReducer(initialState, setTaskEntityAction({task}));
+ expect(state).toEqual({...initialState, 1: ["taskId1", "taskId2", "taskId3"]});
+ });
+
});
diff --git a/src/app/store/process/reducers/statement-tasks.reducer.ts b/src/app/store/process/reducers/statement-tasks.reducer.ts
index d8a9ce8..854726c 100644
--- a/src/app/store/process/reducers/statement-tasks.reducer.ts
+++ b/src/app/store/process/reducers/statement-tasks.reducer.ts
@@ -13,18 +13,18 @@
import {createReducer, on} from "@ngrx/store";
import {arrayJoin, filterDistinctValues, TStoreEntities} from "../../../util/store";
-import {deleteTaskAction, setTasksAction} from "../actions";
+import {deleteTaskAction, setStatementTasksAction, setTaskEntityAction} from "../actions";
export const statementTaskReducer = createReducer<TStoreEntities<string[]>>(
{},
- on(setTasksAction, (state, payload) => {
+ on(setStatementTasksAction, (state, payload) => {
const statementId = payload?.statementId;
if (typeof statementId !== "number") {
return state;
}
- const taskIds = !Array.isArray(payload?.tasks) ? [] : payload.tasks
+ const taskIds = arrayJoin(payload.tasks)
.filter((task) => task?.statementId === statementId && typeof task?.taskId === "string")
.map((task) => task.taskId);
@@ -33,6 +33,14 @@
[statementId]: taskIds.length === 0 ? undefined : filterDistinctValues(taskIds)
};
}),
+ on(setTaskEntityAction, (state, payload) => {
+ const statementId = payload.task?.statementId;
+ const taskId = payload.task?.taskId;
+ return statementId == null ? state : {
+ ...state,
+ [statementId]: filterDistinctValues(arrayJoin(state[statementId], [taskId]))
+ };
+ }),
on(deleteTaskAction, (state, payload) => {
if (typeof payload?.statementId !== "number") {
return state;
diff --git a/src/app/store/process/reducers/tasks.reducer.spec.ts b/src/app/store/process/reducers/tasks.reducer.spec.ts
index ffc41ab..ef60629 100644
--- a/src/app/store/process/reducers/tasks.reducer.spec.ts
+++ b/src/app/store/process/reducers/tasks.reducer.spec.ts
@@ -13,20 +13,20 @@
import {EAPIProcessTaskDefinitionKey, IAPIProcessTask} from "../../../core/api/process";
import {TStoreEntities} from "../../../util/store";
-import {deleteTaskAction, setTasksAction, updateTaskAction} from "../actions";
+import {deleteTaskAction, setStatementTasksAction, setTaskEntityAction} from "../actions";
import {tasksReducer} from "./tasks.reducer";
describe("tasksReducer", () => {
- it("should set the tasks to the given statementid", () => {
+ it("should set tasks to the given statementid", () => {
const actionPayload = {statementId: "1"} as unknown as { statementId: number, tasks: IAPIProcessTask[] };
let initialState: TStoreEntities<IAPIProcessTask> = {};
- let action = setTasksAction(actionPayload);
+ let action = setStatementTasksAction(actionPayload);
let state = tasksReducer(initialState, action);
expect(state).toEqual(initialState);
- action = setTasksAction(undefined);
+ action = setStatementTasksAction(undefined);
state = tasksReducer(initialState, action);
expect(state).toEqual(initialState);
@@ -38,6 +38,7 @@
taskDefinitionKey: EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA,
processDefinitionKey: "processDefinitionKey",
assignee: "assignee",
+ authorized: true,
requiredVariables: {}
},
{
@@ -46,34 +47,35 @@
taskDefinitionKey: EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA,
processDefinitionKey: "processDefinitionKey",
assignee: "assignee",
+ authorized: true,
requiredVariables: {}
}
] as IAPIProcessTask[];
- action = setTasksAction(actionPayload);
+ action = setStatementTasksAction(actionPayload);
state = tasksReducer(initialState, action);
expect(state).toEqual({taskId: actionPayload.tasks[0]});
initialState = state;
actionPayload.statementId = 2;
- action = setTasksAction(actionPayload);
+ action = setStatementTasksAction(actionPayload);
state = tasksReducer(initialState, action);
expect(state).toEqual({taskId: actionPayload.tasks[0], taskId2: actionPayload.tasks[1]});
initialState = state;
actionPayload.statementId = 3;
- action = setTasksAction(actionPayload);
+ action = setStatementTasksAction(actionPayload);
state = tasksReducer(initialState, action);
expect(state).toEqual(initialState);
initialState = state;
actionPayload.statementId = 1;
actionPayload.tasks[0].taskId = "changedTaskId";
- action = setTasksAction(actionPayload);
+ action = setStatementTasksAction(actionPayload);
state = tasksReducer(initialState, action);
expect(state).toEqual({changedTaskId: actionPayload.tasks[0], taskId2: actionPayload.tasks[1]});
});
- it("should delete the task for the given taskId from the state", () => {
+ it("should delete a task for a given taskId from the state", () => {
const actionPayload = {statementId: "1", taskId: "taskId"} as unknown as { statementId: number, taskId: string };
const initialState: TStoreEntities<IAPIProcessTask> = {
@@ -83,6 +85,7 @@
taskDefinitionKey: EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA,
processDefinitionKey: "processDefinitionKey",
assignee: "assignee",
+ authorized: true,
requiredVariables: {}
}
};
@@ -104,7 +107,7 @@
expect(state).toEqual({});
});
- it("should update the given task in the state", () => {
+ it("should update a given task in the state", () => {
const actionPayload = {task: null} as unknown as { task: IAPIProcessTask };
const initialState: TStoreEntities<IAPIProcessTask> = {
taskId: {
@@ -113,21 +116,17 @@
taskDefinitionKey: EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA,
processDefinitionKey: "processDefinitionKey",
assignee: "assignee",
+ authorized: true,
requiredVariables: {}
}
};
- let action = updateTaskAction(actionPayload);
+ let action = setTaskEntityAction(actionPayload);
let state = tasksReducer(initialState, action);
expect(state).toEqual(initialState);
- actionPayload.task = {taskId: "unknownTaskId"} as IAPIProcessTask;
- action = updateTaskAction(actionPayload);
- state = tasksReducer(initialState, action);
- expect(state).toEqual(initialState);
-
actionPayload.task = {taskId: "taskId", assignee: "changedValue"} as IAPIProcessTask;
- action = updateTaskAction(actionPayload);
+ action = setTaskEntityAction(actionPayload);
state = tasksReducer(initialState, action);
expect(state).toEqual({
taskId: {
@@ -136,6 +135,7 @@
taskDefinitionKey: EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA,
processDefinitionKey: "processDefinitionKey",
assignee: "changedValue",
+ authorized: true,
requiredVariables: {}
}
});
diff --git a/src/app/store/process/reducers/tasks.reducer.ts b/src/app/store/process/reducers/tasks.reducer.ts
index 589c9c1..fbdb2e6 100644
--- a/src/app/store/process/reducers/tasks.reducer.ts
+++ b/src/app/store/process/reducers/tasks.reducer.ts
@@ -13,12 +13,12 @@
import {createReducer, on} from "@ngrx/store";
import {IAPIProcessTask} from "../../../core/api/process";
-import {arrayToEntities, deleteEntities, entitiesToArray, TStoreEntities} from "../../../util/store";
-import {deleteTaskAction, setTasksAction, updateTaskAction} from "../actions";
+import {arrayToEntities, deleteEntities, entitiesToArray, TStoreEntities, updateEntitiesObject} from "../../../util/store";
+import {deleteTaskAction, setStatementTasksAction, setTaskEntityAction} from "../actions";
export const tasksReducer = createReducer<TStoreEntities<IAPIProcessTask>>(
{},
- on(setTasksAction, (state, payload) => {
+ on(setStatementTasksAction, (state, payload) => {
const statementId = payload?.statementId;
if (typeof statementId !== "number") {
@@ -36,6 +36,9 @@
...arrayToEntities<IAPIProcessTask>(newTasks, (task) => task.taskId)
};
}),
+ on(setTaskEntityAction, (state, payload) => {
+ return updateEntitiesObject(state, [payload.task], (task) => task.taskId);
+ }),
on(deleteTaskAction, (state, payload) => {
if (typeof payload?.statementId !== "number") {
return state;
@@ -50,19 +53,5 @@
.map((task) => task.taskId);
return deleteEntities(state, taskIdsToDelete);
- }),
- on(updateTaskAction, (state, payload) => {
- const taskId = payload?.task?.taskId;
- if (typeof taskId !== "string" || state[taskId] == null) {
- return state;
- }
-
- return {
- ...state,
- [taskId]: {
- ...state[taskId],
- ...payload.task
- }
- };
})
);
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/store/root/actions/error.actions.ts
similarity index 75%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/store/root/actions/error.actions.ts
index a3980e1..af04c23 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/store/root/actions/error.actions.ts
@@ -11,4 +11,9 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+import {createAction, props} from "@ngrx/store";
+
+export const setErrorAction = createAction(
+ "[API] Set error",
+ props<{ error: string, statementId?: number | "new" }>()
+);
diff --git a/src/app/store/root/actions/index.ts b/src/app/store/root/actions/index.ts
index fefc7d9..287c32a 100644
--- a/src/app/store/root/actions/index.ts
+++ b/src/app/store/root/actions/index.ts
@@ -11,4 +11,5 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
+export * from "./error.actions";
export * from "./root.actions";
diff --git a/src/app/store/root/effects/index.ts b/src/app/store/root/effects/index.ts
index a792a0f..8d6f1f3 100644
--- a/src/app/store/root/effects/index.ts
+++ b/src/app/store/root/effects/index.ts
@@ -15,5 +15,6 @@
export * from "./keep-alive.effect";
export * from "./open-new-tab.effect";
export * from "./router.effects";
+export * from "./toast.effect";
export * from "./user.effect";
export * from "./version.effect";
diff --git a/src/app/store/root/effects/open-new-tab.effect.ts b/src/app/store/root/effects/open-new-tab.effect.ts
index de4a5fd..2f71a3d 100644
--- a/src/app/store/root/effects/open-new-tab.effect.ts
+++ b/src/app/store/root/effects/open-new-tab.effect.ts
@@ -13,9 +13,11 @@
import {Inject, Injectable} from "@angular/core";
import {Actions, createEffect, ofType} from "@ngrx/effects";
+import {EMPTY, of} from "rxjs";
import {filter, mergeMap, switchMap} from "rxjs/operators";
import {AuthService, CONTACT_DATA_BASE_ROUTE, SPA_BACKEND_ROUTE, URL_TOKEN, WINDOW} from "../../../core";
-import {openContactDataBaseAction, openFileAction} from "../actions";
+import {openContactDataBaseAction, openFileAction, setErrorAction} from "../actions";
+import {EErrorCode} from "../model";
@Injectable({providedIn: "root"})
export class OpenNewTabEffect {
@@ -23,14 +25,20 @@
public openContactDataBase$ = createEffect(() => this.actions.pipe(
ofType(openContactDataBaseAction),
filter(() => this.authenticationService.token != null),
- switchMap(() => this.open(this.contactDataBaseRoute, true))
- ), {dispatch: false});
+ switchMap(() => {
+ const tab = this.open(this.contactDataBaseRoute, true);
+ return tab ? EMPTY : of(setErrorAction({error: EErrorCode.UNEXPECTED}));
+ })
+ ), {dispatch: true});
public openFile$ = createEffect(() => this.actions.pipe(
ofType(openFileAction),
filter((action) => action.file instanceof File),
- mergeMap((action) => this.openFile(action.file))
- ), {dispatch: false});
+ mergeMap((action) => {
+ const tab = this.openFile(action.file);
+ return tab ? EMPTY : of(setErrorAction({error: EErrorCode.UNEXPECTED}));
+ })
+ ), {dispatch: true});
public constructor(
public actions: Actions,
diff --git a/src/app/store/root/effects/toast.effect.ts b/src/app/store/root/effects/toast.effect.ts
new file mode 100644
index 0000000..56da623
--- /dev/null
+++ b/src/app/store/root/effects/toast.effect.ts
@@ -0,0 +1,50 @@
+/********************************************************************************
+ * 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 {Injectable} from "@angular/core";
+import {Actions, createEffect, ofType} from "@ngrx/effects";
+import {TranslateService} from "@ngx-translate/core";
+import {MessageService} from "primeng/api";
+import {filter, mergeMap, take} from "rxjs/operators";
+import {setErrorAction} from "../actions";
+
+@Injectable({providedIn: "root"})
+export class ToastEffect {
+
+ public toast$ = createEffect(() => this.actions.pipe(
+ ofType(setErrorAction),
+ filter((action) => action.statementId == null && action.error != null),
+ mergeMap(async (action) => this.toast(action.error))
+ ), {dispatch: false});
+
+ public constructor(
+ public actions: Actions,
+ private messageService: MessageService,
+ private translateService: TranslateService
+ ) {
+ }
+
+ public async toast(error: string) {
+ this.messageService.add({
+ severity: "error",
+ life: 7000,
+ summary: await this.getTranslation("shared.errorMessages.title"),
+ detail: await this.getTranslation(error)
+ });
+ }
+
+ private async getTranslation(msg: string) {
+ return this.translateService.get(msg).pipe(take(1)).toPromise();
+ }
+
+}
diff --git a/src/app/store/root/index.ts b/src/app/store/root/index.ts
index 0648394..a3bc6b1 100644
--- a/src/app/store/root/index.ts
+++ b/src/app/store/root/index.ts
@@ -14,5 +14,6 @@
export * from "./actions";
export * from "./model";
export * from "./selectors";
+export * from "./services";
export * from "./root-store.module";
diff --git a/src/app/store/root/model/EErrorCode.ts b/src/app/store/root/model/EErrorCode.ts
new file mode 100644
index 0000000..f5cbecc
--- /dev/null
+++ b/src/app/store/root/model/EErrorCode.ts
@@ -0,0 +1,25 @@
+/********************************************************************************
+ * 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 enum EErrorCode {
+ UNEXPECTED = "shared.errorMessages.unexpected",
+ TASK_TO_COMPLETE_NOT_FOUND = "shared.errorMessages.taskToCompleteNotFound",
+ CLAIMED_BY_OTHER_USER = "shared.errorMessages.claimedByAnotherUser",
+ MISSING_FORM_DATA = "shared.errorMessages.missingFormData",
+ FAILED_LOADING_CONTACT = "shared.errorMessages.failedLoadingContact",
+ CONTACT_MODULE_NO_ACCESS = "shared.errorMessages.noAccessToContactModule",
+ FAILED_FILE_UPLOAD = "shared.errorMessages.failedFileUpload",
+ FAILED_MAIL_TRANSFER = "shared.errorMessages.failedMailTransfer",
+ INVALID_TEXT_ARRANGEMENT = "shared.errorMessages.invalidTextArrangement",
+ COULD_NOT_LOAD_MAIL_DATA = "shared.errorMessages.couldNotLoadMailData"
+}
diff --git a/src/app/store/root/model/IRootStoreState.ts b/src/app/store/root/model/IRootStoreState.ts
index a913c60..2316bdf 100644
--- a/src/app/store/root/model/IRootStoreState.ts
+++ b/src/app/store/root/model/IRootStoreState.ts
@@ -42,4 +42,9 @@
* Query parameters extracted from the activated route, i.e. current url
*/
queryParams?: Params;
+
+ /**
+ * Error that's received from any outgoing requests.
+ */
+ error?: string;
}
diff --git a/src/app/store/root/model/index.ts b/src/app/store/root/model/index.ts
index 10b28aa..a3412e9 100644
--- a/src/app/store/root/model/index.ts
+++ b/src/app/store/root/model/index.ts
@@ -11,5 +11,6 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
+export * from "./EErrorCode";
export * from "./EExitCode";
export * from "./IRootStoreState";
diff --git a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss b/src/app/store/root/reducers/error-code.reducer.ts
similarity index 68%
rename from src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss
rename to src/app/store/root/reducers/error-code.reducer.ts
index ac39661..648fcfb 100644
--- a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss
+++ b/src/app/store/root/reducers/error-code.reducer.ts
@@ -11,19 +11,10 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-@import "openk.styles";
+import {createReducer, on} from "@ngrx/store";
+import {setErrorAction} from "../actions";
-.dashboard-item-header-actions {
- display: inline-flex;
- margin-left: auto;
-
- & > * {
- margin-left: 0.5em;
- }
-}
-
-.dashboard-item-body {
- padding: 1em;
- display: flex;
- flex-flow: column;
-}
+export const errorCodeReducer = createReducer<string>(
+ undefined,
+ on(setErrorAction, (state, payload) => payload.statementId != null ? state : payload.error)
+);
diff --git a/src/app/store/root/root-reducers.token.ts b/src/app/store/root/root-reducers.token.ts
index 5788b7a..1e17d9b 100644
--- a/src/app/store/root/root-reducers.token.ts
+++ b/src/app/store/root/root-reducers.token.ts
@@ -15,6 +15,7 @@
import {ActionReducerMap} from "@ngrx/store";
import {IRootStoreState} from "./model";
import {exitCodeReducer, isLoadingReducer, queryParamsReducer, userReducer, versionBackEndReducer, versionReducer} from "./reducers";
+import {errorCodeReducer} from "./reducers/error-code.reducer";
export const ROOT_REDUCER = new InjectionToken<ActionReducerMap<IRootStoreState>>("Root store reducer", {
providedIn: "root",
@@ -24,6 +25,7 @@
user: userReducer,
isLoading: isLoadingReducer,
exitCode: exitCodeReducer,
- queryParams: queryParamsReducer
+ queryParams: queryParamsReducer,
+ error: errorCodeReducer
})
});
diff --git a/src/app/store/root/root-store.module.ts b/src/app/store/root/root-store.module.ts
index ab44541..5745016 100644
--- a/src/app/store/root/root-store.module.ts
+++ b/src/app/store/root/root-store.module.ts
@@ -15,7 +15,7 @@
import {EffectsModule} from "@ngrx/effects";
import {Store, StoreModule} from "@ngrx/store";
import {intializeAction} from "./actions";
-import {InitializationEffect, KeepAliveEffect, OpenNewTabEffect, RouterEffects, UserEffect, VersionEffect} from "./effects";
+import {InitializationEffect, KeepAliveEffect, OpenNewTabEffect, RouterEffects, ToastEffect, UserEffect, VersionEffect} from "./effects";
import {ROOT_REDUCER} from "./root-reducers.token";
export function dispatchInitialization(store: Store) {
@@ -31,7 +31,8 @@
OpenNewTabEffect,
RouterEffects,
UserEffect,
- VersionEffect
+ VersionEffect,
+ ToastEffect
])
],
providers: [
diff --git a/src/app/store/root/selectors/query-params.selectors.ts b/src/app/store/root/selectors/query-params.selectors.ts
index 2619dac..242e71a 100644
--- a/src/app/store/root/selectors/query-params.selectors.ts
+++ b/src/app/store/root/selectors/query-params.selectors.ts
@@ -34,3 +34,8 @@
queryParamsSelector,
(state): string => state?.taskId
);
+
+export const queryParamsMailIdSelector = createSelector(
+ queryParamsSelector,
+ (state): string => state?.mailId
+);
diff --git a/src/app/store/root/selectors/root.selectors.ts b/src/app/store/root/selectors/root.selectors.ts
index 7e7e578..fa2fbc4 100644
--- a/src/app/store/root/selectors/root.selectors.ts
+++ b/src/app/store/root/selectors/root.selectors.ts
@@ -25,3 +25,8 @@
rootStateSelector,
(state) => state.exitCode
);
+
+export const errorCodeSelector = createSelector(
+ rootStateSelector,
+ (state) => state.error
+);
diff --git a/src/app/store/root/selectors/user.selectors.ts b/src/app/store/root/selectors/user.selectors.ts
index b9d93af..798621a 100644
--- a/src/app/store/root/selectors/user.selectors.ts
+++ b/src/app/store/root/selectors/user.selectors.ts
@@ -13,7 +13,7 @@
import {createSelector} from "@ngrx/store";
import {EAPIUserRoles} from "../../../core/api/core";
-import {selectArrayProjector, selectPropertyProjector} from "../../../util/store";
+import {arrayJoin, selectArrayProjector, selectPropertyProjector} from "../../../util/store";
import {rootStateSelector} from "./root.selectors";
export const userSelector = createSelector(
@@ -48,11 +48,32 @@
selectArrayProjector("roles", [])
);
+
+function hasUserRoleProjector(requiredUserRole: EAPIUserRoles) {
+ return (roles: EAPIUserRoles[]): boolean => arrayJoin(roles).some((role) => role === requiredUserRole);
+}
+
export const isOfficialInChargeSelector = createSelector(
userRolesSelector,
- (roles): boolean => roles.find((role) => role === EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE) != null
+ hasUserRoleProjector(EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE)
);
+export const isDivisionMemberSelector = createSelector(
+ userRolesSelector,
+ hasUserRoleProjector(EAPIUserRoles.DIVISION_MEMBER)
+);
+
+export const isApproverSelector = createSelector(
+ userRolesSelector,
+ hasUserRoleProjector(EAPIUserRoles.SPA_APPROVER)
+);
+
+export const isAdminSelector = createSelector(
+ userRolesSelector,
+ hasUserRoleProjector(EAPIUserRoles.SPA_ADMIN)
+);
+
+
export const userNameSelector = createSelector(
userSelector,
selectPropertyProjector("userName")
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/store/root/services/index.ts
similarity index 91%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/store/root/services/index.ts
index a3980e1..a5f0073 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/store/root/services/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./user-role-route-guard.service";
diff --git a/src/app/store/root/services/user-role-route-guard.service.spec.ts b/src/app/store/root/services/user-role-route-guard.service.spec.ts
new file mode 100644
index 0000000..5a99712
--- /dev/null
+++ b/src/app/store/root/services/user-role-route-guard.service.spec.ts
@@ -0,0 +1,114 @@
+/********************************************************************************
+ * 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
+ ********************************************************************************/
+
+/********************************************************************************
+ * 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 {Location} from "@angular/common";
+import {Component, NgZone} from "@angular/core";
+import {async, TestBed} from "@angular/core/testing";
+import {Router} from "@angular/router";
+import {RouterTestingModule} from "@angular/router/testing";
+import {MemoizedSelector} from "@ngrx/store";
+import {MockStore, provideMockStore} from "@ngrx/store/testing";
+import {EAPIUserRoles} from "../../../core";
+import {isLoadingSelector, OfficialInChargeRouteGuardService, UserRoleRouteGuardService, userRolesSelector} from "../../../store";
+
+describe("UserRoleRouteGuardService", () => {
+
+ let router: Router;
+ let location: Location;
+ let mockStore: MockStore;
+ let service: UserRoleRouteGuardService;
+ let userRolesSelectorMock: MemoizedSelector<any, string[]>;
+ let isLoadingSelectorMock: MemoizedSelector<any, boolean>;
+
+ function callInZone<T>(fn: () => T | Promise<T>): Promise<T> {
+ const ngZone = TestBed.inject(NgZone);
+ return new Promise<T>((res, rej) => {
+ ngZone.run(() => Promise.resolve().then(() => fn()).then(res).catch(rej));
+ });
+ }
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ declarations: [
+ TestComponent
+ ],
+ imports: [
+ RouterTestingModule.withRoutes([{
+ path: "test",
+ pathMatch: "full",
+ component: TestComponent,
+ canActivate: [OfficialInChargeRouteGuardService]
+ }])
+ ],
+ providers: [
+ provideMockStore()
+ ]
+ }).compileComponents();
+ service = TestBed.inject(OfficialInChargeRouteGuardService);
+ router = TestBed.inject(Router);
+ location = TestBed.inject(Location);
+ mockStore = TestBed.inject(MockStore);
+ userRolesSelectorMock = mockStore.overrideSelector(userRolesSelector, []);
+ isLoadingSelectorMock = mockStore.overrideSelector(isLoadingSelector, true);
+ }));
+
+ it("should allow access if user has role", async () => {
+ userRolesSelectorMock.setResult([EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE]);
+ const isRoutingSuccessfulPromise = callInZone(() => router.navigate(["test"]));
+ isLoadingSelector.setResult(false);
+ mockStore.refreshState();
+ await expectAsync(isRoutingSuccessfulPromise).toBeResolvedTo(true);
+ expect(location.path()).toBe("/test");
+ });
+
+ it("should prevent access if user has not allowed role", async () => {
+ userRolesSelectorMock.setResult([EAPIUserRoles.SPA_APPROVER]);
+ const isRoutingSuccessfulPromise = callInZone(() => router.navigate(["test"]));
+ isLoadingSelector.setResult(false);
+ mockStore.refreshState();
+ await expectAsync(isRoutingSuccessfulPromise).toBeResolvedTo(true);
+ expect(location.path()).toBe("/");
+ });
+
+ it("should check if certain user roles are allowed", async () => {
+ service.config.allowed = [EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE, EAPIUserRoles.SPA_APPROVER];
+ expect(service.isUserRoleAllowed(EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE)).toBeTrue();
+ expect(service.isUserRoleAllowed(EAPIUserRoles.SPA_APPROVER)).toBeTrue();
+ expect(service.isUserRoleAllowed(EAPIUserRoles.DIVISION_MEMBER)).toBeFalse();
+ expect(service.isUserRoleAllowed(null)).toBeFalse();
+
+ service.config.allowed = null;
+ expect(service.isUserRoleAllowed(EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE)).toBeFalse();
+
+ service.config = null;
+ expect(service.isUserRoleAllowed(EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE)).toBeFalse();
+ });
+});
+
+
+@Component({template: ""})
+class TestComponent {
+}
diff --git a/src/app/store/root/services/user-role-route-guard.service.ts b/src/app/store/root/services/user-role-route-guard.service.ts
new file mode 100644
index 0000000..e7dc0fa
--- /dev/null
+++ b/src/app/store/root/services/user-role-route-guard.service.ts
@@ -0,0 +1,81 @@
+/********************************************************************************
+ * 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 {Injectable} from "@angular/core";
+import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from "@angular/router";
+import {select, Store} from "@ngrx/store";
+import {Observable} from "rxjs";
+import {filter, map, switchMap} from "rxjs/operators";
+import {EAPIUserRoles} from "../../../core/api/core";
+import {arrayJoin} from "../../../util/store";
+import {isLoadingSelector, userRolesSelector} from "../selectors";
+
+export interface IUserRoleRouteGuardConfiguration {
+
+ allowed: EAPIUserRoles[];
+
+}
+
+export abstract class UserRoleRouteGuardService implements CanActivate {
+
+ public redirectUrlTree: UrlTree = this.router.createUrlTree(["/"]);
+
+ public isInitialized$ = this.store.pipe(
+ select(isLoadingSelector),
+ filter((isLoading) => !isLoading)
+ );
+
+ public isAllowed$ = this.store.pipe(
+ select(userRolesSelector),
+ map((userRoles) => arrayJoin(userRoles).some((role) => this.isUserRoleAllowed(role)))
+ );
+
+ protected constructor(
+ public store: Store,
+ public router: Router,
+ public config: IUserRoleRouteGuardConfiguration
+ ) {
+
+ }
+
+ public isUserRoleAllowed(role: EAPIUserRoles): boolean {
+ return arrayJoin(this.config?.allowed).indexOf(role) > -1;
+ }
+
+ public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
+ return this.isInitialized$.pipe(
+ switchMap(() => this.isAllowed$),
+ map((isAllowed) => isAllowed ? isAllowed : this.redirectUrlTree)
+ );
+ }
+
+}
+
+
+@Injectable({providedIn: "root"})
+export class OfficialInChargeRouteGuardService extends UserRoleRouteGuardService {
+
+ public constructor(store: Store, router: Router) {
+ super(store, router, {allowed: [EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE]});
+ }
+
+}
+
+@Injectable({providedIn: "root"})
+export class OfficialInChargeOrAdminRouteGuardService extends UserRoleRouteGuardService {
+
+ public constructor(store: Store, router: Router) {
+ super(store, router, {allowed: [EAPIUserRoles.SPA_OFFICIAL_IN_CHARGE, EAPIUserRoles.SPA_ADMIN]});
+ }
+
+}
diff --git a/src/app/store/settings/effects/fetch-settings.effect.ts b/src/app/store/settings/effects/fetch-settings.effect.ts
index 7cd6c44..3acc42a 100644
--- a/src/app/store/settings/effects/fetch-settings.effect.ts
+++ b/src/app/store/settings/effects/fetch-settings.effect.ts
@@ -17,8 +17,9 @@
import {concat, merge, Observable} from "rxjs";
import {map, retry, switchMap} from "rxjs/operators";
import {SettingsApiService} from "../../../core";
-import {ignoreError, retryAfter} from "../../../util";
-import {intializeAction} from "../../root/actions";
+import {catchErrorTo, retryAfter} from "../../../util";
+import {intializeAction, setErrorAction} from "../../root/actions";
+import {EErrorCode} from "../../root/model";
import {fetchSettingsAction, setSectorsAction, setStatementTypesAction} from "../actions";
@Injectable({providedIn: "root"})
@@ -52,7 +53,7 @@
return this.settingsApiService.getStatementTypes().pipe(
map((statementTypes) => setStatementTypesAction({statementTypes})),
retry(2),
- ignoreError()
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED}))
);
}
@@ -60,7 +61,7 @@
return this.settingsApiService.getSectors().pipe(
map((sectors) => setSectorsAction({sectors})),
retry(2),
- ignoreError()
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED}))
);
}
diff --git a/src/app/store/statements/actions/error.actions.ts b/src/app/store/statements/actions/error.actions.ts
index 3016963..32ed5d1 100644
--- a/src/app/store/statements/actions/error.actions.ts
+++ b/src/app/store/statements/actions/error.actions.ts
@@ -12,9 +12,15 @@
********************************************************************************/
import {createAction, props} from "@ngrx/store";
+import {IAPITextArrangementErrorModel} from "../../../core/api/text";
import {IStatementErrorEntity} from "../model";
export const setStatementErrorAction = createAction(
"[API] Set statement error",
- props<{ statementId: number, error: IStatementErrorEntity }>()
+ props<{ statementId: number | "new", error: IStatementErrorEntity }>()
+);
+
+export const setStatementArrangementErrorAction = createAction(
+ "[API] Set statement arrangement error",
+ props<{ statementId: number, error: IAPITextArrangementErrorModel[] }>()
);
diff --git a/src/app/store/statements/actions/fetch.actions.ts b/src/app/store/statements/actions/fetch.actions.ts
index 8137edd..ecbf621 100644
--- a/src/app/store/statements/actions/fetch.actions.ts
+++ b/src/app/store/statements/actions/fetch.actions.ts
@@ -39,3 +39,7 @@
"[Edit] Fetch statement text arrangement",
props<{ statementId: number; }>()
);
+
+export const fetchDashboardStatementsAction = createAction(
+ "[Dashboard] Fetch dashboard statements"
+);
diff --git a/src/app/store/statements/effects/comments/comments.effect.ts b/src/app/store/statements/effects/comments/comments.effect.ts
index bc7fbf3..3d1c51d 100644
--- a/src/app/store/statements/effects/comments/comments.effect.ts
+++ b/src/app/store/statements/effects/comments/comments.effect.ts
@@ -13,9 +13,13 @@
import {Injectable} from "@angular/core";
import {Actions, createEffect, ofType} from "@ngrx/effects";
-import {filter, map, retry, switchMap} from "rxjs/operators";
+import {Action} from "@ngrx/store";
+import {Observable} from "rxjs";
+import {filter, ignoreElements, map, retry, switchMap} from "rxjs/operators";
import {StatementsApiService} from "../../../../core/api/statements";
-import {ignoreError} from "../../../../util/rxjs";
+import {catchErrorTo, endWithObservable} from "../../../../util/rxjs";
+import {setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
import {addCommentAction, deleteCommentAction, fetchCommentsAction, updateStatementEntityAction} from "../../actions";
@Injectable({providedIn: "root"})
@@ -43,27 +47,29 @@
}
- public fetchComments(statementId: number) {
+ public fetchComments(statementId: number): Observable<Action> {
return this.statementsApiService.getComments(statementId).pipe(
map((comments) => updateStatementEntityAction({statementId, entity: {comments}})),
retry(2),
- ignoreError()
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
);
}
- public addComment(statementId: number, comment: string) {
+ public addComment(statementId: number, comment: string): Observable<Action> {
return this.statementsApiService.putComment(statementId, comment).pipe(
+ ignoreElements(),
retry(2),
- ignoreError(),
- switchMap(() => this.fetchComments(statementId))
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
+ endWithObservable(() => this.fetchComments(statementId))
);
}
- public deleteComment(statementId: number, commentId: number) {
+ public deleteComment(statementId: number, commentId: number): Observable<Action> {
return this.statementsApiService.deleteComment(statementId, commentId).pipe(
+ ignoreElements(),
retry(2),
- ignoreError(),
- switchMap(() => this.fetchComments(statementId))
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
+ endWithObservable(() => this.fetchComments(statementId))
);
}
diff --git a/src/app/store/statements/effects/compile-statement-arrangement/compile-statement-arrangement.effect.ts b/src/app/store/statements/effects/compile-statement-arrangement/compile-statement-arrangement.effect.ts
index 8b86f09..a61f105 100644
--- a/src/app/store/statements/effects/compile-statement-arrangement/compile-statement-arrangement.effect.ts
+++ b/src/app/store/statements/effects/compile-statement-arrangement/compile-statement-arrangement.effect.ts
@@ -17,8 +17,9 @@
import {Observable, ObservableInput, of} from "rxjs";
import {endWith, filter, startWith, switchMap} from "rxjs/operators";
import {IAPITextArrangementItemModel, IAPITextArrangementValidationModel, TextApiService} from "../../../../core";
-import {catchHttpError, EHttpStatusCodes, ignoreError} from "../../../../util";
-import {openFileAction} from "../../../root/actions";
+import {catchErrorTo, catchHttpError, EHttpStatusCodes} from "../../../../util";
+import {openFileAction, setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
import {compileStatementArrangementAction, setStatementErrorAction, setStatementLoadingAction} from "../../actions";
@Injectable({providedIn: "root"})
@@ -38,12 +39,12 @@
return this.textApiService.compileArrangement(statementId, taskId, arrangement).pipe(
switchMap((file) => {
return of(
- setStatementErrorAction({statementId, error: {arrangement: null}}),
+ setStatementErrorAction({statementId, error: {arrangement: null, errorMessage: null}}),
openFileAction({file})
);
}),
this.catchArrangementError(statementId),
- ignoreError(),
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
startWith(setStatementLoadingAction({loading: {submittingStatementEditorForm: true}})),
endWith(setStatementLoadingAction({loading: {submittingStatementEditorForm: false}}))
);
@@ -53,7 +54,10 @@
return catchHttpError<any, ObservableInput<Action>>(async (response) => {
const errorMessage = response.error instanceof Blob ? await response.error.text() : response.error;
const body: IAPITextArrangementValidationModel = JSON.parse(errorMessage);
- return setStatementErrorAction({statementId, error: {arrangement: body.errors}});
+ return setStatementErrorAction({
+ statementId,
+ error: {arrangement: body.errors, errorMessage: EErrorCode.INVALID_TEXT_ARRANGEMENT}
+ });
}, EHttpStatusCodes.FAILED_DEPENDENCY);
}
diff --git a/src/app/store/statements/effects/fetch-dashboard-statements/fetch-dash-board-statements.effect.spec.ts b/src/app/store/statements/effects/fetch-dashboard-statements/fetch-dash-board-statements.effect.spec.ts
new file mode 100644
index 0000000..b8dbfb7
--- /dev/null
+++ b/src/app/store/statements/effects/fetch-dashboard-statements/fetch-dash-board-statements.effect.spec.ts
@@ -0,0 +1,124 @@
+/********************************************************************************
+ * 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 {HttpClientTestingModule, HttpTestingController} from "@angular/common/http/testing";
+import {fakeAsync, TestBed} from "@angular/core/testing";
+import {provideMockActions} from "@ngrx/effects/testing";
+import {Action} from "@ngrx/store";
+import {Observable, of, Subscription} from "rxjs";
+import {EAPIProcessTaskDefinitionKey} from "../../../../core/api/process";
+import {IAPIDashboardStatementModel} from "../../../../core/api/statements/IAPIDashboardStatementModel";
+import {SPA_BACKEND_ROUTE} from "../../../../core/external-routes";
+import {setStatementTasksAction} from "../../../process/actions";
+import {fetchDashboardStatementsAction, setStatementLoadingAction, updateStatementEntityAction} from "../../actions";
+import {FetchDashboardStatementsEffect} from "./fetch-dashboard-statements.effect";
+
+describe("FetchDashboardStatementsEffect", () => {
+
+ let httpTestingController: HttpTestingController;
+ let effect: FetchDashboardStatementsEffect;
+ let subscription: Subscription;
+ let actions$: Observable<Action>;
+
+ const returnValue: IAPIDashboardStatementModel[] = [
+ {
+ info: {
+ id: 1,
+ finished: false,
+ title: "Statement",
+ dueDate: "string",
+ receiptDate: "string",
+ typeId: 2,
+ city: "string",
+ district: "string",
+ contactId: "string",
+ sourceMailId: "string",
+ creationDate: "string",
+ customerReference: "string"
+ },
+ tasks: [
+ {
+ statementId: 1,
+ taskId: "string",
+ taskDefinitionKey: EAPIProcessTaskDefinitionKey.APPROVE_STATEMENT,
+ processDefinitionKey: "string",
+ assignee: "string",
+ authorized: true,
+ requiredVariables: {}
+ }
+ ],
+ editedByMe: true,
+ mandatoryDepartmentsCount: 5,
+ mandatoryContributionsCount: 4,
+ optionalForMyDepartment: true,
+ completedForMyDepartment: false
+ }
+ ];
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({
+ imports: [
+ HttpClientTestingModule
+ ],
+ providers: [
+ FetchDashboardStatementsEffect,
+ provideMockActions(() => actions$),
+ {
+ provide: SPA_BACKEND_ROUTE,
+ useValue: "/"
+ }
+ ]
+ });
+ effect = TestBed.inject(FetchDashboardStatementsEffect);
+ httpTestingController = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ if (subscription != null) {
+ subscription.unsubscribe();
+ }
+ });
+
+ it("should fetch dashboard statements list", fakeAsync(() => {
+
+ const results: Action[] = [];
+
+ const expectedResults: Action[] = [
+ setStatementLoadingAction({loading: {fetchingDashboardStatements: true}})
+ ];
+ for (const {tasks, ...statement} of returnValue) {
+
+ expectedResults.push(updateStatementEntityAction({statementId: 1, entity: statement}));
+ }
+ for (const statement of returnValue) {
+ expectedResults.push(setStatementTasksAction({statementId: 1, tasks: statement.tasks}));
+ }
+ expectedResults.push(setStatementLoadingAction({loading: {fetchingDashboardStatements: false}}));
+
+ actions$ = of(fetchDashboardStatementsAction());
+ subscription = effect.fetch$.subscribe((action) => results.push(action));
+
+ expectDashboardStatementsRequest();
+ expect(results).toEqual(expectedResults);
+
+ httpTestingController.verify();
+ }));
+
+ function expectDashboardStatementsRequest() {
+ const url = `/dashboard/statements`;
+ const request = httpTestingController.expectOne(url);
+ expect(request.request.method).toBe("GET");
+ request.flush(returnValue);
+ }
+
+});
diff --git a/src/app/store/statements/effects/fetch-dashboard-statements/fetch-dashboard-statements.effect.ts b/src/app/store/statements/effects/fetch-dashboard-statements/fetch-dashboard-statements.effect.ts
new file mode 100644
index 0000000..3d704ce
--- /dev/null
+++ b/src/app/store/statements/effects/fetch-dashboard-statements/fetch-dashboard-statements.effect.ts
@@ -0,0 +1,61 @@
+/********************************************************************************
+ * 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 {Injectable} from "@angular/core";
+import {Actions, createEffect, ofType} from "@ngrx/effects";
+import {endWith, startWith, switchMap} from "rxjs/operators";
+import {StatementsApiService} from "../../../../core";
+import {catchErrorTo} from "../../../../util/rxjs";
+import {setStatementTasksAction} from "../../../process/actions";
+import {setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
+import {fetchDashboardStatementsAction, setStatementLoadingAction, updateStatementEntityAction} from "../../actions";
+
+@Injectable({providedIn: "root"})
+export class FetchDashboardStatementsEffect {
+
+ public fetch$ = createEffect(() => this.actions.pipe(
+ ofType(fetchDashboardStatementsAction),
+ switchMap(() => {
+ return this.fetch();
+ })
+ ));
+
+ public constructor(
+ private readonly actions: Actions,
+ private readonly statementsApiService: StatementsApiService) {
+
+ }
+
+ public fetch() {
+ return this.statementsApiService.getDashboardStatements().pipe(
+ switchMap((statements) => {
+ return [
+ ...statements.map((statement) => {
+ const {tasks, ...entity} = statement;
+ return updateStatementEntityAction({statementId: statement?.info?.id, entity});
+ }),
+ ...statements.map((statement) => {
+ const {tasks} = statement;
+ return setStatementTasksAction({statementId: statement?.info?.id, tasks});
+ })
+ ];
+ }),
+ startWith(setStatementLoadingAction({loading: {fetchingDashboardStatements: true}})),
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
+ endWith(setStatementLoadingAction({loading: {fetchingDashboardStatements: false}}))
+ );
+ }
+
+}
+
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/store/statements/effects/fetch-dashboard-statements/index.ts
similarity index 91%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/store/statements/effects/fetch-dashboard-statements/index.ts
index a3980e1..bad42e9 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/store/statements/effects/fetch-dashboard-statements/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./fetch-dashboard-statements.effect";
diff --git a/src/app/store/statements/effects/fetch-statement-details/fetch-statement-details.effect.ts b/src/app/store/statements/effects/fetch-statement-details/fetch-statement-details.effect.ts
index c14d357..e9c8d46 100644
--- a/src/app/store/statements/effects/fetch-statement-details/fetch-statement-details.effect.ts
+++ b/src/app/store/statements/effects/fetch-statement-details/fetch-statement-details.effect.ts
@@ -17,11 +17,13 @@
import {EMPTY, merge, Observable, of} from "rxjs";
import {filter, map, retry, startWith, switchMap} from "rxjs/operators";
import {ProcessApiService, SettingsApiService, StatementsApiService} from "../../../../core";
-import {ignoreError} from "../../../../util/rxjs";
+import {catchErrorTo} from "../../../../util/rxjs";
import {arrayJoin} from "../../../../util/store";
import {fetchAttachmentsAction} from "../../../attachments/actions";
import {setDiagramAction, setHistoryAction} from "../../../process/actions";
import {ProcessTaskEffect} from "../../../process/effects";
+import {setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
import {
fetchCommentsAction,
fetchStatementDetailsAction,
@@ -68,7 +70,7 @@
startWith(updateStatementEntityAction({statementId, entity: {info}}))
);
}),
- ignoreError()
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
);
}
@@ -79,7 +81,8 @@
parentIds = arrayJoin(parentIds);
const fetchParentsInfo$ = this.statementsApiService.getStatements(...parentIds).pipe(
map((items) => updateStatementInfoAction({items})),
- retry(2)
+ retry(2),
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
);
return merge(
of(updateStatementEntityAction({statementId, entity: {parentIds}})),
@@ -93,7 +96,7 @@
return this.statementsApiService.getWorkflowData(statementId).pipe(
retry(2),
map((workflow) => updateStatementEntityAction({statementId, entity: {workflow}})),
- ignoreError()
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
);
}
@@ -101,7 +104,7 @@
return this.statementsApiService.getContributions(statementId).pipe(
retry(2),
map((contributions) => updateStatementEntityAction({statementId, entity: {contributions}})),
- ignoreError()
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
);
}
@@ -109,7 +112,7 @@
return this.settingsApiService.getDepartmentsConfiguration(statementId).pipe(
map((departments) => updateStatementConfigurationAction({statementId, entity: {departments}})),
retry(2),
- ignoreError()
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
);
}
@@ -117,7 +120,7 @@
return this.processApiService.getStatementHistory(statementId).pipe(
map((history) => setHistoryAction({statementId, history})),
retry(2),
- ignoreError()
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
);
}
@@ -125,7 +128,7 @@
return this.processApiService.getStatementProcessDiagram(statementId).pipe(
map((diagram) => setDiagramAction({statementId, diagram})),
retry(2),
- ignoreError()
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
);
}
@@ -133,7 +136,7 @@
return this.statementsApiService.getSectors(statementId).pipe(
map((sectors) => updateStatementConfigurationAction({statementId, entity: {sectors}})),
retry(2),
- ignoreError()
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
);
}
diff --git a/src/app/store/statements/effects/fetch-text-arrangement/fetch-text-arrangement.effect.ts b/src/app/store/statements/effects/fetch-text-arrangement/fetch-text-arrangement.effect.ts
index 0a3d27f..cb86b83 100644
--- a/src/app/store/statements/effects/fetch-text-arrangement/fetch-text-arrangement.effect.ts
+++ b/src/app/store/statements/effects/fetch-text-arrangement/fetch-text-arrangement.effect.ts
@@ -17,7 +17,9 @@
import {merge, Observable} from "rxjs";
import {filter, map, retry, switchMap} from "rxjs/operators";
import {TextApiService} from "../../../../core";
-import {ignoreError} from "../../../../util";
+import {catchErrorTo} from "../../../../util";
+import {setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
import {fetchStatementTextArrangementAction, updateStatementConfigurationAction, updateStatementEntityAction} from "../../actions";
@Injectable({providedIn: "root"})
@@ -44,7 +46,7 @@
return this.textApiService.getArrangement(statementId).pipe(
map((arrangement) => updateStatementEntityAction({statementId, entity: {arrangement}})),
retry(2),
- ignoreError()
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED}))
);
}
@@ -52,7 +54,7 @@
return this.textApiService.getConfiguration(statementId).pipe(
map((text) => updateStatementConfigurationAction({statementId, entity: {text}})),
retry(2),
- ignoreError()
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED}))
);
}
diff --git a/src/app/store/statements/effects/search/search-statements.effect.ts b/src/app/store/statements/effects/search/search-statements.effect.ts
index 80aab75..7bc0559 100644
--- a/src/app/store/statements/effects/search/search-statements.effect.ts
+++ b/src/app/store/statements/effects/search/search-statements.effect.ts
@@ -17,7 +17,9 @@
import {Observable, of} from "rxjs";
import {concatMap, debounceTime, endWith, filter, startWith, switchMap} from "rxjs/operators";
import {IAPISearchOptions, StatementsApiService} from "../../../../core";
-import {ignoreError} from "../../../../util/rxjs";
+import {catchErrorTo} from "../../../../util/rxjs";
+import {setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
import {
setStatementLoadingAction,
setStatementSearchResultAction,
@@ -50,7 +52,7 @@
setStatementSearchResultAction({results})
);
}),
- ignoreError(),
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
startWith(setStatementLoadingAction({loading: {search: true}})),
endWith(setStatementLoadingAction({loading: {search: false}}))
);
diff --git a/src/app/store/statements/effects/submit-information-form/submit-statement-information-form.effect.ts b/src/app/store/statements/effects/submit-information-form/submit-statement-information-form.effect.ts
index 8570828..06a42db 100644
--- a/src/app/store/statements/effects/submit-information-form/submit-statement-information-form.effect.ts
+++ b/src/app/store/statements/effects/submit-information-form/submit-statement-information-form.effect.ts
@@ -15,13 +15,18 @@
import {Router} from "@angular/router";
import {Actions, createEffect, ofType} from "@ngrx/effects";
import {Action} from "@ngrx/store";
-import {concat, defer, EMPTY, Observable} from "rxjs";
-import {catchError, endWith, exhaustMap, filter, map, startWith, switchMap} from "rxjs/operators";
+import {concat, defer, EMPTY, Observable, OperatorFunction, pipe} from "rxjs";
+import {endWith, filter, map, startWith, switchMap, tap} from "rxjs/operators";
import {EAPIProcessTaskDefinitionKey, ProcessApiService, StatementsApiService} from "../../../../core";
-import {ignoreError} from "../../../../util/rxjs";
+import {shrinkString} from "../../../../util/forms";
+import {catchHttpErrorTo, EHttpStatusCodes} from "../../../../util/http";
+import {catchErrorTo, endWithObservable, ignoreError, throwAfterActionType} from "../../../../util/rxjs";
import {SubmitAttachmentsEffect} from "../../../attachments/effects/submit";
+import {setTaskEntityAction} from "../../../process/actions";
import {ProcessTaskEffect} from "../../../process/effects";
-import {setStatementLoadingAction, submitStatementInformationFormAction} from "../../actions";
+import {setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
+import {setStatementLoadingAction, submitStatementInformationFormAction, updateStatementEntityAction} from "../../actions";
import {IStatementInformationFormValue} from "../../model";
@Injectable({providedIn: "root"})
@@ -31,10 +36,10 @@
ofType(submitStatementInformationFormAction),
filter((action) => action.value != null),
filter((action) => action.new || action.statementId != null && action.taskId != null),
- exhaustMap((action) => {
+ switchMap((action) => {
return action.new ?
- this.submitNewStatement(action.value, action.responsible) :
- this.submit(action.statementId, action.taskId, action.value, action.responsible);
+ this.__submit(action.value, action.responsible) :
+ this.__submit(action.value, action.responsible, action.statementId, action.taskId);
})
));
@@ -49,81 +54,33 @@
}
- public submit(
- statementId: number,
- taskId: string,
+ public __submit(
value: IStatementInformationFormValue,
- responsible?: boolean
+ responsible?: boolean,
+ statementId?: number,
+ taskId?: string
): Observable<Action> {
- return this.updateStatement(statementId, taskId, value).pipe(
- switchMap(() => {
- const submitAttachments$ = this.submitAttachmentsEffect.submit(statementId, taskId, value?.attachments).pipe(
- catchError(() => {
- responsible = undefined;
- return EMPTY;
- })
- );
-
- return concat(
- submitAttachments$,
- defer(() => this.finalizeSubmit(statementId, taskId, responsible))
- );
- }),
+ return concat<Action>(
+ statementId != null ?
+ this.updateStatement(statementId, taskId, value) :
+ this.createStatement(value, (_statementId) => statementId = _statementId).pipe(
+ endWithObservable(() => this.claimNext(statementId, (_taskId) => taskId = _taskId))
+ ),
+ // In case of a new statement, the defer call is required here
+ // because statementId and taskId are created in the first step:
+ defer(() => this.submitAttachmentsEffect.submit(statementId, taskId, value.attachments))
+ ).pipe(
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
+ throwAfterActionType(setErrorAction),
+ tap({error: () => responsible = undefined}),
ignoreError(),
+ endWithObservable(() => this.finalizeSubmit(statementId, taskId, responsible)),
startWith(setStatementLoadingAction({loading: {submittingStatementInformation: true}})),
endWith(setStatementLoadingAction({loading: {submittingStatementInformation: false}}))
);
}
- public submitNewStatement(
- value: IStatementInformationFormValue,
- responsible?: boolean
- ): Observable<Action> {
- let statementId: number;
- let taskId: string;
- return this.createStatement(value).pipe(
- map((info) => statementId = info.id),
- switchMap(() => this.taskEffect.claimNext(statementId, EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA)),
- switchMap((_) => {
- taskId = _;
- const submitAttachments$ = this.submitAttachmentsEffect.submit(statementId, taskId, value?.attachments).pipe(
- catchError(() => {
- responsible = undefined;
- return EMPTY;
- })
- );
-
- return concat(
- submitAttachments$,
- defer(() => this.finalizeSubmit(statementId, taskId, responsible))
- );
- }),
- catchError(() => {
- if (statementId == null) {
- return EMPTY;
- }
- return this.taskEffect.navigateTo(statementId).pipe(switchMap(() => EMPTY));
- }),
- ignoreError(),
- startWith(setStatementLoadingAction({loading: {submittingStatementInformation: true}})),
- endWith(setStatementLoadingAction({loading: {submittingStatementInformation: false}}))
- );
- }
-
- public finalizeSubmit(statementId: number, taskId: string, responsible?: boolean): Observable<Action> {
- return defer(() => {
- if (taskId != null && responsible != null) {
- return this.taskEffect
- .completeTask(statementId, taskId, {responsible: {type: "Boolean", value: responsible}}, true);
- } else {
- return this.taskEffect.navigateTo(statementId, taskId);
- }
- }).pipe(
- ignoreError()
- );
- }
-
- public createStatement(value: IStatementInformationFormValue) {
+ public createStatement(value: IStatementInformationFormValue, afterCreation: (statementId: number) => void): Observable<Action> {
return this.statementsApiService.putStatement({
title: value.title,
dueDate: value.dueDate,
@@ -131,11 +88,21 @@
typeId: value.typeId,
city: value.city,
district: value.district,
- contactId: value.contactId
- });
+ contactId: value.contactId,
+ sourceMailId: value.sourceMailId,
+ creationDate: value.creationDate,
+ customerReference: shrinkString(value.customerReference, null)
+ }).pipe(
+ map((info) => {
+ const statementId = info.id;
+ afterCreation(statementId);
+ return updateStatementEntityAction({statementId, entity: {info}});
+ }),
+ catchPostStatementInfo("new")
+ );
}
- public updateStatement(statementId: number, taskId: string, value: IStatementInformationFormValue) {
+ public updateStatement(statementId: number, taskId: string, value: IStatementInformationFormValue): Observable<Action> {
return this.statementsApiService.postStatement(statementId, taskId, {
title: value.title,
dueDate: value.dueDate,
@@ -143,8 +110,48 @@
typeId: value.typeId,
city: value.city,
district: value.district,
- contactId: value.contactId
- });
+ contactId: value.contactId,
+ sourceMailId: value.sourceMailId,
+ creationDate: value.creationDate,
+ customerReference: shrinkString(value.customerReference, null)
+ }).pipe(
+ map((info) => updateStatementEntityAction({statementId, entity: {info}})),
+ catchPostStatementInfo(statementId)
+ );
}
+ public claimNext(statementId: number, afterClaim: (taskId: string) => void): Observable<Action> {
+ return this.taskEffect.claimNext(statementId, EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA).pipe(
+ filter((task) => task != null),
+ map((task) => {
+ afterClaim(task.taskId);
+ return setTaskEntityAction({task});
+ })
+ );
+ }
+
+ public finalizeSubmit(statementId: number, taskId: string, responsible?: boolean): Observable<Action> {
+ if (statementId == null) {
+ return EMPTY;
+ }
+ if (taskId != null && responsible != null) {
+ return this.taskEffect.completeTask(statementId, taskId, {responsible: {type: "Boolean", value: responsible}}, true);
+ } else {
+ return this.taskEffect.navigateTo(statementId, taskId);
+ }
+ }
+
+}
+
+export function catchPostStatementInfo<T>(statementId: "new" | number): OperatorFunction<T, T | Action> {
+ return pipe(
+ catchHttpErrorTo(setErrorAction({
+ statementId,
+ error: EErrorCode.MISSING_FORM_DATA
+ }), EHttpStatusCodes.BAD_REQUEST),
+ catchHttpErrorTo(setErrorAction({
+ statementId,
+ error: EErrorCode.FAILED_LOADING_CONTACT
+ }), EHttpStatusCodes.UNPROCESSABLE_ENTITY)
+ );
}
diff --git a/src/app/store/statements/effects/submit-statement-editor-form/submit-statement-editor-form.effect.ts b/src/app/store/statements/effects/submit-statement-editor-form/submit-statement-editor-form.effect.ts
index 7cb78a2..395ee39 100644
--- a/src/app/store/statements/effects/submit-statement-editor-form/submit-statement-editor-form.effect.ts
+++ b/src/app/store/statements/effects/submit-statement-editor-form/submit-statement-editor-form.effect.ts
@@ -14,8 +14,8 @@
import {Injectable} from "@angular/core";
import {Actions, createEffect, ofType} from "@ngrx/effects";
import {Action} from "@ngrx/store";
-import {concat, EMPTY, Observable, throwError} from "rxjs";
-import {endWith, filter, ignoreElements, map, retry, startWith, switchMap, tap} from "rxjs/operators";
+import {concat, EMPTY, Observable} from "rxjs";
+import {endWith, filter, ignoreElements, map, startWith, switchMap} from "rxjs/operators";
import {
EAPIProcessTaskDefinitionKey,
EAPIStaticAttachmentTagIds,
@@ -24,11 +24,19 @@
TCompleteTaskVariable,
TextApiService
} from "../../../../core";
-import {arrayJoin, endWithObservable, ignoreError} from "../../../../util";
+import {catchErrorTo, ignoreError, throwAfterActionType} from "../../../../util";
import {SubmitAttachmentsEffect} from "../../../attachments/effects";
import {ProcessTaskEffect} from "../../../process/effects";
-import {setStatementLoadingAction, submitStatementEditorFormAction, updateStatementEntityAction} from "../../actions";
+import {setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
+import {
+ setStatementErrorAction,
+ setStatementLoadingAction,
+ submitStatementEditorFormAction,
+ updateStatementEntityAction
+} from "../../actions";
import {IDepartmentOptionValue, IStatementEditorFormValue} from "../../model";
+import {reduceDepartmentOptionsToGroups} from "../../util";
import {CompileStatementArrangementEffect} from "../compile-statement-arrangement";
@Injectable({providedIn: "root"})
@@ -65,9 +73,9 @@
): Observable<Action> {
options = options == null ? {} : options;
return concat<Action>(
- value.contributions ? this.submitContributions(statementId, taskId, value.contributions.selected) : EMPTY,
this.submitArrangement(statementId, taskId, value.arrangement),
- this.submitAttachmentsEffect.submit(statementId, taskId, value.attachments),
+ this.submitAttachmentsEffect.submit(statementId, taskId, value.attachments).pipe(ignoreError()),
+ value.contributions ? this.submitContributions(statementId, taskId, value.contributions.selected) : EMPTY,
options.compile ? this.compile(statementId, taskId, value.arrangement) : EMPTY,
options.file instanceof File ? this.submitStatementFile(statementId, taskId, options.file) : EMPTY,
options.contribute ?
@@ -76,6 +84,8 @@
this.taskEffect.completeTask(statementId, taskId, options.completeTask, options.claimNext) :
EMPTY
).pipe(
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
+ throwAfterActionType(setErrorAction, setStatementErrorAction),
ignoreError(),
startWith(setStatementLoadingAction({loading: {submittingStatementEditorForm: true}})),
endWith(setStatementLoadingAction({loading: {submittingStatementEditorForm: false}}))
@@ -84,27 +94,21 @@
public submitArrangement(statementId: number, taskId: string, arrangement: IAPITextArrangementItemModel[]): Observable<Action> {
return this.textApiService.postArrangement(statementId, taskId, arrangement).pipe(
- map(() => updateStatementEntityAction({statementId, entity: {arrangement}})),
- retry(2)
+ map(() => updateStatementEntityAction({statementId, entity: {arrangement}}))
);
}
- public submitContributions(statementId: number, taskId: string, contributions: IDepartmentOptionValue[]): Observable<Action> {
- const departmentGroups = contributions.reduce((current, value) => {
- return {
- ...current,
- [value.groupName]: arrayJoin(current[value.groupName], [value.name])
- };
- }, {});
- return this.statementsApiService.postContributions(statementId, taskId, departmentGroups).pipe(
- map(() => updateStatementEntityAction({statementId, entity: {contributions: departmentGroups}})),
- retry(2)
+ public submitContributions(statementId: number, taskId: string, contributionOptions: IDepartmentOptionValue[]): Observable<Action> {
+ const contributions = reduceDepartmentOptionsToGroups(contributionOptions);
+ return this.statementsApiService.postContributions(statementId, taskId, contributions).pipe(
+ map(() => updateStatementEntityAction({statementId, entity: {contributions}}))
);
}
public contribute(statementId: number, taskId: string): Observable<Action> {
- return this.statementsApiService.contribute(statementId, taskId)
- .pipe(switchMap(() => this.taskEffect.navigateTo(statementId)));
+ return this.statementsApiService.contribute(statementId, taskId).pipe(
+ switchMap(() => this.taskEffect.navigateTo(statementId))
+ );
}
private submitStatementFile(statementId: number, taskId: string, file: File) {
@@ -120,12 +124,9 @@
}
private compile(statementId: number, taskId: string, arrangement: IAPITextArrangementItemModel[]) {
- let err: any;
return this.textApiService.compileArrangement(statementId, taskId, arrangement).pipe(
map((file) => updateStatementEntityAction({statementId, entity: {file}})),
- tap({error: (_) => err = _}),
- this.compileStatementArrangementEffect.catchArrangementError(statementId),
- endWithObservable(() => err == null ? EMPTY : throwError(err))
+ this.compileStatementArrangementEffect.catchArrangementError(statementId)
);
}
diff --git a/src/app/store/statements/effects/submit-workflow-form/submit-workflow-form.effect.spec.ts b/src/app/store/statements/effects/submit-workflow-form/submit-workflow-form.effect.spec.ts
index b5e48ae..2315701 100644
--- a/src/app/store/statements/effects/submit-workflow-form/submit-workflow-form.effect.spec.ts
+++ b/src/app/store/statements/effects/submit-workflow-form/submit-workflow-form.effect.spec.ts
@@ -153,14 +153,14 @@
indeterminate: []
}
,
- geographicPosition: "",
+ geographicPosition: "49.87627265513224,8.659071922302248,14",
parentIds
};
}
function createData(): IAPIWorkflowData {
return {
- geoPosition: "",
+ geoPosition: "49.87627265513224,8.659071922302248,14",
mandatoryDepartments: {
"Group A": ["Department 1", "Department 2"],
"Group B": ["Department 1"]
diff --git a/src/app/store/statements/effects/submit-workflow-form/submit-workflow-form.effect.ts b/src/app/store/statements/effects/submit-workflow-form/submit-workflow-form.effect.ts
index ec019a9..a32a312 100644
--- a/src/app/store/statements/effects/submit-workflow-form/submit-workflow-form.effect.ts
+++ b/src/app/store/statements/effects/submit-workflow-form/submit-workflow-form.effect.ts
@@ -14,15 +14,15 @@
import {Injectable} from "@angular/core";
import {Actions, createEffect, ofType} from "@ngrx/effects";
import {Action} from "@ngrx/store";
-import {concat, EMPTY, merge, Observable} from "rxjs";
+import {concat, EMPTY, Observable} from "rxjs";
import {endWith, exhaustMap, filter, map, retry, startWith} from "rxjs/operators";
-import {IAPIDepartmentGroups} from "../../../../core/api/settings";
-import {IAPIWorkflowData, StatementsApiService} from "../../../../core/api/statements";
-import {emitOnComplete} from "../../../../util/rxjs";
-import {arrayJoin} from "../../../../util/store";
+import {IAPIWorkflowData, StatementsApiService} from "../../../../core";
+import {catchErrorTo, emitOnComplete, ignoreError, throwAfterActionType} from "../../../../util/rxjs";
import {ProcessTaskEffect} from "../../../process/effects";
+import {EErrorCode, setErrorAction} from "../../../root";
import {setStatementLoadingAction, submitWorkflowDataFormAction, updateStatementEntityAction} from "../../actions";
import {IWorkflowFormValue} from "../../model";
+import {reduceDepartmentOptionsToGroups} from "../../util";
@Injectable({providedIn: "root"})
export class SubmitWorkflowFormEffect {
@@ -49,12 +49,15 @@
completeTask?: boolean
): Observable<Action> {
return concat(
- merge(
+ concat(
this.postWorkflowData(statementId, taskId, data),
this.postParentIds(statementId, taskId, data)
).pipe(emitOnComplete()),
completeTask ? this.taskEffect.completeTask(statementId, taskId, {}, true) : EMPTY
).pipe(
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
+ throwAfterActionType(setErrorAction),
+ ignoreError(),
startWith(setStatementLoadingAction({loading: {submittingWorkflowData: true}})),
endWith(setStatementLoadingAction({loading: {submittingWorkflowData: false}}))
);
@@ -66,21 +69,9 @@
data: IWorkflowFormValue
): Observable<Action> {
const body: IAPIWorkflowData = {
- mandatoryDepartments: data.departments.selected
- .reduce<IAPIDepartmentGroups>((current, value) => {
- return {
- ...current,
- [value.groupName]: arrayJoin(current[value.groupName], [value.name])
- };
- }, {}),
- optionalDepartments: data.departments.indeterminate
- .reduce<IAPIDepartmentGroups>((current, value) => {
- return {
- ...current,
- [value.groupName]: arrayJoin(current[value.groupName], [value.name])
- };
- }, {}),
- geoPosition: ""
+ mandatoryDepartments: reduceDepartmentOptionsToGroups(data.departments.selected),
+ optionalDepartments: reduceDepartmentOptionsToGroups(data.departments.indeterminate),
+ geoPosition: data.geographicPosition
};
return this.statementsApiService.postWorkflowData(statementId, taskId, body).pipe(
map(() => updateStatementEntityAction({statementId, entity: {workflow: body}})),
diff --git a/src/app/store/statements/effects/validate-statement-arrangement/validate-statement-arrangement.effect.ts b/src/app/store/statements/effects/validate-statement-arrangement/validate-statement-arrangement.effect.ts
index 300498c..e9b31c3 100644
--- a/src/app/store/statements/effects/validate-statement-arrangement/validate-statement-arrangement.effect.ts
+++ b/src/app/store/statements/effects/validate-statement-arrangement/validate-statement-arrangement.effect.ts
@@ -14,10 +14,12 @@
import {Injectable} from "@angular/core";
import {Actions, createEffect, ofType} from "@ngrx/effects";
import {Action} from "@ngrx/store";
-import {Observable, of} from "rxjs";
-import {endWith, filter, startWith, switchMap} from "rxjs/operators";
+import {Observable} from "rxjs";
+import {endWith, filter, map, startWith, switchMap} from "rxjs/operators";
import {IAPITextArrangementItemModel, TextApiService} from "../../../../core";
-import {ignoreError} from "../../../../util/rxjs";
+import {catchErrorTo} from "../../../../util/rxjs";
+import {setErrorAction} from "../../../root/actions";
+import {EErrorCode} from "../../../root/model";
import {setStatementErrorAction, setStatementLoadingAction, validateStatementArrangementAction} from "../../actions";
@Injectable({providedIn: "root"})
@@ -35,14 +37,15 @@
public validate(statementId: number, taskId: string, arrangement: IAPITextArrangementItemModel[]): Observable<Action> {
return this.textApiService.validateArrangement(statementId, taskId, arrangement).pipe(
- switchMap((result) => {
- return of(
- result.valid ?
- setStatementErrorAction({statementId, error: {arrangement: null}}) :
- setStatementErrorAction({statementId, error: {arrangement: result.errors}})
- );
+ map((result) => {
+ return setStatementErrorAction({
+ statementId,
+ error: result.valid ?
+ {arrangement: null, errorMessage: null} :
+ {arrangement: result.errors, errorMessage: EErrorCode.INVALID_TEXT_ARRANGEMENT}
+ });
}),
- ignoreError(),
+ catchErrorTo(setErrorAction({error: EErrorCode.UNEXPECTED})),
startWith(setStatementLoadingAction({loading: {submittingStatementEditorForm: true}})),
endWith(setStatementLoadingAction({loading: {submittingStatementEditorForm: false}}))
);
diff --git a/src/app/store/statements/model/IStatementEntity.ts b/src/app/store/statements/model/IStatementEntity.ts
index 70f662a..e614b23 100644
--- a/src/app/store/statements/model/IStatementEntity.ts
+++ b/src/app/store/statements/model/IStatementEntity.ts
@@ -11,6 +11,7 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
+import {IAPIProcessTask} from "../../../core/api/process";
import {IAPIDepartmentGroups} from "../../../core/api/settings";
import {IAPICommentModel, IAPIStatementModel, IAPIWorkflowData} from "../../../core/api/statements";
import {IAPITextArrangementItemModel} from "../../../core/api/text";
@@ -31,4 +32,18 @@
file?: File;
+ editedByMe?: boolean;
+
+ mandatoryDepartmentsCount?: number;
+
+ mandatoryContributionsCount?: number;
+
+ optionalForMyDepartment?: boolean;
+
+ completedForMyDepartment?: boolean;
+
+}
+
+export interface IStatementEntityWithTasks extends IStatementEntity {
+ tasks?: IAPIProcessTask[];
}
diff --git a/src/app/store/statements/model/IStatementErrorEntity.ts b/src/app/store/statements/model/IStatementErrorEntity.ts
index d700fef..655921f 100644
--- a/src/app/store/statements/model/IStatementErrorEntity.ts
+++ b/src/app/store/statements/model/IStatementErrorEntity.ts
@@ -10,10 +10,13 @@
*
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
+
import {IAPITextArrangementErrorModel} from "../../../core/api/text";
export interface IStatementErrorEntity {
arrangement?: IAPITextArrangementErrorModel[];
+ errorMessage?: string;
+
}
diff --git a/src/app/store/statements/model/IStatementLoadingEntity.ts b/src/app/store/statements/model/IStatementLoadingEntity.ts
index d0b7a36..59343a7 100644
--- a/src/app/store/statements/model/IStatementLoadingEntity.ts
+++ b/src/app/store/statements/model/IStatementLoadingEntity.ts
@@ -15,6 +15,8 @@
search?: boolean;
+ fetchingDashboardStatements?: boolean;
+
submittingStatementInformation?: boolean;
submittingWorkflowData?: boolean;
diff --git a/src/app/store/statements/model/statement-info-form/IStatementInformationFormValue.ts b/src/app/store/statements/model/statement-info-form/IStatementInformationFormValue.ts
index 304c3ce..0de47c7 100644
--- a/src/app/store/statements/model/statement-info-form/IStatementInformationFormValue.ts
+++ b/src/app/store/statements/model/statement-info-form/IStatementInformationFormValue.ts
@@ -25,12 +25,15 @@
export function createStatementInformationForm() {
return createFormGroup<IStatementInformationFormValue>({
title: new FormControl(undefined, [Validators.required]),
+ creationDate: new FormControl(undefined, [Validators.required]),
dueDate: new FormControl(undefined, [Validators.required]),
receiptDate: new FormControl(undefined, [Validators.required]),
typeId: new FormControl(undefined, [Validators.required]),
city: new FormControl(undefined, [Validators.required]),
district: new FormControl(undefined, [Validators.required]),
contactId: new FormControl(undefined, [Validators.required]),
+ sourceMailId: new FormControl(null),
+ customerReference: new FormControl(),
attachments: createAttachmentForm()
});
}
diff --git a/src/app/store/statements/reducers/error/statement-error.reducer.ts b/src/app/store/statements/reducers/error/statement-error.reducer.ts
index 15e2462..93c5aac 100644
--- a/src/app/store/statements/reducers/error/statement-error.reducer.ts
+++ b/src/app/store/statements/reducers/error/statement-error.reducer.ts
@@ -13,12 +13,16 @@
import {createReducer, on} from "@ngrx/store";
import {TStoreEntities, updateEntitiesObject} from "../../../../util";
+import {setErrorAction} from "../../../root/actions";
import {setStatementErrorAction} from "../../actions";
import {IStatementErrorEntity} from "../../model";
export const statementErrorReducer = createReducer<TStoreEntities<IStatementErrorEntity>>(
undefined,
+ on(setErrorAction, (state, payload) => {
+ return updateEntitiesObject(state, [{errorMessage: payload.error}], () => payload.statementId);
+ }),
on(setStatementErrorAction, (state, payload) => {
- return payload == null ? state : updateEntitiesObject(state, [payload.error], () => payload.statementId);
+ return updateEntitiesObject(state, [payload.error], () => payload.statementId);
})
);
diff --git a/src/app/store/statements/selectors/list/statement-list.selectors.spec.ts b/src/app/store/statements/selectors/list/statement-list.selectors.spec.ts
index e4d64e1..8c241d7 100644
--- a/src/app/store/statements/selectors/list/statement-list.selectors.spec.ts
+++ b/src/app/store/statements/selectors/list/statement-list.selectors.spec.ts
@@ -11,11 +11,23 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-import {finishedStatementListSelector, statementListSelector, unfinishedStatementListSelector} from "./statement-list.selectors";
+import {EAPIProcessTaskDefinitionKey} from "../../../../core/api/process";
+import {TStoreEntities} from "../../../../util/store";
+import {IStatementEntity, IStatementEntityWithTasks} from "../../model";
+import {
+ finishedStatementListSelector,
+ getDashboardDivisionMemberStatementsSelector,
+ getDashboardOfficialInChargeStatementsSelector,
+ getDashboardStatementsToApproveSelector,
+ getOtherDashboardStatementsSelector,
+ statementEntitiesSortedByDueDateSelector,
+ statementListSelector,
+ unfinishedStatementListSelector
+} from "./statement-list.selectors";
describe("statementsSelectors", () => {
- const entities = {
+ const entities: TStoreEntities<IStatementEntity> = {
19: undefined,
190: {},
191: {
@@ -30,6 +42,82 @@
const finished = [entities[1919].info];
const unfinished = [entities[191].info];
+ const statementEntitiesWithTasks: IStatementEntityWithTasks[] = [
+ {
+ info: {
+ id: 2,
+ dueDate: "2020-10-21",
+ finished: false
+ },
+ tasks: [
+ {
+ authorized: true,
+ taskDefinitionKey: EAPIProcessTaskDefinitionKey.ADD_WORK_FLOW_DATA
+ }
+ ]
+ } as IStatementEntityWithTasks,
+ {
+ info: {
+ id: 4,
+ dueDate: "2021-10-21",
+ finished: false
+ },
+ tasks: [
+ {
+ authorized: true,
+ taskDefinitionKey: EAPIProcessTaskDefinitionKey.CHECK_AND_FORMULATE_RESPONSE
+ }
+ ]
+ } as IStatementEntityWithTasks,
+ {
+ info: {
+ id: 5,
+ dueDate: "2022-10-21",
+ finished: false
+ },
+ tasks: [
+ {
+ authorized: false,
+ taskDefinitionKey: EAPIProcessTaskDefinitionKey.CHECK_AND_FORMULATE_RESPONSE
+ }
+ ]
+ } as IStatementEntityWithTasks,
+ {
+ info: {
+ id: 3,
+ dueDate: "2020-10-22",
+ finished: false
+ },
+ tasks: [
+ {
+ authorized: true,
+ taskDefinitionKey: EAPIProcessTaskDefinitionKey.ENRICH_DRAFT
+ },
+ {
+ authorized: false,
+ taskDefinitionKey: EAPIProcessTaskDefinitionKey.APPROVE_STATEMENT
+ }
+ ]
+ } as IStatementEntityWithTasks,
+ {
+ info: {
+ id: 1,
+ dueDate: "2020-10-19",
+ finished: false
+ },
+ tasks: [
+ {
+ authorized: false,
+ taskDefinitionKey: EAPIProcessTaskDefinitionKey.ENRICH_DRAFT
+ },
+ {
+ authorized: true,
+ taskDefinitionKey: EAPIProcessTaskDefinitionKey.APPROVE_STATEMENT
+ }
+ ]
+ } as IStatementEntityWithTasks
+ ];
+
it("statementListSelector should return a list of all statements", () => {
expect(statementListSelector.projector({})).toEqual([]);
expect(statementListSelector.projector(entities)).toEqual([entities[191].info, entities[1919].info]);
@@ -47,4 +135,32 @@
expect(unfinishedStatementListSelector.projector(finished)).toEqual([]);
});
+ it("statementEntitiesSortedByDueDateSelector should return all statements sorted by dueDate", () => {
+ expect(statementEntitiesSortedByDueDateSelector.projector(statementEntitiesWithTasks).map((_) => _.info.id))
+ .toEqual([1, 2, 3, 4, 5]);
+ });
+
+ it("getDashboardOfficialInChargeStatementsSelector should return all dashboard statements for official in charge", () => {
+ expect(getDashboardOfficialInChargeStatementsSelector.projector(statementEntitiesWithTasks).map((_) => _.info.id))
+ .toEqual([2, 4]);
+ });
+
+ it("getDashboardDivisionMemberStatementsSelector should return all dashboard statements for division member", () => {
+ expect(getDashboardDivisionMemberStatementsSelector.projector(statementEntitiesWithTasks).map((_) => _.info.id))
+ .toEqual([3]);
+ });
+
+ it("getDashboardStatementsToApproveSelector should return all dashboard statements for approver", () => {
+ expect(getDashboardStatementsToApproveSelector.projector(statementEntitiesWithTasks).map((_) => _.info.id))
+ .toEqual([1]);
+ });
+
+ it("getOtherDashboardStatementsSelector should return all dashboard statements that are not included in the other selectors", () => {
+ expect(getOtherDashboardStatementsSelector.projector(
+ statementEntitiesSortedByDueDateSelector.projector(statementEntitiesWithTasks),
+ getDashboardOfficialInChargeStatementsSelector.projector(statementEntitiesWithTasks),
+ getDashboardDivisionMemberStatementsSelector.projector(statementEntitiesWithTasks),
+ getDashboardStatementsToApproveSelector.projector(statementEntitiesWithTasks)
+ ).map((_) => _.info.id)).toEqual([5]);
+ });
});
diff --git a/src/app/store/statements/selectors/list/statement-list.selectors.ts b/src/app/store/statements/selectors/list/statement-list.selectors.ts
index 4590bb2..d362118 100644
--- a/src/app/store/statements/selectors/list/statement-list.selectors.ts
+++ b/src/app/store/statements/selectors/list/statement-list.selectors.ts
@@ -12,6 +12,11 @@
********************************************************************************/
import {createSelector} from "@ngrx/store";
+import {EAPIProcessTaskDefinitionKey} from "../../../../core/api/process";
+import {arrayJoin, entitiesToArray} from "../../../../util/store";
+import {processStateSelector} from "../../../process/selectors";
+import {userRolesSelector} from "../../../root/selectors";
+import {IStatementEntityWithTasks} from "../../model";
import {statementEntitiesSelector} from "../statement.selectors";
export const statementListSelector = createSelector(
@@ -32,3 +37,94 @@
statementListSelector,
(list) => list.filter((statement) => !statement.finished)
);
+
+
+export const statementsWithTasksSelector = createSelector(
+ statementEntitiesSelector,
+ processStateSelector,
+ (statements, processState) => {
+ return entitiesToArray(statements).map((_) => {
+ const statementId = _?.info?.id;
+ const taskIds = processState?.statementTasks[statementId];
+ const tasks = arrayJoin(taskIds)
+ .map((taskId) => processState.tasks[taskId])
+ .filter((task) => task != null);
+ return {
+ ..._,
+ tasks
+ };
+ });
+ }
+);
+
+function filterStatementEntitiesForDashboardProjector(
+ ...taskKey: EAPIProcessTaskDefinitionKey[]
+): (allStatements: IStatementEntityWithTasks[]) => IStatementEntityWithTasks[] {
+ return (allStatements: IStatementEntityWithTasks[]): IStatementEntityWithTasks[] => {
+ return arrayJoin(allStatements).filter((statement) => {
+ return arrayJoin(statement?.tasks)
+ .some((task) => task.authorized && taskKey.indexOf(task.taskDefinitionKey) > -1);
+ });
+ };
+}
+
+export const statementEntitiesSortedByDueDateSelector = createSelector(
+ statementsWithTasksSelector,
+ (statementEntities) => {
+ return statementEntities.sort((a, b) => {
+ return new Date(a?.info?.dueDate).getTime() - new Date(b?.info?.dueDate).getTime();
+ });
+ }
+);
+
+export const getDashboardOfficialInChargeStatementsSelector = createSelector(
+ statementEntitiesSortedByDueDateSelector,
+ filterStatementEntitiesForDashboardProjector(
+ EAPIProcessTaskDefinitionKey.ADD_BASIC_INFO_DATA,
+ EAPIProcessTaskDefinitionKey.CREATE_NEGATIVE_RESPONSE,
+ EAPIProcessTaskDefinitionKey.ADD_WORK_FLOW_DATA,
+ EAPIProcessTaskDefinitionKey.CREATE_DRAFT,
+ EAPIProcessTaskDefinitionKey.CHECK_AND_FORMULATE_RESPONSE,
+ EAPIProcessTaskDefinitionKey.SEND_STATEMENT
+ )
+);
+
+export const getDashboardDivisionMemberStatementsSelector = createSelector(
+ statementEntitiesSortedByDueDateSelector,
+ filterStatementEntitiesForDashboardProjector(
+ EAPIProcessTaskDefinitionKey.ENRICH_DRAFT
+ )
+);
+
+export const getDashboardStatementsToApproveSelector = createSelector(
+ statementEntitiesSortedByDueDateSelector,
+ filterStatementEntitiesForDashboardProjector(
+ EAPIProcessTaskDefinitionKey.APPROVE_STATEMENT
+ )
+);
+
+export const getOtherDashboardStatementsSelector = createSelector(
+ statementEntitiesSortedByDueDateSelector,
+ getDashboardOfficialInChargeStatementsSelector,
+ getDashboardDivisionMemberStatementsSelector,
+ getDashboardStatementsToApproveSelector,
+ userRolesSelector,
+ (
+ allStatements,
+ statementsForOfficialInCharge,
+ statementsForDivisionMember,
+ statementsToApprove
+ ): IStatementEntityWithTasks[] => {
+ const alreadySelectedStatements = arrayJoin(
+ statementsForOfficialInCharge,
+ statementsForDivisionMember,
+ statementsToApprove
+ );
+ return allStatements
+ .filter((statement) => {
+ return !statement?.info?.finished && !alreadySelectedStatements
+ .some((_) => _?.info?.id === statement.info.id);
+ });
+ }
+);
+
diff --git a/src/app/store/statements/selectors/statement-editor-form/statement-editor-form.selectors.ts b/src/app/store/statements/selectors/statement-editor-form/statement-editor-form.selectors.ts
index 0aece5c..35620b6 100644
--- a/src/app/store/statements/selectors/statement-editor-form/statement-editor-form.selectors.ts
+++ b/src/app/store/statements/selectors/statement-editor-form/statement-editor-form.selectors.ts
@@ -12,10 +12,13 @@
********************************************************************************/
import {createSelector} from "@ngrx/store";
+import {EAPIProcessTaskDefinitionKey} from "../../../../core/api/process";
import {IAPITextArrangementItemModel, IAPITextBlockGroupModel} from "../../../../core/api/text";
-import {arrayJoin, selectArrayProjector, selectPropertyProjector, TStoreEntities} from "../../../../util/store";
+import {arrayJoin, filterDistinctValues, selectArrayProjector, selectPropertyProjector, TStoreEntities} from "../../../../util/store";
+import {taskSelector} from "../../../process/selectors";
import {IStatementEditorControlConfiguration} from "../../model";
import {getStatementTextConfigurationSelector} from "../statement-configuration.selectors";
+import {statementArrangementSelector} from "../statement.selectors";
import {getStatementErrorSelector} from "../statements-store-state.selectors";
const getTextConfigurationSelectorSelector = createSelector(
@@ -33,18 +36,45 @@
selectPropertyProjector("selects", {})
);
-export const getStatementTextBlockGroups = createSelector(
+export const getStatementTextBlockGroupsSelector = createSelector(
getTextConfigurationSelectorSelector,
selectArrayProjector("groups", [])
);
+export const getStatementTextBlockGroupsForNegativeAnswerSelector = createSelector(
+ getTextConfigurationSelectorSelector,
+ selectArrayProjector("negativeGroups", [])
+);
+
+export const getStatementTextBlockGroupsForCurrentTaskSelector = createSelector(
+ taskSelector,
+ getStatementTextBlockGroupsSelector,
+ getStatementTextBlockGroupsForNegativeAnswerSelector,
+ (task, groups, negativeGroups): IAPITextBlockGroupModel[] => {
+ return task?.taskDefinitionKey === EAPIProcessTaskDefinitionKey.CREATE_NEGATIVE_RESPONSE ? negativeGroups : groups;
+ }
+);
+
+export const getStatementArrangementForCurrentTaskSelector = createSelector(
+ statementArrangementSelector,
+ getStatementTextBlockGroupsForCurrentTaskSelector,
+ (arrangement, groups) => {
+ const availableIds = filterDistinctValues(
+ arrayJoin(...groups.map((group) => group.textBlocks.map((textBlock) => textBlock.id)))
+ );
+ const usedIds = filterDistinctValues(arrangement.map((item) => item.textblockId));
+ const isSomeTextBlockNotAvailable = usedIds.some((textBlockId) => availableIds.indexOf(textBlockId) === -1);
+ return isSomeTextBlockNotAvailable ? [] : arrangement;
+ }
+);
+
export const getStatementArrangementErrorSelector = createSelector(
getStatementErrorSelector,
selectArrayProjector("arrangement", [])
);
export const getStatementEditorControlConfigurationSelector = createSelector(
- getStatementTextBlockGroups,
+ getStatementTextBlockGroupsForCurrentTaskSelector,
getStatementStaticTextReplacementsSelector,
getStatementTextSelectsSelector,
(
diff --git a/src/app/store/statements/selectors/statement.selectors.ts b/src/app/store/statements/selectors/statement.selectors.ts
index ad4d2d9..04810f8 100644
--- a/src/app/store/statements/selectors/statement.selectors.ts
+++ b/src/app/store/statements/selectors/statement.selectors.ts
@@ -66,3 +66,8 @@
statementSelector,
selectPropertyProjector("file")
);
+
+export const statementMailIdSelector = createSelector(
+ statementInfoSelector,
+ selectPropertyProjector("sourceMailId")
+);
diff --git a/src/app/store/statements/selectors/statements-store-state.selectors.ts b/src/app/store/statements/selectors/statements-store-state.selectors.ts
index adc3fbb..746f49f 100644
--- a/src/app/store/statements/selectors/statements-store-state.selectors.ts
+++ b/src/app/store/statements/selectors/statements-store-state.selectors.ts
@@ -30,12 +30,24 @@
selectPropertyProjector("loading", {})
);
+export const getDashboardLoadingSelector = createSelector(
+ getStatementLoadingSelector,
+ selectPropertyProjector("fetchingDashboardStatements", false)
+);
+
export const getStatementErrorSelector = createSelector(
statementsStoreStateSelector,
queryParamsIdSelector,
selectEntityWithIdProjector({}, "error")
);
+export const getStatementErrorForNewSelector = createSelector(
+ statementsStoreStateSelector,
+ (state) => {
+ return state?.error ? state.error?.new : undefined;
+ }
+);
+
export const statementLoadingSelector = createSelector(
getStatementLoadingSelector,
processLoadingSelector,
diff --git a/src/app/store/statements/selectors/workflow-form/workflow-form.selectors.spec.ts b/src/app/store/statements/selectors/workflow-form/workflow-form.selectors.spec.ts
index 5d24fab..48ac4ae 100644
--- a/src/app/store/statements/selectors/workflow-form/workflow-form.selectors.spec.ts
+++ b/src/app/store/statements/selectors/workflow-form/workflow-form.selectors.spec.ts
@@ -139,7 +139,6 @@
result = workflowFormValueSelector.projector(
createWorkflowDataMock({geoPosition}), departments.selected, departments.indeterminate, parentIds);
- console.log(result);
expect(result.departments).toEqual(departments);
expect(result.geographicPosition).toBe(geoPosition);
expect(result.parentIds).toEqual(parentIds);
diff --git a/src/app/store/statements/statements-store.module.ts b/src/app/store/statements/statements-store.module.ts
index 2e335b4..a8880fc 100644
--- a/src/app/store/statements/statements-store.module.ts
+++ b/src/app/store/statements/statements-store.module.ts
@@ -22,8 +22,9 @@
SearchStatementsEffect,
SubmitStatementInformationFormEffect,
SubmitWorkflowFormEffect,
- ValidateStatementArrangementEffect,
+ ValidateStatementArrangementEffect
} from "./effects";
+import {FetchDashboardStatementsEffect} from "./effects/fetch-dashboard-statements";
import {SubmitStatementEditorFormEffect} from "./effects/submit-statement-editor-form";
import {STATEMENTS_NAME, STATEMENTS_REDUCER} from "./statements-reducers.token";
@@ -39,7 +40,8 @@
SubmitStatementEditorFormEffect,
SubmitStatementInformationFormEffect,
SubmitWorkflowFormEffect,
- ValidateStatementArrangementEffect
+ ValidateStatementArrangementEffect,
+ FetchDashboardStatementsEffect
])
]
})
diff --git a/src/app/features/dashboard/components/dashboard-item/index.ts b/src/app/store/statements/util/index.ts
similarity index 92%
copy from src/app/features/dashboard/components/dashboard-item/index.ts
copy to src/app/store/statements/util/index.ts
index a3980e1..7f4f8bb 100644
--- a/src/app/features/dashboard/components/dashboard-item/index.ts
+++ b/src/app/store/statements/util/index.ts
@@ -11,4 +11,4 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-export * from "./dashboard-item.component";
+export * from "./statements.util";
diff --git a/src/app/store/statements/util/statements.util.ts b/src/app/store/statements/util/statements.util.ts
new file mode 100644
index 0000000..d2fee2c
--- /dev/null
+++ b/src/app/store/statements/util/statements.util.ts
@@ -0,0 +1,25 @@
+/********************************************************************************
+ * 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 {IAPIDepartmentGroups} from "../../../core/api/settings";
+import {arrayJoin} from "../../../util/store";
+import {IDepartmentOptionValue} from "../model";
+
+export function reduceDepartmentOptionsToGroups(options: IDepartmentOptionValue[]): IAPIDepartmentGroups {
+ return arrayJoin(options).reduce<IAPIDepartmentGroups>((current, value) => {
+ return {
+ ...current,
+ [value.groupName]: arrayJoin(current[value.groupName], [value.name])
+ };
+ }, {});
+}
diff --git a/src/app/test/create-email-model-mock.spec.ts b/src/app/test/create-email-model-mock.spec.ts
new file mode 100644
index 0000000..246e195
--- /dev/null
+++ b/src/app/test/create-email-model-mock.spec.ts
@@ -0,0 +1,35 @@
+/********************************************************************************
+ * 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 {IAPIEmailAttachmentModel, IAPIEmailModel} from "../core/api/mail";
+
+export function createEmailModelMock(mailId: string, options?: Partial<IAPIEmailModel>): IAPIEmailModel {
+ return {
+ ...{} as IAPIEmailModel,
+ identifier: mailId,
+ attachments: [],
+ ...options
+ };
+}
+
+export function createListOfEmailAttachmentModelMocks(name, size: number): IAPIEmailAttachmentModel[] {
+ return Array(size).fill(0).map((_, id) => createEmailAttachmentModelMock(name + " " + id));
+}
+
+export function createEmailAttachmentModelMock(name: string, options?: Partial<IAPIEmailAttachmentModel>): IAPIEmailAttachmentModel {
+ return {
+ ...{} as IAPIEmailAttachmentModel,
+ name,
+ ...options
+ };
+}
diff --git a/src/app/test/create-statement-model-mock.spec.ts b/src/app/test/create-statement-model-mock.spec.ts
index b3996fa..55e39aa 100644
--- a/src/app/test/create-statement-model-mock.spec.ts
+++ b/src/app/test/create-statement-model-mock.spec.ts
@@ -17,12 +17,14 @@
return {
id,
title: "Title " + id,
- dueDate: "2019-09-10",
+ dueDate: "2019-09-11",
receiptDate: "2019-09-10",
+ creationDate: "2019-09-09",
finished: true,
typeId,
city: "Darmstadt",
district: "Heppenheim",
- contactId: "ABCD"
+ contactId: "ABCD",
+ sourceMailId: null
};
}
diff --git a/src/app/test/create-statement-text-configuration-mock.spec.ts b/src/app/test/create-statement-text-configuration-mock.spec.ts
index 6897e88..709d176 100644
--- a/src/app/test/create-statement-text-configuration-mock.spec.ts
+++ b/src/app/test/create-statement-text-configuration-mock.spec.ts
@@ -49,7 +49,9 @@
options: ["null", "eins", "zwei", "drei", "siebenundzwanzig"]
},
groups: Array(3).fill(0)
- .map((_, i) => createTextBlockModelGroup(i, 5))
+ .map((_, i) => createTextBlockModelGroup(i, 5)),
+ negativeGroups: Array(3).fill(0)
+ .map((_, i) => createTextBlockModelGroup(-i, 5))
},
replacements: {
diff --git a/src/app/test/index.ts b/src/app/test/index.ts
index dd3e7d0..b3ee08d 100644
--- a/src/app/test/index.ts
+++ b/src/app/test/index.ts
@@ -14,6 +14,7 @@
export * from "./create-attachment-file-mock.spec";
export * from "./create-attachment-model-mock.spec";
export * from "./create-attachment-tag-mock.spec";
+export * from "./create-email-model-mock.spec";
export * from "./create-file-mock.spec";
export * from "./create-pagination-response-mock.spec";
export * from "./create-select-options.spec";
diff --git a/src/app/util/forms/forms.util.spec.ts b/src/app/util/forms/forms.util.spec.ts
new file mode 100644
index 0000000..cda7165
--- /dev/null
+++ b/src/app/util/forms/forms.util.spec.ts
@@ -0,0 +1,30 @@
+/********************************************************************************
+ * 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 {shrinkString} from "./forms.util";
+
+describe("FormsUtil", () => {
+
+ it("shrinkString", () => {
+ expect(shrinkString(" A B C ")).toEqual("A B C");
+ expect(shrinkString(" ")).not.toBeDefined();
+ expect(shrinkString(null)).not.toBeDefined();
+ expect(shrinkString(undefined)).not.toBeDefined();
+
+ expect(shrinkString(" A B C ", "default")).toEqual("A B C");
+ expect(shrinkString(" ", "default")).toEqual("default");
+ expect(shrinkString(null, "default")).toEqual("default");
+ expect(shrinkString(undefined, "default")).toEqual("default");
+ });
+
+});
diff --git a/src/app/util/forms/forms.util.ts b/src/app/util/forms/forms.util.ts
index 10dbd57..7897223 100644
--- a/src/app/util/forms/forms.util.ts
+++ b/src/app/util/forms/forms.util.ts
@@ -20,3 +20,14 @@
): FormGroup {
return new FormGroup(controls, validatorOrOpts, asyncValidator);
}
+
+/**
+ * Trims a string value and returns a default value if it is empty.
+ */
+export function shrinkString(value: string, defaultValue?: string): string {
+ if (typeof value !== "string") {
+ return defaultValue;
+ }
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : defaultValue;
+}
diff --git a/src/app/util/http/EHttpStatusCodes.ts b/src/app/util/http/EHttpStatusCodes.ts
index 71dab8a..33bf00b 100644
--- a/src/app/util/http/EHttpStatusCodes.ts
+++ b/src/app/util/http/EHttpStatusCodes.ts
@@ -38,6 +38,7 @@
UNSUPPORTED_MEDIA_TYPE = 415,
REQUESTED_RANGE_NOT_SATISFIABLE = 416,
EXPECTATION_FAILED = 417,
+ UNPROCESSABLE_ENTITY = 422,
FAILED_DEPENDENCY = 424,
INTERNAL_SERVER_ERROR = 500,
diff --git a/src/app/util/http/http.util.spec.ts b/src/app/util/http/http.util.spec.ts
index 12633a2..9348f57 100644
--- a/src/app/util/http/http.util.spec.ts
+++ b/src/app/util/http/http.util.spec.ts
@@ -14,7 +14,7 @@
import {HttpErrorResponse, HttpHeaders, HttpResponse} from "@angular/common/http";
import {defer, Observable, of, throwError} from "rxjs";
import {EHttpStatusCodes} from "./EHttpStatusCodes";
-import {catchHttpError, isHttpErrorWithStatus, mapHttpResponseToFile, objectToHttpParams, urlJoin} from "./http.util";
+import {catchHttpError, catchHttpErrorTo, isHttpErrorWithStatus, mapHttpResponseToFile, objectToHttpParams, urlJoin} from "./http.util";
describe("HttpUtil", () => {
@@ -71,6 +71,18 @@
await expectAsync(promise).toBeRejectedWith(httpError);
});
+ it("catchHttpErrorTo", async () => {
+ const httpError = new HttpErrorResponse({status: EHttpStatusCodes.UNAUTHORIZED});
+ const error$: Observable<any> = throwError(httpError);
+
+ await expectAsync(error$.pipe(catchHttpErrorTo(19)).toPromise())
+ .toBeResolvedTo(19);
+ await expectAsync(error$.pipe(catchHttpErrorTo(19, EHttpStatusCodes.UNAUTHORIZED)).toPromise())
+ .toBeResolvedTo(19);
+ await expectAsync(error$.pipe(catchHttpErrorTo(19, EHttpStatusCodes.NOT_FOUND)).toPromise())
+ .toBeRejectedWith(httpError);
+ });
+
it("mapHttpResponseToFile", async () => {
const fileName = "" + Math.floor(Math.random() * 1000) + ".txt";
const headers = new HttpHeaders({
diff --git a/src/app/util/http/http.util.ts b/src/app/util/http/http.util.ts
index 2bae036..f7d44a7 100644
--- a/src/app/util/http/http.util.ts
+++ b/src/app/util/http/http.util.ts
@@ -63,8 +63,8 @@
/**
* Returns an rxjs operator which catches HttpErrorRespones.
- * @param callback Function which is called to when an error occurs
- * @param status List of all status valued which should be catched (if empty, all status values are catched).
+ * @param callback Function which is called when an error occurs.
+ * @param status List of all response status codes which are catched (if empty, all http errors are catched).
*/
export function catchHttpError<T, O extends ObservableInput<any>>(
callback: (error: HttpErrorResponse) => O,
@@ -79,6 +79,18 @@
}
/**
+ * Returns an rxjs operator which catches every http error and maps it to the given value.
+ * @param value Value, to which an http error is mapped to.
+ * @param status List of all response status codes which are catched (if empty, all http errors are catched).
+ */
+export function catchHttpErrorTo<T, O>(
+ value: O,
+ ...status: EHttpStatusCodes[]
+) {
+ return catchHttpError<T, ObservableInput<O>>(() => of(value), ...status);
+}
+
+/**
* Returns an rxjs operator function which maps a HttpResponse from the server to a Javascript file object.
* The filename of the returned file will be extracted from the headers of the HttpResponse.
*/
diff --git a/src/app/util/moment/moment.util.spec.ts b/src/app/util/moment/moment.util.spec.ts
index 8e3b7eb..8ea772b 100644
--- a/src/app/util/moment/moment.util.spec.ts
+++ b/src/app/util/moment/moment.util.spec.ts
@@ -12,11 +12,11 @@
********************************************************************************/
import * as moment from "moment";
-import {momentFormatDisplayNumeric, momentFormatInternal, parseMomentToDate, parseMomentToString} from "./moment.util";
+import {momentDiff, momentFormatDisplayNumeric, momentFormatInternal, parseMomentToDate, parseMomentToString} from "./moment.util";
describe("MomentUtil", () => {
- it("should parse MomentInputs to Dates", () => {
+ it("parseMomentToDate", () => {
const moments = [
"01.05.2020",
moment("2020-05-01", momentFormatInternal),
@@ -37,7 +37,7 @@
expect(parseMomentToDate(1588180997731, momentFormatInternal, defaultDate)).toBe(defaultDate);
});
- it("should parse MomentInputs to String", () => {
+ it("parseMomentToString", () => {
const moments = [
"01.05.2020",
moment("2020-05-01", momentFormatInternal),
@@ -51,5 +51,9 @@
expect(parseMomentToString(1588180997731, momentFormatDisplayNumeric, momentFormatInternal)).toBe("");
});
+ it("momentDiff", () => {
+ expect(momentDiff("2019-09-19", "2019-09-21")).toBe(-2 * 1000 * 60 * 60 * 24);
+ });
+
});
diff --git a/src/app/util/moment/moment.util.ts b/src/app/util/moment/moment.util.ts
index a9b0658..d475589 100644
--- a/src/app/util/moment/moment.util.ts
+++ b/src/app/util/moment/moment.util.ts
@@ -20,6 +20,12 @@
export const momentFormatDisplayFullDateAndTime = "DD.MM.YYYY HH:mm:ss";
+/**
+ * Transforms a time string (or other MomentInputs) to a JS date object.
+ * @param input Time string (or other MomentInput) to parse.
+ * @param inputFormat Time format for the input.
+ * @param defaultDate Default date (if input is no valid date).
+ */
export function parseMomentToDate(input: MomentInput, inputFormat: MomentFormatSpecification, defaultDate?: Date): Date {
try {
const m = moment(input, inputFormat);
@@ -29,11 +35,17 @@
}
}
+/**
+ * Parses a time string from one format to another.
+ * @param input Time string which is parsed
+ * @param inputFormat Time format for the input
+ * @param outputFormat Time format for the result
+ */
export function parseMomentToString(
input: MomentInput,
inputFormat: MomentFormatSpecification,
outputFormat: string
-) {
+): string {
try {
const m = moment(input, inputFormat);
return m.isValid() ? m.format(outputFormat) : "";
@@ -41,3 +53,16 @@
return "";
}
}
+
+/**
+ * Returns the time between two time strings in milliseconds.
+ */
+export function momentDiff(inputA: MomentInput, inputB: MomentInput, inputFormat: MomentFormatSpecification = momentFormatInternal) {
+ try {
+ const momentA = moment(inputA, inputFormat);
+ const momentB = moment(inputB, inputFormat);
+ return momentA.diff(momentB);
+ } finally {
+ // Do nothing...
+ }
+}
diff --git a/src/app/util/rxjs/rxjs.util.spec.ts b/src/app/util/rxjs/rxjs.util.spec.ts
index dace0ee..000bc6f 100644
--- a/src/app/util/rxjs/rxjs.util.spec.ts
+++ b/src/app/util/rxjs/rxjs.util.spec.ts
@@ -11,10 +11,21 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
+import {NgZone} from "@angular/core";
import {fakeAsync, tick} from "@angular/core/testing";
+import {createAction} from "@ngrx/store";
import {concat, of, throwError, timer} from "rxjs";
-import {map, toArray} from "rxjs/operators";
-import {emitOnComplete, endWithObservable, ignoreError, retryAfter} from "./rxjs.util";
+import {map, tap, toArray} from "rxjs/operators";
+import {
+ catchErrorTo,
+ emitOnComplete,
+ endWithObservable,
+ ignoreError,
+ retryAfter,
+ runInZone,
+ runOutsideZone,
+ throwAfterActionType
+} from "./rxjs.util";
describe("retryAfter", () => {
@@ -111,12 +122,35 @@
it("should complete an throwing observable", async () => {
const observable = throwError(new Error("Test Error")).pipe(ignoreError());
- const result = await observable.toPromise().catch(() => false);
- expect(result).not.toBeDefined();
+ await expectAsync(observable.toPromise()).not.toBeRejected();
});
});
+describe("catchErrorTo", () => {
+
+ it("should catch errors and emit the given value", async () => {
+ const observable = throwError(new Error("Test Error")).pipe(catchErrorTo(19));
+ await expectAsync(observable.toPromise()).toBeResolvedTo(19);
+ });
+
+});
+
+describe("throwAfterActionType", () => {
+
+ const testAction = createAction("Test Action");
+ const throwAction = createAction("Throw Action");
+
+ it("should throw an error after emitting a given action type", async () => {
+ const results: any[] = [];
+ const observable = of(testAction(), throwAction()).pipe(throwAfterActionType(throwAction), tap((_) => results.push(_)));
+ await expectAsync(observable.toPromise()).toBeRejectedWith(throwAction());
+ expect(results).toEqual([testAction(), throwAction()]);
+ });
+
+});
+
+
describe("endWithObservable", () => {
it("should end an observable with another observable", async () => {
@@ -150,3 +184,32 @@
}));
});
+
+describe("runInZone/runOutsideZone", () => {
+
+ it("should leave and enter the zone", fakeAsync(() => {
+ const zone = {
+ run: (fn: () => any) => fn(),
+ runOutsideAngular: (fn: () => any) => fn()
+ } as NgZone;
+
+ const runSpy = spyOn(zone, "run").and.callThrough();
+ const runOutsideAngularSpy = spyOn(zone, "runOutsideAngular").and.callThrough();
+
+ const observable$ = concat(
+ of(19)
+ ).pipe(
+ runInZone(zone),
+ runOutsideZone(zone)
+ );
+
+ const result: any[] = [];
+ const subscription = observable$.subscribe((_) => result.push(_));
+
+ expect(subscription.closed).toBeTrue();
+ expect(result).toEqual([19]);
+ expect(runSpy).toHaveBeenCalled();
+ expect(runOutsideAngularSpy).toHaveBeenCalled();
+ }));
+
+});
diff --git a/src/app/util/rxjs/rxjs.util.ts b/src/app/util/rxjs/rxjs.util.ts
index 643500b..e0be1fb 100644
--- a/src/app/util/rxjs/rxjs.util.ts
+++ b/src/app/util/rxjs/rxjs.util.ts
@@ -11,7 +11,10 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-import {EMPTY, ObservableInput, of, OperatorFunction, pipe, throwError, timer} from "rxjs";
+import {NgZone} from "@angular/core";
+import {ofType} from "@ngrx/effects";
+import {Action, ActionCreator, Creator} from "@ngrx/store";
+import {concat, EMPTY, Observable, ObservableInput, of, OperatorFunction, pipe, throwError, timer} from "rxjs";
import {catchError, concatMap, endWith, map, retryWhen, switchMap, toArray} from "rxjs/operators";
/**
@@ -46,7 +49,25 @@
}
/**
- * The current observables ends with the subscription to another one provided by the fiven factory function.
+ * Catches all errors and maps them to the given value.
+ */
+export function catchErrorTo<T, O = T>(value: O) {
+ return catchError<T, ObservableInput<O>>(() => of(value));
+}
+
+/**
+ * An error is thrown after a specific rxjs action is emitted by the observable.
+ */
+export function throwAfterActionType<AC extends ActionCreator<string, Creator>[], U extends Action = Action, V = ReturnType<AC[number]>>(
+ ...allowedTypes: AC
+) {
+ return concatMap<U, Observable<U>>((v) => {
+ return concat(of(v), of(v).pipe(ofType(...allowedTypes), concatMap(() => throwError(v))));
+ });
+}
+
+/**
+ * The current observables ends with the subscription to another one provided by the given factory function.
*/
export function endWithObservable<T, Z = T>(endWithFactory: () => ObservableInput<Z>): OperatorFunction<T, T | Z> {
const TOKEN: T = {} as any;
@@ -56,9 +77,43 @@
);
}
+/**
+ * All emitted values of an observable will be delayed until completion.
+ */
export function emitOnComplete<T>() {
return pipe(
toArray<T>(),
switchMap((_) => of<T>(..._))
);
}
+
+/**
+ * Reenters the angular zone for all emitted values.
+ */
+export function runInZone<T>(zone: NgZone): OperatorFunction<T, T> {
+ return (source) => {
+ return new Observable<T>(observer => {
+ return source.subscribe({
+ next: (x) => zone.run(() => observer.next(x)),
+ error: (err) => observer.error(err),
+ complete: () => observer.complete()
+ });
+ });
+ };
+}
+
+/**
+ * Leaves the angular zone for all emitted values.
+ */
+export function runOutsideZone<T>(zone: NgZone): OperatorFunction<T, T> {
+ return (source) => {
+ return new Observable<T>(observer => {
+ return source.subscribe({
+ next: (x) => zone.runOutsideAngular(() => observer.next(x)),
+ error: (err) => observer.error(err),
+ complete: () => observer.complete()
+ });
+ });
+ };
+}
+
diff --git a/src/assets/i18n/de.i18.json b/src/assets/i18n/de.i18.json
index bfab544..d12efee 100644
--- a/src/assets/i18n/de.i18.json
+++ b/src/assets/i18n/de.i18.json
@@ -5,12 +5,21 @@
"momentJS": "de",
"loading": "Daten werden geladen...",
"submitting": "Daten werden übertragen...",
+ "header": {
+ "home": "Übersicht",
+ "search": "Stellungnahme suchen",
+ "mail": "Posteingang",
+ "new": "Neue Stellungnahme anlegen",
+ "settings": "Einstellungen",
+ "help": "Hilfe"
+ },
"actions": {
"logout": "Abmelden",
"backToDashboard": "Zurück zur Übersicht",
"backToDetails": "Zurück zur Detailansicht",
"addNewStatement": "Neue Stellungnahme anlegen",
- "showListOfStatements": "Alle Stellungnahmen anzeigen"
+ "showListOfStatements": "Alle Stellungnahmen anzeigen",
+ "createStatementFromEmail": "Als Stellungnahme überführen"
},
"exit": {
"logout": {
@@ -45,6 +54,18 @@
}
}
},
+ "dashboard": {
+ "showAll": "Alle Vorgänge anzeigen",
+ "showEditedByMe": "Eigene Vorgänge anzeigen",
+ "toInbox": "Es sind neue Nachrichten im Posteingang verfügbar.",
+ "statements": {
+ "forOfficialInCharge": "Laufende Vorgänge der Stellungnahmen-Sachbearbeiter:",
+ "forAllDepartments": "Laufende Vorgänge aller Fachbereiche:",
+ "forMyDepartment": "Laufende Vorgänge meines Fachbereichs:",
+ "forApprover": "Laufende Vorgänge zur Genehmigung:",
+ "other": "Andere laufende Vorgänge:"
+ }
+ },
"details": {
"sideMenu": {
"title": "Detailansicht",
@@ -53,7 +74,7 @@
"createNegativeStatement": "Negativmeldung verfassen",
"editInfoData": "Informationsdaten bearbeiten",
"editWorkflowData": "Workflowdaten bearbeiten",
- "createDraft": "Entwurf anlegen",
+ "createDraft": "Entwurf bearbeiten",
"editDraft": "Entwurf bearbeiten",
"checkDraft": "Entwurf prüfen",
"completeDraft": "Entwurf finalisieren",
@@ -85,7 +106,9 @@
"edit": "Dokumente übernehmen:",
"add": "Dokumente hinzufügen:",
"selectFile": "Datei auswählen",
- "fileDropPlaceholder": "Dialog öffnen oder Dateien via Drag and Drop hinzufügen."
+ "fileDropPlaceholder": "Dialog öffnen oder Dateien via Drag and Drop hinzufügen.",
+ "email": "Email",
+ "addEmailAttachments": "Email-Anhänge übernehmen:"
},
"comments": {
"title": "Kommentare",
@@ -123,11 +146,13 @@
},
"controls": {
"title": "Titel:",
+ "creationDate": "Erstellungsdatum:",
"dueDate": "Frist:",
"receiptDate": "Eingangsdatum:",
"city": "Ort:",
"district": "Ortsteil:",
- "typeId": "Art des Vorgangs:"
+ "typeId": "Art des Vorgangs:",
+ "customerReference": "Referenzzeichen:"
}
},
"workflowDataForm": {
@@ -163,10 +188,13 @@
"container": {
"contributionStatus": "Bearbeitungsstatus der Fachbereiche",
"draft": "Entwurf der Stellungnahme",
- "attachments": "Anhänge"
+ "attachments": "Anhänge für den Versand"
}
},
"shared": {
+ "map": {
+ "openGIS": "Im GIS öffnen"
+ },
"linkedStatements": {
"precedingStatements": "Vorhergehende Vorgänge",
"successiveStatements": "Nachfolgende Vorgänge"
@@ -175,10 +203,14 @@
"caption": "Stellungnahmen",
"id": "ID",
"title": "Titel",
- "statementType": "Vorgangstyp",
- "receiptDate": "Eingangsdatum",
"city": "Ort",
- "district": "Ortsteil"
+ "district": "Ortsteil",
+ "statementType": "Vorgangstyp",
+ "creationDate": "Erstellungs\u00ADdatum",
+ "receiptDate": "Eingangs\u00ADdatum",
+ "dueDate": "Fristende",
+ "currentTaskName": "Bearbeitungs\u00ADstatus",
+ "contributions": "Antworten der Fachbereiche"
},
"statementSelect": {
"clear": "Auswahl löschen"
@@ -190,6 +222,19 @@
"sectors": {
"available": "In diesem Ortsteil sind folgende Sparten betroffen:",
"none": "In diesem Ortsteil konnte keine zuständige Sparte ausgemacht werden."
+ },
+ "errorMessages": {
+ "title": "Fehlerbenachrichtung",
+ "unexpected": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es nocheinmal oder kontaktieren Sie den Support.",
+ "taskToCompleteNotFound": "Der aktuelle Bearbeitungsschritt wurde bereits abgeschlossen. Bitte prüfen Sie die Daten der Stellungnahmen.",
+ "claimedByAnotherUser": "Die Stellungnahme ist bereits von einem anderen Nutzer in Bearbeitung. Bitte kehren Sie zu einem späteren Zeitpunkt zurück.",
+ "missingFormData": "Ein Feld des Formulars benötigt noch einen Wert. Bitte prüfen Sie Ihre Eingaben auf Vollständigkeit.",
+ "failedLoadingContact": "Die Details für den ausgewählten Kontakt konnten nicht geladen werden. Bitte prüfen Sie Ihre Auswahl auf Vollständigkeit.",
+ "failedFileUpload": "Beim Hochladen einer Datei ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support.",
+ "failedMailTransfer": "Beim Transferieren der Email ist ein Fehler aufgetreten. Bitte prüfen Sie Ihre Auswahl und versuchen Sie es erneut.",
+ "invalidTextArrangement": "Die Zusammenstellung der Stellungnahme weist Fehler auf. Bitte prüfen Sie die Auswahl an Textbausteinen.",
+ "couldNotLoadMailData": "Die Daten der ausgewählten Email konnten nicht geladen werden. Eventuell besteht ein Problem mit der Verbindung zum Mail-Server. Bitte versuchen Sie es nocheinmal oder kontaktieren Sie den Support.",
+ "noAccessToContactModule": "Der Zugriff auf das Kontaktstammdatenmodul ist nicht möglich. Bitte kontaktieren Sie den Support."
}
},
"textBlocks": {
@@ -206,5 +251,14 @@
"pagebreak": "Seitenumbruch",
"newLine": "Zeilenumbruch"
}
+ },
+ "mails": {
+ "noContent": "Die Email hat keinen Textinhalt.",
+ "sender": "Absender:",
+ "date": "Datum:",
+ "from": "von:",
+ "at": "vom:",
+ "inbox": "Email Eingang",
+ "attachments": "Anhänge"
}
}
diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts
index c9f27b2..66dc98e 100644
--- a/src/environments/environment.prod.ts
+++ b/src/environments/environment.prod.ts
@@ -17,5 +17,6 @@
production: true,
version: npm.version,
routes: {...npm.routes},
+ leaflet: {...npm.leaflet},
imports: []
};
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index 3d7711a..99596a3 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -22,6 +22,7 @@
production: false,
version: npm.version + "dev",
routes: {...npm.routes},
+ leaflet: {...npm.leaflet},
imports: [
StoreDevtoolsModule.instrument({maxAge: 50, logOnly: false})
]
diff --git a/src/styles/colors/colors.styles.scss b/src/styles/colors/colors.styles.scss
index a498974..fc50f3b 100644
--- a/src/styles/colors/colors.styles.scss
+++ b/src/styles/colors/colors.styles.scss
@@ -29,6 +29,8 @@
$openk-header-gradient: linear-gradient(to right, rgba(232, 238, 231, 1) 0%, rgba(229, 237, 242, 1) 75%) repeat scroll 0 0 rgba(0, 0, 0, 0);
$openk-header-gadient-border: linear-gradient(to right, rgba(121, 182, 28, 1) 0%, rgba(2, 129, 196, 1) 75%) repeat scroll 0 0 rgba(0, 0, 0, 0);
+$openk-error-color: get-color($openk-danger-palette, 200);
+
$openk-background: #f8fafd; // Default background
$openk-background-card: #f5f8fc; // For cards on the front, e.g. tables
$openk-background-highlight: #e9f0f9; // E.g. for zebra highlighting in tables
diff --git a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss b/src/theme/leaflet/_leaflet.theme.scss
similarity index 63%
copy from src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss
copy to src/theme/leaflet/_leaflet.theme.scss
index ac39661..f810012 100644
--- a/src/app/features/dashboard/components/dashboard-item/dashboard-item.component.scss
+++ b/src/theme/leaflet/_leaflet.theme.scss
@@ -12,18 +12,23 @@
********************************************************************************/
@import "openk.styles";
+@import "~leaflet/dist/leaflet.css";
-.dashboard-item-header-actions {
- display: inline-flex;
- margin-left: auto;
-
- & > * {
- margin-left: 0.5em;
- }
+.leaflet-container {
+ background: $openk-form-border;
}
-.dashboard-item-body {
- padding: 1em;
- display: flex;
- flex-flow: column;
+.openk-leaflet-marker {
+ margin-top: -30px !important;
+ margin-left: -15px !important;
+
+ color: get-color($openk-info-palette);
+
+ &::after {
+ @include material-icon-mixin(30px);
+
+ position: absolute;
+ top: 0;
+ content: "place";
+ }
}
diff --git a/src/theme/misc/_table.theme.scss b/src/theme/misc/_table.theme.scss
new file mode 100644
index 0000000..80ac27b
--- /dev/null
+++ b/src/theme/misc/_table.theme.scss
@@ -0,0 +1,46 @@
+/********************************************************************************
+ * 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
+ ********************************************************************************/
+
+.openk-table {
+ border-radius: inherit;
+ line-height: 1.25;
+ width: 100%;
+ font-size: 0.875em;
+ border-spacing: 0;
+
+ tr {
+ transition: background-color 100ms ease-in-out;
+
+ &:hover {
+ background-color: $openk-background-highlight;
+ }
+ }
+
+ th {
+ font-weight: bold;
+ background-color: $openk-background-highlight;
+ }
+
+ td,
+ th {
+ border-bottom: 1px solid $openk-form-border;
+ padding: 0.5em;
+ }
+}
+
+.openk-table---last-row-without-border {
+
+ tr:last-child td {
+ border-bottom: 0;
+ }
+}
diff --git a/src/theme/misc/misc.theme.scss b/src/theme/misc/misc.theme.scss
index d4ade00..e140dc5 100644
--- a/src/theme/misc/misc.theme.scss
+++ b/src/theme/misc/misc.theme.scss
@@ -12,6 +12,7 @@
********************************************************************************/
@import "cursor.theme";
+@import "table.theme";
@import "user-select.theme";
.highlight-bpmn:not(.djs-connection) .djs-visual > :nth-child(1) {
@@ -38,3 +39,27 @@
cursor: not-allowed !important;
}
+.no-pointer-events {
+ pointer-events: none !important;
+
+ * {
+ pointer-events: none !important;
+ }
+}
+
+
+.openk-no-whitespace-wrap {
+ white-space: nowrap;
+}
+
+.openk-left {
+ text-align: left;
+}
+
+.openk-center {
+ text-align: center;
+}
+
+.openk-right {
+ text-align: right;
+}
diff --git a/src/theme/openk.theme.scss b/src/theme/openk.theme.scss
index 621a581..3935918 100644
--- a/src/theme/openk.theme.scss
+++ b/src/theme/openk.theme.scss
@@ -17,6 +17,7 @@
@import "primeng/primeng.theme";
@import "font/source-sans-pro.theme";
@import "material/material.theme";
+@import "leaflet/leaflet.theme";
@import "misc/misc.theme";
@import "user-controls/user-controls.theme";
@import "drag/drag-drop.theme";
diff --git a/src/theme/primeng/primeng.theme.scss b/src/theme/primeng/primeng.theme.scss
index afcbad4..d830673 100644
--- a/src/theme/primeng/primeng.theme.scss
+++ b/src/theme/primeng/primeng.theme.scss
@@ -13,3 +13,80 @@
@import "~primeng/resources/primeng.css";
@import "datepicker.theme";
+
+.ui-toast-message {
+ -webkit-box-shadow: 0 3px 6px 0 rgba(0, 0, 0, 0.16);
+ -moz-box-shadow: 0 3px 6px 0 rgba(0, 0, 0, 0.16);
+ box-shadow: 0 3px 6px 0 rgba(0, 0, 0, 0.16);
+ margin: 0 0 1em 0;
+}
+
+.ui-toast-message.ui-toast-message-info {
+ background-color: #7fbcec;
+ border: 0 none;
+ color: #212121;
+}
+
+.ui-toast-message.ui-toast-message-info .ui-toast-close-icon {
+ color: #212121;
+}
+
+.ui-toast-message.ui-toast-message-success {
+ background-color: #b7d8b7;
+ border: 0 none;
+ color: #212121;
+}
+
+.ui-toast-message.ui-toast-message-success .ui-toast-close-icon {
+ color: #212121;
+}
+
+.ui-toast-message.ui-toast-message-warn {
+ background-color: #ffe399;
+ border: 0 none;
+ color: #212121;
+}
+
+.ui-toast-message.ui-toast-message-warn .ui-toast-close-icon {
+ color: #212121;
+}
+
+.ui-toast-message.ui-toast-message-error {
+ background-color: #f8b7bd;
+ border: 0 none;
+ color: #212121;
+}
+
+.ui-toast-message.ui-toast-message-error .ui-toast-close-icon {
+ color: #212121;
+}
+
+.ui-toast-message.ui-toast-message-error .ui-toast-message-content .ui-toast-icon::before {
+ @include material-icon-mixin(1em);
+ content: "error";
+}
+
+.ui-toast-message.ui-toast-message-warn .ui-toast-message-content .ui-toast-icon::before {
+ @include material-icon-mixin(1em);
+ content: "warning";
+}
+
+.ui-toast-message.ui-toast-message-success .ui-toast-message-content .ui-toast-icon::before {
+ @include material-icon-mixin(1em);
+ content: "done";
+}
+
+.ui-toast-message.ui-toast-message-info .ui-toast-message-content .ui-toast-icon::before {
+ @include material-icon-mixin(1em);
+ transform: rotate(180deg);
+ content: "error_outline";
+}
+
+.ui-toast-close-icon::before {
+ @include material-icon-mixin();
+ content: "close";
+}
+
+.ui-toast-close-icon {
+ outline: none;
+}