Advertir al usuario de los cambios no guardados antes de salir de la página

112

Me gustaría advertir a los usuarios de los cambios no guardados antes de que abandonen una página en particular de mi aplicación angular 2. Normalmente lo usaría window.onbeforeunload, pero eso no funciona para aplicaciones de una sola página.

Descubrí que en angular 1, puedes conectarte al $locationChangeStartevento para lanzar una confirmcaja para el usuario, pero no he visto nada que muestre cómo hacer que esto funcione para angular 2, o si ese evento aún está presente. . También he visto complementos para ag1 que brindan funcionalidad para onbeforeunload, pero nuevamente, no he visto ninguna forma de usarlo para ag2.

Espero que alguien más haya encontrado una solución a este problema; cualquier método funcionará bien para mis propósitos.

Whelch
fuente
2
Funciona para aplicaciones de una sola página, cuando intenta cerrar la página / pestaña. Entonces, cualquier respuesta a la pregunta sería solo una solución parcial si ignoran ese hecho.
9ilsdx 9rvj 0lo

Respuestas:

74

El enrutador proporciona una devolución de llamada de ciclo de vida CanDeactivate

para más detalles vea el tutorial de guardias

class UserToken {}
class Permissions {
  canActivate(user: UserToken, id: string): boolean {
    return true;
  }
}
@Injectable()
class CanActivateTeam implements CanActivate {
  constructor(private permissions: Permissions, private currentUser: UserToken) {}
  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): Observable<boolean>|Promise<boolean>|boolean {
    return this.permissions.canActivate(this.currentUser, route.params.id);
  }
}
@NgModule({
  imports: [
    RouterModule.forRoot([
      {
        path: 'team/:id',
        component: TeamCmp,
        canActivate: [CanActivateTeam]
      }
    ])
  ],
  providers: [CanActivateTeam, UserToken, Permissions]
})
class AppModule {}

original (enrutador RC.x)

