LP1852782 MARC editor prevent navigation with changes
authorBill Erickson <berickxx@gmail.com>
Tue, 10 Dec 2019 22:35:49 +0000 (17:35 -0500)
committerBill Erickson <berickxx@gmail.com>
Fri, 21 Feb 2020 16:44:38 +0000 (11:44 -0500)
Show a confirmation dialog when the user attempts to navigate away from
the MARC edit tab in the catalog if the MARC editor has pending changes.

The dialog will be shown if the user attempts to change tabs or navigate
away from the record detail page w/in Angular.

If the user unloads / reloads the page, the stock browser onbeforeunload
confirmation dialog will be displayed instead.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
Open-ILS/src/eg2/src/app/share/util/can-deactivate.guard.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.html
Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts

diff --git a/Open-ILS/src/eg2/src/app/share/util/can-deactivate.guard.ts b/Open-ILS/src/eg2/src/app/share/util/can-deactivate.guard.ts
new file mode 100644 (file)
index 0000000..c0ddeff
--- /dev/null
@@ -0,0 +1,33 @@
+import {Injectable} from '@angular/core';
+import {CanDeactivate} from '@angular/router';
+import {Observable} from 'rxjs';
+
+/**
+ * https://angular.io/guide/router#candeactivate-handling-unsaved-changes
+ *
+ * routing:
+ * {
+ *   path: 'record/:id/:tab',
+ *   component: MyComponent,
+ *   canDeactivate: [CanDeactivateGuard]
+ * }
+ *
+ * export class MyComponent {
+ *   canDeactivate() ... {
+ *      ...
+ *   }
+ * }
+ */
+
+export interface CanComponentDeactivate {
+    canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
+}
+
+@Injectable({providedIn: 'root'})
+export class CanDeactivateGuard
+    implements CanDeactivate<CanComponentDeactivate> {
+
+    canDeactivate(component: CanComponentDeactivate) {
+        return component.canDeactivate ? component.canDeactivate() : true;
+    }
+}
index 49ec2e5..51d81b3 100644 (file)
@@ -4,6 +4,11 @@
   </eg-title>
 </ng-container>
 
+<eg-confirm-dialog #pendingChangesDialog
+  i18n-dialogTitle dialogTitle="Unsaved Changes Confirmation" 
+  i18n-dialogBoby  dialogBody="Unsaved changes will be lost.  Continue navigation?">
+</eg-confirm-dialog>
+
 <div id="staff-catalog-record-container">
   <div id='staff-catalog-bib-summary-container' class='mb-1'>
     <eg-bib-summary [bibSummary]="summary">
@@ -29,7 +34,8 @@
             (click)="setDefaultTab()" i18n>Set Default View</button>
       </div>
     </div>
-    <ngb-tabset #recordTabs [activeId]="recordTab" (tabChange)="onTabChange($event)">
+    <ngb-tabset #recordTabs [activeId]="recordTab" 
+      (tabChange)="beforeTabChange($event)">
       <ngb-tab title="Item Table" i18n-title id="catalog">
         <ng-template ngbTabContent>
           <eg-catalog-copies [recordId]="recordId"></eg-catalog-copies>
@@ -39,7 +45,7 @@
       <ngb-tab title="MARC Edit" i18n-title id="marc_edit">
         <ng-template ngbTabContent>
           <div class="mt-3">
-            <eg-marc-editor (recordSaved)="handleMarcRecordSaved()" 
+            <eg-marc-editor #marcEditor (recordSaved)="handleMarcRecordSaved()" 
               [recordId]="recordId"></eg-marc-editor>
           </div>
         </ng-template>
index 83ce9b3..9c0ce9d 100644 (file)
@@ -1,4 +1,4 @@
-import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {Component, OnInit, Input, ViewChild, HostListener} from '@angular/core';
 import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
 import {PcrudService} from '@eg/core/pcrud.service';
