From d97d484fe000128f21c45bc59e0f2e32502af521 Mon Sep 17 00:00:00 2001 From: Gion-Andri Cantieni Date: Mon, 9 Oct 2023 15:23:31 +0200 Subject: [PATCH] feat: allow back navigation without loosing state --- README.md | 9 ++ src/app/app-routing.module.ts | 63 +++++---- src/app/app.component.html | 2 +- src/app/app.module.ts | 122 ++++++++++-------- .../events-list/events-list.component.ts | 33 +++-- .../routing/app-router-outlet.directive.ts | 39 ++++++ src/app/routing/router-reuse.strategy.ts | 88 +++++++++++++ 7 files changed, 262 insertions(+), 94 deletions(-) create mode 100644 src/app/routing/app-router-outlet.directive.ts create mode 100644 src/app/routing/router-reuse.strategy.ts diff --git a/README.md b/README.md index d7bda21..9f07903 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@ This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 16.1.0. +## Tecnical explanations + +### Back routing + +To restore the events in the events list after back routing, a router reuse strategy is defined. +For all the routes that contain a `reuseRouteKey` parameter in the route data, the `RouteReuseStrategy` will be used. `reuseRouteKey` should be unique. + +To have access to `onAttach` and `onDetach` lifecycle events, we implement our own router outlet. This router outlet will be used in the `app.component.html` file. + ## Development server Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index e106af4..b10967d 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,30 +1,30 @@ -import {NgModule} from '@angular/core'; -import {RouterModule, Routes} from '@angular/router'; -import {EventsListComponent} from "./pages/events/events-list/events-list.component"; -import {EventsDetailsComponent} from "./pages/events/events-details/events-details.component"; -import {LoginComponent} from "./pages/u/login/login.component"; -import {LogoutComponent} from "./pages/u/logout/logout.component"; -import {notAuthGuard} from "./routing/not-auth.guard"; -import {authGuard} from "./routing/auth.guard"; -import {NotFoundComponent} from "./pages/static/not-found/not-found.component"; -import {canMatchEventId} from "./routing/match-event-id.guard"; -import {HelpComponent} from "./pages/static/help/help.component"; -import {ContactComponent} from "./pages/static/contact/contact.component"; -import {OrganisationComponent} from "./pages/static/organisation/organisation.component"; -import {ImprintComponent} from "./pages/static/imprint/imprint.component"; -import {PrivacyComponent} from "./pages/static/privacy/privacy.component"; -import {EventsComponent} from "./pages/u/events/events.component"; -import {ForgotPasswordComponent} from "./pages/u/forgot-password/forgot-password.component"; -import {RegisterComponent} from "./pages/u/register/register.component"; -import {NewEventComponent} from "./pages/admin/new-event/new-event.component"; -import {MyEventsComponent} from "./pages/admin/my-events/my-events.component"; -import {MySubscriptionsComponent} from "./pages/admin/my-subscriptions/my-subscriptions.component"; -import {ProfileComponent} from "./pages/admin/profile/profile.component"; -import {ConfirmEmailComponent} from "./pages/u/confirm-email/confirm-email.component"; -import {ConfirmPasswordComponent} from "./pages/u/confirm-password/confirm-password.component"; -import {ChangePasswordComponent} from "./pages/admin/change-password/change-password.component"; -import {ModeratorEventsComponent} from "./pages/moderator/moderator-events/moderator-events.component"; -import {UsersComponent} from "./pages/administrator/users/users.component"; +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { EventsListComponent } from "./pages/events/events-list/events-list.component"; +import { EventsDetailsComponent } from "./pages/events/events-details/events-details.component"; +import { LoginComponent } from "./pages/u/login/login.component"; +import { LogoutComponent } from "./pages/u/logout/logout.component"; +import { notAuthGuard } from "./routing/not-auth.guard"; +import { authGuard } from "./routing/auth.guard"; +import { NotFoundComponent } from "./pages/static/not-found/not-found.component"; +import { canMatchEventId } from "./routing/match-event-id.guard"; +import { HelpComponent } from "./pages/static/help/help.component"; +import { ContactComponent } from "./pages/static/contact/contact.component"; +import { OrganisationComponent } from "./pages/static/organisation/organisation.component"; +import { ImprintComponent } from "./pages/static/imprint/imprint.component"; +import { PrivacyComponent } from "./pages/static/privacy/privacy.component"; +import { EventsComponent } from "./pages/u/events/events.component"; +import { ForgotPasswordComponent } from "./pages/u/forgot-password/forgot-password.component"; +import { RegisterComponent } from "./pages/u/register/register.component"; +import { NewEventComponent } from "./pages/admin/new-event/new-event.component"; +import { MyEventsComponent } from "./pages/admin/my-events/my-events.component"; +import { MySubscriptionsComponent } from "./pages/admin/my-subscriptions/my-subscriptions.component"; +import { ProfileComponent } from "./pages/admin/profile/profile.component"; +import { ConfirmEmailComponent } from "./pages/u/confirm-email/confirm-email.component"; +import { ConfirmPasswordComponent } from "./pages/u/confirm-password/confirm-password.component"; +import { ChangePasswordComponent } from "./pages/admin/change-password/change-password.component"; +import { ModeratorEventsComponent } from "./pages/moderator/moderator-events/moderator-events.component"; +import { UsersComponent } from "./pages/administrator/users/users.component"; const routes: Routes = [ { @@ -67,7 +67,14 @@ const routes: Routes = [ {path: 'organisation', pathMatch: 'full', component: OrganisationComponent}, {path: 'imprint', pathMatch: 'full', component: ImprintComponent}, {path: 'privacy', pathMatch: 'full', component: PrivacyComponent}, - {path: '', pathMatch: 'full', component: EventsListComponent}, + { + path: '', + pathMatch: 'full', + component: EventsListComponent, + data: { + reuseRouteKey: 'events-list' + } + }, {path: '**', pathMatch: 'full', component: NotFoundComponent}, ]; diff --git a/src/app/app.component.html b/src/app/app.component.html index 7050524..4b34061 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -4,7 +4,7 @@
- +
diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 214a365..779edba 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,58 +1,61 @@ -import {NgModule} from '@angular/core'; -import {BrowserModule, provideClientHydration} from '@angular/platform-browser'; +import { NgModule } from '@angular/core'; +import { BrowserModule, provideClientHydration } from '@angular/platform-browser'; -import {AppRoutingModule} from './app-routing.module'; -import {AppComponent} from './app.component'; -import {EventsListComponent} from './pages/events/events-list/events-list.component'; -import {EventsDetailsComponent} from './pages/events/events-details/events-details.component'; -import {HeaderComponent} from './components/header/header.component'; -import {BackButtonComponent} from './components/back-button/back-button.component'; -import {LoginComponent} from './pages/u/login/login.component'; -import {LogoutComponent} from './pages/u/logout/logout.component'; -import {FormsModule, ReactiveFormsModule} from "@angular/forms"; -import {HttpClient, HttpClientModule} from "@angular/common/http"; -import {JWT_OPTIONS, JwtModule} from "@auth0/angular-jwt"; -import {environment} from "../environments/environment"; -import {HelpComponent} from './pages/static/help/help.component'; -import {ContactComponent} from './pages/static/contact/contact.component'; -import {ImprintComponent} from './pages/static/imprint/imprint.component'; -import {PrivacyComponent} from './pages/static/privacy/privacy.component'; -import {OrganisationComponent} from './pages/static/organisation/organisation.component'; -import {NotFoundComponent} from './pages/static/not-found/not-found.component'; -import {EventsComponent} from './pages/u/events/events.component'; -import {ForgotPasswordComponent} from './pages/u/forgot-password/forgot-password.component'; -import {RegisterComponent} from './pages/u/register/register.component'; -import {NewEventComponent} from './pages/admin/new-event/new-event.component'; -import {MyEventsComponent} from './pages/admin/my-events/my-events.component'; -import {MySubscriptionsComponent} from './pages/admin/my-subscriptions/my-subscriptions.component'; -import {ProfileComponent} from './pages/admin/profile/profile.component'; -import {AuthenticationService} from "./services/authentication.service"; -import {ShortDomainPipe} from './pipes/short-domain.pipe'; -import {EventCardComponent} from './components/events/event-card/event-card.component'; -import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; -import {EventFilterComponent} from './components/events/event-filter/event-filter.component'; -import {FooterComponent} from './components/footer/footer.component'; -import {MessagesComponent} from './components/messages/messages.component'; -import {ConfirmEmailComponent} from './pages/u/confirm-email/confirm-email.component'; -import {ConfirmPasswordComponent} from './pages/u/confirm-password/confirm-password.component'; -import {ChangePasswordComponent} from './pages/admin/change-password/change-password.component'; -import {NewEventButtonComponent} from './components/new-event-button/new-event-button.component'; -import {ModeratorEventsComponent} from './pages/moderator/moderator-events/moderator-events.component'; -import {StatusBadgeComponent} from './components/status-badge/status-badge.component'; -import {PaginationComponent} from './components/pagination/pagination.component'; -import {EventPreviewComponent} from './components/event-preview/event-preview.component'; -import {EventDiffComponent} from './components/event-diff/event-diff.component'; -import {DiffFieldComponent} from './components/event-diff/diff-field/diff-field.component'; -import {UsersComponent} from "./pages/administrator/users/users.component"; -import {RoleBadgeComponent} from './components/role-badge/role-badge.component'; -import {UserComponent} from './components/forms/user/user.component'; -import {ChangeUserComponent} from './components/change-user/change-user.component'; -import {NgxFileDropModule} from "ngx-file-drop"; -import {FileListComponent} from './components/file-list/file-list.component'; -import {DeleteEventComponent} from './components/modals/delete-event/delete-event.component'; -import {ReasonForChangeComponent} from './components/modals/reason-for-change/reason-for-change.component'; -import {TranslateLoader, TranslateModule} from "@ngx-translate/core"; -import {TranslateHttpLoader} from "@ngx-translate/http-loader"; +import { AppRoutingModule } from './app-routing.module'; +import { AppComponent } from './app.component'; +import { EventsListComponent } from './pages/events/events-list/events-list.component'; +import { EventsDetailsComponent } from './pages/events/events-details/events-details.component'; +import { HeaderComponent } from './components/header/header.component'; +import { BackButtonComponent } from './components/back-button/back-button.component'; +import { LoginComponent } from './pages/u/login/login.component'; +import { LogoutComponent } from './pages/u/logout/logout.component'; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { HttpClient, HttpClientModule } from "@angular/common/http"; +import { JWT_OPTIONS, JwtModule } from "@auth0/angular-jwt"; +import { environment } from "../environments/environment"; +import { HelpComponent } from './pages/static/help/help.component'; +import { ContactComponent } from './pages/static/contact/contact.component'; +import { ImprintComponent } from './pages/static/imprint/imprint.component'; +import { PrivacyComponent } from './pages/static/privacy/privacy.component'; +import { OrganisationComponent } from './pages/static/organisation/organisation.component'; +import { NotFoundComponent } from './pages/static/not-found/not-found.component'; +import { EventsComponent } from './pages/u/events/events.component'; +import { ForgotPasswordComponent } from './pages/u/forgot-password/forgot-password.component'; +import { RegisterComponent } from './pages/u/register/register.component'; +import { NewEventComponent } from './pages/admin/new-event/new-event.component'; +import { MyEventsComponent } from './pages/admin/my-events/my-events.component'; +import { MySubscriptionsComponent } from './pages/admin/my-subscriptions/my-subscriptions.component'; +import { ProfileComponent } from './pages/admin/profile/profile.component'; +import { AuthenticationService } from "./services/authentication.service"; +import { ShortDomainPipe } from './pipes/short-domain.pipe'; +import { EventCardComponent } from './components/events/event-card/event-card.component'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { EventFilterComponent } from './components/events/event-filter/event-filter.component'; +import { FooterComponent } from './components/footer/footer.component'; +import { MessagesComponent } from './components/messages/messages.component'; +import { ConfirmEmailComponent } from './pages/u/confirm-email/confirm-email.component'; +import { ConfirmPasswordComponent } from './pages/u/confirm-password/confirm-password.component'; +import { ChangePasswordComponent } from './pages/admin/change-password/change-password.component'; +import { NewEventButtonComponent } from './components/new-event-button/new-event-button.component'; +import { ModeratorEventsComponent } from './pages/moderator/moderator-events/moderator-events.component'; +import { StatusBadgeComponent } from './components/status-badge/status-badge.component'; +import { PaginationComponent } from './components/pagination/pagination.component'; +import { EventPreviewComponent } from './components/event-preview/event-preview.component'; +import { EventDiffComponent } from './components/event-diff/event-diff.component'; +import { DiffFieldComponent } from './components/event-diff/diff-field/diff-field.component'; +import { UsersComponent } from "./pages/administrator/users/users.component"; +import { RoleBadgeComponent } from './components/role-badge/role-badge.component'; +import { UserComponent } from './components/forms/user/user.component'; +import { ChangeUserComponent } from './components/change-user/change-user.component'; +import { NgxFileDropModule } from "ngx-file-drop"; +import { FileListComponent } from './components/file-list/file-list.component'; +import { DeleteEventComponent } from './components/modals/delete-event/delete-event.component'; +import { ReasonForChangeComponent } from './components/modals/reason-for-change/reason-for-change.component'; +import { TranslateLoader, TranslateModule } from "@ngx-translate/core"; +import { TranslateHttpLoader } from "@ngx-translate/http-loader"; +import { RouteReuseStrategy } from '@angular/router'; +import { RouterReuseStrategy } from './routing/router-reuse.strategy'; +import { AppRouterOutletDirective } from './routing/app-router-outlet.directive'; export function jwtOptionsFactory(authService: AuthenticationService) { return { @@ -117,7 +120,8 @@ export function createTranslateLoader(http: HttpClient) { ChangeUserComponent, FileListComponent, DeleteEventComponent, - ReasonForChangeComponent + ReasonForChangeComponent, + AppRouterOutletDirective, ], imports: [ BrowserModule, @@ -143,7 +147,13 @@ export function createTranslateLoader(http: HttpClient) { defaultLanguage: 'rm' }), ], - providers: [provideClientHydration()], + providers: [ + { + provide: RouteReuseStrategy, + useClass: RouterReuseStrategy + }, + provideClientHydration() + ], bootstrap: [AppComponent] }) export class AppModule { diff --git a/src/app/pages/events/events-list/events-list.component.ts b/src/app/pages/events/events-list/events-list.component.ts index 5a535cc..67319f3 100644 --- a/src/app/pages/events/events-list/events-list.component.ts +++ b/src/app/pages/events/events-list/events-list.component.ts @@ -1,19 +1,22 @@ -import {Component, OnDestroy, OnInit} from '@angular/core'; -import {EventsService} from "../../../services/events.service"; -import {EventFilter, EventLookup} from "../../../data/event"; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { EventsService } from "../../../services/events.service"; +import { EventFilter, EventLookup } from "../../../data/event"; import * as dayjs from 'dayjs' -import {rmLocale} from "../../../utils/day-js-locale"; -import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; -import {EventFilterComponent} from "../../../components/events/event-filter/event-filter.component"; -import {EventsFilterService} from "../../../services/events-filter.service"; -import {Page} from "../../../data/page"; +import { rmLocale } from "../../../utils/day-js-locale"; +import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; +import { EventFilterComponent } from "../../../components/events/event-filter/event-filter.component"; +import { EventsFilterService } from "../../../services/events-filter.service"; +import { Page } from "../../../data/page"; +import { OnAttach, OnDetach } from '../../../routing/app-router-outlet.directive'; + +const LOCALSTORAGE_EVENTS_LIST_SCROLL_POSITION = 'events-scroll-position'; @Component({ selector: 'app-events-list', templateUrl: './events-list.component.html', styleUrls: ['./events-list.component.scss'] }) -export class EventsListComponent implements OnInit, OnDestroy { +export class EventsListComponent implements OnInit, OnDestroy, OnAttach, OnDetach { public events: EventLookup[] = []; public categorizedEvents: { date: string, formattedDate: string, events: EventLookup[] }[] = []; @@ -56,6 +59,18 @@ export class EventsListComponent implements OnInit, OnDestroy { } } + onAttach(): void { + const scrollPosition = +(localStorage.getItem(LOCALSTORAGE_EVENTS_LIST_SCROLL_POSITION) || 0); + window.scrollTo({ + top: scrollPosition, + behavior: 'instant', + }); + } + + onDetach(): void { + localStorage.setItem(LOCALSTORAGE_EVENTS_LIST_SCROLL_POSITION, window.scrollY.toString()); + } + openFilter(): void { const modalRef = this.modalService.open(EventFilterComponent, {size: 'lg'}); } diff --git a/src/app/routing/app-router-outlet.directive.ts b/src/app/routing/app-router-outlet.directive.ts new file mode 100644 index 0000000..f949dff --- /dev/null +++ b/src/app/routing/app-router-outlet.directive.ts @@ -0,0 +1,39 @@ +import { ActivatedRoute, RouterOutlet } from '@angular/router'; +import { ComponentRef, Directive } from '@angular/core'; + +export interface OnAttach { + /** + * A callback method that is invoked when the RouteReuseStrategy instructs + * to re-attach a previously detached component / subtree + */ + onAttach(activatedRoute: ActivatedRoute): void; +} + +export interface OnDetach { + /** + * A callback method that is invoked when the RouteReuseStrategy instructs + * to detach component / subtree + */ + onDetach(): void; +} + +@Directive({ + selector: 'app-router-outlet', +}) +export class AppRouterOutletDirective extends RouterOutlet { + + override detach(): ComponentRef { + const instance: any = this.component; + if (instance && typeof instance.onDetach === 'function') { + instance.onDetach(); + } + return super.detach(); + } + + override attach(ref: ComponentRef, activatedRoute: ActivatedRoute): void { + super.attach(ref, activatedRoute); + if (ref.instance && typeof ref.instance.onAttach === 'function') { + ref.instance.onAttach(activatedRoute); + } + } +} diff --git a/src/app/routing/router-reuse.strategy.ts b/src/app/routing/router-reuse.strategy.ts new file mode 100644 index 0000000..e3a6243 --- /dev/null +++ b/src/app/routing/router-reuse.strategy.ts @@ -0,0 +1,88 @@ +import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'; +import { ComponentRef } from '@angular/core'; + +export class RouterReuseStrategy implements RouteReuseStrategy { + private handlers: { [key: string]: DetachedRouteHandle } = {}; + + shouldDetach(route: ActivatedRouteSnapshot): boolean { + if (!route.routeConfig || route.routeConfig.loadChildren) { + return false; + } + + /** Whether this route should be reused or not */ + return !!(route.routeConfig.data && route.routeConfig.data['reuseRouteKey']); + } + + store(route: ActivatedRouteSnapshot, handler: DetachedRouteHandle): void { + const routeKey = this.getKey(route); + if (handler && routeKey) { + this.handlers[routeKey] = handler; + } + } + + shouldAttach(route: ActivatedRouteSnapshot): boolean { + const routeKey = this.getKey(route); + + if (!routeKey) { + return false; + } + + const shouldAttach = !!this.handlers[routeKey]; + return shouldAttach; + } + + retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null { + const routeKey = this.getKey(route); + + if (!route.routeConfig || route.routeConfig.loadChildren || !routeKey) { + return null; + } + + return this.handlers[routeKey]; + } + + shouldReuseRoute(future: ActivatedRouteSnapshot, current: ActivatedRouteSnapshot): boolean { + /** We only want to reuse the route if the data of the route config contains a reuse true boolean */ + let reUseUrl = false; + if (future.routeConfig && future.routeConfig.data && typeof future.routeConfig.data['reuseRoute']) { + reUseUrl = !!future.routeConfig.data['reuseRoute']; + } + + const defaultReuse = future.routeConfig === current.routeConfig; + return reUseUrl || defaultReuse; + } + + /** + * Returns the reuse key for the current route or null if none is set + * @param route + */ + private getKey(route: ActivatedRouteSnapshot): string | null { + if (route.routeConfig && route.routeConfig.data) { + return route.routeConfig.data['reuseRouteKey']; + } + return null; + } + + /** + * Clearing / Destorying all handles + */ + clearHandles() { + for (const key in this.handlers) { + this.destroyHandle(this.handlers[key]); + } + this.handlers = {}; + } + + /** + * Destroying a handle + * @param handle + */ + private destroyHandle(handle: DetachedRouteHandle): void { + // @ts-ignore + const componentRef: ComponentRef = handle['componentRef']; + + if (componentRef) { + componentRef.destroy(); + } + } +}