class CanActivateTeam implements CanActivate {
  constructor(private permissions: Permissions, private currentUser: UserToken) {}
  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):Observable<boolean> {
    return this.permissions.canActivate(this.currentUser, this.route.params.id);
  }
}
bootstrap(AppComponent, [
  CanActivateTeam,
  provideRouter([{
    path: 'team/:id',
    component: Team,
    canActivate: [CanActivateTeam]
  }])
);
Günter Zöchbauer
fuente
28
A diferencia de lo que pidió el OP, CanDeactivate -actualmente- no se engancha al evento onbeforeunload (desafortunadamente). Lo que significa que si el usuario intenta navegar a una URL externa, cerrar la ventana, etc. CanDeactivate no se activará. Parece funcionar solo cuando el usuario permanece dentro de la aplicación.
Christophe Vidal
1
@ChristopheVidal tiene razón. Consulte mi respuesta para obtener una solución que también cubre la navegación a una URL externa, el cierre de la ventana, la recarga de la página, etc.
stewdebaker
Esto funciona al cambiar de ruta. ¿Y si es un SPA? ¿Alguna otra forma de lograrlo?
sujay kodamala
stackoverflow.com/questions/36763141/… También necesitaría esto con las rutas. Si la ventana está cerrada o se navega fuera del sitio actual canDeactivate, no funcionará.
Günter Zöchbauer
214

Para cubrir también las protecciones contra las actualizaciones del navegador, el cierre de la ventana, etc. (consulte el comentario de @ChristopheVidal a la respuesta de Günter para obtener detalles sobre el problema), me ha resultado útil agregar el @HostListenerdecorador a la canDeactivateimplementación de su clase para escuchar el beforeunload windowevento. Cuando se configura correctamente, esto protegerá contra la navegación externa y dentro de la aplicación al mismo tiempo.

Por ejemplo:

Componente:

import { ComponentCanDeactivate } from './pending-changes.guard';
import { HostListener } from '@angular/core';
import { Observable } from 'rxjs/Observable';

export class MyComponent implements ComponentCanDeactivate {
  // @HostListener allows us to also guard against browser refresh, close, etc.
  @HostListener('window:beforeunload')
  canDeactivate(): Observable<boolean> | boolean {
    // insert logic to check if there are pending changes here;
    // returning true will navigate without confirmation
    // returning false will show a confirm dialog before navigating away
  }
}

Guardia:

import { CanDeactivate } from '@angular/router';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';

export interface ComponentCanDeactivate {
  canDeactivate: () => boolean | Observable<boolean>;
}

@Injectable()
export class PendingChangesGuard implements CanDeactivate<ComponentCanDeactivate> {
  canDeactivate(component: ComponentCanDeactivate): boolean | Observable<boolean> {
    // if there are no pending changes, just allow deactivation; else confirm first
    return component.canDeactivate() ?
      true :
      // NOTE: this warning message will only be shown when navigating elsewhere within your angular app;
      // when navigating away from your angular app, the browser will show a generic warning message
      // see http://stackoverflow.com/a/42207299/7307355
      confirm('WARNING: You have unsaved changes. Press Cancel to go back and save these changes, or OK to lose these changes.');
  }
}

Rutas:

import { PendingChangesGuard } from './pending-changes.guard';
import { MyComponent } from './my.component';
import { Routes } from '@angular/router';

export const MY_ROUTES: Routes = [
  { path: '', component: MyComponent, canDeactivate: [PendingChangesGuard] },
];

Módulo:

import { PendingChangesGuard } from './pending-changes.guard';
import { NgModule } from '@angular/core';

@NgModule({
  // ...
  providers: [PendingChangesGuard],
  // ...
})
export class AppModule {}

NOTA : Como señaló @JasperRisseeuw, IE y Edge manejan el beforeunloadevento de manera diferente a otros navegadores e incluirán la palabra falseen el cuadro de diálogo de confirmación cuando el beforeunloadevento se active (por ejemplo, el navegador se actualiza, cierra la ventana, etc.). La navegación dentro de la aplicación Angular no se ve afectada y mostrará correctamente el mensaje de advertencia de confirmación designado. Aquellos que necesitan ser compatibles con IE / Edge y no quieren falsemostrar / quieren un mensaje más detallado en el cuadro de diálogo de confirmación cuando beforeunloadse activa el evento, también pueden querer ver la respuesta de @ JasperRisseeuw para una solución alternativa.

estofador
fuente
2
¡Esto funciona muy bien @stewdebaker! Tengo una adición a esta solución, vea mi respuesta a continuación.
Jasper Risseeuw
1
importar {Observable} de 'rxjs / Observable'; Falta en ComponentCanDeactivate
Mahmoud Ali Kassem
1
Tuve que agregar @Injectable()a la clase PendingChangesGuard. Además, tuve que agregar PendingChangesGuard a mis proveedores en@NgModule
spottedmahn
Tuve que agregarimport { HostListener } from '@angular/core';
fidev
4
Vale la pena notar que debe devolver un booleano en caso de que se lo esté navegando beforeunload. Si devuelve un Observable, no funcionará. Es posible que desee cambiar su interfaz para algo así canDeactivate: (internalNavigation: true | undefined)y llame a su componente de esta manera: return component.canDeactivate(true). De esta manera, puede verificar si no está navegando internamente para regresar en falselugar de un Observable.
jsgoupil
58

El ejemplo con @Hostlistener de stewdebaker funciona muy bien, pero hice un cambio más porque IE y Edge muestran el "falso" que devuelve el método canDeactivate () en la clase MyComponent al usuario final.

Componente:

import {ComponentCanDeactivate} from "./pending-changes.guard";
import { Observable } from 'rxjs'; // add this line

export class MyComponent implements ComponentCanDeactivate {

  canDeactivate(): Observable<boolean> | boolean {
    // insert logic to check if there are pending changes here;
    // returning true will navigate without confirmation
    // returning false will show a confirm alert before navigating away
  }

  // @HostListener allows us to also guard against browser refresh, close, etc.
  @HostListener('window:beforeunload', ['$event'])
  unloadNotification($event: any) {
    if (!this.canDeactivate()) {
        $event.returnValue = "This message is displayed to the user in IE and Edge when they navigate without using Angular routing (type another URL/close the browser/etc)";
    }
  }
}
Jasper Risseeuw
fuente
2
¡Buena captura @JasperRisseeuw! No me di cuenta de que IE / Edge manejaba esto de manera diferente. Esta es una solución muy útil para aquellos que necesitan admitir IE / Edge y no quieren falseque se muestre en el cuadro de diálogo de confirmación. Hice una pequeña edición a su respuesta para incluirla '$event'en la @HostListeneranotación, ya que es necesaria para poder acceder a ella en la unloadNotificationfunción.
stewdebaker
1
Gracias, me olvidé de copiar ", ['$ event']" de mi propio código, ¡así que también te lo pido bien!
Jasper Risseeuw
la única solución que funciona fue esta (usando Edge). todos los demás funcionan, pero solo muestran el mensaje de diálogo predeterminado (Chrome / Firefox), no mi texto ... Incluso hice una pregunta para comprender lo que está sucediendo
Elmer Dantas
@ElmerDantas, consulte mi respuesta a su pregunta para obtener una explicación de por qué se muestra el mensaje de diálogo predeterminado en Chrome / Firefox.
stewdebaker
2
De hecho, funciona, ¡lo siento! Tuve que hacer referencia al guardia en los proveedores de módulos.
tudor.iliescu
6

Implementé la solución de @stewdebaker que funciona muy bien, sin embargo, quería una buena ventana emergente de arranque en lugar de la torpe confirmación estándar de JavaScript. Suponiendo que ya está usando ngx-bootstrap, puede usar la solución de @ stwedebaker, pero cambie el 'Guard' por el que estoy mostrando aquí. También debe presentar ngx-bootstrap/modaly agregar un nuevo ConfirmationComponent:

Guardia

(reemplace 'confirmar' con una función que abrirá un modal de arranque, mostrando un nuevo personalizado ConfirmationComponent):

import { Component, OnInit } from '@angular/core';
import { ConfirmationComponent } from './confirmation.component';

import { CanDeactivate } from '@angular/router';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { BsModalService } from 'ngx-bootstrap/modal';
import { BsModalRef } from 'ngx-bootstrap/modal';

export interface ComponentCanDeactivate {
  canDeactivate: () => boolean | Observable<boolean>;
}

@Injectable()
export class PendingChangesGuard implements CanDeactivate<ComponentCanDeactivate> {

  modalRef: BsModalRef;

  constructor(private modalService: BsModalService) {};

  canDeactivate(component: ComponentCanDeactivate): boolean | Observable<boolean> {
    // if there are no pending changes, just allow deactivation; else confirm first
    return component.canDeactivate() ?
      true :
      // NOTE: this warning message will only be shown when navigating elsewhere within your angular app;
      // when navigating away from your angular app, the browser will show a generic warning message
      // see http://stackoverflow.com/a/42207299/7307355
      this.openConfirmDialog();
  }

  openConfirmDialog() {
    this.modalRef = this.modalService.show(ConfirmationComponent);
    return this.modalRef.content.onClose.map(result => {
        return result;
    })
  }
}

confirmación.componente.html

<div class="alert-box">
    <div class="modal-header">
        <h4 class="modal-title">Unsaved changes</h4>
    </div>
    <div class="modal-body">
        Navigate away and lose them?
    </div>
    <div class="modal-footer">
        <button type="button" class="btn btn-secondary" (click)="onConfirm()">Yes</button>
        <button type="button" class="btn btn-secondary" (click)="onCancel()">No</button>        
    </div>
</div>

confirmación.componente.ts

import { Component } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { BsModalRef } from 'ngx-bootstrap/modal';

@Component({
    templateUrl: './confirmation.component.html'
})
export class ConfirmationComponent {

    public onClose: Subject<boolean>;

    constructor(private _bsModalRef: BsModalRef) {

    }

    public ngOnInit(): void {
        this.onClose = new Subject();
    }

    public onConfirm(): void {
        this.onClose.next(true);
        this._bsModalRef.hide();
    }

    public onCancel(): void {
        this.onClose.next(false);
        this._bsModalRef.hide();
    }
}

Y dado que el nuevo ConfirmationComponentse mostrará sin usar selectoren una plantilla html, debe declararse entryComponentsen su raíz app.module.ts(o como sea que nombre su módulo raíz). Realice los siguientes cambios en app.module.ts:

app.module.ts

import { ModalModule } from 'ngx-bootstrap/modal';
import { ConfirmationComponent } from './confirmation.component';

@NgModule({
  declarations: [
     ...
     ConfirmationComponent
  ],
  imports: [
     ...
     ModalModule.forRoot()
  ],
  entryComponents: [ConfirmationComponent]
Chris Halcrow
fuente
1
¿Existe alguna posibilidad de mostrar un modelo personalizado para la actualización del navegador?
k11k2
Debe haber una forma, aunque esta solución estaba bien para mis necesidades. Si tengo tiempo, desarrollaré las cosas más, aunque no podré actualizar esta respuesta por un tiempo, ¡lo siento!
Chris Halcrow
1

La solución fue más fácil de lo esperado, no la use hrefporque esto no es manejado por la routerLinkdirectiva de uso de Angular Routing .

Byron Lopez
fuente
1

Respuesta de junio de 2020:

Tenga en cuenta que todas las soluciones propuestas hasta este punto no tratan con un defecto conocido significativo con los canDeactivateguardias de Angular :

  1. El usuario hace clic en el botón "Atrás" en el navegador, aparece un cuadro de diálogo y el usuario hace clic en CANCELAR .
  2. El usuario vuelve a hacer clic en el botón 'Atrás', aparece un cuadro de diálogo y el usuario hace clic en CONFIRMAR .
  3. Nota: se navega al usuario hacia atrás 2 veces, lo que incluso podría sacarlo de la aplicación por completo :(

Esto se ha discutido aquí , aquí y en detalle aquí.


Consulte mi solución al problema que se muestra aquí, que soluciona de forma segura este problema *. Esto se ha probado en Chrome, Firefox y Edge.


* AVISO IMPORTANTE : En esta etapa, lo anterior borrará el historial de avance cuando se haga clic en el botón Atrás, pero conservará el historial de retroceso. Esta solución no será apropiada si preservar su historial de reenvíos es vital. En mi caso, normalmente uso una estrategia de enrutamiento de detalles maestros cuando se trata de formularios, por lo que mantener el historial de reenvío no es importante.

Stephen Paul
fuente