From c83db17612f518ca8b6468aa9d1903dcf822ba2b Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Tue, 10 Dec 2019 17:35:49 -0500 Subject: [PATCH] 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 Signed-off-by: Jane Sandberg --- .../eg2/src/app/share/util/can-deactivate.guard.ts | 33 +++++++++++++ .../app/staff/catalog/record/record.component.html | 10 +++- .../app/staff/catalog/record/record.component.ts | 55 ++++++++++++++++++++-- .../eg2/src/app/staff/catalog/routing.module.ts | 4 +- .../share/marc-edit/editable-content.component.ts | 13 +++-- .../app/staff/share/marc-edit/editor-context.ts | 12 ++++- .../app/staff/share/marc-edit/editor.component.ts | 12 +++++ .../share/marc-edit/flat-editor.component.html | 1 + .../staff/share/marc-edit/flat-editor.component.ts | 4 ++ 9 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/share/util/can-deactivate.guard.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 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 | Promise | boolean; +} + +@Injectable({providedIn: 'root'}) +export class CanDeactivateGuard + implements CanDeactivate { + + 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 @@ + + +
@@ -29,7 +34,8 @@ (click)="setDefaultTab()" i18n>Set Default View
- + @@ -39,7 +45,7 @@
-
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 { + + 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 { 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 @@
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; + } } -- 2.11.0