diff --git a/backend/app/Http/Controllers/AuthController.php b/backend/app/Http/Controllers/AuthController.php index 800ac813..343f16b8 100644 --- a/backend/app/Http/Controllers/AuthController.php +++ b/backend/app/Http/Controllers/AuthController.php @@ -19,6 +19,12 @@ public function __construct() public function register(Request $request) { + if (User::where('email', $request->email)->exists()) { + return response()->json([ + 'message' => 'Internal Server Error', + ], 500); + } + $this->validate($request, [ 'name' => 'required', 'email' => 'required|email', @@ -102,7 +108,7 @@ public function logout() */ public function refresh() { - return $this->respondWithToken(auth()->refresh()); + return $this->respondWithToken(auth()->refresh(true)); } /** diff --git a/backend/routes/web.php b/backend/routes/web.php index 876b4968..b34026ed 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -25,7 +25,7 @@ Route::post('login', 'AuthController@login'); Route::put('changePassword', 'AuthController@changePassword'); Route::post('logout', 'AuthController@logout'); - Route::post('refresh', 'AuthController@refresh'); + Route::post('refresh', ['uses' => 'AuthController@refresh', 'middleware' => 'jwt.refresh']); Route::get('me', 'AuthController@me'); }); diff --git a/backend/sample-requests.http b/backend/sample-requests.http index e005e3df..29e5a045 100644 --- a/backend/sample-requests.http +++ b/backend/sample-requests.http @@ -1,25 +1,36 @@ -### Authorization by token, part 1. Retrieve and save token. +### Register a new user POST {{domain}}/v1/auth/register Content-Type: application/json { - "email": "test@mailinator.com", + "email": "test1@test.com", "name": "Test", "password": "123456" } -### Authorization by token, part 1. Retrieve and save token. +### Login with the registered user POST {{domain}}/v1/auth/login Content-Type: application/json { - "email": "test@mailinator.com", - "password": "123456" + "email": "test1@test.com", + "password": "12345678" } -> {% client.global.set("auth_token", response.body.access_token); %} +> {% + client.global.set("auth_token", response.body.access_token); +%} + +### Refresh token +POST {{domain}}/v1/auth/refresh +Accept: application/json +Authorization: Bearer {{auth_token}} + +> {% + client.global.set("auth_token", response.body.access_token); +%} -### Authorization by token, part 2. Use token to authorize. +### Get user information GET {{domain}}/v1/auth/me Accept: application/json Authorization: Bearer {{auth_token}} diff --git a/frontend/src/app/auth/auth-interceptor.service.ts b/frontend/src/app/auth/auth-interceptor.service.ts index 4e57e3ac..ff4e7565 100644 --- a/frontend/src/app/auth/auth-interceptor.service.ts +++ b/frontend/src/app/auth/auth-interceptor.service.ts @@ -1,9 +1,10 @@ -import { Injectable } from '@angular/core'; -import { HttpEvent, HttpHandler, HttpHeaders, HttpInterceptor, HttpRequest } from '@angular/common/http'; -import { Observable } from 'rxjs'; -import { exhaustMap, map, take } from 'rxjs/operators'; -import { Store } from '@ngrx/store'; -import * as fromApp from '../store/app.reducer'; +import { Injectable } from '@angular/core' +import { HttpErrorResponse, HttpEvent, HttpHandler, HttpHeaders, HttpInterceptor, HttpRequest } from '@angular/common/http' +import { Observable, throwError } from 'rxjs' +import { catchError, filter, map, switchMap, take, takeWhile } from 'rxjs/operators' +import { Store } from '@ngrx/store' +import * as fromApp from '../store/app.reducer' +import * as AuthActions from './store/auth.actions' @Injectable() export class AuthInterceptorService implements HttpInterceptor { @@ -13,17 +14,49 @@ export class AuthInterceptorService implements HttpInterceptor { return this.store.select('auth').pipe( take(1), map((authState) => { - return authState.user; + return authState.user }), - exhaustMap((user) => { + switchMap((user) => { if (!user) { - return next.handle(req); + return next.handle(req) } - const modifiedReq = req.clone({ - headers: new HttpHeaders().set('Authorization', `Bearer ${user.token}`), - }); - return next.handle(modifiedReq); - }) - ); + const modifiedReq = this.modifyRequest(req, user.token) + return next.handle(modifiedReq).pipe(catchError((x) => this.handleAuthError(x, next, modifiedReq))) + }), + ) + } + + private handleAuthError(err: HttpErrorResponse, next: HttpHandler, modifiedReq: HttpRequest): Observable> { + if (err.status === 401 && err.url?.includes('refresh') === false) { + return this.refreshToken$().pipe( + switchMap((token) => { + const newReq = this.modifyRequest(modifiedReq, token) + return next.handle(newReq).pipe( + catchError((err) => { + this.store.dispatch(new AuthActions.Logout()) + return throwError(err) + }), + ) + }), + ) + } + + return throwError(err) + } + + private modifyRequest(request: HttpRequest, token: string) { + return request.clone({ + headers: new HttpHeaders().set('Authorization', `Bearer ${token}`), + }) + } + + private refreshToken$(): Observable { + this.store.dispatch(new AuthActions.RefreshToken()) + + return this.store.select('auth').pipe( + takeWhile((authState) => authState.loading, true), + filter((authState) => !authState.loading), + map((authState) => authState.user?.token ?? ''), + ) } } diff --git a/frontend/src/app/auth/auth.component.ts b/frontend/src/app/auth/auth.component.ts index 982fa640..7f521680 100644 --- a/frontend/src/app/auth/auth.component.ts +++ b/frontend/src/app/auth/auth.component.ts @@ -1,9 +1,9 @@ -import { Component, OnInit } from '@angular/core'; -import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; -import * as AuthActions from './store/auth.actions'; -import { Store } from '@ngrx/store'; -import * as fromApp from '../store/app.reducer'; -import { Subscription } from 'rxjs'; +import { Component, OnInit } from '@angular/core' +import { UntypedFormControl, UntypedFormGroup } from '@angular/forms' +import * as AuthActions from './store/auth.actions' +import { Store } from '@ngrx/store' +import * as fromApp from '../store/app.reducer' +import { Subscription } from 'rxjs' @Component({ selector: 'app-auth', @@ -14,30 +14,30 @@ export class AuthComponent implements OnInit { form: UntypedFormGroup = new UntypedFormGroup({ email: new UntypedFormControl(''), password: new UntypedFormControl(''), - }); - isLoginMode = true; - isLoading = false; - error: string; - private storeSub: Subscription; + }) + isLoginMode = true + isLoading = false + error?: string + private storeSub: Subscription constructor(private store: Store) {} ngOnInit(): void { this.storeSub = this.store.select('auth').subscribe((authState) => { - this.isLoading = authState.loading; - this.error = authState.authError; - }); + this.isLoading = authState.loading + this.error = authState.authError + }) } onSubmit(): void { if (!this.form.valid) { - return; + return } - const email = this.form.value.email; - const password = this.form.value.password; + const email = this.form.value.email + const password = this.form.value.password - this.store.dispatch(new AuthActions.LoginStart({ email, password })); + this.store.dispatch(new AuthActions.LoginStart({ email, password })) - this.form.reset(); + this.form.reset() } } diff --git a/frontend/src/app/auth/auth.guard.spec.ts b/frontend/src/app/auth/auth.guard.spec.ts index 3d1d7413..4e128b38 100644 --- a/frontend/src/app/auth/auth.guard.spec.ts +++ b/frontend/src/app/auth/auth.guard.spec.ts @@ -39,6 +39,7 @@ describe('AuthGuard', () => { it('should return true if user is authenticated', (done) => { store.setState({ auth: { user: { id: '1', email: 'test@test.com' } } }) + // @ts-ignore const canActivate = guard.canActivate(null, null) as Observable canActivate.subscribe((isAuth) => { @@ -51,6 +52,7 @@ describe('AuthGuard', () => { const urlTree = router.createUrlTree(['/auth']) createUrlTreeSpy.and.returnValue(urlTree) + // @ts-ignore const canActivate = guard.canActivate(null, null) as Observable canActivate.subscribe((result) => { diff --git a/frontend/src/app/auth/auth.model.spec.ts b/frontend/src/app/auth/auth.model.spec.ts index 3f6b19ff..45bef41f 100644 --- a/frontend/src/app/auth/auth.model.spec.ts +++ b/frontend/src/app/auth/auth.model.spec.ts @@ -1,30 +1,20 @@ // FILEPATH: /Users/pmueller/workspace/intern/cashbox/frontend/src/app/auth/auth.model.spec.ts -import { Auth } from './auth.model'; +import { Auth } from './auth.model' describe('Auth', () => { - let auth: Auth; + let auth: Auth beforeEach(() => { - auth = new Auth('test@test.com', 'token', new Date()); - }); + auth = new Auth('test@test.com', 'token', new Date()) + }) it('should be created', () => { - expect(auth).toBeTruthy(); - }); + expect(auth).toBeTruthy() + }) it('should return token if token expiration date is in the future', () => { - auth = new Auth('test@test.com', 'token', new Date(new Date().getTime() + 10000)); - expect(auth.token).toEqual('token'); - }); - - it('should return null if token expiration date is in the past', () => { - auth = new Auth('test@test.com', 'token', new Date(new Date().getTime() - 10000)); - expect(auth.token).toBeNull(); - }); - - it('should return null if token expiration date is not set', () => { - auth = new Auth('test@test.com', 'token', null); - expect(auth.token).toBeNull(); - }); -}); \ No newline at end of file + auth = new Auth('test@test.com', 'token', new Date(new Date().getTime() + 10000)) + expect(auth.token).toEqual('token') + }) +}) diff --git a/frontend/src/app/auth/auth.model.ts b/frontend/src/app/auth/auth.model.ts index 48c46f17..786ab130 100644 --- a/frontend/src/app/auth/auth.model.ts +++ b/frontend/src/app/auth/auth.model.ts @@ -6,9 +6,6 @@ export class Auth { ) {} get token(): string { - if (!this._tokenExpirationDate || new Date() > this._tokenExpirationDate) { - return null - } return this._token } } diff --git a/frontend/src/app/auth/auth.service.spec.ts b/frontend/src/app/auth/auth.service.spec.ts deleted file mode 100644 index 436eb013..00000000 --- a/frontend/src/app/auth/auth.service.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { fakeAsync, TestBed, tick } from '@angular/core/testing' -import { AuthService } from './auth.service' -import { MockStore, provideMockStore } from '@ngrx/store/testing' -import * as AuthActions from './store/auth.actions' - -describe('AuthService', () => { - let systemUnderTest: AuthService - let store: MockStore - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [AuthService, provideMockStore({ initialState: {} })], - }) - - systemUnderTest = TestBed.inject(AuthService) - store = TestBed.inject(MockStore) - }) - - it('should be created', () => { - expect(systemUnderTest).toBeTruthy() - }) - - describe('setLogoutTimer', () => { - it('should dispatch Logout action after the expiration duration', fakeAsync(() => { - const expirationDuration = 5000 - spyOn(store, 'dispatch').and.callThrough() - - systemUnderTest.setLogoutTimer(expirationDuration) - - expect(systemUnderTest['tokenExpirationTimer']).toBeDefined() - expect(store.dispatch).not.toHaveBeenCalledWith(new AuthActions.Logout()) - - tick(5000) - - expect(store.dispatch).toHaveBeenCalledWith(new AuthActions.Logout()) - })) - }) - - describe('clearLogoutTimer', () => { - it('should clear the timer', fakeAsync(() => { - const expirationDuration = 5000 - spyOn(window, 'clearTimeout').and.callThrough() - - systemUnderTest.setLogoutTimer(expirationDuration) - - systemUnderTest.clearLogoutTimer() - - expect(window.clearTimeout).toHaveBeenCalled() - expect(systemUnderTest['tokenExpirationTimer']).toBeNull() - })) - - it('should not clear the timer if it has already expired', fakeAsync(() => { - const expirationDuration = 0 - spyOn(window, 'clearTimeout').and.callThrough() - - systemUnderTest.clearLogoutTimer() - - expect(window.clearTimeout).not.toHaveBeenCalled() - expect(systemUnderTest['tokenExpirationTimer']).toBeUndefined() - })) - }) -}) diff --git a/frontend/src/app/auth/auth.service.ts b/frontend/src/app/auth/auth.service.ts deleted file mode 100644 index d405d5e4..00000000 --- a/frontend/src/app/auth/auth.service.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Injectable } from '@angular/core' -import { Store } from '@ngrx/store' - -import * as fromApp from '../store/app.reducer' -import * as AuthActions from './store/auth.actions' - -@Injectable({ providedIn: 'root' }) -export class AuthService { - private tokenExpirationTimer - - constructor(private store: Store) {} - - setLogoutTimer(expirationDuration: number): void { - this.tokenExpirationTimer = setTimeout(() => { - this.store.dispatch(new AuthActions.Logout()) - }, expirationDuration) - } - - clearLogoutTimer(): void { - if (this.tokenExpirationTimer) { - clearTimeout(this.tokenExpirationTimer) - this.tokenExpirationTimer = null - } - } -} diff --git a/frontend/src/app/auth/store/auth.actions.ts b/frontend/src/app/auth/store/auth.actions.ts index 6ff5e3bb..7c9efdf4 100644 --- a/frontend/src/app/auth/store/auth.actions.ts +++ b/frontend/src/app/auth/store/auth.actions.ts @@ -1,63 +1,68 @@ -import { Action } from '@ngrx/store'; +import { Action } from '@ngrx/store' -export const LOGIN_START = '[Auth] Login Start'; -export const AUTHENTICATE_SUCCESS = '[Auth] Login'; -export const AUTHENTICATE_FAIL = '[Auth] Login Fail'; -export const SIGNUP_START = '[Auth] Signup Start'; -export const CLEAR_ERROR = '[Auth] Clear Error'; -export const AUTO_LOGIN = '[Auth] Auto Login'; -export const LOGOUT = '[Auth] Logout'; -export const CHANGE_PASSWORD = '[Auth] Change Password'; +export const LOGIN_START = '[Auth] Login Start' +export const AUTHENTICATE_SUCCESS = '[Auth] Login' +export const AUTHENTICATE_FAIL = '[Auth] Login Fail' +export const SIGNUP_START = '[Auth] Signup Start' +export const CLEAR_ERROR = '[Auth] Clear Error' +export const AUTO_LOGIN = '[Auth] Auto Login' +export const REFRESH_TOKEN = '[Auth] Refresh Token' +export const LOGOUT = '[Auth] Logout' +export const CHANGE_PASSWORD = '[Auth] Change Password' export class AuthenticateSuccess implements Action { - readonly type = AUTHENTICATE_SUCCESS; + readonly type = AUTHENTICATE_SUCCESS constructor( public payload: { - email: string; - token: string; - expirationDate: Date; - redirect: boolean; - } + email: string + token: string + expirationDate: Date + redirect: boolean + }, ) {} } export class Logout implements Action { - readonly type = LOGOUT; + readonly type = LOGOUT } export class LoginStart implements Action { - readonly type = LOGIN_START; + readonly type = LOGIN_START constructor(public payload: { email: string; password: string }) {} } export class AuthenticateFail implements Action { - readonly type = AUTHENTICATE_FAIL; + readonly type = AUTHENTICATE_FAIL constructor(public payload: string) {} } export class SignupStart implements Action { - readonly type = SIGNUP_START; + readonly type = SIGNUP_START constructor(public payload: { email: string; password: string }) {} } export class ClearError implements Action { - readonly type = CLEAR_ERROR; + readonly type = CLEAR_ERROR } export class AutoLogin implements Action { - readonly type = AUTO_LOGIN; + readonly type = AUTO_LOGIN } export class ChangePassword implements Action { - readonly type = CHANGE_PASSWORD; + readonly type = CHANGE_PASSWORD constructor(public payload: { oldPassword: string; password: string }) {} } +export class RefreshToken implements Action { + readonly type = REFRESH_TOKEN +} + export type AuthActions = | AuthenticateSuccess | Logout @@ -66,4 +71,5 @@ export type AuthActions = | SignupStart | ClearError | AutoLogin - | ChangePassword; + | ChangePassword + | RefreshToken diff --git a/frontend/src/app/auth/store/auth.effects.spec.ts b/frontend/src/app/auth/store/auth.effects.spec.ts index ec4ee6ad..60e65779 100644 --- a/frontend/src/app/auth/store/auth.effects.spec.ts +++ b/frontend/src/app/auth/store/auth.effects.spec.ts @@ -1,42 +1,40 @@ -import { Subject } from 'rxjs'; -import { Action } from '@ngrx/store'; -import { HttpClient, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; -import { provideMockActions } from '@ngrx/effects/testing'; -import { AuthEffects } from './auth.effects'; -import { Router } from '@angular/router'; -import { AuthService } from '../auth.service'; +import { Subject } from 'rxjs' +import { Action } from '@ngrx/store' +import { HttpClient, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing' +import { TestBed } from '@angular/core/testing' +import { provideMockActions } from '@ngrx/effects/testing' +import { AuthEffects } from './auth.effects' +import { Router } from '@angular/router' describe('AuthEffects', () => { - let systemUnderTest: AuthEffects; - let actions$ = new Subject(); - let httpClient: HttpClient; - let httpTestingController: HttpTestingController; + let systemUnderTest: AuthEffects + let actions$ = new Subject() + let httpClient: HttpClient + let httpTestingController: HttpTestingController beforeEach(() => { TestBed.configureTestingModule({ - imports: [], - providers: [ + imports: [], + providers: [ AuthEffects, provideMockActions(() => actions$), { provide: Router, useValue: {} }, - { provide: AuthService, useValue: {} }, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), - ] -}); + ], + }) - systemUnderTest = TestBed.inject(AuthEffects); - httpClient = TestBed.inject(HttpClient); - httpTestingController = TestBed.inject(HttpTestingController); - }); + systemUnderTest = TestBed.inject(AuthEffects) + httpClient = TestBed.inject(HttpClient) + httpTestingController = TestBed.inject(HttpTestingController) + }) afterEach(() => { - httpTestingController.verify(); - }); + httpTestingController.verify() + }) it('should be created', () => { - expect(systemUnderTest).toBeDefined(); - }); -}); + expect(systemUnderTest).toBeDefined() + }) +}) diff --git a/frontend/src/app/auth/store/auth.effects.ts b/frontend/src/app/auth/store/auth.effects.ts index 51f437b9..500ffb58 100644 --- a/frontend/src/app/auth/store/auth.effects.ts +++ b/frontend/src/app/auth/store/auth.effects.ts @@ -1,164 +1,187 @@ -import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { catchError, map, switchMap, take, tap } from 'rxjs/operators'; -import { of } from 'rxjs'; -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Injectable } from '@angular/core' +import { Router } from '@angular/router' +import { Actions, createEffect, ofType } from '@ngrx/effects' +import { catchError, exhaustMap, map, switchMap, take, tap } from 'rxjs/operators' +import { of } from 'rxjs' +import { HttpClient, HttpErrorResponse } from '@angular/common/http' -import * as AuthActions from './auth.actions'; -import { Auth } from '../auth.model'; -import { AuthService } from '../auth.service'; -import { environment } from '../../../environments/environment'; +import * as AuthActions from './auth.actions' +import { Auth } from '../auth.model' +import { environment } from '../../../environments/environment' export interface AuthResponseData { - status: string; - access_token: string; - expires_in: string; + status: string + access_token: string + expires_in: string } -const handleAuthentication = (expiresIn: number, email: string, token: string) => { - const expirationDate = new Date(new Date().getTime() + expiresIn * 1000); - const user = new Auth(email, token, expirationDate); - localStorage.setItem('userData', JSON.stringify(user)); - return new AuthActions.AuthenticateSuccess({ - email, - token, - expirationDate, - redirect: true, - }); -}; - -const handleError = (errorRes: HttpErrorResponse) => { - let errorMessage = 'An unknown error occurred!'; - if (!errorRes.error || !errorRes.error.error) { - return of(new AuthActions.AuthenticateFail(errorMessage)); - } - switch (errorRes.error.error.message) { - case 'EMAIL_EXISTS': - errorMessage = 'This email exists already'; - break; - case 'EMAIL_NOT_FOUND': - errorMessage = 'This email does not exist.'; - break; - case 'INVALID_PASSWORD': - errorMessage = 'This password is not correct.'; - break; - } - return of(new AuthActions.AuthenticateFail(errorMessage)); -}; - @Injectable() export class AuthEffects { - readonly ENDPOINT_AUTH = `${environment.backendDomain}/v1/auth`; + readonly ENDPOINT_AUTH = `${environment.backendDomain}/v1/auth` - - authSignup = createEffect(() => this.actions$.pipe( - ofType(AuthActions.SIGNUP_START), - switchMap((signupAction: AuthActions.SignupStart) => { - return this.http - .post(`${this.ENDPOINT_AUTH}/signup`, { - email: signupAction.payload.email, - password: signupAction.payload.password, - }) - .pipe( - tap(() => { - this.router.navigate(['/auth/login']); - }), - catchError((errorRes) => { - return handleError(errorRes); + authSignup = createEffect( + () => + this.actions$.pipe( + ofType(AuthActions.SIGNUP_START), + switchMap((signupAction: AuthActions.SignupStart) => { + return this.http + .post(`${this.ENDPOINT_AUTH}/signup`, { + email: signupAction.payload.email, + password: signupAction.payload.password, + }) + .pipe( + tap(() => { + this.router.navigate(['/auth/login']) + }), + catchError((errorRes) => { + return this.handleError(errorRes) + }), + ) + }), + ), + { dispatch: false }, + ) + + authLogin = createEffect(() => + this.actions$.pipe( + ofType(AuthActions.LOGIN_START), + switchMap((authData: AuthActions.LoginStart) => { + return this.http + .post(`${this.ENDPOINT_AUTH}/login`, { + email: authData.payload.email, + password: authData.payload.password, }) - ); - }) - ), { dispatch: false }); + .pipe( + map((resData) => { + return this.handleAuthentication(+resData.expires_in * 1000, authData.payload.email, resData.access_token, true) + }), + catchError((errorRes) => { + return this.handleError(errorRes) + }), + ) + }), + ), + ) - - authLogin = createEffect(() => this.actions$.pipe( - ofType(AuthActions.LOGIN_START), - switchMap((authData: AuthActions.LoginStart) => { - return this.http - .post(`${this.ENDPOINT_AUTH}/login`, { - email: authData.payload.email, - password: authData.payload.password, - }) - .pipe( - tap((resData) => { - this.authService.setLogoutTimer(+resData.expires_in * 1000); - }), + authRedirect = createEffect( + () => + this.actions$.pipe( + ofType(AuthActions.AUTHENTICATE_SUCCESS), + tap((authSuccessAction: AuthActions.AuthenticateSuccess) => { + if (authSuccessAction.payload.redirect) { + this.router.navigate(['/']) + } + }), + ), + { dispatch: false }, + ) + + autoLogin = createEffect(() => + this.actions$.pipe( + ofType(AuthActions.AUTO_LOGIN), + map(() => { + const userData: { + email: string + _token: string + _tokenExpirationDate: string + } = JSON.parse(localStorage.getItem('userData')!) + if (!userData) { + return { type: 'DUMMY' } + } + + const loadedUser = new Auth(userData.email, userData._token, new Date(userData._tokenExpirationDate)) + + if (loadedUser.token) { + const expirationDuration = new Date(userData._tokenExpirationDate).getTime() - new Date().getTime() + return this.handleAuthentication(expirationDuration, loadedUser.email, loadedUser.token, false) + } + return { type: 'DUMMY' } + }), + ), + ) + + tokenRefresh$ = createEffect(() => + this.actions$.pipe( + ofType(AuthActions.REFRESH_TOKEN), + exhaustMap((_: AuthActions.RefreshToken) => { + return this.http.post(`${this.ENDPOINT_AUTH}/refresh`, {}).pipe( map((resData) => { - return handleAuthentication(+resData.expires_in, authData.payload.email, resData.access_token); + return this.handleAuthentication(+resData.expires_in, '!!!TODO!!!', resData.access_token, false) }), catchError((errorRes) => { - return handleError(errorRes); - }) - ); - }) - )); - - - authRedirect = createEffect(() => this.actions$.pipe( - ofType(AuthActions.AUTHENTICATE_SUCCESS), - tap((authSuccessAction: AuthActions.AuthenticateSuccess) => { - if (authSuccessAction.payload.redirect) { - this.router.navigate(['/']); - } - }) - ), { dispatch: false }); - - - autoLogin = createEffect(() => this.actions$.pipe( - ofType(AuthActions.AUTO_LOGIN), - map(() => { - const userData: { - email: string; - _token: string; - _tokenExpirationDate: string; - } = JSON.parse(localStorage.getItem('userData')); - if (!userData) { - return { type: 'DUMMY' }; - } + console.log('Error refreshing token', errorRes) + return this.handleError(errorRes) + }), + ) + }), + ), + ) - const loadedUser = new Auth(userData.email, userData._token, new Date(userData._tokenExpirationDate)); + authLogout = createEffect( + () => + this.actions$.pipe( + ofType(AuthActions.LOGOUT), + tap(() => { + localStorage.removeItem('userData') + this.router.navigate(['/auth']) + }), + ), + { dispatch: false }, + ) - if (loadedUser.token) { - // this.user.next(loadedUser); - const expirationDuration = new Date(userData._tokenExpirationDate).getTime() - new Date().getTime(); - this.authService.setLogoutTimer(expirationDuration); - return new AuthActions.AuthenticateSuccess({ - email: loadedUser.email, - token: loadedUser.token, - expirationDate: new Date(userData._tokenExpirationDate), - redirect: false, - }); - } - return { type: 'DUMMY' }; - }) - )); + changePassword = createEffect( + () => + this.actions$.pipe( + ofType(AuthActions.CHANGE_PASSWORD), + tap((action: AuthActions.ChangePassword) => { + this.http + .put(`${this.ENDPOINT_AUTH}/changePassword`, { + oldPassword: action.payload.oldPassword, + password: action.payload.password, + }) + .pipe(take(1)) + .subscribe(() => { + this.router.navigate(['/']) + }) + }), + ), + { dispatch: false }, + ) - - authLogout = createEffect(() => this.actions$.pipe( - ofType(AuthActions.LOGOUT), - tap(() => { - this.authService.clearLogoutTimer(); - localStorage.removeItem('userData'); - this.router.navigate(['/auth']); - }) - ), { dispatch: false }); + constructor( + private actions$: Actions, + private http: HttpClient, + private router: Router, + ) {} - - changePassword = createEffect(() => this.actions$.pipe( - ofType(AuthActions.CHANGE_PASSWORD), - tap((action: AuthActions.ChangePassword) => { - this.http - .put(`${this.ENDPOINT_AUTH}/changePassword`, { - oldPassword: action.payload.oldPassword, - password: action.payload.password, - }) - .pipe(take(1)) - .subscribe(() => { - this.router.navigate(['/']); - }); + private handleAuthentication = (expiresIn: number, email: string, token: string, redirect: boolean = false) => { + const expirationDate = new Date(new Date().getTime() + expiresIn) + const user = new Auth(email, token, expirationDate) + localStorage.setItem('userData', JSON.stringify(user)) + return new AuthActions.AuthenticateSuccess({ + email, + token, + expirationDate, + redirect, }) - ), { dispatch: false }); + } - constructor(private actions$: Actions, private http: HttpClient, private router: Router, private authService: AuthService) {} + private handleError = (errorRes: HttpErrorResponse) => { + let errorMessage = 'An unknown error occurred!' + if (!errorRes.error || !errorRes.error.error) { + return of(new AuthActions.AuthenticateFail(errorMessage)) + } + switch (errorRes.error.error.message) { + case 'EMAIL_EXISTS': + errorMessage = 'This email exists already' + break + case 'EMAIL_NOT_FOUND': + errorMessage = 'This email does not exist.' + break + case 'INVALID_PASSWORD': + errorMessage = 'This password is not correct.' + break + } + return of(new AuthActions.AuthenticateFail(errorMessage)) + } } diff --git a/frontend/src/app/auth/store/auth.reducer.ts b/frontend/src/app/auth/store/auth.reducer.ts index 5e84729d..c438b4ee 100644 --- a/frontend/src/app/auth/store/auth.reducer.ts +++ b/frontend/src/app/auth/store/auth.reducer.ts @@ -1,60 +1,52 @@ -import { Auth } from '../auth.model'; -import * as AuthActions from './auth.actions'; +import { Auth } from '../auth.model' +import * as AuthActions from './auth.actions' export interface State { - user: Auth; - authError: string; - loading: boolean; + user?: Auth + authError?: string + loading: boolean } const initialState: State = { - user: null, - authError: null, loading: false, -}; +} -export function authReducer( - state = initialState, - action: AuthActions.AuthActions -): State { +export function authReducer(state = initialState, action: AuthActions.AuthActions): State { switch (action.type) { case AuthActions.AUTHENTICATE_SUCCESS: - const user = new Auth( - action.payload.email, - action.payload.token, - action.payload.expirationDate - ); + const user = new Auth(action.payload.email, action.payload.token, action.payload.expirationDate) return { ...state, - authError: null, + authError: undefined, user, loading: false, - }; + } case AuthActions.LOGOUT: return { ...state, - user: null, - }; + user: undefined, + } case AuthActions.LOGIN_START: case AuthActions.SIGNUP_START: + case AuthActions.REFRESH_TOKEN: return { ...state, - authError: null, + authError: undefined, loading: true, - }; + } case AuthActions.AUTHENTICATE_FAIL: return { ...state, - user: null, + user: undefined, authError: action.payload, loading: false, - }; + } case AuthActions.CLEAR_ERROR: return { ...state, - authError: null, - }; + authError: undefined, + } default: - return state; + return state } } diff --git a/frontend/src/app/budget-plan/components/budget-plan-chart/budget-plan-chart.component.spec.ts b/frontend/src/app/budget-plan/components/budget-plan-chart/budget-plan-chart.component.spec.ts index 394341fe..cff1a4fd 100644 --- a/frontend/src/app/budget-plan/components/budget-plan-chart/budget-plan-chart.component.spec.ts +++ b/frontend/src/app/budget-plan/components/budget-plan-chart/budget-plan-chart.component.spec.ts @@ -47,7 +47,7 @@ describe('BudgetPlanChartComponent', () => { component.ngOnInit() - expect(component.lineChartOptions.aspectRatio).toEqual(4) + expect(component.lineChartOptions?.aspectRatio).toEqual(4) }) it('should update chart data on changes', () => { diff --git a/frontend/src/app/budget-plan/components/budget-plan-chart/budget-plan-chart.component.ts b/frontend/src/app/budget-plan/components/budget-plan-chart/budget-plan-chart.component.ts index 68354611..5ae817ad 100644 --- a/frontend/src/app/budget-plan/components/budget-plan-chart/budget-plan-chart.component.ts +++ b/frontend/src/app/budget-plan/components/budget-plan-chart/budget-plan-chart.component.ts @@ -70,10 +70,10 @@ export class BudgetPlanChartComponent implements OnInit, OnChanges { take(1), ) .subscribe((isHandset) => { - this.lineChartOptions.aspectRatio = isHandset ? 2 : 4 + this.lineChartOptions!.aspectRatio = isHandset ? 2 : 4 }) - this.lineChartData.datasets.find((d) => d.label === 'Planned').data = [ + this.lineChartData.datasets.find((d) => d.label === 'Planned')!.data = [ { x: new Date(this.budgetPlan.start_date), y: this.budgetPlan.budget }, { x: new Date(this.budgetPlan.end_date), y: 0 }, ] @@ -92,7 +92,7 @@ export class BudgetPlanChartComponent implements OnInit, OnChanges { this.generateDataFromEntries(this.budgetPlan, grouped, this.budgetPlan.budget, data) let dataset = this.lineChartData.datasets.find((d) => d.label === 'Real') - if (dataset.data.length === 0) { + if (dataset?.data.length === 0) { dataset.data.push(...data) } } @@ -109,11 +109,6 @@ export class BudgetPlanChartComponent implements OnInit, OnChanges { const d1 = new Date(a.date) const d2 = new Date(b.date) - const same = d1.getTime() === d2.getTime() - if (same) { - return 0 - } - if (d1 > d2) { return 1 } @@ -121,6 +116,8 @@ export class BudgetPlanChartComponent implements OnInit, OnChanges { if (d1 < d2) { return -1 } + + return 0 } } diff --git a/frontend/src/app/budget-plan/components/budget-plan-edit/budget-plan-edit.component.ts b/frontend/src/app/budget-plan/components/budget-plan-edit/budget-plan-edit.component.ts index a5fec290..f53a134c 100644 --- a/frontend/src/app/budget-plan/components/budget-plan-edit/budget-plan-edit.component.ts +++ b/frontend/src/app/budget-plan/components/budget-plan-edit/budget-plan-edit.component.ts @@ -1,12 +1,12 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; -import { BudgetPlan } from '../../../model/budget-plan.model'; -import { Store } from '@ngrx/store'; -import * as fromApp from '../../../store/app.reducer'; -import { ActivatedRoute, Router } from '@angular/router'; -import * as BudgetPlanAction from '../../store/budget-plan.actions'; -import { Subscription } from 'rxjs'; -import { take } from 'rxjs/operators'; +import { Component, OnDestroy, OnInit } from '@angular/core' +import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms' +import { BudgetPlan } from '../../../model/budget-plan.model' +import { Store } from '@ngrx/store' +import * as fromApp from '../../../store/app.reducer' +import { ActivatedRoute, Router } from '@angular/router' +import * as BudgetPlanAction from '../../store/budget-plan.actions' +import { Subscription } from 'rxjs' +import { take } from 'rxjs/operators' @Component({ selector: 'app-budget-plan-edit', @@ -14,50 +14,47 @@ import { take } from 'rxjs/operators'; styleUrls: ['./budget-plan-edit.component.scss'], }) export class BudgetPlanEditComponent implements OnInit, OnDestroy { - form: UntypedFormGroup; - loading: boolean; - error: string; - editMode = false; - budgetPlan: BudgetPlan; - cashBoxId: number; - private sub: Subscription; + form: UntypedFormGroup + loading: boolean + error?: string + editMode = false + budgetPlan: BudgetPlan + cashBoxId: number + private sub: Subscription - constructor(private store: Store, private activatedRoute: ActivatedRoute, private readonly router: Router) {} + constructor( + private store: Store, + private activatedRoute: ActivatedRoute, + private readonly router: Router, + ) {} ngOnInit(): void { - this.budgetPlan = this.activatedRoute.snapshot.data.budgetPlan; - this.editMode = !!this.budgetPlan; - this.activatedRoute.parent.data.pipe(take(1)).subscribe((data) => { - this.cashBoxId = data.cashBoxId; - }); + this.budgetPlan = this.activatedRoute.snapshot.data.budgetPlan + this.editMode = !!this.budgetPlan + this.activatedRoute.parent!.data.pipe(take(1)).subscribe((data) => { + this.cashBoxId = data.cashBoxId + }) this.sub = this.store.select('budgetPlan').subscribe((state) => { - this.loading = state.loading; - this.error = state.error; - }); - this.initForm(); + this.loading = state.loading + this.error = state.error + }) + this.initForm() } private initForm(): void { this.form = new UntypedFormGroup({ - name: new UntypedFormControl(this.budgetPlan?.name, [ - Validators.required, - Validators.maxLength(255), - ]), + name: new UntypedFormControl(this.budgetPlan?.name, [Validators.required, Validators.maxLength(255)]), budget: new UntypedFormControl(this.budgetPlan?.budget, [Validators.required]), range: new UntypedFormGroup({ - start_date: new UntypedFormControl(this.budgetPlan?.start_date, [ - Validators.required, - ]), - end_date: new UntypedFormControl(this.budgetPlan?.end_date, [ - Validators.required, - ]), + start_date: new UntypedFormControl(this.budgetPlan?.start_date, [Validators.required]), + end_date: new UntypedFormControl(this.budgetPlan?.end_date, [Validators.required]), }), - }); + }) } onSubmit(): void { if (!this.form.valid) { - return; + return } if (!this.editMode) { this.store.dispatch( @@ -68,8 +65,8 @@ export class BudgetPlanEditComponent implements OnInit, OnDestroy { start_date: this.fixDate(this.form.value.range.start_date), end_date: this.fixDate(this.form.value.range.end_date), }, - }) - ); + }), + ) } else { this.store.dispatch( BudgetPlanAction.updateBudgetPlan({ @@ -80,20 +77,20 @@ export class BudgetPlanEditComponent implements OnInit, OnDestroy { end_date: this.fixDate(this.form.value.range.end_date), }, index: this.budgetPlan.id, - }) - ); + }), + ) } - this.router.navigate([`/cash-boxes/${this.cashBoxId}`]); + this.router.navigate([`/cash-boxes/${this.cashBoxId}`]) } private fixDate(date: string): string { - const d = new Date(date); - d.setMinutes(-1 * d.getTimezoneOffset()); - return d.toISOString().split('T')[0]; + const d = new Date(date) + d.setMinutes(-1 * d.getTimezoneOffset()) + return d.toISOString().split('T')[0] } ngOnDestroy(): void { - this.sub.unsubscribe(); + this.sub.unsubscribe() } } diff --git a/frontend/src/app/budget-plan/components/budget-plan-entry-dialog/budget-plan-entry-dialog.component.ts b/frontend/src/app/budget-plan/components/budget-plan-entry-dialog/budget-plan-entry-dialog.component.ts index 1f184fb5..cb3f0761 100644 --- a/frontend/src/app/budget-plan/components/budget-plan-entry-dialog/budget-plan-entry-dialog.component.ts +++ b/frontend/src/app/budget-plan/components/budget-plan-entry-dialog/budget-plan-entry-dialog.component.ts @@ -1,22 +1,22 @@ -import { AfterViewInit, Component, Inject, OnInit, ViewChild } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog'; -import { BudgetPlanEntry } from '../../../model/budget-plan-entry.model'; -import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; -import { Store } from '@ngrx/store'; -import * as fromApp from '../../../store/app.reducer'; -import * as BudgetPlanActions from '../../store/budget-plan.actions'; -import { combineLatest, fromEvent, Observable } from 'rxjs'; -import { PredefinedDescription } from '../../../model/cash-box.model'; -import { delay, filter, map, startWith, switchMap } from 'rxjs/operators'; -import { MatAutocomplete } from '@angular/material/autocomplete'; -import { loadCashBoxSettings } from '../../../cash-box/store/cash-box-settings/cash-box-settings.actions'; -import { selectCashBoxSettings } from '../../../cash-box/store/cash-box-settings/cash-box-settings.selectors'; -import { MatCheckbox } from '@angular/material/checkbox'; +import { AfterViewInit, Component, Inject, OnInit, ViewChild } from '@angular/core' +import { MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog' +import { BudgetPlanEntry } from '../../../model/budget-plan-entry.model' +import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms' +import { Store } from '@ngrx/store' +import * as fromApp from '../../../store/app.reducer' +import * as BudgetPlanActions from '../../store/budget-plan.actions' +import { combineLatest, fromEvent, Observable } from 'rxjs' +import { PredefinedDescription } from '../../../model/cash-box.model' +import { delay, filter, map, startWith, switchMap } from 'rxjs/operators' +import { MatAutocomplete } from '@angular/material/autocomplete' +import { loadCashBoxSettings } from '../../../cash-box/store/cash-box-settings/cash-box-settings.actions' +import { selectCashBoxSettings } from '../../../cash-box/store/cash-box-settings/cash-box-settings.selectors' +import { MatCheckbox } from '@angular/material/checkbox' export interface BudgetPlanEntryDialogData { - data: BudgetPlanEntry; - budgetPlanId: number; - cashBoxId: number; + data?: BudgetPlanEntry + budgetPlanId: number + cashBoxId: number } @Component({ @@ -25,36 +25,38 @@ export interface BudgetPlanEntryDialogData { styleUrls: ['./budget-plan-entry-dialog.component.scss'], }) export class BudgetPlanEntryDialogComponent implements OnInit, AfterViewInit { - form: UntypedFormGroup; - element: BudgetPlanEntry; - private descriptions$: Observable; - filteredDescriptions$: Observable; - @ViewChild('auto') autocomplete: MatAutocomplete; - @ViewChild('createAnotherCheckbox') createAnotherCheckbox: MatCheckbox; + form: UntypedFormGroup + element: BudgetPlanEntry + private descriptions$: Observable + filteredDescriptions$: Observable + @ViewChild('auto') autocomplete: MatAutocomplete + @ViewChild('createAnotherCheckbox') createAnotherCheckbox: MatCheckbox constructor( @Inject(MAT_DIALOG_DATA) public data: BudgetPlanEntryDialogData, private readonly store: Store, - private readonly matDialog: MatDialog + private readonly matDialog: MatDialog, ) {} ngOnInit(): void { - this.element = this.data.data; - this.initForm(); + this.element = this.data.data as BudgetPlanEntry + this.initForm() - this.descriptions$ = this.store.select(selectCashBoxSettings).pipe(map((state) => state.settings[this.data.cashBoxId]?.descriptions)); + this.descriptions$ = this.store + .select(selectCashBoxSettings) + .pipe(map((state) => state.settings[this.data.cashBoxId]?.descriptions ?? [])) this.store.dispatch( loadCashBoxSettings({ cashBoxId: this.data.cashBoxId, - }) - ); + }), + ) - this.filteredDescriptions$ = this.form.get('description').valueChanges.pipe( + this.filteredDescriptions$ = this.form.get('description')!.valueChanges.pipe( startWith(''), filter((value) => typeof value === 'string'), - switchMap((value) => this._filter(value)) - ); + switchMap((value) => this._filter(value)), + ) } private initForm(): void { @@ -62,7 +64,7 @@ export class BudgetPlanEntryDialogComponent implements OnInit, AfterViewInit { description: new UntypedFormControl(this.data?.data?.description, [Validators.required, Validators.maxLength(255)]), value: new UntypedFormControl(this.data?.data?.value, [Validators.required]), date: new UntypedFormControl(this.data.data?.date ?? new Date(), [Validators.required]), - }); + }) } onSubmit(): void { @@ -73,47 +75,46 @@ export class BudgetPlanEntryDialogComponent implements OnInit, AfterViewInit { ...this.form.value, date: this.fixDate(this.form.value.date), }, - }; + } if (this.element) { this.store.dispatch( BudgetPlanActions.updateBudgetPlanEntry({ ...body, budgetPlanEntryId: this.element.id, - }) - ); + }), + ) } else { - this.store.dispatch(BudgetPlanActions.addBudgetPlanEntry(body)); + this.store.dispatch(BudgetPlanActions.addBudgetPlanEntry(body)) } - this.reopenDialogWhenRequested(); + this.reopenDialogWhenRequested() } private _filter(value: string): Observable { return this.descriptions$.pipe( map((list) => { return list?.filter((option) => { - return option.value.toLowerCase().includes(value.toLowerCase()); - }); - }) - ); + return option.value.toLowerCase().includes(value.toLowerCase()) + }) + }), + ) } private fixDate(date: string): string { - const d = new Date(date); - d.setMinutes(-1 * d.getTimezoneOffset()); - return d.toISOString().split('T')[0]; + const d = new Date(date) + d.setMinutes(-1 * d.getTimezoneOffset()) + return d.toISOString().split('T')[0] } private reopenDialogWhenRequested(): void { if (this.createAnotherCheckbox?.checked) { this.matDialog.open(BudgetPlanEntryDialogComponent, { data: { - data: null, budgetPlanId: this.data.budgetPlanId, cashBoxId: this.data.cashBoxId, } as BudgetPlanEntryDialogData, - }); + }) } } @@ -121,14 +122,14 @@ export class BudgetPlanEntryDialogComponent implements OnInit, AfterViewInit { combineLatest([this.autocomplete.opened, fromEvent(window, 'resize')]) .pipe(delay(100)) .subscribe(() => { - const panel: HTMLElement = document.getElementsByClassName('pre-defined-descriptions-panel')[0] as HTMLElement; + const panel: HTMLElement = document.getElementsByClassName('pre-defined-descriptions-panel')[0] as HTMLElement - const boundingRect: DOMRect = panel.getBoundingClientRect(); + const boundingRect: DOMRect = panel.getBoundingClientRect() if (boundingRect.top < 0) { - panel.style.maxHeight = `${boundingRect.height + boundingRect.top}px`; + panel.style.maxHeight = `${boundingRect.height + boundingRect.top}px` } else if (boundingRect.bottom > window.innerHeight) { - panel.style.maxHeight = `${window.innerHeight - boundingRect.top}px`; + panel.style.maxHeight = `${window.innerHeight - boundingRect.top}px` } - }); + }) } } diff --git a/frontend/src/app/budget-plan/services/budget-plan.resolver.spec.ts b/frontend/src/app/budget-plan/services/budget-plan.resolver.spec.ts index 28e9265b..51b97f92 100644 --- a/frontend/src/app/budget-plan/services/budget-plan.resolver.spec.ts +++ b/frontend/src/app/budget-plan/services/budget-plan.resolver.spec.ts @@ -11,7 +11,7 @@ import { Action } from '@ngrx/store' describe('BudgetPlanResolver', () => { let resolver: BudgetPlanResolver let store: MockStore - let actions$ = new BehaviorSubject(null) + let actions$ = new BehaviorSubject(null) let route: ActivatedRouteSnapshot beforeEach(() => { @@ -28,6 +28,7 @@ describe('BudgetPlanResolver', () => { it('should return budget plan if it exists in the store', (done) => { store.setState({ budgetPlan: { budgetPlans: [{ id: 1 }] } }) + // @ts-ignore let result = resolver.resolve(route, null) as Observable result.subscribe((budgetPlan) => { @@ -42,6 +43,7 @@ describe('BudgetPlanResolver', () => { actions$.next(BudgetPlanAction.loadBudgetPlansSuccess({ budgetPlans: [{ id: 1 } as BudgetPlan] })) + // @ts-ignore let result = resolver.resolve(route, null) as Observable result.subscribe((budgetPlan) => { diff --git a/frontend/src/app/budget-plan/services/budget-plan.resolver.ts b/frontend/src/app/budget-plan/services/budget-plan.resolver.ts index 52b86992..12012a1c 100644 --- a/frontend/src/app/budget-plan/services/budget-plan.resolver.ts +++ b/frontend/src/app/budget-plan/services/budget-plan.resolver.ts @@ -20,7 +20,7 @@ export class BudgetPlanResolver { ) {} resolve(route: ActivatedRouteSnapshot, _: RouterStateSnapshot): Observable | Promise | BudgetPlan { - const id = route.paramMap.get('id') + const id = route.paramMap.get('id') as string return this.store.select('budgetPlan').pipe( take(1), switchMap((storeState) => { @@ -34,7 +34,7 @@ export class BudgetPlanResolver { ofType(BudgetPlanAction.loadBudgetPlansSuccess), take(1), map((result) => { - return result.budgetPlans.find((c) => c.id === +id) + return result.budgetPlans.find((c) => c.id === +id) as BudgetPlan }), ) } diff --git a/frontend/src/app/budget-plan/services/budget-plans-resolver.service.ts b/frontend/src/app/budget-plan/services/budget-plans-resolver.service.ts index 48f1ebac..a09f4d85 100644 --- a/frontend/src/app/budget-plan/services/budget-plans-resolver.service.ts +++ b/frontend/src/app/budget-plan/services/budget-plans-resolver.service.ts @@ -17,7 +17,7 @@ export class BudgetPlansResolver { constructor(private store: Store) {} resolve(route: ActivatedRouteSnapshot, _state: RouterStateSnapshot): Observable | Promise | boolean { - const id = route.paramMap.get('id') + const id = route.paramMap.get('id') as string return this.store.select('budgetPlan').pipe( take(1), diff --git a/frontend/src/app/budget-plan/services/cash-box-id.resolver.ts b/frontend/src/app/budget-plan/services/cash-box-id.resolver.ts index 20edcf5a..6a1bb899 100644 --- a/frontend/src/app/budget-plan/services/cash-box-id.resolver.ts +++ b/frontend/src/app/budget-plan/services/cash-box-id.resolver.ts @@ -9,6 +9,7 @@ export const resolveCashBoxId: ResolveFn = (route: ActivatedRouteSnapsho @Injectable({ providedIn: 'root' }) export class CashBoxIdResolver { resolve(route: ActivatedRouteSnapshot, _: RouterStateSnapshot): Observable | Promise | number { - return +route.paramMap.get('id') + const id = route.paramMap.get('id') as string + return +id } } diff --git a/frontend/src/app/budget-plan/shared/components/budget-plan-report/budget-plan-report.component.ts b/frontend/src/app/budget-plan/shared/components/budget-plan-report/budget-plan-report.component.ts index 17e97b17..4d8bf7bc 100644 --- a/frontend/src/app/budget-plan/shared/components/budget-plan-report/budget-plan-report.component.ts +++ b/frontend/src/app/budget-plan/shared/components/budget-plan-report/budget-plan-report.component.ts @@ -16,7 +16,7 @@ import { Subscription } from 'rxjs' export class BudgetPlanReportComponent implements OnInit, AfterViewChecked, OnDestroy { @Input() cashBoxId: number @Input() budgetPlanId: number - name: string + name?: string report: BudgetPlanReport @ViewChild('paidByUserSort', { static: false }) paidByUserSort: MatSort @@ -76,7 +76,7 @@ export class BudgetPlanReportComponent implements OnInit, AfterViewChecked, OnDe } return previousValue - }, []) + }, [] as DataSet[]) groups = groups.sort((a: DataSet, b: DataSet) => { return b.value - a.value diff --git a/frontend/src/app/budget-plan/shared/components/budget-plan-view/budget-plan-view.component.ts b/frontend/src/app/budget-plan/shared/components/budget-plan-view/budget-plan-view.component.ts index 13192f8e..28758708 100644 --- a/frontend/src/app/budget-plan/shared/components/budget-plan-view/budget-plan-view.component.ts +++ b/frontend/src/app/budget-plan/shared/components/budget-plan-view/budget-plan-view.component.ts @@ -35,7 +35,7 @@ export class BudgetPlanViewComponent implements OnInit { showDescription = false entriesLoaded$: Observable - activeUserEmail$ = this.store.select('auth').pipe(map((state) => state.user.email)) + activeUserEmail$ = this.store.select('auth').pipe(map((state) => state.user?.email)) constructor( private store: Store, @@ -48,7 +48,7 @@ export class BudgetPlanViewComponent implements OnInit { case 'date': return new Date(item.date).getTime() case 'user': - return item.user.name + return item.user.name ?? item.user.email default: return item[property] } diff --git a/frontend/src/app/budget-plan/store/budget-plan.effects.spec.ts b/frontend/src/app/budget-plan/store/budget-plan.effects.spec.ts index 097f4fed..4e7cc23d 100644 --- a/frontend/src/app/budget-plan/store/budget-plan.effects.spec.ts +++ b/frontend/src/app/budget-plan/store/budget-plan.effects.spec.ts @@ -17,9 +17,14 @@ describe('BudgetPlanEffects', function () { beforeEach(() => { TestBed.configureTestingModule({ - imports: [], - providers: [BudgetPlanEffects, provideMockActions(() => actions$), provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] -}) + imports: [], + providers: [ + BudgetPlanEffects, + provideMockActions(() => actions$), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }) systemUnderTest = TestBed.inject(BudgetPlanEffects) httpClient = TestBed.inject(HttpClient) @@ -32,7 +37,7 @@ describe('BudgetPlanEffects', function () { describe('closeBudgetPlan$', () => { it('should set budget plan to closed and dispatch to success event', () => { - let type = null + let type systemUnderTest.closeBudgetPlan$.subscribe((action) => { type = action.type }) @@ -48,7 +53,7 @@ describe('BudgetPlanEffects', function () { }) it('should set budget plan to open and dispatch to success event', () => { - let type = null + let type systemUnderTest.closeBudgetPlan$.subscribe((action) => { type = action.type }) @@ -64,7 +69,7 @@ describe('BudgetPlanEffects', function () { }) it('should set budget plan to closed and dispatch to failure event if forbidden', () => { - let type = null + let type systemUnderTest.closeBudgetPlan$.subscribe((action) => { type = action.type }) @@ -81,7 +86,7 @@ describe('BudgetPlanEffects', function () { describe('updateBudgetPlan$', () => { it('should update budget plan and dispatch to success event', () => { - let type = null + let type systemUnderTest.updateBudgetPlan$.subscribe((action) => { type = action.type }) @@ -109,7 +114,7 @@ describe('BudgetPlanEffects', function () { describe('addBudgetPlan$', () => { it('should update budget plan and dispatch to success event', () => { - let type = null + let type systemUnderTest.addBudgetPlan$.subscribe((action) => { type = action.type }) diff --git a/frontend/src/app/budget-plan/store/budget-plan.reducer.ts b/frontend/src/app/budget-plan/store/budget-plan.reducer.ts index 9cbdeb37..4e53b16b 100644 --- a/frontend/src/app/budget-plan/store/budget-plan.reducer.ts +++ b/frontend/src/app/budget-plan/store/budget-plan.reducer.ts @@ -1,6 +1,6 @@ -import { BudgetPlan, BudgetPlanReport } from '../../model/budget-plan.model'; -import { Action, createReducer, on } from '@ngrx/store'; -import * as BudgetPlanAction from './budget-plan.actions'; +import { BudgetPlan, BudgetPlanReport } from '../../model/budget-plan.model' +import { Action, createReducer, on } from '@ngrx/store' +import * as BudgetPlanAction from './budget-plan.actions' import { addBudgetPlan, addBudgetPlanEntry, @@ -19,32 +19,30 @@ import { updateBudgetPlanEntryFail, updateBudgetPlanFail, updateBudgetPlanSuccess, -} from './budget-plan.actions'; -import { BudgetPlanEntry } from '../../model/budget-plan-entry.model'; -import { CallState, LoadingState } from '../../store/state'; +} from './budget-plan.actions' +import { BudgetPlanEntry } from '../../model/budget-plan-entry.model' +import { CallState, LoadingState } from '../../store/state' export interface State { - budgetPlans: BudgetPlan[]; - activeBudgetPlanId: number; - selectedBudgetPlanId: number; - budgetPlansEntries: { [budgetPlanId: number]: BudgetPlanEntry[] }; - budgetPlansReports: { [budgetPlanId: number]: BudgetPlanReport }; + budgetPlans: BudgetPlan[] + activeBudgetPlanId?: number + selectedBudgetPlanId?: number + budgetPlansEntries: { [budgetPlanId: number]: BudgetPlanEntry[] } + budgetPlansReports: { [budgetPlanId: number]: BudgetPlanReport } - loadBudgetPlansState: CallState; - loadActiveBudgetPlanState: CallState; - loadBudgetPlanEntriesState: CallState; - loadBudgetPlanReportsState: CallState; - updateBudgetPlanState: CallState; + loadBudgetPlansState: CallState + loadActiveBudgetPlanState: CallState + loadBudgetPlanEntriesState: CallState + loadBudgetPlanReportsState: CallState + updateBudgetPlanState: CallState // todo deprecated - loading: boolean; - error: string; + loading: boolean + error?: string } export const initialState: State = { budgetPlans: [], - activeBudgetPlanId: null, - selectedBudgetPlanId: null, budgetPlansEntries: {}, budgetPlansReports: {}, loadBudgetPlansState: LoadingState.INIT, @@ -55,87 +53,99 @@ export const initialState: State = { // todo deprecated loading: false, - error: null, -}; +} const budgetPlanReducer = createReducer( initialState, on(loadBudgetPlans, (state) => { - return { ...state, loadBudgetPlansState: LoadingState.LOADING }; + return { ...state, loadBudgetPlansState: LoadingState.LOADING } }), on(loadBudgetPlansSuccess, (state, { budgetPlans }) => { - return { ...state, loadBudgetPlansState: LoadingState.LOADED, budgetPlans }; + return { ...state, loadBudgetPlansState: LoadingState.LOADED, budgetPlans } }), on(loadBudgetPlansFail, (state) => { - return { ...state, loadBudgetPlansState: { errorMsg: 'Failed to load budget plans' } }; + return { ...state, loadBudgetPlansState: { errorMsg: 'Failed to load budget plans' } } }), on(addBudgetPlan, updateBudgetPlan, deleteBudgetPlan, (state) => { - return { ...state, loadBudgetPlansState: LoadingState.LOADING }; + return { ...state, loadBudgetPlansState: LoadingState.LOADING } }), on(updateBudgetPlanSuccess, (state) => { - return { ...state, updateBudgetPlanState: LoadingState.LOADED }; + return { ...state, updateBudgetPlanState: LoadingState.LOADED } }), on(updateBudgetPlanFail, (state, { error }) => { - return { ...state, updateBudgetPlanState: { errorMsg: error } }; + return { ...state, updateBudgetPlanState: { errorMsg: error } } }), on(setSelectedBudgetPlan, (state, { id }) => { - return { ...state, selectedBudgetPlanId: id }; + return { ...state, selectedBudgetPlanId: id } }), on(closeBudgetPlan, (state) => { - return { ...state, updateBudgetPlanState: LoadingState.LOADING }; + return { ...state, updateBudgetPlanState: LoadingState.LOADING } }), // todo feature active budget plan on(loadActiveBudgetPlan, (state) => { - return { ...state, loadActiveBudgetPlanState: LoadingState.LOADING }; + return { ...state, loadActiveBudgetPlanState: LoadingState.LOADING } }), on(loadActiveBudgetPlanSuccess, (state, { budgetPlan }) => { - return { ...state, loadActiveBudgetPlanState: LoadingState.LOADED, activeBudgetPlanId: budgetPlan?.id }; + return { + ...state, + loadActiveBudgetPlanState: LoadingState.LOADED, + activeBudgetPlanId: budgetPlan?.id, + } }), on(loadActiveBudgetPlanFail, (state) => { - return { ...state, loadActiveBudgetPlanState: { errorMsg: 'Failed to load active budget plan' } }; + return { + ...state, + loadActiveBudgetPlanState: { errorMsg: 'Failed to load active budget plan' }, + } }), // todo feature budget plan entries on(BudgetPlanAction.loadBudgetPlanEntries, (state) => { - return { ...state, loadBudgetPlanEntriesState: LoadingState.LOADING }; + return { ...state, loadBudgetPlanEntriesState: LoadingState.LOADING } }), on(BudgetPlanAction.loadBudgetPlanEntriesSuccess, (state, { budgetPlanId, entries }) => { return { ...state, loadBudgetPlanEntriesState: LoadingState.LOADED, budgetPlansEntries: { [budgetPlanId]: entries }, - }; + } }), on(BudgetPlanAction.loadBudgetPlanEntriesFail, (state, { error }) => { - return { ...state, loadBudgetPlanEntriesState: { errorMsg: 'Failed to load budget plan entries' } }; + return { + ...state, + loadBudgetPlanEntriesState: { errorMsg: 'Failed to load budget plan entries' }, + } }), on(addBudgetPlanEntry, updateBudgetPlanEntry, deleteBudgetPlanEntry, (state) => { - return { ...state, loadBudgetPlanEntriesState: LoadingState.LOADING }; + return { ...state, loadBudgetPlanEntriesState: LoadingState.LOADING } }), on(updateBudgetPlanEntryFail, (state) => { - return { ...state, loadBudgetPlanEntriesState: { errorMsg: 'Failed to update budget plan' } }; + return { ...state, loadBudgetPlanEntriesState: { errorMsg: 'Failed to update budget plan' } } }), // todo feature budget plan report on(BudgetPlanAction.loadBudgetPlanReport, (state) => { - return { ...state, loadBudgetPlanReportsState: LoadingState.LOADING }; + return { ...state, loadBudgetPlanReportsState: LoadingState.LOADING } }), on(BudgetPlanAction.loadBudgetPlanReportSuccess, (state, { budgetPlanId, report }) => { return { ...state, loadBudgetPlanReportsState: LoadingState.LOADED, budgetPlansReports: { [budgetPlanId]: report }, - }; + } }), on(BudgetPlanAction.loadBudgetPlanReportFail, (state, { error }) => { - return { ...state, loadBudgetPlanReportsState: { errorMsg: 'Failed to load budget plan report' } }; - }) -); + return { + ...state, + loadBudgetPlanReportsState: { errorMsg: 'Failed to load budget plan report' }, + } + }), +) export function reducer(state: State | undefined, action: Action): State { - return budgetPlanReducer(state, action); + return budgetPlanReducer(state, action) } diff --git a/frontend/src/app/cash-box/components/cash-box-plans/cash-box-plans.component.ts b/frontend/src/app/cash-box/components/cash-box-plans/cash-box-plans.component.ts index 6485da5b..fe16797f 100644 --- a/frontend/src/app/cash-box/components/cash-box-plans/cash-box-plans.component.ts +++ b/frontend/src/app/cash-box/components/cash-box-plans/cash-box-plans.component.ts @@ -1,21 +1,18 @@ -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { map } from 'rxjs/operators'; -import { Observable } from 'rxjs'; +import { Component, OnInit } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { map } from 'rxjs/operators' +import { Observable } from 'rxjs' @Component({ selector: 'app-cash-box-plans', - template: - '', + template: '', }) export class CashBoxPlansComponent implements OnInit { - cashBoxId$: Observable; + cashBoxId$: Observable constructor(private activatedRoute: ActivatedRoute) {} ngOnInit(): void { - this.cashBoxId$ = this.activatedRoute.parent.data.pipe( - map((data) => data.cashBox.id) - ); + this.cashBoxId$ = this.activatedRoute.parent?.data.pipe(map((data) => data.cashBox.id)) as Observable } } diff --git a/frontend/src/app/cash-box/components/cash-box-report/cash-box-report.component.ts b/frontend/src/app/cash-box/components/cash-box-report/cash-box-report.component.ts index eb254489..87981643 100644 --- a/frontend/src/app/cash-box/components/cash-box-report/cash-box-report.component.ts +++ b/frontend/src/app/cash-box/components/cash-box-report/cash-box-report.component.ts @@ -1,23 +1,26 @@ -import { Component, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; -import { ActivatedRoute } from '@angular/router'; -import { map } from 'rxjs/operators'; -import { Store } from '@ngrx/store'; -import * as fromApp from '../../../store/app.reducer'; -import { selectActiveBudgetPlan } from '../../../budget-plan/store/budget-plan.selectors'; +import { Component, OnInit } from '@angular/core' +import { Observable } from 'rxjs' +import { ActivatedRoute } from '@angular/router' +import { map } from 'rxjs/operators' +import { Store } from '@ngrx/store' +import * as fromApp from '../../../store/app.reducer' +import { selectActiveBudgetPlan } from '../../../budget-plan/store/budget-plan.selectors' @Component({ selector: 'app-cash-box-report', template: '', }) export class CashBoxReportComponent implements OnInit { - cashBoxId$: Observable; - budgetPlanId$: Observable; + cashBoxId$: Observable + budgetPlanId$: Observable - constructor(private activatedRoute: ActivatedRoute, private store: Store) {} + constructor( + private activatedRoute: ActivatedRoute, + private store: Store, + ) {} ngOnInit(): void { - this.cashBoxId$ = this.activatedRoute.parent.data.pipe(map((data) => data.cashBox.id)); - this.budgetPlanId$ = this.store.select(selectActiveBudgetPlan).pipe(map((plan) => plan.id)); + this.cashBoxId$ = this.activatedRoute.parent?.data.pipe(map((data) => data.cashBox.id)) as Observable + this.budgetPlanId$ = this.store.select(selectActiveBudgetPlan).pipe(map((plan) => plan?.id)) as Observable } } diff --git a/frontend/src/app/cash-box/components/cash-box-settings/cash-box-settings.component.ts b/frontend/src/app/cash-box/components/cash-box-settings/cash-box-settings.component.ts index 4adb2957..d6de8b72 100644 --- a/frontend/src/app/cash-box/components/cash-box-settings/cash-box-settings.component.ts +++ b/frontend/src/app/cash-box/components/cash-box-settings/cash-box-settings.component.ts @@ -1,19 +1,19 @@ -import { Component, OnInit } from '@angular/core'; -import { Store } from '@ngrx/store'; -import * as fromApp from '../../../store/app.reducer'; -import { map, take } from 'rxjs/operators'; -import { ActivatedRoute } from '@angular/router'; -import { CashBoxSettings, PredefinedDescription } from '../../../model/cash-box.model'; -import { combineLatest, Observable } from 'rxjs'; -import { MatChipInputEvent } from '@angular/material/chips'; -import { COMMA, ENTER } from '@angular/cdk/keycodes'; -import { LoadingState } from '../../../store/state'; +import { Component, OnInit } from '@angular/core' +import { Store } from '@ngrx/store' +import * as fromApp from '../../../store/app.reducer' +import { map, take } from 'rxjs/operators' +import { ActivatedRoute } from '@angular/router' +import { CashBoxSettings, PredefinedDescription } from '../../../model/cash-box.model' +import { combineLatest, Observable } from 'rxjs' +import { MatChipInputEvent } from '@angular/material/chips' +import { COMMA, ENTER } from '@angular/cdk/keycodes' +import { LoadingState } from '../../../store/state' import { addCashBoxDescription, loadCashBoxSettings, removeCashBoxDescription, -} from '../../store/cash-box-settings/cash-box-settings.actions'; -import { selectCashBoxSettings } from '../../store/cash-box-settings/cash-box-settings.selectors'; +} from '../../store/cash-box-settings/cash-box-settings.actions' +import { selectCashBoxSettings } from '../../store/cash-box-settings/cash-box-settings.selectors' @Component({ selector: 'app-cash-box-settings', @@ -21,27 +21,30 @@ import { selectCashBoxSettings } from '../../store/cash-box-settings/cash-box-se styleUrls: ['./cash-box-settings.component.scss'], }) export class CashBoxSettingsComponent implements OnInit { - readonly separatorKeysCodes: number[] = [ENTER, COMMA]; + readonly separatorKeysCodes: number[] = [ENTER, COMMA] - settings$: Observable; - isLoading$: Observable; + settings$: Observable + isLoading$: Observable - private cashBoxId$: Observable; + private cashBoxId$: Observable - constructor(private store: Store, private activatedRoute: ActivatedRoute) {} + constructor( + private store: Store, + private activatedRoute: ActivatedRoute, + ) {} ngOnInit(): void { - this.cashBoxId$ = this.activatedRoute.parent.data.pipe(map((data) => data.cashBox.id)); + this.cashBoxId$ = this.activatedRoute.parent?.data.pipe(map((data) => data.cashBox.id)) as Observable this.settings$ = combineLatest([this.cashBoxId$, this.store.select(selectCashBoxSettings)]).pipe( - map(([id, state]) => state.settings[id]) - ); + map(([id, state]) => state.settings[id]), + ) - this.isLoading$ = this.store.select(selectCashBoxSettings).pipe(map((state) => state.loadCashBoxSettingState === LoadingState.LOADING)); + this.isLoading$ = this.store.select(selectCashBoxSettings).pipe(map((state) => state.loadCashBoxSettingState === LoadingState.LOADING)) this.cashBoxId$.pipe(take(1)).subscribe((cashBoxId) => { - this.store.dispatch(loadCashBoxSettings({ cashBoxId })); - }); + this.store.dispatch(loadCashBoxSettings({ cashBoxId })) + }) } remove(description: PredefinedDescription): void { @@ -50,16 +53,16 @@ export class CashBoxSettingsComponent implements OnInit { removeCashBoxDescription({ cashBoxId: id, descriptionId: description.id, - }) - ); - }); + }), + ) + }) } add($event: MatChipInputEvent): void { - const input = $event.input; + const input = $event.input if (!input.value) { - return; + return } this.cashBoxId$.pipe(take(1)).subscribe((id) => { @@ -67,12 +70,12 @@ export class CashBoxSettingsComponent implements OnInit { addCashBoxDescription({ cashBoxId: id, value: input.value.trim(), - }) - ); + }), + ) if (input) { - input.value = ''; + input.value = '' } - }); + }) } } diff --git a/frontend/src/app/cash-box/components/cash-box-view/cash-box-view.component.spec.ts b/frontend/src/app/cash-box/components/cash-box-view/cash-box-view.component.spec.ts index 39a3b3f6..ea680625 100644 --- a/frontend/src/app/cash-box/components/cash-box-view/cash-box-view.component.spec.ts +++ b/frontend/src/app/cash-box/components/cash-box-view/cash-box-view.component.spec.ts @@ -1,13 +1,13 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { CashBoxViewComponent } from './cash-box-view.component'; -import { provideMockStore } from '@ngrx/store/testing'; -import { ActivatedRoute } from '@angular/router'; -import { CashBox } from '../../../model/cash-box.model'; -import { BudgetPlan } from '../../../model/budget-plan.model'; -import { Component, Input } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing' +import { CashBoxViewComponent } from './cash-box-view.component' +import { provideMockStore } from '@ngrx/store/testing' +import { ActivatedRoute } from '@angular/router' +import { CashBox } from '../../../model/cash-box.model' +import { BudgetPlan } from '../../../model/budget-plan.model' +import { Component, Input } from '@angular/core' -const expectedCashBox = mockCashBox(3); -const expectedBudgetPlan = mockBudgetPlan(2); +const expectedCashBox = mockCashBox(3) +const expectedBudgetPlan = mockBudgetPlan(2) const initialState = { cashBoxes: { cashBoxes: [mockCashBox(1), mockCashBox(2), expectedCashBox, mockCashBox(4)], @@ -17,59 +17,65 @@ const initialState = { budgetPlans: [mockBudgetPlan(1), expectedBudgetPlan, mockBudgetPlan(3)], activeBudgetPlanId: 2, }, -}; +} @Component({ selector: 'app-cash-box-budget-plan-view', template: ``, }) class MockBudgetPlanViewComponent { - @Input() budgetPlan: BudgetPlan; - @Input() cashBoxId: number; + @Input() budgetPlan: BudgetPlan + @Input() cashBoxId: number } describe('CashBoxViewComponent', () => { - let component: CashBoxViewComponent; - let fixture: ComponentFixture; + let component: CashBoxViewComponent + let fixture: ComponentFixture - beforeEach( - waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [CashBoxViewComponent, MockBudgetPlanViewComponent], - providers: [provideMockStore({ initialState }), { provide: ActivatedRoute, useValue: {} }], - }).compileComponents(); - }) - ); + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [CashBoxViewComponent, MockBudgetPlanViewComponent], + providers: [provideMockStore({ initialState }), { provide: ActivatedRoute, useValue: {} }], + }).compileComponents() + })) beforeEach(() => { - fixture = TestBed.createComponent(CashBoxViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); + fixture = TestBed.createComponent(CashBoxViewComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) it('should be created', () => { - expect(component).toBeDefined(); - }); + expect(component).toBeDefined() + }) it('should initialize the correct cash box from the store', (done) => { component.cashBox$.subscribe((cashBox) => { - expect(cashBox).toBe(expectedCashBox); - done(); - }); - }); + expect(cashBox).toBe(expectedCashBox) + done() + }) + }) it('should initialize the correct budget plan from the store', (done) => { component.budgetPlan$.subscribe((budgetPlan) => { - expect(budgetPlan).toBe(expectedBudgetPlan); - done(); - }); - }); -}); + expect(budgetPlan).toBe(expectedBudgetPlan) + done() + }) + }) +}) function mockCashBox(id: number): CashBox { - return { id, name: `Test ${id}` }; + return { id, name: `Test ${id}` } } function mockBudgetPlan(id: number): BudgetPlan { - return { id, name: `Test ${id}`, start_date: null, end_date: null, budget: 1, entries: [], closed: false }; + return { + id, + name: `Test ${id}`, + start_date: '', + end_date: 'null', + budget: 1, + entries: [], + closed: false, + } } diff --git a/frontend/src/app/cash-box/components/cash-box-view/cash-box-view.component.ts b/frontend/src/app/cash-box/components/cash-box-view/cash-box-view.component.ts index 3deb48dd..6fbf4be9 100644 --- a/frontend/src/app/cash-box/components/cash-box-view/cash-box-view.component.ts +++ b/frontend/src/app/cash-box/components/cash-box-view/cash-box-view.component.ts @@ -1,12 +1,12 @@ -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { CashBox } from '../../../model/cash-box.model'; -import { Store } from '@ngrx/store'; -import * as fromApp from '../../../store/app.reducer'; -import { Observable } from 'rxjs'; -import { BudgetPlan } from '../../../model/budget-plan.model'; -import { selectActiveBudgetPlan } from '../../../budget-plan/store/budget-plan.selectors'; -import { map } from 'rxjs/operators'; +import { Component, OnInit } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { CashBox } from '../../../model/cash-box.model' +import { Store } from '@ngrx/store' +import * as fromApp from '../../../store/app.reducer' +import { Observable } from 'rxjs' +import { BudgetPlan } from '../../../model/budget-plan.model' +import { selectActiveBudgetPlan } from '../../../budget-plan/store/budget-plan.selectors' +import { map } from 'rxjs/operators' @Component({ selector: 'app-cash-box-view', @@ -14,13 +14,16 @@ import { map } from 'rxjs/operators'; styleUrls: ['./cash-box-view.component.scss'], }) export class CashBoxViewComponent implements OnInit { - cashBox$: Observable; - budgetPlan$: Observable; + cashBox$: Observable + budgetPlan$: Observable - constructor(private activatedRoute: ActivatedRoute, private store: Store) {} + constructor( + private activatedRoute: ActivatedRoute, + private store: Store, + ) {} ngOnInit(): void { - this.cashBox$ = this.store.select('cashBoxes').pipe(map((state) => state.cashBoxes.find((box) => box.id === state.selectedCashBoxId))); - this.budgetPlan$ = this.store.select(selectActiveBudgetPlan); + this.cashBox$ = this.store.select('cashBoxes').pipe(map((state) => state.cashBoxes.find((box) => box.id === state.selectedCashBoxId))) + this.budgetPlan$ = this.store.select(selectActiveBudgetPlan) } } diff --git a/frontend/src/app/cash-box/services/cash-box.resolver.ts b/frontend/src/app/cash-box/services/cash-box.resolver.ts index a023cc33..d75b7558 100644 --- a/frontend/src/app/cash-box/services/cash-box.resolver.ts +++ b/frontend/src/app/cash-box/services/cash-box.resolver.ts @@ -24,7 +24,7 @@ export class CashBoxResolver { ) {} resolve(route: ActivatedRouteSnapshot, _: RouterStateSnapshot): Observable | Promise | CashBox { - const id = route.paramMap.get('id') + const id = route.paramMap.get('id') as string this.store.dispatch(CashBoxActions.setSelectedCashBox({ cashBoxId: +id })) this.store.dispatch(loadActiveBudgetPlan({ cashBoxId: +id })) @@ -43,7 +43,11 @@ export class CashBoxResolver { ofType(CashBoxAction.loadCashBoxesSuccess), take(1), map((result) => { - return result.cashBoxes.find((c) => c.id === +id) + let cashBox = result.cashBoxes.find((c) => c.id === +id) + if (!cashBox) { + throw new Error('Cash box not found') + } + return cashBox }), ) } diff --git a/frontend/src/app/cash-box/store/cash-boxes/cash-box.reducer.ts b/frontend/src/app/cash-box/store/cash-boxes/cash-box.reducer.ts index 71f19b5a..43d9f4a5 100644 --- a/frontend/src/app/cash-box/store/cash-boxes/cash-box.reducer.ts +++ b/frontend/src/app/cash-box/store/cash-boxes/cash-box.reducer.ts @@ -1,41 +1,40 @@ -import { CashBox } from '../../../model/cash-box.model'; -import { Action, createReducer, on } from '@ngrx/store'; -import * as CashBoxAction from './cash-box.actions'; -import { CallState, LoadingState } from '../../../store/state'; +import { CashBox } from '../../../model/cash-box.model' +import { Action, createReducer, on } from '@ngrx/store' +import * as CashBoxAction from './cash-box.actions' +import { CallState, LoadingState } from '../../../store/state' export interface State { - cashBoxes: CashBox[]; - loadCashBoxState: CallState; - selectedCashBoxId: number; + cashBoxes: CashBox[] + loadCashBoxState: CallState + selectedCashBoxId?: number } const initialState: State = { cashBoxes: [], loadCashBoxState: LoadingState.INIT, - selectedCashBoxId: null, -}; +} const cashBoxReducer = createReducer( initialState, on(CashBoxAction.loadCashBoxes, (state) => { - return { ...state, loadCashBoxState: LoadingState.LOADING }; + return { ...state, loadCashBoxState: LoadingState.LOADING } }), on(CashBoxAction.loadCashBoxesSuccess, (state, { cashBoxes }) => { - return { ...state, cashBoxes, loadCashBoxState: LoadingState.LOADED }; + return { ...state, cashBoxes, loadCashBoxState: LoadingState.LOADED } }), on(CashBoxAction.loadCashBoxesFail, (state, { error }) => { - return { ...state, loadCashBoxState: { errorMsg: 'Failed to load cash boxes' } }; + return { ...state, loadCashBoxState: { errorMsg: 'Failed to load cash boxes' } } }), on(CashBoxAction.addCashBox, CashBoxAction.updateCashBox, CashBoxAction.deleteCashBox, (state) => { - return { ...state, loadCashBoxState: LoadingState.LOADING }; + return { ...state, loadCashBoxState: LoadingState.LOADING } }), on(CashBoxAction.updateCashBoxFail, (state) => { - return { ...state, loadCashBoxState: { errorMsg: 'Failed to update cash boxes' } }; + return { ...state, loadCashBoxState: { errorMsg: 'Failed to update cash boxes' } } }), on(CashBoxAction.setSelectedCashBox, (state, { cashBoxId }) => { - return { ...state, selectedCashBoxId: cashBoxId }; - }) -); + return { ...state, selectedCashBoxId: cashBoxId } + }), +) export function reducer(state: State | undefined, action: Action): State { - return cashBoxReducer(state, action); + return cashBoxReducer(state, action) } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 5da3dcfd..3636579b 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -16,6 +16,7 @@ "es2018", "dom" ], - "useDefineForClassFields": false + "useDefineForClassFields": false, + "strictNullChecks": true } }