diff --git a/apps/angular/55-back-button-navigation/src/app/app.routes.ts b/apps/angular/55-back-button-navigation/src/app/app.routes.ts index 7deecd57a..05d50caa4 100644 --- a/apps/angular/55-back-button-navigation/src/app/app.routes.ts +++ b/apps/angular/55-back-button-navigation/src/app/app.routes.ts @@ -1,4 +1,5 @@ import { Routes } from '@angular/router'; +import { canDeactivateGuard } from './can-deactivate.guard'; import { HomeComponent } from './home/home.component'; import { SensitiveActionComponent } from './sensitive-action/sensitive-action.component'; import { SimpleActionComponent } from './simple-action/simple-action.component'; @@ -16,9 +17,11 @@ export const APP_ROUTES: Routes = [ { path: 'simple-action', component: SimpleActionComponent, + canDeactivate: [canDeactivateGuard], }, { path: 'sensitive-action', component: SensitiveActionComponent, + canDeactivate: [canDeactivateGuard], }, ]; diff --git a/apps/angular/55-back-button-navigation/src/app/base-dialog.ts b/apps/angular/55-back-button-navigation/src/app/base-dialog.ts new file mode 100644 index 000000000..592916a47 --- /dev/null +++ b/apps/angular/55-back-button-navigation/src/app/base-dialog.ts @@ -0,0 +1,33 @@ +import { + AfterContentInit, + ChangeDetectionStrategy, + Component, + inject, +} from '@angular/core'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { DialogData, DialogStrategyType } from './dialog-strategy'; +import { DialogService } from './dialog.service'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: '', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BaseDialogComponent implements AfterContentInit { + readonly #data = inject(MAT_DIALOG_DATA); + protected readonly dialogService = inject(DialogService); + + ngAfterContentInit(): void { + this.initStrategy(); + } + + private initStrategy() { + const strategyType: DialogStrategyType = this.#data?.strategy?.type + ? this.#data?.strategy?.type + : 'default'; + + this.dialogService.setStrategy(strategyType); + } +} diff --git a/apps/angular/55-back-button-navigation/src/app/can-deactivate.guard.ts b/apps/angular/55-back-button-navigation/src/app/can-deactivate.guard.ts new file mode 100644 index 000000000..3f54cee3e --- /dev/null +++ b/apps/angular/55-back-button-navigation/src/app/can-deactivate.guard.ts @@ -0,0 +1,18 @@ +import { CanDeactivateFn, UrlTree } from '@angular/router'; +import { Observable } from 'rxjs'; + +export type CanDeactivateType = + | Observable + | Promise + | boolean + | UrlTree; + +export interface CanComponentDeactivate { + canDeactivate: () => CanDeactivateType; +} + +export const canDeactivateGuard: CanDeactivateFn = ( + component: CanComponentDeactivate, +) => { + return component.canDeactivate ? component.canDeactivate() : true; +}; diff --git a/apps/angular/55-back-button-navigation/src/app/confirm-dialog/confirm-dialog.component.html b/apps/angular/55-back-button-navigation/src/app/confirm-dialog/confirm-dialog.component.html new file mode 100644 index 000000000..6370bdeca --- /dev/null +++ b/apps/angular/55-back-button-navigation/src/app/confirm-dialog/confirm-dialog.component.html @@ -0,0 +1,19 @@ +

Confirm Action

+ + Are you sure you want to perform this action? + + + + + diff --git a/apps/angular/55-back-button-navigation/src/app/confirm-dialog/confirm-dialog.component.ts b/apps/angular/55-back-button-navigation/src/app/confirm-dialog/confirm-dialog.component.ts new file mode 100644 index 000000000..9c6a68067 --- /dev/null +++ b/apps/angular/55-back-button-navigation/src/app/confirm-dialog/confirm-dialog.component.ts @@ -0,0 +1,28 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { + MatDialogActions, + MatDialogClose, + MatDialogContent, + MatDialogTitle, +} from '@angular/material/dialog'; +import { BaseDialogComponent } from '../base-dialog'; + +@Component({ + selector: 'app-confirm-dialog', + templateUrl: './confirm-dialog.component.html', + standalone: true, + imports: [ + MatButtonModule, + MatDialogActions, + MatDialogClose, + MatDialogTitle, + MatDialogContent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfirmDialogComponent extends BaseDialogComponent { + constructor() { + super(); + } +} diff --git a/apps/angular/55-back-button-navigation/src/app/dialog-strategy.ts b/apps/angular/55-back-button-navigation/src/app/dialog-strategy.ts new file mode 100644 index 000000000..3a8392be6 --- /dev/null +++ b/apps/angular/55-back-button-navigation/src/app/dialog-strategy.ts @@ -0,0 +1,47 @@ +import { Injectable, inject } from '@angular/core'; +import { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component'; +import { DialogService } from './dialog.service'; + +export type DialogStrategyType = 'default' | 'confirm'; + +export type DialogData = { + strategy: { + type: DialogStrategyType; + }; +}; + +export abstract class DialogStrategy { + protected readonly dialogService = inject(DialogService); + + abstract onBackBrowserNavigation(): boolean; +} + +@Injectable({ + providedIn: 'root', +}) +export class DefaultDialogStrategy extends DialogStrategy { + override onBackBrowserNavigation(): boolean { + this.dialogService.closeActiveDialog(); + + return false; + } +} + +@Injectable({ + providedIn: 'root', +}) +export class ConfirmDialogStrategy extends DialogStrategy { + override onBackBrowserNavigation(): boolean { + this.dialogService.openDialog(ConfirmDialogComponent, { + width: '250px', + closeOnNavigation: false, + }); + + return false; + } +} + +export const DialogStrategyMap = new Map([ + ['default', DefaultDialogStrategy], + ['confirm', ConfirmDialogStrategy], +]); diff --git a/apps/angular/55-back-button-navigation/src/app/dialog.service.ts b/apps/angular/55-back-button-navigation/src/app/dialog.service.ts new file mode 100644 index 000000000..219448e4f --- /dev/null +++ b/apps/angular/55-back-button-navigation/src/app/dialog.service.ts @@ -0,0 +1,109 @@ +import { ComponentType } from '@angular/cdk/portal'; +import { Injectable, Injector, inject } from '@angular/core'; +import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; +import { take, tap } from 'rxjs'; +import { + DialogStrategy, + DialogStrategyMap, + DialogStrategyType, +} from './dialog-strategy'; + +@Injectable({ + providedIn: 'root', +}) +export class DialogService { + readonly #dialog = inject(MatDialog); + readonly #dialogsStrategyState: { + id: string; + type: DialogStrategy; + active: boolean; + }[] = []; + + readonly #injector = inject(Injector); + + openDialog, Z extends MatDialogConfig>( + component: T, + config: Z, + ) { + const dialogRef = this.#dialog.open(component, config); + + dialogRef + .afterClosed() + .pipe( + take(1), + tap(() => this.removeDialogStrategyStateById(dialogRef.id)), + ) + .subscribe(); + } + + setStrategy(type: DialogStrategyType) { + this.#dialog.openDialogs.forEach((d) => { + const state = this.getDialogStrategyStateById(d.id); + + if (!state) { + this.addDialogStrategyState(d.id, type); + } + }); + } + + getStrategyType() { + const active = this.getActiveDialog(); + + if (!active) { + return null; + } + + return this.getDialogStrategyStateById(active.id)?.type; + } + + closeActiveDialog() { + const activeDialog = this.#dialogsStrategyState.find((d) => d.active); + + if (!activeDialog) { + return; + } + + this.#dialog.getDialogById(activeDialog.id)?.close(); + } + + closeAll() { + this.#dialogsStrategyState.splice(0, this.#dialogsStrategyState.length); + + this.#dialog.closeAll(); + } + + getActiveDialog() { + return this.#dialogsStrategyState.find((d) => d.active); + } + + addDialogStrategyState(id: string, type: DialogStrategyType) { + const t = this.#injector.get(DialogStrategyMap.get(type)); + + this.#dialogsStrategyState.map((x) => (x.active = false)); + + this.#dialogsStrategyState.push({ id, type: t, active: true }); + } + + getDialogStrategyStateById(id: string) { + return this.#dialogsStrategyState.find((s) => s.id === id); + } + + removeDialogStrategyStateById(id: string) { + const indexToRemove = this.#dialogsStrategyState.findIndex( + (d) => d.id === id, + ); + + if (indexToRemove > 0) { + this.#dialogsStrategyState[indexToRemove - 1] = { + ...this.#dialogsStrategyState[indexToRemove - 1], + active: true, + }; + } + + this.#dialogsStrategyState.splice(indexToRemove, 1); + } + + getDialogsStrategyState() { + return this.#dialogsStrategyState; + } +} diff --git a/apps/angular/55-back-button-navigation/src/app/dialog/dialog.component.ts b/apps/angular/55-back-button-navigation/src/app/dialog/dialog.component.ts index 9a9dd0fef..70028870e 100644 --- a/apps/angular/55-back-button-navigation/src/app/dialog/dialog.component.ts +++ b/apps/angular/55-back-button-navigation/src/app/dialog/dialog.component.ts @@ -1,15 +1,15 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatDialogActions, MatDialogClose, MatDialogContent, - MatDialogRef, MatDialogTitle, } from '@angular/material/dialog'; +import { BaseDialogComponent } from '../base-dialog'; @Component({ - selector: 'app-dialog-dialog', + selector: 'app-dialog', templateUrl: './dialog.component.html', imports: [ MatButtonModule, @@ -20,6 +20,8 @@ import { ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DialogComponent { - readonly dialogRef = inject(MatDialogRef); +export class DialogComponent extends BaseDialogComponent { + constructor() { + super(); + } } diff --git a/apps/angular/55-back-button-navigation/src/app/home/home.component.html b/apps/angular/55-back-button-navigation/src/app/home/home.component.html index cce9e6d4f..40d4fc89d 100644 --- a/apps/angular/55-back-button-navigation/src/app/home/home.component.html +++ b/apps/angular/55-back-button-navigation/src/app/home/home.component.html @@ -1,7 +1,7 @@ - + Go to simple dialog action page - + Go to sensitive dialog action page diff --git a/apps/angular/55-back-button-navigation/src/app/sensitive-action/sensitive-action.component.html b/apps/angular/55-back-button-navigation/src/app/sensitive-action/sensitive-action.component.html index bcb7382e9..5623d70f0 100644 --- a/apps/angular/55-back-button-navigation/src/app/sensitive-action/sensitive-action.component.html +++ b/apps/angular/55-back-button-navigation/src/app/sensitive-action/sensitive-action.component.html @@ -1,3 +1,5 @@ + +Home diff --git a/apps/angular/55-back-button-navigation/src/app/sensitive-action/sensitive-action.component.ts b/apps/angular/55-back-button-navigation/src/app/sensitive-action/sensitive-action.component.ts index a97282c33..d071474c5 100644 --- a/apps/angular/55-back-button-navigation/src/app/sensitive-action/sensitive-action.component.ts +++ b/apps/angular/55-back-button-navigation/src/app/sensitive-action/sensitive-action.component.ts @@ -1,19 +1,30 @@ import { Component, inject } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MatDialog } from '@angular/material/dialog'; -import { DialogComponent } from '../dialog/dialog.component'; +import { RouterLink } from '@angular/router'; +import { CanComponentDeactivate } from '../can-deactivate.guard'; +import { DialogService } from '../dialog.service'; +import { SensitiveDialogComponent } from '../sensitive-dialog/sensitive-dialog.component'; @Component({ - imports: [MatButtonModule], + standalone: true, + imports: [MatButtonModule, RouterLink], selector: 'app-sensitive-action', templateUrl: './sensitive-action.component.html', }) -export class SensitiveActionComponent { - readonly #dialog = inject(MatDialog); +export class SensitiveActionComponent implements CanComponentDeactivate { + readonly #dialogService = inject(DialogService); openDialog(): void { - this.#dialog.open(DialogComponent, { - width: '250px', + this.#dialogService.openDialog(SensitiveDialogComponent, { + width: '450px', + data: { strategy: { type: 'confirm' } }, + closeOnNavigation: false, }); } + + canDeactivate() { + return ( + this.#dialogService.getStrategyType()?.onBackBrowserNavigation() ?? true + ); + } } diff --git a/apps/angular/55-back-button-navigation/src/app/sensitive-dialog/sensitive-dialog.component.html b/apps/angular/55-back-button-navigation/src/app/sensitive-dialog/sensitive-dialog.component.html new file mode 100644 index 000000000..9ef327511 --- /dev/null +++ b/apps/angular/55-back-button-navigation/src/app/sensitive-dialog/sensitive-dialog.component.html @@ -0,0 +1,6 @@ +

Sensitive File Deletion

+Would you like to delete cat.jpeg? + + + + diff --git a/apps/angular/55-back-button-navigation/src/app/sensitive-dialog/sensitive-dialog.component.ts b/apps/angular/55-back-button-navigation/src/app/sensitive-dialog/sensitive-dialog.component.ts new file mode 100644 index 000000000..135b5afe7 --- /dev/null +++ b/apps/angular/55-back-button-navigation/src/app/sensitive-dialog/sensitive-dialog.component.ts @@ -0,0 +1,28 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { + MatDialogActions, + MatDialogClose, + MatDialogContent, + MatDialogTitle, +} from '@angular/material/dialog'; +import { BaseDialogComponent } from '../base-dialog'; + +@Component({ + selector: 'app-sensitive-dialog', + templateUrl: './sensitive-dialog.component.html', + standalone: true, + imports: [ + MatButtonModule, + MatDialogActions, + MatDialogClose, + MatDialogTitle, + MatDialogContent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SensitiveDialogComponent extends BaseDialogComponent { + constructor() { + super(); + } +} diff --git a/apps/angular/55-back-button-navigation/src/app/simple-action/simple-action.component.html b/apps/angular/55-back-button-navigation/src/app/simple-action/simple-action.component.html index 95f63e65e..a6f273fec 100644 --- a/apps/angular/55-back-button-navigation/src/app/simple-action/simple-action.component.html +++ b/apps/angular/55-back-button-navigation/src/app/simple-action/simple-action.component.html @@ -1 +1,2 @@ +Home diff --git a/apps/angular/55-back-button-navigation/src/app/simple-action/simple-action.component.ts b/apps/angular/55-back-button-navigation/src/app/simple-action/simple-action.component.ts index fe97e7368..d8556017d 100644 --- a/apps/angular/55-back-button-navigation/src/app/simple-action/simple-action.component.ts +++ b/apps/angular/55-back-button-navigation/src/app/simple-action/simple-action.component.ts @@ -1,19 +1,30 @@ import { Component, inject } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MatDialog } from '@angular/material/dialog'; +import { RouterLink } from '@angular/router'; +import { CanComponentDeactivate } from '../can-deactivate.guard'; +import { DialogService } from '../dialog.service'; import { DialogComponent } from '../dialog/dialog.component'; @Component({ - imports: [MatButtonModule], + standalone: true, + imports: [MatButtonModule, RouterLink], selector: 'app-simple-action', templateUrl: './simple-action.component.html', }) -export class SimpleActionComponent { - readonly #dialog = inject(MatDialog); +export class SimpleActionComponent implements CanComponentDeactivate { + readonly #dialogService = inject(DialogService); openDialog(): void { - this.#dialog.open(DialogComponent, { - width: '250px', + this.#dialogService.openDialog(DialogComponent, { + width: '450px', + data: { strategy: { type: 'default' } }, + closeOnNavigation: false, }); } + + canDeactivate() { + return ( + this.#dialogService.getStrategyType()?.onBackBrowserNavigation() ?? true + ); + } }