Skip to content

Commit

Permalink
Merge pull request #295 from swisstopo/feature/asset-238-fehler-bei-g…
Browse files Browse the repository at this point in the history
…leichem-workgroup-namen

Feature 238: Fehler bei gleichem Workgroup-Namen ordentlich anzeigen
  • Loading branch information
daniel-va authored Oct 16, 2024
2 parents 65a25e8 + 7aa4fd2 commit a37e7b3
Show file tree
Hide file tree
Showing 12 changed files with 109 additions and 13 deletions.
3 changes: 3 additions & 0 deletions apps/client-asset-sg/src/app/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export const deAppTranslations = {
datePlaceholder: 'JJJJ-MM-TT',
workgroup: {
title: 'Arbeitsgruppe',
errors: {
nameTaken: "Der Name '{{name}}' wird bereits von einer anderen Arbeitsgruppe verwendet.",
},
},
menuBar: {
filters: 'Filter',
Expand Down
3 changes: 3 additions & 0 deletions apps/client-asset-sg/src/app/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export const enAppTranslations: AppTranslations = {
datePlaceholder: 'YYYY-MM-DD',
workgroup: {
title: 'Workgroup',
errors: {
nameTaken: "The name '{{name}}' is already taken by another workgroup.",
},
},
menuBar: {
filters: 'Filters',
Expand Down
3 changes: 3 additions & 0 deletions apps/client-asset-sg/src/app/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export const frAppTranslations: AppTranslations = {
datePlaceholder: 'AAAA-MM-JJ',
workgroup: {
title: 'groupe de travail',
errors: {
nameTaken: "Le nom '{{name}}' est déjà utilisé par un autre groupe de travail.",
},
},
menuBar: {
filters: 'Filtres',
Expand Down
3 changes: 3 additions & 0 deletions apps/client-asset-sg/src/app/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export const itAppTranslations: AppTranslations = {
datePlaceholder: 'AAAA-MM-GG',
workgroup: {
title: 'IT Arbeitsgruppe',
errors: {
nameTaken: "IT Der Name '{{name}}' wird bereits von einer anderen Arbeitsgruppe verwendet.",
},
},
menuBar: {
filters: 'IT Filter',
Expand Down
5 changes: 4 additions & 1 deletion apps/client-asset-sg/src/app/i18n/rm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export const rmAppTranslations: AppTranslations = {
close: 'RM Schliessen',
datePlaceholder: 'AAAA-MM-GG',
workgroup: {
title: 'IT Arbeitsgruppe',
title: 'RM Arbeitsgruppe',
errors: {
nameTaken: "RM Name '{{name}}' wird bereits von einer anderen Arbeitsgruppe verwendet.",
},
},
menuBar: {
filters: 'RM Filter',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Response } from 'express';

@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaExceptionFilter implements ExceptionFilter<Prisma.PrismaClientKnownRequestError> {
private readonly logger = new Logger();

catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();

// Unique constraint failed.
// See https://www.prisma.io/docs/orm/reference/error-reference#p2002.
if (exception.code === 'P2002') {
// We assume that the unique constraint was caused by the user passing a duplicate value to a unique field.
return response.status(HttpStatus.UNPROCESSABLE_ENTITY).json({
message: 'Unique constraint failed',
details: {
fields: (exception.meta as { target: string[] }).target,
},
});
}

this.logger.error(exception.stack);
const isDevelopment = process.env.NODE_ENV === 'development';
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
message: 'Internal Server Error',
details: isDevelopment
? {
code: exception.code,
message: exception.message,
}
: undefined,
});
}
}
2 changes: 2 additions & 0 deletions apps/server-asset-sg/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';

import { AppModule } from './app.module';
import { PrismaExceptionFilter } from '@/core/exception-filters/prisma.exception-filter';

export * from 'fp-ts';
export * from '@prisma/client';
Expand All @@ -13,6 +14,7 @@ async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix(API_PREFIX);
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
app.useGlobalFilters(new PrismaExceptionFilter());
await app.listen(API_PORT);
Logger.log(`🚀 application is running on: http://localhost:${API_PORT}/${API_PREFIX}`);
}
Expand Down
47 changes: 39 additions & 8 deletions libs/admin/src/lib/state/admin.effects.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { HttpErrorResponse } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { CURRENT_LANG } from '@asset-sg/client-shared';
import { User, Workgroup } from '@asset-sg/shared/v2';
import { AlertType, CURRENT_LANG, showAlert } from '@asset-sg/client-shared';
import { User, Workgroup, WorkgroupData } from '@asset-sg/shared/v2';
import { UntilDestroy } from '@ngneat/until-destroy';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { first, map, switchMap, withLatestFrom } from 'rxjs';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { catchError, EMPTY, first, map, OperatorFunction, switchMap, withLatestFrom } from 'rxjs';

import { AdminService } from '../services/admin.service';
import * as actions from './admin.actions';
Expand All @@ -16,7 +19,9 @@ export class AdminEffects {
private readonly adminService = inject(AdminService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly currentLang$ = inject(CURRENT_LANG);
private readonly translate = inject(TranslateService);

public findUser$ = createEffect(() =>
this.actions$.pipe(
Expand Down Expand Up @@ -46,7 +51,9 @@ export class AdminEffects {
public createWorkgroup$ = createEffect(() =>
this.actions$.pipe(
ofType(actions.createWorkgroup),
switchMap(({ workgroup }) => this.adminService.createWorkgroup(workgroup)),
switchMap(({ workgroup }) =>
this.adminService.createWorkgroup(workgroup).pipe(this.catchWorkgroupError(workgroup))
),
withLatestFrom(this.currentLang$),
map(([workgroup, currentLang]) => {
void this.router.navigate([`/${currentLang}/admin/workgroups/${workgroup.id}`], { relativeTo: this.route });
Expand All @@ -59,10 +66,9 @@ export class AdminEffects {
this.actions$.pipe(
ofType(actions.updateWorkgroup),
switchMap(({ workgroupId, workgroup }) =>
this.adminService
.updateWorkgroup(workgroupId, workgroup)
.pipe(map((workgroup: Workgroup) => actions.setWorkgroup({ workgroup })))
)
this.adminService.updateWorkgroup(workgroupId, workgroup).pipe(this.catchWorkgroupError(workgroup))
),
map((workgroup: Workgroup) => actions.setWorkgroup({ workgroup }))
)
);

Expand All @@ -83,4 +89,29 @@ export class AdminEffects {
)
)
);

private readonly catchWorkgroupError = (data: WorkgroupData): OperatorFunction<Workgroup, Workgroup> =>
catchError((error) => {
if (!(error instanceof HttpErrorResponse)) {
throw error;
}
if (
error.status === 422 &&
error.error.message === 'Unique constraint failed' &&
error.error.details.fields.includes('name')
) {
this.store.dispatch(
showAlert({
alert: {
id: `duplicate-workgroup-name`,
text: this.translate.get('workgroup.errors.nameTaken', { name: data.name }),
type: AlertType.Error,
isPersistent: false,
},
})
);
return EMPTY;
}
throw error;
});
}
4 changes: 4 additions & 0 deletions libs/auth/src/lib/services/auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ export class AuthInterceptor implements HttpInterceptor, OnDestroy {
);
break;
default: {
if (error.status < 500) {
throw error;
}

// In some requests, the error is returned as Blob,
// which we then need to manually parse to JSON.
const text =
Expand Down
4 changes: 3 additions & 1 deletion libs/client-shared/src/lib/features/alert/alert.model.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Observable } from 'rxjs';

export type AlertId = string;

/**
Expand All @@ -20,7 +22,7 @@ export interface Alert {
/**
* The display text.
*/
text: string;
text: string | Observable<string>;

/**
* Whether the alert stays until manually closed by the user,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<progress *ngIf="progress !== null" [value]="progress" [max]="1"></progress>
<svg-icon class="icon" [key]="icon"></svg-icon>
<p>{{ alert.text }}</p>
<p>{{ text$ | async }}</p>
<svg-icon class="close" key="close"></svg-icon>
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Component, HostBinding, HostListener, Input, OnDestroy, OnInit, inject } from '@angular/core';
import { Component, HostBinding, HostListener, inject, Input, OnDestroy, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Subscription, asyncScheduler, interval } from 'rxjs';
import { asyncScheduler, interval, Observable, of, Subscription } from 'rxjs';

import { hideAlert } from '../alert.actions';
import { Alert, AlertType } from '../alert.model';
Expand Down Expand Up @@ -50,6 +50,11 @@ export class AlertComponent implements OnInit, OnDestroy {
this.remove();
}

get text$(): Observable<string> {
const { text } = this.alert;
return typeof text === 'string' ? of(text) : text;
}

get icon(): string {
switch (this.alert.type) {
case AlertType.Success:
Expand Down

0 comments on commit a37e7b3

Please sign in to comment.