From: Bill Erickson <berickxx@gmail.com>
Date: Tue, 10 Dec 2019 22:35:49 +0000 (-0500)
Subject: LP1852782 MARC editor prevent navigation with changes
X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=c83db17612f518ca8b6468aa9d1903dcf822ba2b;p=evergreen%2Fequinox.git

LP1852782 MARC editor prevent navigation with changes

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>
---

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
index 0000000000..c0ddeffe86
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/util/can-deactivate.guard.ts
@@ -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;
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
index 49ec2e55f5..51d81b330c 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
@@ -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>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
index 83ce9b36c3..9c0ce9d762 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
@@ -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() {
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
index e0da65f9d1..b70290583e 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
@@ -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',
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
index a926baa675..c4d848dda0 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
@@ -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();
                 }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
index 520ddbfbbe..ae7af497c4 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
@@ -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) {
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
index 138161611d..1ecfb9e6e6 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
@@ -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;
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.html
index eaf54a92c1..0ef573f91a 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.html
@@ -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>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts
index 465a738eb2..86a64ac021 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts
@@ -42,6 +42,10 @@ export class MarcFlatEditorComponent implements OnInit {
         }
         return 40;
     }
+
+    textChanged() {
+        this.context.changesPending = true;
+    }
 }