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

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



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

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

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

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

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

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

[TOB-53] feat: Add email functionality

* 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's avatarChristopher Keim <keim@develop-group.de>
parent ebb6e8bb
......@@ -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"
......
......@@ -35,8 +35,11 @@ export class AttachmentListFormComponent extends AbstractReactiveFormArrayCompon
@Input()
public appTitle: string;
@Input()
public appHideNotUsedTags = true;
@Output()
public appDownloadAttachment = new EventEmitter<number>();
public appDownloadAttachment = new EventEmitter<number | string>();
}
......
......@@ -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>
<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 *ngIf="appMailId != null && mail != null" class="attachments--email">
<div>
<span>{{"attachments.email" | translate}}</span>
<div class="attachments--container--email-text">
<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>
......@@ -13,6 +13,8 @@
$break-point: 16em;
@import "openk.styles";
:host {
display: block;
width: 100%;
......@@ -20,16 +22,62 @@ $break-point: 16em;
.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-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;
flex-flow: column;
margin: 0.5em;
}
......@@ -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 @@ export class AttachmentsFormGroupComponent
@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 @@ export class AttachmentsFormGroupComponent
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 @@ export class AttachmentsFormGroupComponent
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");
}
}
......@@ -51,6 +51,7 @@
<app-side-menu-status
*appSideMenu="'center'"
[appLoadingMessage]="'core.submitting' | translate"
[appErrorMessage]="appErrorMessage"
[appLoading]="appLoading">
</app-side-menu-status>
......
......@@ -42,6 +42,9 @@ interface ITaskUserLayoutMap<T> {
})
export class StatementEditorSideMenuComponent implements OnChanges {
@Input()
public appErrorMessage: string;
@Input()
public appForFinalization: boolean;
......@@ -149,7 +152,7 @@ export class StatementEditorSideMenuComponent implements OnChanges {
}),
label: "statementEditorForm.sideMenu.backToInfoData",
icon: "subject",
cssClass: "openk-info"
cssClass: "openk-danger"
},
{
emit: emitFactory(this.appSaveAndFinalize),
......
......@@ -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
......
......@@ -90,7 +90,10 @@ describe("StatementEditorFormComponent", () => {
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 @@ describe("StatementEditorFormComponent", () => {
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
}));
});
......
......@@ -28,14 +28,17 @@ import {
fetchStatementTextArrangementAction,
getContributionsSelector,
getStatementArrangementErrorSelector,
getStatementArrangementForCurrentTaskSelector,
getStatementEditorControlConfigurationSelector,
getStatementErrorSelector,
getStatementStaticTextReplacementsSelector,
getStatementTextBlockGroups,
getStatementTextBlockGroupsForCurrentTaskSelector,
IStatementEditorFormValue,
IStatementErrorEntity,
queryParamsIdSelector,
requiredContributionsGroupsSelector,
requiredContributionsOptionsSelector,
statementArrangementSelector,
setErrorAction,
statementFileSelector,
statementLoadingSelector,
submitStatementEditorFormAction,
......@@ -101,29 +104,32 @@ export class StatementEditorFormComponent extends AbstractReactiveFormComponent<
map((value) => filterDistinctValues(value.arrangement.map((item) => item.textblockId)))
);
public arrangement$ = this.store.pipe(select(statementArrangementSelector));
public textBlockGroups$ = this.store.pipe(select(getStatementTextBlockGroupsForCurrentTaskSelector));
public file$ = this.store.pipe(select(statementFileSelector));
public arrangement$ = this.store.pipe(select(getStatementArrangementForCurrentTaskSelector));
public textBlockGroups$ = this.store.pipe(select(getStatementTextBlockGroups));
public file$ = this.store.pipe(select(statementFileSelector));
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 @@ export class StatementEditorFormComponent extends AbstractReactiveFormComponent<
}
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 @@ export class StatementEditorFormComponent extends AbstractReactiveFormComponent<
}
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 @@ export class StatementEditorFormComponent extends AbstractReactiveFormComponent<
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 @@ export class StatementEditorFormComponent extends AbstractReactiveFormComponent<
}
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 @@ export class StatementEditorFormComponent extends AbstractReactiveFormComponent<
}
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 @@ export class StatementEditorFormComponent extends AbstractReactiveFormComponent<
}),
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}));
}
}
}
......@@ -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>