From fa38cde693696ca80923260457f3e2dcd3eba6e1 Mon Sep 17 00:00:00 2001 From: Pablo Fraile Alonso Date: Mon, 13 Nov 2023 18:37:51 +0100 Subject: [PATCH] feat: inital implementation of reservations ngrx --- app/src/app/app.module.ts | 4 + .../reservation-picker.component.ts | 120 +++-------------- .../pages/reservations/reservations.page.html | 10 +- .../pages/reservations/reservations.page.ts | 124 ++---------------- .../reservations/create-reservation.ts | 8 +- .../reservations/delete-reservation.ts | 8 +- .../app/schemas/reservations/reservation.ts | 2 + .../reservations/reservations.service.ts | 8 +- .../reservations/user-reservation.service.ts | 2 +- app/src/app/state/app.state.ts | 2 + .../reservations/reservations.actions.ts | 43 ++++++ .../reservations/reservations.effects.ts | 114 ++++++++++++++++ .../reservations/reservations.reducer.ts | 77 +++++++++++ .../reservations/reservations.selectors.ts | 19 +++ app/src/environments/environment.ts | 2 +- 15 files changed, 311 insertions(+), 232 deletions(-) create mode 100644 app/src/app/state/reservations/reservations.actions.ts create mode 100644 app/src/app/state/reservations/reservations.effects.ts create mode 100644 app/src/app/state/reservations/reservations.reducer.ts create mode 100644 app/src/app/state/reservations/reservations.selectors.ts diff --git a/app/src/app/app.module.ts b/app/src/app/app.module.ts index 0a257e9..57efbb1 100644 --- a/app/src/app/app.module.ts +++ b/app/src/app/app.module.ts @@ -31,6 +31,8 @@ import { mapReducer } from './state/map/map.reducer'; import { MapEffects } from './state/map/map.effects'; import { searchReducer } from './state/components/search/search.reducer'; import { SearchEffects } from './state/components/search/search.effects'; +import { ReservationsEffects } from './state/reservations/reservations.effects'; +import { reservationsReducer } from './state/reservations/reservations.reducer'; export function createTranslateLoader(http: HttpClient) { return new TranslateHttpLoader(http, './assets/i18n/', '.json'); @@ -59,6 +61,7 @@ export function createTranslateLoader(http: HttpClient) { map: mapReducer, modal: modalReducer, searchCompletion: searchReducer, + reservations: reservationsReducer, }, { // This is because inmutability objects on actions (and google maps libraries and capacitor change the object from the action) @@ -83,6 +86,7 @@ export function createTranslateLoader(http: HttpClient) { LanguageEffects, MapEffects, SearchEffects, + ReservationsEffects, ]), ], providers: [ diff --git a/app/src/app/components/reservation-picker/reservation-picker.component.ts b/app/src/app/components/reservation-picker/reservation-picker.component.ts index 9f46d16..953a49c 100644 --- a/app/src/app/components/reservation-picker/reservation-picker.component.ts +++ b/app/src/app/components/reservation-picker/reservation-picker.component.ts @@ -1,17 +1,13 @@ import { Component, Input, OnInit } from '@angular/core'; import { fromISOString } from '../../schemas/night/night'; -import { - CreateReservation, - CreateReservationError, -} from '../../schemas/reservations/create-reservation'; -import { match } from 'ts-pattern'; -import { Reservation } from '../../schemas/reservations/reservation'; -import { Color } from '@ionic/core'; import { AuthService } from '../../services/auth/auth.service'; -import { ReservationsService } from '../../services/reservations/reservations.service'; import { Refuge } from '../../schemas/refuge/refuge'; import { ToastController } from '@ionic/angular'; import { TranslateService } from '@ngx-translate/core'; +import { AppState } from 'src/app/state/app.state'; +import { Store } from '@ngrx/store'; +import { addReservation } from '../../state/reservations/reservations.actions'; +import { getCreateReservationErrors } from '../../state/reservations/reservations.selectors'; @Component({ selector: 'app-reservation-picker', @@ -22,111 +18,27 @@ export class ReservationPickerComponent implements OnInit { @Input({ required: true }) refuge!: Refuge; date = ''; - constructor( - private authService: AuthService, - private toastController: ToastController, - private translateService: TranslateService, - private reservationService: ReservationsService, - ) {} + createErrors = this.store.select(getCreateReservationErrors); + + constructor(private store: Store) { + this.createErrors.subscribe((errors) => { + console.log(errors); + }); + } ngOnInit() {} onBookClick() { const night = fromISOString(this.date); - this.authService.getUserId().then((userId) => { - if (userId === null) return; - if (this.refuge === undefined) return; - this.reservationService - .createReservation(userId, this.refuge.id, night) - .subscribe({ - next: (response) => { - this.handleCreateReservationResponse(response); - }, - error: () => { - this.handleClientErrorOnCreateReservation(); - }, - }); - }); + const reservation = { + refuge_id: this.refuge.id, + night, + }; + this.store.dispatch(addReservation({ reservation })); } getCurrentDate(): string { const date = new Date(); return date.toISOString(); } - - private handleCreateReservationResponse(response: CreateReservation) { - match(response) - .with({ status: 'ok' }, (response) => { - this.handleCorrectCreateReservation(response.reservation); - }) - .with({ status: 'error' }, (response) => { - this.handleCreateReservationError(response.error); - }) - .exhaustive(); - } - - private handleCorrectCreateReservation(reservation: Reservation) { - this.showToast( - 'RESERVATIONS.CREATE_OPERATION.CORRECT', - { day: reservation.night.day }, - 'success', - ); - } - - private handleCreateReservationError(error: CreateReservationError) { - match(error) - .with(CreateReservationError.NOT_AUTHENTICATED_OR_INVALID_DATE, () => { - this.showToast( - 'RESERVATIONS.CREATE_OPERATION.NOT_AUTHENTICATED_OR_INVALID_DATE', - {}, - 'danger', - ); - }) - .with(CreateReservationError.NOT_ALLOWED_CREATION_FOR_USER, () => { - this.showToast( - 'RESERVATIONS.CREATE_OPERATION.NOT_ALLOWED_CREATION_FOR_USER', - {}, - 'danger', - ); - }) - .with(CreateReservationError.PROGRAMMING_ERROR, () => { - this.showToast( - 'RESERVATIONS.CREATE_OPERATION.PROGRAMMING_ERROR', - {}, - 'danger', - ); - }) - .with(CreateReservationError.REFUGE_OR_USER_NOT_FOUND, () => { - this.showToast( - 'RESERVATIONS.CREATE_OPERATION.REFUGE_OR_USER_NOT_FOUND', - {}, - 'danger', - ); - }) - .with( - CreateReservationError.SERVER_ERROR, - CreateReservationError.UNKNOWN_ERROR, - CreateReservationError.NTP_SERVER_IS_DOWN, - () => { - this.showToast( - 'RESERVATIONS.CREATE_OPERATION.SERVER_ERROR', - {}, - 'danger', - ); - }, - ) - .exhaustive(); - } - - private showToast(messageKey: string, props: any, color: Color) { - this.toastController - .create({ - message: this.translateService.instant(messageKey, props), - duration: 2000, - color: color, - }) - .then((toast) => toast.present()); - } - - private handleClientErrorOnCreateReservation() {} } diff --git a/app/src/app/pages/reservations/reservations.page.html b/app/src/app/pages/reservations/reservations.page.html index 8a1c755..440430e 100644 --- a/app/src/app/pages/reservations/reservations.page.html +++ b/app/src/app/pages/reservations/reservations.page.html @@ -7,7 +7,7 @@ - + @@ -16,13 +16,13 @@

- {{'RESERVATIONS.DETAILS.HEADER' | translate: {day: - reservation.night.day} }} + {{'RESERVATIONS.DETAILS.HEADER' | translate: { day: + reservation.night.day } }}

- {{'RESERVATIONS.DETAILS.MESSAGE' | translate: {month: + {{'RESERVATIONS.DETAILS.MESSAGE' | translate: { month: reservation.night.month, year: reservation.night.year, day: - reservation.night.day} }} + reservation.night.day } }}

; + reservations = this.store.select(getReservationsSortedByRefuge); onRemoveReservation(reservation: ReservationWithId) { this.showDeleteReservationMessage(reservation, () => { @@ -30,71 +22,13 @@ export class ReservationsPage implements OnInit { } constructor( - userReservationService: UserReservationService, - private reservationService: ReservationsService, - private authService: AuthService, - private router: Router, + private store: Store, private alertController: AlertController, - private toastController: ToastController, private translateService: TranslateService, - ) { - this.authService.getUserId().then((userId) => { - if (userId == null) { - console.log('TODO: handle user not logged in'); - return; - } - this.reservations = - userReservationService.getReservationsGroupedByRefugeForUser(userId); - }); - } + ) {} private removeReservation(reservation: ReservationWithId) { - this.reservationService.deleteReservation(reservation.id).subscribe({ - next: (response) => this.handleDeleteResponse(response), - error: () => this.handleConnectionOrServerDown().then(), - }); - } - - private handleDeleteResponse(response: DeleteReservation) { - match(response) - .with({ status: 'ok' }, () => this.showReservationDeletedMessage()) - .with({ status: 'error' }, (error) => this.handleError(error)) - .exhaustive(); - } - - private handleError(response: ErrorDeleteReservation) { - match(response.error) - .with(DeleteReservationError.RESERVATION_NOT_FOUND, () => - this.handleReservationNotFound(), - ) - .with( - DeleteReservationError.NOT_AUTHENTICATED, - DeleteReservationError.PROGRAMMING_ERROR, - DeleteReservationError.SERVER_ERROR, - DeleteReservationError.NOT_ALLOWED_DELETION_FOR_USER, - () => this.handleProgrammingErrors(), - ) - .with(DeleteReservationError.UNKNOWN_ERROR, () => - this.handleUnknownError(), - ) - .exhaustive(); - } - - private async handleConnectionOrServerDown() { - const alert = await this.alertController.create({ - header: this.translateService.instant('HOME.CLIENT_ERROR.HEADER'), - subHeader: this.translateService.instant('HOME.CLIENT_ERROR.SUBHEADER'), - message: this.translateService.instant('HOME.CLIENT_ERROR.MESSAGE'), - buttons: [ - { - text: this.translateService.instant('HOME.CLIENT_ERROR.OKAY_BUTTON'), - handler: () => { - this.alertController.dismiss().then(); - }, - }, - ], - }); - return await alert.present(); + this.store.dispatch(deleteReservation({ id: reservation.id })); } private async showDeleteReservationMessage( @@ -125,45 +59,5 @@ export class ReservationsPage implements OnInit { return await alert.present(); } - private showReservationDeletedMessage() { - this.toastController - .create({ - message: this.translateService.instant( - 'RESERVATIONS.DELETE_OPERATION.CORRECT', - ), - duration: 2000, - color: 'success', - }) - .then((toast) => toast.present()); - } - - private handleReservationNotFound() { - this.toastController - .create({ - message: this.translateService.instant( - 'RESERVATIONS.DELETE_OPERATION.NOT_FOUND', - ), - duration: 2000, - color: 'danger', - }) - .then((toast) => toast.present()); - } - ngOnInit() {} - - private handleProgrammingErrors() { - this.router - .navigate(['programming-error'], { - skipLocationChange: true, - }) - .then(); - } - - private handleUnknownError() { - this.router - .navigate(['internal-error-page'], { - skipLocationChange: true, - }) - .then(); - } } diff --git a/app/src/app/schemas/reservations/create-reservation.ts b/app/src/app/schemas/reservations/create-reservation.ts index e3a909e..d311cbf 100644 --- a/app/src/app/schemas/reservations/create-reservation.ts +++ b/app/src/app/schemas/reservations/create-reservation.ts @@ -1,6 +1,10 @@ import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http'; import { isMatching, match } from 'ts-pattern'; -import { Reservation, ReservationPattern } from './reservation'; +import { + Reservation, + ReservationPattern, + ReservationWithId, +} from './reservation'; export enum CreateReservationError { REFUGE_OR_USER_NOT_FOUND = 'REFUGE_OR_USER_NOT_FOUND', @@ -46,7 +50,7 @@ export namespace CreateReservationError { export type CorrectCreateReservation = { status: 'ok'; - reservation: Reservation; + reservation: ReservationWithId; }; export type ErrorCreateReservation = { diff --git a/app/src/app/schemas/reservations/delete-reservation.ts b/app/src/app/schemas/reservations/delete-reservation.ts index 2439cd5..bb339ea 100644 --- a/app/src/app/schemas/reservations/delete-reservation.ts +++ b/app/src/app/schemas/reservations/delete-reservation.ts @@ -1,6 +1,10 @@ import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http'; import { isMatching, match } from 'ts-pattern'; -import { Reservation, ReservationPattern } from './reservation'; +import { + Reservation, + ReservationPattern, + ReservationWithId, +} from './reservation'; export enum DeleteReservationError { RESERVATION_NOT_FOUND = 'RESERVATION_NOT_FOUND', @@ -41,7 +45,7 @@ export namespace DeleteReservationError { export type CorrectDeleteReservation = { status: 'ok'; - reservation: Reservation; + reservation: ReservationWithId; }; export type ErrorDeleteReservation = { diff --git a/app/src/app/schemas/reservations/reservation.ts b/app/src/app/schemas/reservations/reservation.ts index 0bfd25d..67ed988 100644 --- a/app/src/app/schemas/reservations/reservation.ts +++ b/app/src/app/schemas/reservations/reservation.ts @@ -7,6 +7,8 @@ export type Reservation = { night: Night; }; +export type ReservationWithoutUserId = Omit; + export type ReservationWithId = Reservation & { id: string }; export type Reservations = ReservationWithId[]; diff --git a/app/src/app/services/reservations/reservations.service.ts b/app/src/app/services/reservations/reservations.service.ts index f9cb77e..1ee8524 100644 --- a/app/src/app/services/reservations/reservations.service.ts +++ b/app/src/app/services/reservations/reservations.service.ts @@ -19,8 +19,8 @@ import { import { Night } from '../../schemas/night/night'; import { CreateReservation, - fromResponse as fromCreateResponse, fromError as fromCreateError, + fromResponse as fromCreateResponse, } from '../../schemas/reservations/create-reservation'; @Injectable({ @@ -47,7 +47,7 @@ export class ReservationsService { ); } - createReservation( + createReservationFromPrimitives( userId: string, refugeId: string, night: Night, @@ -57,6 +57,10 @@ export class ReservationsService { refuge_id: refugeId, night, }; + return this.createReservation(reservation); + } + + createReservation(reservation: Reservation): Observable { const createReservation = this.getReservationsUri(); return this.http .post(createReservation, reservation) diff --git a/app/src/app/services/reservations/user-reservation.service.ts b/app/src/app/services/reservations/user-reservation.service.ts index c22e86f..d477368 100644 --- a/app/src/app/services/reservations/user-reservation.service.ts +++ b/app/src/app/services/reservations/user-reservation.service.ts @@ -61,7 +61,7 @@ export class UserReservationService { .pipe(share()); } - private getReservationsForUserFromCurrentDate( + getReservationsForUserFromCurrentDate( userId: string, ): Observable { const reservationsWithErrors = this.getReservationsWithErrorsFor(userId); diff --git a/app/src/app/state/app.state.ts b/app/src/app/state/app.state.ts index 2bcf3b3..47965fe 100644 --- a/app/src/app/state/app.state.ts +++ b/app/src/app/state/app.state.ts @@ -5,6 +5,7 @@ import { InitializerStatus } from './init/init.reducer'; import { ModalState } from './components/modal/modal.reducer'; import { MapStatus } from './map/map.reducer'; import { SearchState } from './components/search/search.reducer'; +import { ReservationsState } from './reservations/reservations.reducer'; export interface AppState { auth: AuthState; @@ -14,4 +15,5 @@ export interface AppState { map: MapStatus; modal: ModalState; searchCompletion: SearchState; + reservations: ReservationsState; } diff --git a/app/src/app/state/reservations/reservations.actions.ts b/app/src/app/state/reservations/reservations.actions.ts new file mode 100644 index 0000000..31caa1c --- /dev/null +++ b/app/src/app/state/reservations/reservations.actions.ts @@ -0,0 +1,43 @@ +import { createAction, props } from '@ngrx/store'; +import { + ReservationWithId, + ReservationWithoutUserId, +} from '../../schemas/reservations/reservation'; +import { RefugeReservationsRelations } from '../../services/reservations/grouped-by/refuge'; + +export const addReservation = createAction( + '[Reservations] Add Reservation', + props<{ reservation: ReservationWithoutUserId }>(), +); + +export const addedReservation = createAction( + '[Reservations] Added Reservation', + props<{ reservation: ReservationWithId }>(), +); + +export const fetchReservations = createAction( + '[Reservations] Fetched Reservations', + props<{ reservations: RefugeReservationsRelations }>(), +); + +export const errorAddingReservation = createAction( + '[Reservations] Error Adding Reservation', + // TODO: This error shouldn't be any + props<{ error: any }>(), +); + +export const deleteReservation = createAction( + '[Reservations] Delete Reservation', + props<{ id: string }>(), +); + +export const deletedReservation = createAction( + '[Reservations] Deleted Reservation', + props<{ reservation: ReservationWithId }>(), +); + +export const errorDeletingReservation = createAction( + '[Reservations] Error Deleting Reservation', + // TODO: This error shouldn't be any + props<{ error: any }>(), +); diff --git a/app/src/app/state/reservations/reservations.effects.ts b/app/src/app/state/reservations/reservations.effects.ts new file mode 100644 index 0000000..7a89d67 --- /dev/null +++ b/app/src/app/state/reservations/reservations.effects.ts @@ -0,0 +1,114 @@ +import { Injectable } from '@angular/core'; +import { + Actions, + createEffect, + ofType, + ROOT_EFFECTS_INIT, +} from '@ngrx/effects'; +import { combineLatest, map, mergeMap, switchMap, tap } from 'rxjs'; +import { loginCompleted } from '../auth/auth.actions'; +import { UserReservationService } from '../../services/reservations/user-reservation.service'; +import { + addedReservation, + addReservation, + deletedReservation, + deleteReservation, + errorAddingReservation, + errorDeletingReservation, + fetchReservations, +} from './reservations.actions'; +import { ReservationsService } from '../../services/reservations/reservations.service'; +import { orderByRefuge } from '../../services/reservations/grouped-by/refuge'; +import { RefugeService } from '../../services/refuge/refuge.service'; + +@Injectable() +export class ReservationsEffects { + reservationFactory = (refugeId: string) => + this.refugeService.getRefugeIgnoringErrorsFrom(refugeId); + + constructor( + private actions$: Actions, + private userReservationService: UserReservationService, + private reservationService: ReservationsService, + private refugeService: RefugeService, + ) {} + + fetchReservationsOnLoggedUser$ = createEffect(() => + combineLatest([ + this.actions$.pipe(ofType(loginCompleted)), + this.actions$.pipe(ofType(ROOT_EFFECTS_INIT)), + ]).pipe( + switchMap((actions) => + // TODO: this is doing it every 3 seconds, maybe we shouldn't pull? + this.userReservationService + .getReservationsGroupedByRefugeForUser(actions[0].userId) + .pipe( + map((reservations) => { + return fetchReservations({ reservations }); + }), + ), + ), + ), + ); + + deleteReservationsOnDelete$ = createEffect(() => + combineLatest([ + this.actions$.pipe(ofType(loginCompleted)), + this.actions$.pipe(ofType(deleteReservation)), + ]).pipe( + switchMap((action) => + this.reservationService.deleteReservation(action[1].id).pipe( + map((reservations) => { + if (reservations.status == 'ok') + return deletedReservation({ + reservation: reservations.reservation, + }); + return errorDeletingReservation({ error: reservations.error }); + }), + ), + ), + ), + ); + + addReservation$ = createEffect(() => + combineLatest([ + this.actions$.pipe(ofType(loginCompleted)), + this.actions$.pipe(ofType(addReservation)), + ]).pipe( + switchMap((actions) => + this.reservationService + .createReservation({ + user_id: actions[0].userId, + ...actions[1].reservation, + }) + .pipe( + map((reservations) => { + if (reservations.status == 'ok') + return addedReservation({ + reservation: reservations.reservation, + }); + return errorAddingReservation({ error: reservations.error }); + }), + ), + ), + ), + ); + + fetchReservationsAgainAfterAdd$ = createEffect(() => + combineLatest([ + this.actions$.pipe(ofType(loginCompleted)), + this.actions$.pipe(ofType(addedReservation)), + ]).pipe( + switchMap((actions) => + this.userReservationService + .getReservationsForUserFromCurrentDate(actions[0].userId) + .pipe( + mergeMap((reservations) => + orderByRefuge(reservations, this.reservationFactory), + ), + map((reservations) => fetchReservations({ reservations })), + ), + ), + ), + ); +} diff --git a/app/src/app/state/reservations/reservations.reducer.ts b/app/src/app/state/reservations/reservations.reducer.ts new file mode 100644 index 0000000..26527d2 --- /dev/null +++ b/app/src/app/state/reservations/reservations.reducer.ts @@ -0,0 +1,77 @@ +import { createReducer, on } from '@ngrx/store'; +import { + addedReservation, + addReservation, + deletedReservation, + deleteReservation, + errorAddingReservation, + errorDeletingReservation, + fetchReservations, +} from './reservations.actions'; +import { RefugeReservationsRelations } from '../../services/reservations/grouped-by/refuge'; + +export type ReservationsState = { + reservations: RefugeReservationsRelations; + createError?: any; + deleteError?: any; + isLoading: boolean; +}; + +export const reservationState = { + reservations: [], + isLoading: false, +} as ReservationsState; + +export const reservationsReducer = createReducer( + reservationState, + on(deleteReservation, (state, action) => ({ + ...state, + deleteError: undefined, + isLoading: true, + })), + on(fetchReservations, (state, action) => ({ + ...state, + reservations: action.reservations, + })), + on(addReservation, (state, action) => ({ + ...state, + createError: undefined, + isLoading: true, + })), + on(addedReservation, (state, action) => ({ + ...state, + isLoading: false, + })), + on(deletedReservation, (state, action) => ({ + ...state, + isLoading: false, + reservations: removeReservationWithId( + action.reservation.id, + state.reservations, + ), + })), + on(errorAddingReservation, (state, action) => ({ + ...state, + isLoading: false, + createError: action.error, + })), + on(errorDeletingReservation, (state, action) => ({ + ...state, + isLoading: false, + deleteError: action.error, + })), +); + +function removeReservationWithId( + id: string, + reservations: RefugeReservationsRelations, +) { + return reservations.map((relation) => { + return { + ...relation, + reservations: relation.reservations.filter( + (reservation) => reservation.id !== id, + ), + }; + }); +} diff --git a/app/src/app/state/reservations/reservations.selectors.ts b/app/src/app/state/reservations/reservations.selectors.ts new file mode 100644 index 0000000..9de912d --- /dev/null +++ b/app/src/app/state/reservations/reservations.selectors.ts @@ -0,0 +1,19 @@ +import { createSelector } from '@ngrx/store'; +import { AppState } from '../app.state'; + +export const selectReservations = (state: AppState) => state.reservations; + +export const getReservationsSortedByRefuge = createSelector( + selectReservations, + (reservations) => reservations.reservations, +); + +export const getCreateReservationErrors = createSelector( + selectReservations, + (reservations) => reservations.createError, +); + +export const isLoadingReservations = createSelector( + selectReservations, + (reservations) => reservations.isLoading, +); diff --git a/app/src/environments/environment.ts b/app/src/environments/environment.ts index 93efacf..693bf06 100644 --- a/app/src/environments/environment.ts +++ b/app/src/environments/environment.ts @@ -4,7 +4,7 @@ export const environment = { production: false, - API: 'https://backend.refuapp.online', + API: 'http://localhost:8000', MAPS_FORCE_CREATE: true, SENSORS_API: 'http://localhost:8001', };