diff --git a/angular.json b/angular.json index 235b97e..5fac9a5 100644 --- a/angular.json +++ b/angular.json @@ -3,7 +3,7 @@ "version": 1, "cli": { "packageManager": "yarn", - "defaultCollection": "@angular-eslint/schematics" + "defaultCollection": "@ngrx/schematics" }, "newProjectRoot": "projects", "projects": { @@ -39,7 +39,9 @@ "src/styles.scss" ], "scripts": [], - "allowedCommonJsDependencies": ["nestjsx/crud-request"] + "allowedCommonJsDependencies": [ + "nestjsx/crud-request" + ] }, "configurations": { "production": { @@ -131,4 +133,4 @@ } }, "defaultProject": "ngrx-blog-course" -} +} \ No newline at end of file diff --git a/package.json b/package.json index 3eeaf97..f7ed3fc 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,11 @@ "@angular/platform-browser-dynamic": "~11.2.5", "@angular/router": "~11.2.5", "@nestjsx/crud-request": "^5.0.0-alpha.3", + "@ngrx/effects": "^12.0.0", + "@ngrx/entity": "^12.0.0", + "@ngrx/router-store": "11.1.1", + "@ngrx/store": "11.1.1", + "@ngrx/store-devtools": "11.1.1", "rxjs": "~6.6.0", "tslib": "^2.0.0", "zone.js": "~0.11.3" @@ -36,6 +41,7 @@ "@angular-eslint/template-parser": "4.2.0", "@angular/cli": "~11.2.4", "@angular/compiler-cli": "~11.2.5", + "@ngrx/schematics": "^12.0.0", "@types/jasmine": "~3.6.0", "@types/node": "^12.11.1", "@typescript-eslint/eslint-plugin": "4.16.1", diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index c6f87a9..48b3d0e 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -8,10 +8,6 @@ const routes: Routes = [ pathMatch: 'full', redirectTo: '/auth/login', }, - { - path: 'auth', - loadChildren: () => import('./auth/auth.module').then((m) => m.AuthModule), - }, { path: 'blogs', loadChildren: () => diff --git a/src/app/app.component.html b/src/app/app.component.html index 31822b0..50c2cab 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -7,11 +7,13 @@ Blog App - -
+ + +
+
@@ -29,22 +31,26 @@
- - login - Login - - - app_registration - Register - - - logout - Logout - - - article - Blogs - + + + login + Login + + + app_registration + Register + + + + + logout + Logout + + + article + Blogs + +
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 6bb8ae8..3d9db7e 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,9 +1,13 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { MatDrawer } from '@angular/material/sidenav'; import { NavigationEnd, Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; import { filter } from 'rxjs/operators'; -import { AuthService } from './core/services'; +import { User } from './auth/models'; +import { AuthActions } from './auth/redux/auth.actions'; +import { AuthSelectors } from './auth/redux/auth.selectors'; @Component({ selector: 'app-root', @@ -11,12 +15,12 @@ import { AuthService } from './core/services'; styleUrls: ['./app.component.scss'], }) export class AppComponent implements OnInit { - constructor( - private readonly router: Router, - public readonly authService: AuthService - ) {} + constructor(private readonly router: Router, private readonly store: Store) {} @ViewChild(MatDrawer) drawer: MatDrawer; + currentUser$: Observable; + isLoggedIn$: Observable; + isLoggedOut$: Observable; ngOnInit() { this.router.events @@ -26,6 +30,10 @@ export class AppComponent implements OnInit { this.drawer.close(); } }); + + this.currentUser$ = this.store.select(AuthSelectors.currentUser); + this.isLoggedIn$ = this.store.select(AuthSelectors.isLoggedIn); + this.isLoggedOut$ = this.store.select(AuthSelectors.isLoggedOut); } toggleSidenav() { @@ -33,6 +41,6 @@ export class AppComponent implements OnInit { } logout() { - return this.authService.logout().subscribe(); + this.store.dispatch(AuthActions.logoutRequested()); } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 1984c88..e4749a3 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -9,12 +9,22 @@ import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatToolbarModule } from '@angular/material/toolbar'; import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + NavigationActionTiming, + RouterState, + StoreRouterConnectingModule, +} from '@ngrx/router-store'; +import { Store, StoreModule } from '@ngrx/store'; +import { StoreDevtoolsModule } from '@ngrx/store-devtools'; +import { environment } from '../environments/environment'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; +import { AuthModule } from './auth/auth.module'; import { AuthInterceptor } from './auth/interceptors'; import { CoreModule } from './core/core.module'; -import { AuthService } from './core/services'; +import { globalReducers, metaReducers } from './core/redux/reducers'; +import { EffectsModule } from '@ngrx/effects'; @NgModule({ declarations: [AppComponent], @@ -23,7 +33,6 @@ import { AuthService } from './core/services'; BrowserAnimationsModule, HttpClientModule, FlexLayoutModule, - AppRoutingModule, MatToolbarModule, MatButtonModule, MatButtonModule, @@ -32,6 +41,18 @@ import { AuthService } from './core/services'; MatListModule, MatSnackBarModule, CoreModule, + AuthModule, + AppRoutingModule, + StoreModule.forRoot(globalReducers, { metaReducers }), + EffectsModule.forRoot(), + StoreDevtoolsModule.instrument({ + maxAge: 25, + logOnly: environment.production, + }), + StoreRouterConnectingModule.forRoot({ + routerState: RouterState.Minimal, + navigationActionTiming: NavigationActionTiming.PostActivation, + }), ], bootstrap: [AppComponent], providers: [ @@ -39,7 +60,7 @@ import { AuthService } from './core/services'; provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true, - deps: [AuthService], + deps: [Store], }, ], }) diff --git a/src/app/auth/auth-routing.module.ts b/src/app/auth/auth-routing.module.ts index d40f510..be2fbb9 100644 --- a/src/app/auth/auth-routing.module.ts +++ b/src/app/auth/auth-routing.module.ts @@ -4,12 +4,17 @@ import { LoginComponent, RegisterComponent } from './components'; const routes: Routes = [ { - path: 'login', - component: LoginComponent, - }, - { - path: 'register', - component: RegisterComponent, + path: 'auth', + children: [ + { + path: 'login', + component: LoginComponent, + }, + { + path: 'register', + component: RegisterComponent, + }, + ], }, ]; diff --git a/src/app/auth/auth.module.ts b/src/app/auth/auth.module.ts index 4cb7cb1..1995c54 100644 --- a/src/app/auth/auth.module.ts +++ b/src/app/auth/auth.module.ts @@ -1,5 +1,4 @@ import { CommonModule } from '@angular/common'; -import { HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { FlexLayoutModule } from '@angular/flex-layout'; import { ReactiveFormsModule } from '@angular/forms'; @@ -9,9 +8,13 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { RouterModule } from '@angular/router'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule } from '@ngrx/store'; import { AuthRoutingModule } from './auth-routing.module'; import { LoginComponent, RegisterComponent } from './components'; +import { AuthEffects } from './redux/auth.effects'; +import { authFeature, authReducer } from './redux/auth.reducer'; @NgModule({ declarations: [LoginComponent, RegisterComponent], @@ -26,6 +29,8 @@ import { LoginComponent, RegisterComponent } from './components'; MatButtonModule, MatIconModule, ReactiveFormsModule, + StoreModule.forFeature(authFeature, authReducer), + EffectsModule.forFeature([AuthEffects]), ], }) export class AuthModule {} diff --git a/src/app/auth/components/login/login.component.ts b/src/app/auth/components/login/login.component.ts index 9ead85e..2fc0768 100644 --- a/src/app/auth/components/login/login.component.ts +++ b/src/app/auth/components/login/login.component.ts @@ -1,13 +1,8 @@ import { Component, OnInit } from '@angular/core'; -import { - FormBuilder, - FormControl, - FormGroup, - Validators, -} from '@angular/forms'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { Router } from '@angular/router'; -import { AuthService } from '../../../core/services'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; + +import { AuthActions } from '../../redux/auth.actions'; @Component({ selector: 'app-login', @@ -15,14 +10,10 @@ import { AuthService } from '../../../core/services'; styleUrls: ['./login.component.scss'], }) export class LoginComponent implements OnInit { - constructor( - private readonly authService: AuthService, - private readonly router: Router, - private readonly matSnackbar: MatSnackBar - ) {} + constructor(private readonly store: Store) {} hide = true; - formGroup!: FormGroup; + formGroup: FormGroup; ngOnInit() { this.formGroup = new FormGroup({ @@ -38,14 +29,7 @@ export class LoginComponent implements OnInit { onFormSubmit() { if (this.formGroup.valid) { const { email, password } = this.formGroup.value; - this.authService.login(email, password).subscribe( - () => { - this.router.navigate(['blogs']); - }, - () => { - this.matSnackbar.open('Login Failed!', 'OK'); - } - ); + this.store.dispatch(AuthActions.loginRequested({ email, password })); } } } diff --git a/src/app/auth/components/register/register.component.html b/src/app/auth/components/register/register.component.html index 2d26d65..5ae914e 100644 --- a/src/app/auth/components/register/register.component.html +++ b/src/app/auth/components/register/register.component.html @@ -29,7 +29,7 @@

Register

/> - FirstName is required + First Name is required diff --git a/src/app/auth/components/register/register.component.ts b/src/app/auth/components/register/register.component.ts index d550492..996742d 100644 --- a/src/app/auth/components/register/register.component.ts +++ b/src/app/auth/components/register/register.component.ts @@ -8,8 +8,8 @@ import { import { FormControl, FormGroup, Validators } from '@angular/forms'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; -import { AuthService } from '../../../core/services'; +import { AuthService } from '../../../core/services'; import { ConfirmPasswordValidator } from './confirm-password.validator'; @Component({ diff --git a/src/app/auth/interceptors/auth.interceptor.ts b/src/app/auth/interceptors/auth.interceptor.ts index f3236bc..5756e2a 100644 --- a/src/app/auth/interceptors/auth.interceptor.ts +++ b/src/app/auth/interceptors/auth.interceptor.ts @@ -5,64 +5,109 @@ import { HttpInterceptor, HttpRequest, } from '@angular/common/http'; -import { BehaviorSubject, Observable, throwError } from 'rxjs'; -import { catchError, concatMap, filter, switchMap, take } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { BehaviorSubject, Observable, of, throwError } from 'rxjs'; +import { + catchError, + concatMap, + filter, + first, + mergeMap, + switchMap, + take, +} from 'rxjs/operators'; -import { AuthService } from '../../core/services'; +import { AuthActions } from '../redux/auth.actions'; +import { AuthSelectors } from '../redux/auth.selectors'; export class AuthInterceptor implements HttpInterceptor { - constructor(private readonly authService: AuthService) {} + constructor(private readonly store: Store) {} readonly tokenBSubject = new BehaviorSubject(null); isTokenRefreshing = false; - get isTokenExpired() { - return this.authService.isTokenExpired(); + isTokenExpired(expiresIn: number) { + if (typeof expiresIn === 'number') { + return Date.now() > expiresIn; + } + return false; } intercept( req: HttpRequest, next: HttpHandler ): Observable> { - return next.handle(this.attachToken(req)).pipe( + return this.attachToken$(req, next).pipe( + first(), + concatMap((reqWithToken) => next.handle(reqWithToken)), catchError((err) => { - if ( - err instanceof HttpErrorResponse && - err.status === 401 && - this.isTokenExpired - ) { - return this.handleRefreshToken(req, next); - } - return throwError(err); + return this.store.select(AuthSelectors.expiresIn).pipe( + mergeMap((expiresIn) => { + if ( + err instanceof HttpErrorResponse && + err.status === 401 && + this.isTokenExpired(expiresIn) + ) { + return this.handleRefreshToken(req, next); + } + return throwError(err); + }) + ); }) ); } - attachToken(req: HttpRequest) { - const accessToken = this.authService.getAccessToken(); - req = req.clone({ - headers: req.headers.set('Authorization', `Bearer ${accessToken}`), - }); - return req; + attachToken$(req: HttpRequest, next: HttpHandler) { + return this.store.select(AuthSelectors.accessToken).pipe( + first(), + mergeMap((token) => { + if (token) { + req = req.clone({ + headers: req.headers.set('Authorization', `Bearer ${token}`), + }); + } + return of(req); + }) + ); } - handleRefreshToken(req: HttpRequest, next: HttpHandler) { + handleRefreshToken( + req: HttpRequest, + next: HttpHandler + ): Observable> { if (!this.isTokenRefreshing) { this.isTokenRefreshing = true; this.tokenBSubject.next(null); - - return this.authService.refreshToken().pipe( - concatMap((payload) => { - this.isTokenRefreshing = false; - this.tokenBSubject.next(payload.accessToken); - return next.handle(this.attachToken(req)); - }) + // * make sure to clear existing accessToken and expiresIn + // * so that it doesn't reuse the same old token + return of( + this.store.dispatch(AuthActions.refreshTokenRequestedByInterceptor()) + ).pipe( + concatMap(() => + this.store.select(AuthSelectors.accessToken).pipe( + filter((token) => !!token), + first(), + concatMap((token) => { + this.isTokenRefreshing = false; + this.tokenBSubject.next(token); + return this.attachToken$(req, next).pipe( + first(), + mergeMap((reqWithToken) => next.handle(reqWithToken)) + ); + }) + ) + ) ); } else { return this.tokenBSubject.pipe( filter((token) => !!token), take(1), - switchMap(() => next.handle(this.attachToken(req))) + switchMap(() => + this.attachToken$(req, next).pipe( + first(), + mergeMap((reqWithToken) => next.handle(reqWithToken)) + ) + ) ); } } diff --git a/src/app/auth/models/user.model.ts b/src/app/auth/models/user.model.ts index 3c9b3ab..f0def1b 100644 --- a/src/app/auth/models/user.model.ts +++ b/src/app/auth/models/user.model.ts @@ -31,11 +31,15 @@ export class User { this.fullName = data.fullName; this.password = data.password; this.role = data.role; - this.posts = data.posts ?? []; - this.postIds = data.postIds; - this.comments = data.comments ?? []; - this.commentIds = data.commentIds; - this.bookmarkIds = data.bookmarkIds; + this.posts = Array.isArray(data.posts) + ? data.posts.map((post) => post) + : []; + this.postIds = [...data.postIds]; + this.comments = Array.isArray(data.comments) + ? data.comments.map((item) => item) + : []; + this.commentIds = [...data.commentIds]; + this.bookmarkIds = [...data.bookmarkIds]; this.createdBy = data.createdBy; this.modifiedBy = data.modifiedBy; this.createdOn = data.createdOn; diff --git a/src/app/auth/redux/auth.actions.ts b/src/app/auth/redux/auth.actions.ts new file mode 100644 index 0000000..76e2e6b --- /dev/null +++ b/src/app/auth/redux/auth.actions.ts @@ -0,0 +1,73 @@ +import { createAction, props } from '@ngrx/store'; +import { uuid } from '../../core/types'; + +import { User } from '../models'; + +const loginRequested = createAction( + '[Login Component] Login Requested', + props<{ email: string; password: string }>() +); + +const loginSucceed = createAction( + '[Auth Effects] Login Succeed', + props<{ accessToken: string; expiresIn: number }>() +); + +const loginFailed = createAction('[Auth Service] Login Failed'); + +const currentUserRequested = createAction( + '[Auth Effects] Current User Requested' +); + +const storeCurrentUser = createAction( + '[Auth Effects] Store Current User', + props<{ user: User }>() +); + +const updateCurrentUser = createAction( + '[Effects] Update Current User', + props<{ user: Partial }>() +); + +const refreshTokenRequestedByGuard = createAction( + '[Token Guard] Token Refresh Requested' +); + +const refreshTokenRequestedByInterceptor = createAction( + '[Auth Interceptor] Token Refresh Requested' +); + +const refreshTokenSucceed = createAction( + '[Auth Effects] Token Refresh Succeed', + props<{ accessToken: string; expiresIn: number }>() +); + +const logoutRequested = createAction('[Navigation Drawer] Logout Requested'); + +const logoutSucceed = createAction('[Auth Effects] Logout Succeed'); + +const registration = createAction( + '[Register Component] Registration', + props<{ user: FormData }>() +); + +const toggleBookmark = createAction( + '[Post Effect] Toggle Bookmark For User', + props<{ bookmarkId: uuid; isRemoved: boolean }>() +); + +export const AuthActions = { + loginRequested, + loginSucceed, + loginFailed, + currentUserRequested, + storeCurrentUser, + updateCurrentUser, + refreshTokenRequestedByGuard, + refreshTokenRequestedByInterceptor, + refreshTokenSucceed, + logoutRequested, + logoutSucceed, + registration, + toggleBookmark, +}; diff --git a/src/app/auth/redux/auth.effects.ts b/src/app/auth/redux/auth.effects.ts new file mode 100644 index 0000000..790d2fa --- /dev/null +++ b/src/app/auth/redux/auth.effects.ts @@ -0,0 +1,139 @@ +import { Injectable } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { throwError } from 'rxjs'; +import { catchError, concatMap, map, mergeMap, tap } from 'rxjs/operators'; +import { CommentActions, PostActions } from '../../blogs/redux/actions'; + +import { AuthService } from '../../core/services'; +import { uuid } from '../../core/types'; +import { AuthActions } from './auth.actions'; +import { AuthSelectors } from './auth.selectors'; + +@Injectable() +export class AuthEffects { + constructor( + private readonly action$: Actions, + private readonly store: Store, + private readonly router: Router, + private readonly authService: AuthService, + private readonly matSnackbar: MatSnackBar + ) {} + + login$ = createEffect(() => + this.action$.pipe( + ofType(AuthActions.loginRequested), + concatMap(({ email, password }) => + this.authService.login(email, password).pipe( + mergeMap((payload) => { + return [ + AuthActions.loginSucceed({ ...payload }), + AuthActions.currentUserRequested(), + ]; + }), + catchError(async () => { + this.matSnackbar.open('Login Failed!', 'OK'); + return AuthActions.loginFailed(); + }) + ) + ), + tap(() => this.router.navigate(['blogs'])) + ) + ); + + logout$ = createEffect(() => + this.action$.pipe( + ofType(AuthActions.logoutRequested), + concatMap(() => + this.authService.logout().pipe( + tap(() => this.router.navigate(['/auth/login'])), + concatMap(() => [ + AuthActions.logoutSucceed(), + PostActions.resetAll(), + CommentActions.resetAll(), + ]) + ) + ) + ) + ); + + registration$ = createEffect( + () => + this.action$.pipe( + ofType(AuthActions.registration), + concatMap(({ user }) => + this.authService.register(user).pipe( + tap(() => { + this.matSnackbar.open('Registration Successful!', 'OK'); + this.router.navigate(['/auth/login']); + }), + catchError((err) => { + this.matSnackbar.open(err.message, 'OK'); + return throwError(err); + }) + ) + ) + ), + { dispatch: false } + ); + + refreshToken$ = createEffect(() => + this.action$.pipe( + ofType( + AuthActions.refreshTokenRequestedByGuard, + AuthActions.refreshTokenRequestedByInterceptor + ), + concatMap(() => + this.authService + .refreshToken() + .pipe( + concatMap(({ accessToken, expiresIn }) => [ + AuthActions.refreshTokenSucceed({ accessToken, expiresIn }), + AuthActions.currentUserRequested(), + ]) + ) + ) + ) + ); + + storeCurrentUser$ = createEffect(() => + this.action$.pipe( + ofType(AuthActions.currentUserRequested), + concatMap(() => { + return this.authService + .whoAmI() + .pipe(map((user) => AuthActions.storeCurrentUser({ user }))); + }) + ) + ); + + toggleBookmark$ = createEffect(() => + this.action$.pipe( + ofType(AuthActions.toggleBookmark), + concatLatestFrom(() => this.store.select(AuthSelectors.currentUser)), + map(([{ bookmarkId, isRemoved }, user]) => { + let bookmarkIds: uuid[]; + const isBookmarked = isRemoved ? false : true; + // If the user just bookmarked the post, then add + // the bookmarkId to user's bookmarkIds array + if (isBookmarked) { + bookmarkIds = [...user.bookmarkIds, bookmarkId]; + } + // Else remove the bookmarkId from user's bookmarkIds + // (if he just un-bookmarked it). + else { + const index = user.bookmarkIds.findIndex((id) => id === bookmarkId); + if (index >= 0) { + bookmarkIds = [...user.bookmarkIds]; + bookmarkIds.splice(index, 1); + } + } + return AuthActions.updateCurrentUser({ + user: { id: user.id, bookmarkIds }, + }); + }) + ) + ); +} diff --git a/src/app/auth/redux/auth.reducer.ts b/src/app/auth/redux/auth.reducer.ts new file mode 100644 index 0000000..c12f4e2 --- /dev/null +++ b/src/app/auth/redux/auth.reducer.ts @@ -0,0 +1,57 @@ +import { createReducer, on } from '@ngrx/store'; + +import { User } from '../models'; +import { AuthActions } from './auth.actions'; + +export const authFeature = 'auth'; + +export interface AuthState { + user: User; + accessToken: string; + expiresIn: number; +} + +const initialState: AuthState = { + user: null, + accessToken: null, + expiresIn: null, +}; + +export const authReducer = createReducer( + initialState, + on(AuthActions.loginSucceed, (state, action) => { + return { + ...state, + accessToken: action.accessToken, + expiresIn: action.expiresIn, + }; + }), + on(AuthActions.storeCurrentUser, (state, { user }) => ({ + ...state, + user: new User(user), + })), + on(AuthActions.logoutSucceed, (state) => { + return { + ...state, + user: null, + accessToken: null, + expiresIn: null, + }; + }), + on(AuthActions.refreshTokenSucceed, (state, { accessToken, expiresIn }) => { + return { + ...state, + accessToken, + expiresIn, + }; + }), + on(AuthActions.updateCurrentUser, (state, { user }) => { + return { + ...state, + user: { + ...state.user, + ...user, + }, + }; + }) +); diff --git a/src/app/auth/redux/auth.selectors.ts b/src/app/auth/redux/auth.selectors.ts new file mode 100644 index 0000000..63f05de --- /dev/null +++ b/src/app/auth/redux/auth.selectors.ts @@ -0,0 +1,33 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; + +import { authFeature, AuthState } from './auth.reducer'; + +const authFeatureSelector = + createFeatureSelector>(authFeature); + +const accessToken = createSelector( + authFeatureSelector, + (state = {}) => state.accessToken +); + +const expiresIn = createSelector( + authFeatureSelector, + (state = {}) => state.expiresIn +); + +const currentUser = createSelector( + authFeatureSelector, + (state = {}) => state.user +); + +const isLoggedIn = createSelector(currentUser, (user) => !!user); + +const isLoggedOut = createSelector(isLoggedIn, (loggedIn) => !loggedIn); + +export const AuthSelectors = { + accessToken, + expiresIn, + currentUser, + isLoggedIn, + isLoggedOut, +}; diff --git a/src/app/blogs/blogs-routing.module.ts b/src/app/blogs/blogs-routing.module.ts index 14aee21..8da90f2 100644 --- a/src/app/blogs/blogs-routing.module.ts +++ b/src/app/blogs/blogs-routing.module.ts @@ -1,15 +1,20 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; + import { BlogDetailComponent, BlogListComponent, CreateUpdateBlogComponent, } from './components'; +import { GetPostByIdResolver, PostResolver } from './resolvers'; const routes: Routes = [ { path: '', component: BlogListComponent, + resolve: { + posts: PostResolver, + }, }, { path: 'new', @@ -18,10 +23,16 @@ const routes: Routes = [ { path: ':id', component: BlogDetailComponent, + resolve: { + post: GetPostByIdResolver, + }, }, { path: ':id/edit', component: CreateUpdateBlogComponent, + resolve: { + post: GetPostByIdResolver, + }, }, ]; diff --git a/src/app/blogs/blogs.module.ts b/src/app/blogs/blogs.module.ts index aecbfd9..3f30081 100644 --- a/src/app/blogs/blogs.module.ts +++ b/src/app/blogs/blogs.module.ts @@ -4,13 +4,15 @@ import { FlexLayoutModule } from '@angular/flex-layout'; import { ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; -import { MatChipsModule } from '@angular/material/chips'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { MatDialogModule } from '@angular/material/dialog'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule } from '@ngrx/store'; import { BlogsRoutingModule } from './blogs-routing.module'; import { @@ -18,8 +20,16 @@ import { BlogListComponent, CreateUpdateBlogComponent, } from './components'; -import { BlogService, CommentService } from './services'; import { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component'; +import { CommentEffects, PostEffects } from './redux/effects'; +import { + commentFeature, + commentReducer, + postFeature, + postReducer, +} from './redux/reducers'; +import { GetPostByIdResolver, PostResolver } from './resolvers'; +import { BlogService, CommentService } from './services'; @NgModule({ declarations: [ @@ -42,7 +52,10 @@ import { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dial MatTooltipModule, MatDialogModule, ReactiveFormsModule, + StoreModule.forFeature(postFeature, postReducer), + StoreModule.forFeature(commentFeature, commentReducer), + EffectsModule.forFeature([PostEffects, CommentEffects]), ], - providers: [BlogService, CommentService], + providers: [BlogService, CommentService, PostResolver, GetPostByIdResolver], }) export class BlogsModule {} diff --git a/src/app/blogs/components/blog-detail/blog-detail.component.html b/src/app/blogs/components/blog-detail/blog-detail.component.html index b8c05d8..a450fe2 100644 --- a/src/app/blogs/components/blog-detail/blog-detail.component.html +++ b/src/app/blogs/components/blog-detail/blog-detail.component.html @@ -77,7 +77,7 @@

Comments ({{ post.comments?.length || 0 }})

- +
bookmark_border bookmark @@ -48,7 +56,7 @@ >
- +
diff --git a/src/app/blogs/components/confirm-dialog/confirm-dialog.component.ts b/src/app/blogs/components/confirm-dialog/confirm-dialog.component.ts index 7f97c62..18a4a9f 100644 --- a/src/app/blogs/components/confirm-dialog/confirm-dialog.component.ts +++ b/src/app/blogs/components/confirm-dialog/confirm-dialog.component.ts @@ -1,11 +1,13 @@ -import { Component, Inject, OnInit } from '@angular/core'; -import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Observable } from 'rxjs'; interface MatDialogData { title: string; description: string; onConfirm: () => void; onCancel: () => void; + loading?: Observable; } @Component({ diff --git a/src/app/blogs/components/create-update-blog/create-update-blog.component.ts b/src/app/blogs/components/create-update-blog/create-update-blog.component.ts index c88f29a..f03abb5 100644 --- a/src/app/blogs/components/create-update-blog/create-update-blog.component.ts +++ b/src/app/blogs/components/create-update-blog/create-update-blog.component.ts @@ -9,10 +9,14 @@ import { import { FormControl, FormGroup, Validators } from '@angular/forms'; import { MatChipInputEvent } from '@angular/material/chips'; import { ActivatedRoute, Router } from '@angular/router'; +import { CreateQueryParams } from '@nestjsx/crud-request'; +import { Store } from '@ngrx/store'; +import { delay, filter, first } from 'rxjs/operators'; import { uuid } from '../../../core/types'; import { Post } from '../../models'; -import { BlogService } from '../../services'; +import { PostActions } from '../../redux/actions'; +import { PostSelectors } from '../../redux/selectors'; @Component({ selector: 'app-create-update-blog', @@ -22,9 +26,8 @@ import { BlogService } from '../../services'; export class CreateUpdateBlogComponent implements OnInit { constructor( private readonly activatedRoute: ActivatedRoute, - private readonly blogService: BlogService, private readonly renderer2: Renderer2, - private readonly router: Router + private readonly store: Store ) { this.formGroup = new FormGroup({ coverImage: new FormControl(null), @@ -39,17 +42,20 @@ export class CreateUpdateBlogComponent implements OnInit { readonly allowedTypes = ['image/jpg', 'image/jpeg', 'image/png']; readonly separatorKeysCodes: number[] = [ENTER, COMMA]; formGroup: FormGroup; - isImageSaved!: boolean; - post!: Post; - postId!: uuid; + isImageSaved: boolean; + postId: uuid; ngOnInit() { this.postId = this.activatedRoute.snapshot.params['id']; if (this.postId) { - this.blogService - .getPostById(this.postId, { join: { field: 'image' } }) + this.store + .select(PostSelectors.getById(this.postId)) + .pipe( + filter((post) => !!post), + first(), + delay(1000) + ) .subscribe((payload) => { - this.post = payload; this.formGroup.patchValue({ ...payload, }); @@ -112,19 +118,35 @@ export class CreateUpdateBlogComponent implements OnInit { formData.append('title', post.title); formData.append('content', post.content); formData.append('tags', JSON.stringify(post.tags)); + const qParams: CreateQueryParams = { + join: [ + { field: 'author' }, + { field: 'image' }, + { field: 'author.avatar' }, + ], + }; if (!this.postId) { // Create new POST - this.blogService.createPost(formData).subscribe(() => { - this.router.navigate(['/blogs']); - }); + this.store.dispatch( + PostActions.createOne({ post: formData, query: qParams }) + ); } else { - if (this.post.image && !this.isImageSaved) { - formData.append('deleteCoverImg', 'true'); - } // Update the existing POST - this.blogService.updatePost(this.postId, formData).subscribe(() => { - this.router.navigate(['/blogs']); - }); + this.store + .select(PostSelectors.getById(this.postId)) + .pipe(first()) + .subscribe((post) => { + if (post.image && !this.isImageSaved) { + formData.append('deleteCoverImg', 'true'); + } + // Update the existing POST + this.store.dispatch( + PostActions.updateOne({ + post: { id: this.postId, changes: formData }, + query: qParams, + }) + ); + }); } } } @@ -142,11 +164,14 @@ export class CreateUpdateBlogComponent implements OnInit { const input = event.input; const value = event.value; - const tags = this.formGroup.get('tags')?.value as string[]; + let tags = this.formGroup.get('tags')?.value as string[]; + + tags = tags.map((tag) => tag); // Add tags if ((value || '').trim()) { tags.push(value.trim()); + this.formGroup.get('tags').patchValue(tags); } // Reset the input value diff --git a/src/app/blogs/models/post.model.ts b/src/app/blogs/models/post.model.ts index 63684fe..064a043 100644 --- a/src/app/blogs/models/post.model.ts +++ b/src/app/blogs/models/post.model.ts @@ -29,16 +29,16 @@ export class Post { this.id = data.id; this.title = data.title; this.content = data.content; - this.votes = data.votes ?? []; + this.votes = [...data.votes]; this.author = data.author && new User(data.author); this.authorId = data.authorId; this.comments = data.comments ? data.comments.map((item) => new Comment(item)) : []; - this.commentIds = data.commentIds; + this.commentIds = [...data.commentIds]; this.image = data.image && new Image(data.image); - this.tags = data.tags ?? []; - this.bookmarkedIds = data?.bookmarkedIds ?? []; + this.tags = [...data.tags]; + this.bookmarkedIds = [...data.bookmarkedIds]; this.createdBy = data.createdBy; this.modifiedBy = data.modifiedBy; this.createdOn = data.createdOn; diff --git a/src/app/blogs/redux/actions/comment.actions.ts b/src/app/blogs/redux/actions/comment.actions.ts new file mode 100644 index 0000000..a2777f4 --- /dev/null +++ b/src/app/blogs/redux/actions/comment.actions.ts @@ -0,0 +1,41 @@ +import { CreateQueryParams } from '@nestjsx/crud-request'; +import { createAction, props } from '@ngrx/store'; + +import { uuid } from '../../../core/types'; +import { Comment } from '../../models'; + +const queryAll = createAction( + '[Post Detail Resolver] Query All (By Post ID)', + props<{ postId: uuid; query: CreateQueryParams }>() +); + +const queryAllSuccess = createAction( + '[Comment Effects] Query All Success', + props<{ comments: Comment[] }>() +); + +const queryAllError = createAction('[Comment Effect] Query All Error'); + +const createOne = createAction( + '[Post Detail Component] Create One', + props<{ comment: string; postId: uuid; query?: CreateQueryParams }>() +); + +const createOneSuccess = createAction( + '[Comment Effects] Create One Success', + props<{ comment: Comment }>() +); + +const createOneError = createAction('[Comment Effects] Create One Error'); + +const resetAll = createAction('Reset All Comments'); + +export const CommentActions = { + queryAll, + queryAllSuccess, + queryAllError, + createOne, + createOneSuccess, + createOneError, + resetAll, +}; diff --git a/src/app/blogs/redux/actions/index.ts b/src/app/blogs/redux/actions/index.ts new file mode 100644 index 0000000..9b91fa4 --- /dev/null +++ b/src/app/blogs/redux/actions/index.ts @@ -0,0 +1,2 @@ +export * from './post.actions'; +export * from './comment.actions'; diff --git a/src/app/blogs/redux/actions/post.actions.ts b/src/app/blogs/redux/actions/post.actions.ts new file mode 100644 index 0000000..fcd7671 --- /dev/null +++ b/src/app/blogs/redux/actions/post.actions.ts @@ -0,0 +1,144 @@ +import { CreateQueryParams } from '@nestjsx/crud-request'; +import { Update } from '@ngrx/entity'; +import { createAction, props } from '@ngrx/store'; + +import { uuid } from '../../../core/types'; +import { Post } from '../../models'; + +const queryAll = createAction( + '[Post Resolver] Query All', + props<{ query?: CreateQueryParams }>() +); + +const queryAllSuccess = createAction( + '[Blog Effects] Query All Success', + props<{ posts: Post[] }>() +); + +const queryAllError = createAction('[Blog Effects] Query All Error'); + +const queryOne = createAction( + '[Post Resolver] Query One', + props<{ id: uuid; query?: CreateQueryParams }>() +); + +const queryOneWithComments = createAction( + '[Post Resolver] Query One With Comments', + props<{ id: uuid; query?: CreateQueryParams }>() +); + +const queryOneError = createAction('[Post Effects] Query One Error'); + +const queryOneSuccess = createAction( + '[Post Effects] Query One Success', + props<{ post: Post }>() +); + +const createOne = createAction( + '[Create Blog Component] Create One', + props<{ post: FormData; query?: CreateQueryParams }>() +); + +const createOneSuccess = createAction( + '[Post Effects] Create One Success', + props<{ post: Post }>() +); + +const createOneError = createAction('[Post Effects] Create One Error'); + +const updateOne = createAction( + '[Blog Detail Component] Update One', + props<{ post: Update; query?: CreateQueryParams }>() +); + +const updateOneSuccess = createAction( + '[Blog Effects] Update One Success', + props<{ post: Update }>() +); + +const updateOneError = createAction('[Blog Effects] Update One Error'); + +const deleteOne = createAction( + '[Blog List Component] Delete One', + props<{ id: uuid }>() +); + +const deleteOneSuccess = createAction( + '[Blog Effects] Delete One Success', + props<{ id: uuid }>() +); + +const deleteOneError = createAction('[Blog Effects] Delete One Error'); + +const toggleBookmark = createAction( + '[Blog List Component] Toggle Bookmark', + props<{ post: Post }>() +); + +const toggleBookmarkSuccess = createAction( + '[Post Effects] Toggle Bookmark Success', + props<{ changes: Update }>() +); + +const toggleBookmarkError = createAction( + '[Post Effects] Toggle Bookmark Error', + props<{ postId: uuid }>() +); + +const toggleUpvote = createAction( + '[Blog List Component] Toggle Upvote', + props<{ post: Partial }>() +); + +const toggleUpvoteError = createAction( + '[Post Effects] Toggle Upvote Error', + props<{ post: Partial }>() +); + +const toggleUpvoteSuccess = createAction( + '[Post Effects] Toggle Upvote Success', + props<{ id: uuid; votes: Post['votes'] }>() +); + +const addCommentIdAfterCreate = createAction( + '[Comment Effect] Add Comment ID To Post', + props<{ postId: uuid; commentId: uuid }>() +); + +const triggerAddCommentId = createAction( + '[Server Sent Event] Add Comment ID To Post', + props<{ postId: uuid; commentId: uuid }>() +); + +const resetAll = createAction('Reset All Posts'); + +const emptyAction = createAction('EMPTY_ACTION'); + +export const PostActions = { + queryAll, + queryAllError, + queryAllSuccess, + queryOne, + queryOneWithComments, + queryOneError, + queryOneSuccess, + createOne, + createOneError, + createOneSuccess, + updateOne, + updateOneError, + updateOneSuccess, + deleteOne, + deleteOneError, + deleteOneSuccess, + toggleBookmark, + toggleBookmarkError, + toggleBookmarkSuccess, + toggleUpvote, + toggleUpvoteError, + toggleUpvoteSuccess, + addCommentIdAfterCreate, + triggerAddCommentId, + resetAll, + emptyAction, +}; diff --git a/src/app/blogs/redux/effects/comment.effects.ts b/src/app/blogs/redux/effects/comment.effects.ts new file mode 100644 index 0000000..13a949a --- /dev/null +++ b/src/app/blogs/redux/effects/comment.effects.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@angular/core'; +import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { of } from 'rxjs'; +import { catchError, concatMap, map, mergeMap } from 'rxjs/operators'; + +import { CommentService } from '../../services'; +import { CommentActions, PostActions } from '../actions'; +import { CommentSelectors, PostSelectors } from '../selectors'; + +@Injectable() +export class CommentEffects { + constructor( + private readonly store: Store, + private readonly action$: Actions, + private readonly commentService: CommentService + ) {} + + // find comment by ids + // if the filteredComments.length === post.commentIds.length, + // then return the comments + // else fetch the comments from api and return + queryAll$ = createEffect(() => + this.action$.pipe( + ofType(CommentActions.queryAll), + concatLatestFrom(({ postId }) => + this.store.select(PostSelectors.getById(postId)) + ), + concatLatestFrom(([, post]) => + this.store.select(CommentSelectors.filterByIds(post.commentIds)) + ), + concatMap(([[{ query }, post], comments]) => { + if (post.commentIds.length === comments.length) { + return of(comments); + } + // find difference of comment ids which are not in store already + const diff = post.commentIds.filter( + (id) => !comments.some((comment) => comment.id === id) + ); + // append those comment ids to query + query = { + ...query, + filter: [{ field: 'id', operator: '$in', value: diff }], + }; + // fetch comments from API + return this.commentService.getCommentByPost(post.id, query); + }), + map((payload) => { + if (Array.isArray(payload)) { + return CommentActions.queryAllSuccess({ comments: payload }); + } + return CommentActions.queryAllSuccess({ comments: payload.data }); + }), + catchError(() => [CommentActions.queryAllError()]) + ) + ); + + createOne$ = createEffect(() => + this.action$.pipe( + ofType(CommentActions.createOne), + mergeMap(({ comment, postId, query }) => + this.commentService.createComment(comment, postId, query) + ), + concatMap((comment) => { + // Add commentId to post + return [ + CommentActions.createOneSuccess({ comment }), + PostActions.triggerAddCommentId({ + commentId: comment.id, + postId: comment.postId, + }), + ]; + }), + catchError(() => [CommentActions.createOneError()]) + ) + ); +} diff --git a/src/app/blogs/redux/effects/index.ts b/src/app/blogs/redux/effects/index.ts new file mode 100644 index 0000000..c171d9b --- /dev/null +++ b/src/app/blogs/redux/effects/index.ts @@ -0,0 +1,2 @@ +export * from './post.effects'; +export * from './comment.effects'; diff --git a/src/app/blogs/redux/effects/post.effects.ts b/src/app/blogs/redux/effects/post.effects.ts new file mode 100644 index 0000000..1ccee66 --- /dev/null +++ b/src/app/blogs/redux/effects/post.effects.ts @@ -0,0 +1,216 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { CreateQueryParams } from '@nestjsx/crud-request'; +import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects'; +import { Update } from '@ngrx/entity'; +import { Store } from '@ngrx/store'; +import { throwError } from 'rxjs'; +import { catchError, concatMap, map, mergeMap } from 'rxjs/operators'; + +import { AuthActions } from '../../../auth/redux/auth.actions'; +import { AuthSelectors } from '../../../auth/redux/auth.selectors'; +import { uuid } from '../../../core/types'; +import { Post } from '../../models'; +import { BlogService } from '../../services'; +import { CommentActions, PostActions } from '../actions'; +import { PostSelectors } from '../selectors'; + +@Injectable() +export class PostEffects { + constructor( + private readonly store: Store, + private readonly router: Router, + private readonly action$: Actions, + private readonly blogService: BlogService + ) {} + + queryAll$ = createEffect(() => + this.action$.pipe( + ofType(PostActions.queryAll), + concatLatestFrom(() => this.store.select(PostSelectors.isLoaded)), + mergeMap(([{ query }, loaded]) => { + if (loaded) { + return this.store.select(PostSelectors.getAllPost); + } + return this.blogService.getAllPost(query).pipe(map(({ data }) => data)); + }), + concatLatestFrom(() => this.store.select(AuthSelectors.currentUser)), + map(([posts, user]) => { + const data = posts.map((post) => { + const userBookmarkIds = user.bookmarkIds; + if (post.bookmarkedIds.some((id) => userBookmarkIds.includes(id))) { + post.isBookmarked = true; + } + return post; + }); + return PostActions.queryAllSuccess({ posts: data }); + }), + catchError(() => [PostActions.queryAllError()]) + ) + ); + + queryOne$ = createEffect(() => + this.action$.pipe( + ofType(PostActions.queryOne), + concatLatestFrom(({ id }) => + this.store.select(PostSelectors.getById(id)) + ), + mergeMap(([{ id, query }, post]) => { + if (!post) { + return this.blogService + .getPostById(id, query) + .pipe(map((post) => PostActions.queryOneSuccess({ post }))); + } + return [PostActions.emptyAction()]; + }), + catchError(() => [PostActions.queryOneError()]) + ) + ); + + queryOneWithComments$ = createEffect(() => + this.action$.pipe( + ofType(PostActions.queryOneWithComments), + concatLatestFrom(({ id }) => + this.store.select(PostSelectors.getById(id)) + ), + mergeMap(([{ id, query }, post]) => { + const commentQParams: CreateQueryParams = { + join: [{ field: 'author' }, { field: 'author.avatar' }], + sort: { field: 'createdOn', order: 'DESC' }, + }; + if (!post) { + return this.blogService.getPostById(id, query).pipe( + concatMap((post) => { + const postId = post.id; + return [ + PostActions.queryOneSuccess({ post }), + CommentActions.queryAll({ postId, query: commentQParams }), + ]; + }) + ); + } + return [ + CommentActions.queryAll({ + postId: post.id, + query: commentQParams, + }), + ]; + }), + catchError(() => [PostActions.queryOneError()]) + ) + ); + + createOne$ = createEffect(() => + this.action$.pipe( + ofType(PostActions.createOne), + mergeMap(({ post, query }) => this.blogService.createPost(post, query)), + map((post) => { + this.router.navigate(['/blogs']); + return PostActions.createOneSuccess({ post }); + }), + catchError(() => [PostActions.createOneError()]) + ) + ); + + updateOne$ = createEffect(() => + this.action$.pipe( + ofType(PostActions.updateOne), + mergeMap(({ post, query }) => { + const postId = post.id as uuid; + return this.blogService + .updatePost(postId, post.changes as FormData, query) + .pipe( + map((post) => { + return { id: postId, changes: post } as Update; + }) + ); + }), + map((post) => { + this.router.navigate(['/blogs']); + return PostActions.updateOneSuccess({ post }); + }), + catchError(() => [PostActions.updateOneError()]) + ) + ); + + deleteOne$ = createEffect(() => + this.action$.pipe( + ofType(PostActions.deleteOne), + mergeMap(({ id }) => this.blogService.deletePost(id)), + map(({ id }) => PostActions.deleteOneSuccess({ id })), + catchError(() => [PostActions.deleteOneError()]) + ) + ); + + toggleBookmark$ = createEffect(() => + this.action$.pipe( + ofType(PostActions.toggleBookmark), + concatMap(({ post }) => + this.blogService.toggleBookmark(post.id).pipe( + catchError((err) => { + PostActions.toggleBookmarkError({ postId: post.id }); + return throwError(err); + }) + ) + ), + concatLatestFrom(({ postId }) => [ + this.store.select(PostSelectors.getById(postId)), + ]), + concatMap(([payload, post]) => { + const { bookmarkedIds } = this._updateBookmarkId(payload, post); + return [ + PostActions.toggleBookmarkSuccess({ + changes: { + id: post.id, + changes: { bookmarkedIds }, + }, + }), + AuthActions.toggleBookmark({ + bookmarkId: payload.id, + isRemoved: payload.isRemoved, + }), + ]; + }), + catchError(() => [PostActions.emptyAction]) + ) + ); + + toggleUpvote$ = createEffect(() => + this.action$.pipe( + ofType(PostActions.toggleUpvote), + concatMap(({ post }) => + this.blogService.toggleVote(post.id).pipe( + catchError((err) => { + [PostActions.toggleUpvoteError({ post })]; + return throwError(err); + }) + ) + ), + map(({ votes, id }) => { + return PostActions.toggleUpvoteSuccess({ id: id, votes }); + }), + catchError(() => [PostActions.emptyAction()]) + ) + ); + + /** + * If the user just bookmark the post, then add the `bookmarkId` + * to the post's `bookmarkedIds` array, else remove the `bookmarkId`. + */ + private _updateBookmarkId(payload, post: Post) { + const isBookmarked = payload.isRemoved ? false : true; + let bookmarkedIds: uuid[] = []; + if (isBookmarked) { + bookmarkedIds = [...post.bookmarkedIds, payload.id]; + } else { + const index = post.bookmarkedIds.findIndex((id) => id === payload.id); + if (index >= 0) { + bookmarkedIds = [...post.bookmarkedIds]; + bookmarkedIds.splice(index, 1); + } + } + return { + bookmarkedIds, + }; + } +} diff --git a/src/app/blogs/redux/reducers/comment.reducer.ts b/src/app/blogs/redux/reducers/comment.reducer.ts new file mode 100644 index 0000000..c610fa8 --- /dev/null +++ b/src/app/blogs/redux/reducers/comment.reducer.ts @@ -0,0 +1,68 @@ +import { createEntityAdapter, EntityState } from '@ngrx/entity'; +import { createReducer, on } from '@ngrx/store'; + +import { Comment } from '../../models'; +import { CommentActions } from '../actions'; + +function sortByCreatedOn(c1: Comment, c2: Comment) { + if (c1.createdOn > c2.createdOn) { + return -1; + } else if (c1.createdOn < c2.createdOn) { + return 1; + } else { + return 0; + } +} + +export interface CommentState extends EntityState { + loaded: boolean; + loading: boolean; +} + +const adapter = createEntityAdapter({ + sortComparer: sortByCreatedOn, +}); + +const initialState = adapter.getInitialState({ + entities: {}, + ids: [], + loaded: false, + loading: false, +}); + +export const commentReducer = createReducer( + initialState, + on(CommentActions.resetAll, (state) => { + return adapter.removeAll({ ...state, loaded: false, loading: false }); + }), + on(CommentActions.queryAll, (state) => { + return { + ...state, + loaded: false, + loading: true, + }; + }), + on(CommentActions.queryAllSuccess, (state, { comments }) => { + return adapter.addMany(comments, { + ...state, + loaded: true, + loading: false, + }); + }), + on(CommentActions.queryAllError, (state) => { + return { + ...state, + loaded: false, + loading: false, + }; + }), + on(CommentActions.createOne, (state) => ({ ...state, loading: true })), + on(CommentActions.createOneError, (state) => ({ ...state, loading: false })), + on(CommentActions.createOneSuccess, (state, { comment }) => { + return adapter.addOne(comment, { ...state, loading: false }); + }) +); + +export const commentAdapter = adapter; + +export const commentFeature = 'comments'; diff --git a/src/app/blogs/redux/reducers/index.ts b/src/app/blogs/redux/reducers/index.ts new file mode 100644 index 0000000..11bc7cd --- /dev/null +++ b/src/app/blogs/redux/reducers/index.ts @@ -0,0 +1,17 @@ +import { ActionReducerMap } from '@ngrx/store'; + +import { commentReducer, CommentState } from './comment.reducer'; +import { postReducer, PostState } from './post.reducer'; + +interface BlogReducerState { + posts: PostState; + comments: CommentState; +} + +export const blogReducers: ActionReducerMap = { + posts: postReducer, + comments: commentReducer, +}; + +export * from './post.reducer'; +export * from './comment.reducer'; diff --git a/src/app/blogs/redux/reducers/post.reducer.ts b/src/app/blogs/redux/reducers/post.reducer.ts new file mode 100644 index 0000000..6c6b54c --- /dev/null +++ b/src/app/blogs/redux/reducers/post.reducer.ts @@ -0,0 +1,134 @@ +import { createEntityAdapter, EntityState, Update } from '@ngrx/entity'; +import { createReducer, on } from '@ngrx/store'; + +import { Post } from '../../models'; +import { PostActions } from '../actions'; + +export interface PostState extends EntityState { + loaded: boolean; + loading: boolean; +} + +const adapter = createEntityAdapter(); + +const initialState = adapter.getInitialState({ + ids: [], + entities: {}, + loaded: false, + loading: false, +}); + +export const postReducer = createReducer( + initialState, + on(PostActions.resetAll, (state) => { + return adapter.removeAll({ ...state, loaded: false, loading: false }); + }), + on(PostActions.queryAll, (state) => ({ + ...state, + loading: true, + loaded: false, + })), + on(PostActions.queryAllSuccess, (state, { posts }) => { + return adapter.setAll(posts, { ...state, loaded: true, loading: false }); + }), + on(PostActions.queryAllError, (state) => ({ + ...state, + loading: false, + loaded: false, + })), + on( + PostActions.createOne, + PostActions.queryOne, + PostActions.updateOne, + PostActions.deleteOne, + (state) => ({ + ...state, + loading: true, + }) + ), + on( + PostActions.createOneError, + PostActions.queryOneError, + PostActions.updateOneError, + PostActions.deleteOneError, + PostActions.emptyAction, + (state) => ({ + ...state, + loading: false, + }) + ), + on(PostActions.createOneSuccess, (state, { post }) => { + return adapter.addOne(post, { ...state, loading: false }); + }), + on(PostActions.queryOneSuccess, (state, { post }) => { + return adapter.upsertOne(post, { ...state, loading: false }); + }), + on(PostActions.updateOneSuccess, (state, { post }) => { + return adapter.updateOne(post, { ...state, loading: false }); + }), + on(PostActions.deleteOneSuccess, (state, { id }) => { + return adapter.removeOne(id, { ...state, loading: false }); + }), + on(PostActions.toggleUpvote, (state, { post }) => { + const update: Update = { + id: post.id, + changes: { isUpvoted: !post.isUpvoted }, + }; + return adapter.updateOne(update, { ...state, loading: true }); + }), + on(PostActions.toggleUpvoteSuccess, (state, { id, votes }) => { + const update: Update = { + id: id, + changes: { votes }, + }; + return adapter.updateOne(update, { ...state, loading: false }); + }), + on(PostActions.toggleUpvoteError, (state, { post }) => { + const update: Update = { + id: post.id, + changes: { isUpvoted: !post.isUpvoted }, + }; + return adapter.updateOne(update, { ...state, loading: false }); + }), + on(PostActions.toggleBookmark, (state, { post }) => { + const update: Update = { + id: post.id, + changes: { isBookmarked: !post.isBookmarked }, + }; + return adapter.updateOne(update, { ...state, loading: true }); + }), + on(PostActions.toggleBookmarkSuccess, (state, { changes }) => { + return adapter.updateOne(changes, { ...state, loading: false }); + }), + on(PostActions.toggleBookmarkError, (state, { postId }) => { + const post = adapter.getSelectors().selectEntities(state)[postId]; + const update: Update = { + id: postId, + changes: { isBookmarked: !post.isBookmarked }, + }; + return adapter.updateOne(update, { ...state, loading: false }); + }), + on( + PostActions.addCommentIdAfterCreate, + PostActions.triggerAddCommentId, + (state, { postId, commentId }) => { + const post = adapter.getSelectors().selectEntities(state)[postId]; + if (post) { + const update: Update = { + id: post.id, + changes: { + commentIds: Array.from(new Set([...post.commentIds, commentId])), + }, + }; + return adapter.updateOne(update, state); + } + return { + ...state, + }; + } + ) +); + +export const postFeature = 'posts'; + +export const postAdapter = adapter; diff --git a/src/app/blogs/redux/selectors/comment.selectors.ts b/src/app/blogs/redux/selectors/comment.selectors.ts new file mode 100644 index 0000000..e8ce08a --- /dev/null +++ b/src/app/blogs/redux/selectors/comment.selectors.ts @@ -0,0 +1,27 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { uuid } from '../../../core/types'; +import { commentAdapter, commentFeature, CommentState } from '../reducers'; + +export const commentFeatureSelector = + createFeatureSelector(commentFeature); + +export const allComments = createSelector(commentFeatureSelector, (state) => + commentAdapter.getSelectors().selectAll(state) +); + +/** + * Filter comments based on their ids + * @param ids Comment IDs + * @returns Post[] + */ +export const filterByIds = (ids: uuid[]) => + createSelector(allComments, (comments) => + comments.filter(({ id }) => ids.includes(id)) + ); + +export const isLoading = createSelector( + commentFeatureSelector, + (state) => state.loading +); + +export const CommentSelectors = { allComments, filterByIds, isLoading }; diff --git a/src/app/blogs/redux/selectors/index.ts b/src/app/blogs/redux/selectors/index.ts new file mode 100644 index 0000000..21c3515 --- /dev/null +++ b/src/app/blogs/redux/selectors/index.ts @@ -0,0 +1,2 @@ +export * from './post.selectors'; +export * from './comment.selectors'; diff --git a/src/app/blogs/redux/selectors/post.selectors.ts b/src/app/blogs/redux/selectors/post.selectors.ts new file mode 100644 index 0000000..8b1ac48 --- /dev/null +++ b/src/app/blogs/redux/selectors/post.selectors.ts @@ -0,0 +1,26 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { uuid } from '../../../core/types'; + +import { postAdapter, postFeature, PostState } from '../reducers'; + +const postSelectorFeature = createFeatureSelector(postFeature); + +const isLoaded = createSelector(postSelectorFeature, (state) => state.loaded); + +const isLoading = createSelector(postSelectorFeature, (state) => state.loading); + +const getAllPost = createSelector(postSelectorFeature, (state) => + postAdapter.getSelectors().selectAll(state) +); + +const getById = (id: uuid) => + createSelector(postSelectorFeature, (state) => { + return state.entities[id]; + }); + +export const PostSelectors = { + getAllPost, + isLoaded, + isLoading, + getById, +}; diff --git a/src/app/blogs/resolvers/get-post-by-id.resolver.ts b/src/app/blogs/resolvers/get-post-by-id.resolver.ts new file mode 100644 index 0000000..db5a323 --- /dev/null +++ b/src/app/blogs/resolvers/get-post-by-id.resolver.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; +import { CreateQueryParams } from '@nestjsx/crud-request'; +import { Store } from '@ngrx/store'; + +import { PostActions } from '../redux/actions'; + +@Injectable() +export class GetPostByIdResolver implements Resolve { + constructor(private readonly store: Store) {} + + resolve(route: ActivatedRouteSnapshot): unknown { + const postId = route.params.id; + const query: CreateQueryParams = { + join: [ + { field: 'image' }, + { field: 'author' }, + { field: 'author.avatar' }, + ], + }; + // Either simply dispatch the event (extra logic inside the queryOne) + // or dispatch the event only when existing entity doesn't exists + // inside the store queried via selector. + const isEditRoute = route.url.some((segment) => + segment.path.includes('edit') + ); + if (isEditRoute) { + return this.store.dispatch(PostActions.queryOne({ id: postId, query })); + } + return this.store.dispatch( + PostActions.queryOneWithComments({ id: postId, query }) + ); + } +} diff --git a/src/app/blogs/resolvers/get-posts.resolver.ts b/src/app/blogs/resolvers/get-posts.resolver.ts new file mode 100644 index 0000000..5214909 --- /dev/null +++ b/src/app/blogs/resolvers/get-posts.resolver.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { Resolve } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { filter, finalize, first, tap } from 'rxjs/operators'; +import { PostActions } from '../redux/actions'; +import { PostSelectors } from '../redux/selectors'; + +@Injectable() +export class PostResolver implements Resolve { + constructor(private readonly store: Store) {} + + isLoading = false; + + resolve() { + return this.store.select(PostSelectors.isLoaded).pipe( + tap((loaded) => { + if (!loaded && !this.isLoading) { + this.isLoading = true; + this.store.dispatch( + PostActions.queryAll({ + query: { + join: [ + { field: 'author' }, + { field: 'image' }, + { field: 'author.avatar' }, + ], + sort: { field: 'createdOn', order: 'DESC' }, + }, + }) + ); + } + }), + filter((loaded) => loaded), + first(), + finalize(() => { + this.isLoading = false; + }) + ); + } +} diff --git a/src/app/blogs/resolvers/index.ts b/src/app/blogs/resolvers/index.ts new file mode 100644 index 0000000..7548bfe --- /dev/null +++ b/src/app/blogs/resolvers/index.ts @@ -0,0 +1,2 @@ +export * from './get-posts.resolver'; +export * from './get-post-by-id.resolver'; diff --git a/src/app/blogs/services/blog.service.ts b/src/app/blogs/services/blog.service.ts index 022ed25..814f56a 100644 --- a/src/app/blogs/services/blog.service.ts +++ b/src/app/blogs/services/blog.service.ts @@ -59,23 +59,40 @@ export class BlogService { } deletePost(id: uuid) { - return this.http.delete(`${environment.apiUrl}/posts/${id}`); + return this.http + .delete(`${environment.apiUrl}/posts/${id}`) + .pipe(map((obj) => ({ ...obj, id }))); } toggleVote(postId: uuid) { - return this.http.patch( - `${environment.apiUrl}/posts/${postId}/react`, - {} - ); + return this.http + .patch>( + `${environment.apiUrl}/posts/${postId}/react`, + {} + ) + .pipe( + map((post) => { + return { ...post, id: postId } as Pick; + }) + ); } toggleBookmark(postId: uuid) { - return this.http.post<{ id: uuid; isRemoved: boolean }>( - `${environment.apiUrl}/posts/${postId}/bookmark`, - { - postId, - } - ); + return this.http + .post<{ id: uuid; isRemoved: boolean }>( + `${environment.apiUrl}/posts/${postId}/bookmark`, + { + postId, + } + ) + .pipe( + map((payload) => { + return { + ...payload, + postId, + }; + }) + ); } // Private functions diff --git a/src/app/blogs/services/comment.service.ts b/src/app/blogs/services/comment.service.ts index bfc977b..d1e3325 100644 --- a/src/app/blogs/services/comment.service.ts +++ b/src/app/blogs/services/comment.service.ts @@ -1,15 +1,24 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; - -import { Comment } from '../models'; -import { environment } from '../../../environments/environment'; import { CreateQueryParams, RequestQueryBuilder } from '@nestjsx/crud-request'; +import { Store } from '@ngrx/store'; import { map } from 'rxjs/operators'; -import { CrudListResponse } from '../../core/types'; + +import { environment } from '../../../environments/environment'; +import { ServerEventService } from '../../core/services'; +import { CrudListResponse, uuid } from '../../core/types'; +import { Comment } from '../models'; +import { PostActions } from '../redux/actions'; @Injectable() export class CommentService { - constructor(private readonly http: HttpClient) {} + constructor( + private readonly http: HttpClient, + private readonly store: Store, + private readonly serverEvent: ServerEventService + ) { + this._listenServerEvents(); + } getAllComments(query: CreateQueryParams = {}) { const qb = RequestQueryBuilder.create(query).query(); @@ -27,6 +36,29 @@ export class CommentService { ); } + getCommentByPost(postId: uuid, query: CreateQueryParams) { + const qParams: CreateQueryParams = { + ...query, + filter: [ + { field: 'postId', operator: '$eq', value: postId }, + ...(Array.isArray(query.filter) ? query.filter : [query.filter]), + ], + }; + const qb = RequestQueryBuilder.create(qParams).query(); + return this.http + .get>(`${environment.apiUrl}/comments`, { + params: new HttpParams({ fromString: qb }), + }) + .pipe( + map((payload) => { + return { + ...payload, + data: payload.data.map((comment) => new Comment(comment)), + }; + }) + ); + } + createComment( content: string, postId: string, @@ -44,4 +76,17 @@ export class CommentService { ) .pipe(map((payload) => new Comment(payload))); } + + private _listenServerEvents() { + this.serverEvent + .onEvent<{ id: uuid; postId: uuid }>('CommentCreated') + .subscribe(({ data }) => { + const { id: commentId, postId } = data; + if (postId) { + this.store.dispatch( + PostActions.triggerAddCommentId({ postId, commentId }) + ); + } + }); + } } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index e3b78c2..1cc16a6 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,8 +1,8 @@ import { NgModule } from '@angular/core'; -import { AuthService } from './services'; +import { AuthService, ServerEventService } from './services'; import { TokenGuard } from './guards'; @NgModule({ - providers: [TokenGuard, AuthService], + providers: [TokenGuard, AuthService, ServerEventService], }) export class CoreModule {} diff --git a/src/app/core/guards/token.guard.ts b/src/app/core/guards/token.guard.ts index fd766fc..c6a5e54 100644 --- a/src/app/core/guards/token.guard.ts +++ b/src/app/core/guards/token.guard.ts @@ -1,28 +1,48 @@ import { Injectable } from '@angular/core'; import { CanLoad, Route, UrlSegment, UrlTree } from '@angular/router'; -import { Observable, of } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; +import { concatLatestFrom } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { Observable, of, throwError } from 'rxjs'; +import { + catchError, + concatMap, + filter, + first, + map, + mergeMap, +} from 'rxjs/operators'; -import { AuthService } from '../services'; +import { AuthActions } from '../../auth/redux/auth.actions'; +import { AuthSelectors } from '../../auth/redux/auth.selectors'; @Injectable() export class TokenGuard implements CanLoad { - constructor(private readonly authService: AuthService) {} - canLoad( - route: Route, - segments: UrlSegment[] - ): + constructor(private readonly store: Store) {} + canLoad(): | boolean | UrlTree | Observable | Promise { - const accessToken = this.authService.getAccessToken(); - if (!accessToken) { - return this.authService.refreshToken().pipe( - map(() => true), - catchError(() => of(false)) - ); - } - return true; + return this.store.select(AuthSelectors.accessToken).pipe( + first(), + mergeMap((token) => { + if (!token) { + return of( + this.store.dispatch(AuthActions.refreshTokenRequestedByGuard()) + ).pipe( + concatMap(() => + this.store.select(AuthSelectors.accessToken).pipe( + filter((token) => !!token), + first() + ) + ), + map(() => true), + catchError(() => of(false)) + ); + } else { + return of(true); + } + }) + ); } } diff --git a/src/app/core/redux/reducers/index.ts b/src/app/core/redux/reducers/index.ts new file mode 100644 index 0000000..315e6d2 --- /dev/null +++ b/src/app/core/redux/reducers/index.ts @@ -0,0 +1,12 @@ +import { routerReducer, RouterReducerState } from '@ngrx/router-store'; +import { ActionReducerMap, MetaReducer } from '@ngrx/store'; + +export interface GlobalState { + router: RouterReducerState; +} + +export const globalReducers: ActionReducerMap = { + router: routerReducer, +}; + +export const metaReducers: MetaReducer[] = []; diff --git a/src/app/core/services/auth.service.ts b/src/app/core/services/auth.service.ts index a5648c1..069f124 100644 --- a/src/app/core/services/auth.service.ts +++ b/src/app/core/services/auth.service.ts @@ -2,8 +2,8 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { CreateQueryParams, RequestQueryBuilder } from '@nestjsx/crud-request'; -import { iif, of, throwError } from 'rxjs'; -import { catchError, concatMap, map, tap } from 'rxjs/operators'; +import { throwError } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; import { environment } from '../../../environments/environment'; import { User } from '../../auth/models'; @@ -15,10 +15,6 @@ interface TokensPayload { @Injectable() export class AuthService { - private accessToken: string; - private currentUser: User; - private tokenExpiresIn: number; - constructor( private readonly http: HttpClient, private readonly router: Router @@ -29,32 +25,14 @@ export class AuthService { } login(email: string, password: string) { - return this.http - .post(`${environment.apiUrl}/auth/login`, { - email, - password, - }) - .pipe( - concatMap((payload) => { - // TODO: check from store if current user details are present or not. - this._saveTokenAndExpireDuration(payload); - return iif( - () => !!this.currentUser, - of(payload), - this.whoAmI().pipe(map(() => payload)) - ); - }) - ); + return this.http.post(`${environment.apiUrl}/auth/login`, { + email, + password, + }); } logout() { - return this.http.get(`${environment.apiUrl}/auth/logout`).pipe( - tap(() => { - this.accessToken = null; - this.tokenExpiresIn = null; - this.currentUser = null; - }) - ); + return this.http.get(`${environment.apiUrl}/auth/logout`); } refreshToken() { @@ -63,15 +41,6 @@ export class AuthService { withCredentials: true, }) .pipe( - concatMap((payload) => { - // TODO: check from store if current user details are present or not. - this._saveTokenAndExpireDuration(payload); - return iif( - () => !!this.currentUser, - of(payload), - this.whoAmI().pipe(map(() => payload)) - ); - }), catchError((err) => { this.router.navigate(['/auth/login']); return throwError(err); @@ -81,28 +50,8 @@ export class AuthService { whoAmI(query: CreateQueryParams = { join: { field: 'avatar' } }) { const qb = RequestQueryBuilder.create(query).query(); - return this.http - .get(`${environment.apiUrl}/auth/me`, { - params: new HttpParams({ fromString: qb }), - }) - .pipe(tap((payload) => (this.currentUser = new User(payload)))); - } - - getAccessToken(): string { - return this.accessToken; - } - - getCurrentUser(): User { - return this.currentUser; - } - - isTokenExpired(): boolean { - return Date.now() > this.tokenExpiresIn; - } - - private _saveTokenAndExpireDuration(payload: TokensPayload) { - // Soon this will shift to ngrx/store. - this.accessToken = payload.accessToken; - this.tokenExpiresIn = payload.expiresIn; + return this.http.get(`${environment.apiUrl}/auth/me`, { + params: new HttpParams({ fromString: qb }), + }); } } diff --git a/src/app/core/services/index.ts b/src/app/core/services/index.ts index 5b6968f..00203d4 100644 --- a/src/app/core/services/index.ts +++ b/src/app/core/services/index.ts @@ -1,2 +1,3 @@ export * from './auth.service'; export * from './request-queue.service'; +export * from './server-event.service'; diff --git a/src/app/core/services/request-queue.service.ts b/src/app/core/services/request-queue.service.ts index c18ef8e..ec77be1 100644 --- a/src/app/core/services/request-queue.service.ts +++ b/src/app/core/services/request-queue.service.ts @@ -46,7 +46,8 @@ export class RequestQueueService implements HttpInterceptor { } attachToken(req: HttpRequest) { - const accessToken = this.authService.getAccessToken(); + // const accessToken = this.authService.getAccessToken(); + const accessToken = ''; req = req.clone({ headers: req.headers.set('Authorization', `Bearer ${accessToken}`), }); diff --git a/src/app/core/services/server-event.service.spec.ts b/src/app/core/services/server-event.service.spec.ts new file mode 100644 index 0000000..4bd6c66 --- /dev/null +++ b/src/app/core/services/server-event.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ServerEventService } from './server-event.service'; + +describe('ServerEventService', () => { + let service: ServerEventService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ServerEventService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/core/services/server-event.service.ts b/src/app/core/services/server-event.service.ts new file mode 100644 index 0000000..0e63029 --- /dev/null +++ b/src/app/core/services/server-event.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { environment } from '../../../environments/environment'; + +interface EventPayload { + type: string; + data: T; +} + +@Injectable() +export class ServerEventService { + private readonly eventSource: EventSource; + private readonly event$ = new BehaviorSubject({ + type: null, + data: null, + }); + + constructor() { + this.eventSource = new EventSource(`${environment.apiUrl}/events/sse`); + } + + getEventSource(): EventSource { + return this.eventSource; + } + + /** + * Add the event listener of type `eventType` if not already + * and return an observable emitting events of type specified + * in `eventType` + * @param eventType + * @returns Observable of type EventPayload + */ + onEvent(eventType: string): Observable> { + this.eventSource.addEventListener(eventType, (evt: MessageEvent) => { + this.event$.next({ type: evt.type, data: JSON.parse(evt.data) }); + }); + return this.event$ + .asObservable() + .pipe(filter(({ type }) => type === eventType)); + } +} diff --git a/yarn.lock b/yarn.lock index 5310a36..65ee4b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1226,6 +1226,46 @@ resolved "https://registry.yarnpkg.com/@nestjsx/util/-/util-5.0.0-alpha.3.tgz#d1182280e9254f0d335efa369da17fa25d260135" integrity sha512-UTYIxtyx80M42gOZ0VIInSiOLn78P6k1BCziiN8gE3FglzivQ1RGvPpmsRwioYEWG27gOgnMwOQnXv8Zbq8dzQ== +"@ngrx/effects@^12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@ngrx/effects/-/effects-12.0.0.tgz#87c0aeeb760f11205c9d7c3168169e753780c926" + integrity sha512-T2Xu5sfYXg5+88al8laYv6sSuQzIeIfPzuY2o+XOF1407qYtafTXDjWcPosIqH0HYNBjPMWZFZbdQOHVX38mrQ== + dependencies: + tslib "^2.0.0" + +"@ngrx/entity@^12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@ngrx/entity/-/entity-12.0.0.tgz#7a73ef0b349e2e63831ceb15c889389f0985704c" + integrity sha512-a5VOLE0mIB7Ro1IFbFGoddYXvjG2cxznmqsL5AnpE3A1MnubQCmQOxqHOyO1TQVMnh72IsvYDwb5YP46Hj6k+w== + dependencies: + tslib "^2.0.0" + +"@ngrx/router-store@11.1.1": + version "11.1.1" + resolved "https://registry.yarnpkg.com/@ngrx/router-store/-/router-store-11.1.1.tgz#a4ce866b3247ceb7903f81b4af6a960d869729cf" + integrity sha512-KE3O43wCf0VGFFIilusgSc4G5+CA++5wYL9xvdc8/BVr6sXXmOklrhyRVU/reQqy7HdYzbIofLKox5mXE/smdA== + dependencies: + tslib "^2.0.0" + +"@ngrx/schematics@^12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@ngrx/schematics/-/schematics-12.0.0.tgz#8cb38463a077640f24232074c26727ec4a57e920" + integrity sha512-fn++t/0K0rkJcqJUZ02ZpsP4kRZS0tAblGJrf/KlhK2HeaCArNwxq9cFReMvNLEsclCI4IsuJ0Hetylt8RolQg== + +"@ngrx/store-devtools@11.1.1": + version "11.1.1" + resolved "https://registry.yarnpkg.com/@ngrx/store-devtools/-/store-devtools-11.1.1.tgz#bdefc5c03dcac6fc21fcc9e33a4a3a3464f3e2d6" + integrity sha512-YvTMy8HUMUiiyrzKn4KhrQzkAg2yBfLgd1PHR0yPjyzj7RCaYZHwhq6sP0+AiFMBi1Lat8Wczy7SwwLTa6aZ3w== + dependencies: + tslib "^2.0.0" + +"@ngrx/store@11.1.1": + version "11.1.1" + resolved "https://registry.yarnpkg.com/@ngrx/store/-/store-11.1.1.tgz#e971d767ae5caa8984daad23cda38c6fd94dc888" + integrity sha512-tkeKoyYo631hLJ1I8+bm9EWoi7E0A3i4IMjvf956Vpu5IdMnP6d0HW3lKU/ruhFD5YOXAHcUgEIWyfxxILABag== + dependencies: + tslib "^2.0.0" + "@ngtools/webpack@11.2.10": version "11.2.10" resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-11.2.10.tgz#b341c2dd20b6c2b6be466960fbd9e51a563ef326"