@@ -9,6 +9,8 @@ import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.s
 import {StaffCatalogService} from '../catalog.service';
 import {BibSummaryComponent} from '@eg/staff/share/bib-summary/bib-summary.component';
 import {StoreService} from '@eg/core/store.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {MarcEditorComponent} from '@eg/staff/share/marc-edit/editor.component';
 
 @Component({
   selector: 'eg-catalog-record',
@@ -21,8 +23,12 @@ export class RecordComponent implements OnInit {
     summary: BibRecordSummary;
     searchContext: CatalogSearchContext;
     @ViewChild('recordTabs', { static: true }) recordTabs: NgbTabset;
+    @ViewChild('marcEditor', {static: false}) marcEditor: MarcEditorComponent;
     defaultTab: string; // eg.cat.default_record_tab
 
+    @ViewChild('pendingChangesDialog', {static: false})
+        pendingChangesDialog: ConfirmDialogComponent;
+
     constructor(
         private router: Router,
         private route: ActivatedRoute,
@@ -66,13 +72,54 @@ export class RecordComponent implements OnInit {
 
     // Changing a tab in the UI means changing the route.
     // Changing the route ultimately results in changing the tab.
-    onTabChange(evt: NgbTabChangeEvent) {
-        this.recordTab = evt.nextId;
+    beforeTabChange(evt: NgbTabChangeEvent) {
 
         // prevent tab changing until after route navigation
         evt.preventDefault();
 
-        this.routeToTab();
+        // Protect against tab changes with dirty data.
+        this.canDeactivate().then(ok => {
+            if (ok) {
+                this.recordTab = evt.nextId;
+                this.routeToTab();
+            }
+        });
+    }
+
+    /*
+     * Handle 3 types of navigation which can cause loss of data.
+     * 1. Record detail tab navigation (see also beforeTabChange())
+     * 2. Intra-Angular route navigation away from the record detail page
+     * 3. Browser page unload/reload
+     *
+     * For the #1, and #2, display a eg confirmation dialog.
+     * For #3 use the stock browser onbeforeunload dialog.
+     *
+     * Note in this case a tab change is a route change, but it's one
+     * which does not cause RecordComponent to unload, so it has to be
+     * manually tracked in beforeTabChange().
+     */
+    @HostListener('window:beforeunload', ['$event'])
+    canDeactivate($event?: Event): Promise<boolean> {
+
+        if (this.marcEditor && this.marcEditor.changesPending()) {
+
+            // Each warning dialog clears the current "changes are pending"
+            // flag so the user is not presented with the dialog again
+            // unless new changes are made.
+            this.marcEditor.clearPendingChanges();
+
+            if ($event) { // window.onbeforeunload
+                $event.preventDefault();
+                $event.returnValue = true;
+
+            } else { // tab OR route change.
+                return this.pendingChangesDialog.open().toPromise();
+            }
+
+        } else {
+            return Promise.resolve(true);
+        }
     }
 
     routeToTab() {
index e0da65f..b702905 100644 (file)
@@ -7,6 +7,7 @@ import {CatalogResolver} from './resolver.service';
 import {HoldComponent} from './hold/hold.component';
 import {BrowseComponent} from './browse.component';
 import {CnBrowseComponent} from './cnbrowse.component';
+import {CanDeactivateGuard} from '@eg/share/util/can-deactivate.guard';
 
 const routes: Routes = [{
   path: '',
@@ -23,7 +24,8 @@ const routes: Routes = [{
     component: HoldComponent
   }, {
     path: 'record/:id/:tab',
-    component: RecordComponent
+    component: RecordComponent,
+    canDeactivate: [CanDeactivateGuard]
   }]}, {
     // Browse is a top-level UI
     path: 'browse',
index a926baa..c4d848d 100644 (file)
@@ -304,7 +304,7 @@ export class EditableContentComponent
         undo.position = this.context.lastFocused;
         undo.textContent =  this.undoBackToText;
 
-        this.context.undoStack.unshift(undo);
+        this.context.addToUndoStack(undo);
     }
 
     // Apply the undo or redo action and track its opposite
@@ -436,10 +436,15 @@ export class EditableContentComponent
                     this.context.deleteField(this.field);
                     evt.preventDefault();
 
-                } else if (evt.shiftKey && this.subfield) {
-                    // shift+delete == delete subfield
+                } else if (evt.shiftKey) {
 
-                    this.context.deleteSubfield(this.field, this.subfield);
+                    if (this.subfield) {
+                        // shift+delete == delete subfield
+
+                        this.context.deleteSubfield(this.field, this.subfield);
+                    }
+                    // prevent any shift-delete from bubbling up becuase
+                    // unexpected stuff will be deleted.
                     evt.preventDefault();
                 }
 
index 520ddbf..ae7af49 100644 (file)
@@ -61,6 +61,11 @@ export class MarcEditContext {
     undoStack: UndoRedoAction[] = [];
     redoStack: UndoRedoAction[] = [];
 
+    // True if any changes have been made.
+    // For the 'rich' editor, this is any un-do-able actions.
+    // For the text edtior it's any text change.
+    changesPending: boolean;
+
     private _record: MarcRecord;
     set record(r: MarcRecord) {
         if (r !== this._record) {
@@ -117,6 +122,11 @@ export class MarcEditContext {
         }
     }
 
+    addToUndoStack(action: UndoRedoAction) {
+        this.changesPending = true;
+        this.undoStack.unshift(action);
+    }
+
     handleStructuralUndoRedo(action: StructUndoRedoAction) {
 
         if (action.wasAddition) {
@@ -208,7 +218,7 @@ export class MarcEditContext {
         // time of action will be different than the added field.
         action.prevFocus = this.lastFocused;
 
-        this.undoStack.unshift(action);
+        this.addToUndoStack(action);
     }
 
     deleteField(field: MarcField) {
index 1381616..1ecfb9e 100644 (file)
@@ -111,6 +111,14 @@ export class MarcEditorComponent implements OnInit {
         );
     }
 
+    changesPending(): boolean {
+        return this.context.changesPending;
+    }
+
+    clearPendingChanges() {
+        this.context.changesPending = false;
+    }
+
     // Remember the last used tab as the preferred tab.
     tabChange(evt: NgbTabChangeEvent) {
 
@@ -128,6 +136,10 @@ export class MarcEditorComponent implements OnInit {
     saveRecord(): Promise<any> {
         const xml = this.record.toXml();
 
+        // Save actions clears any pending changes.
+        this.context.changesPending = false;
+        this.context.resetUndos();
+
         let sourceName: string = null;
         let sourceId: number = null;
 
index eaf54a9..0ef573f 100644 (file)
@@ -1,6 +1,7 @@
 
 <div *ngIf="record">
   <textarea class="form-control flat-editor-content" 
+    (change)="textChanged()"
     (blur)="record.absorbBreakerChanges()"
     [(ngModel)]="record.breakerText" rows="{{rowCount()}}" spellcheck="false">
   </textarea>
index 465a738..86a64ac 100644 (file)
@@ -42,6 +42,10 @@ export class MarcFlatEditorComponent implements OnInit {
         }
         return 40;
     }
+
+    textChanged() {
+        this.context.changesPending = true;
+    }
 }