From: Bill Erickson <berickxx@gmail.com>
Date: Fri, 28 Jun 2019 16:29:07 +0000 (-0400)
Subject: LP1834665 Angular catalog MARC flat text editor
X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=5535f9276e786650f784927f7d0465eb79a97741;p=evergreen%2Fequinox.git

LP1834665 Angular catalog MARC flat text editor

Adds a set of components for editing MARC records.  The main component
acts as a container with various actions (source selector, delete,
undelete, and save options).  The body of this component is a tabbed
interface, one tab for the Enriched editor and one for the Flat Text
editor.

The Enriched editor tab directs the user to the AngJS version of the page.
the Flat Text editor tab implements the standard MARC flat text editor
interface.

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/staff/catalog/catalog.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
index e0fbff851d..064c21553c 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
@@ -27,6 +27,7 @@ import {ConjoinedComponent} from './record/conjoined.component';
 import {CnBrowseComponent} from './cnbrowse.component';
 import {CnBrowseResultsComponent} from './cnbrowse/results.component';
 import {SearchTemplatesComponent} from './search-templates.component';
+import {MarcEditModule} from '@eg/staff/share/marc-edit/marc-edit.module';
 
 @NgModule({
   declarations: [
@@ -58,7 +59,8 @@ import {SearchTemplatesComponent} from './search-templates.component';
     CatalogRoutingModule,
     HoldsModule,
     HoldingsModule,
-    BookingModule
+    BookingModule,
+    MarcEditModule
   ],
   providers: [
     StaffCatalogService
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 f562123c4c..98476aacb8 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
@@ -35,12 +35,9 @@
       <!-- NOTE some tabs send the user over to the AngJS app -->
       <ngb-tab title="MARC Edit" i18n-title id="marc_edit">
         <ng-template ngbTabContent>
-          <div class="alert alert-info mt-3" i18n>
-            MARC Edit not yet implemented.  See the
-            <a target="_blank"
-              href="/eg/staff/cat/catalog/record/{{recordId}}/marc_edit">
-              AngularJS MARC Edit Tab.
-            </a>
+          <div class="mt-3">
+            <eg-marc-editor (recordSaved)="handleMarcRecordSaved()" 
+              [recordId]="recordId"></eg-marc-editor>
           </div>
         </ng-template>
       </ngb-tab>
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 c70b5658be..e397444819 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
@@ -110,6 +110,11 @@ export class RecordComponent implements OnInit {
         }
         return null;
     }
+
+    handleMarcRecordSaved() {
+        this.staffCat.currentDetailRecordSummary = null;
+        this.loadRecord();
+    }
 }
 
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
new file mode 100644
index 0000000000..fdaf7e50cd
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
@@ -0,0 +1,73 @@
+
+<eg-confirm-dialog #confirmDelete
+  i18n-dialogTitle dialogTitle="Confirm Delete"
+  i18n-dialogBody dialogBody="Delete Record ID {{record ? record.id : ''}}?">
+</eg-confirm-dialog>
+
+<eg-confirm-dialog #confirmUndelete
+  i18n-dialogTitle dialogTitle="Confirm Undelete"
+  i18n-dialogBody dialogBody="Undelete Record ID {{record ? record.id : ''}}?">
+</eg-confirm-dialog>
+
+<eg-alert-dialog #cannotDelete
+  i18n-dialogBody 
+  dialogBody="Records with holdings attached cannot be deleted.">
+</eg-alert-dialog>
+
+<div class="row d-flex p-2 m-2">
+  <div class="flex-1"></div>
+  <div class="mr-2">
+    <eg-combobox #sourceSelector
+      [entries]="sources"
+      placeholder="Select a Source..."
+      i18n-placeholder>
+    </eg-combobox>
+  </div>
+
+  <ng-container *ngIf="record && record.id">
+    <button *ngIf="!record.deleted" class="btn btn-warning" 
+      (click)="deleteRecord()" i18n>Delete Record</button>
+    <button *ngIf="record.deleted" class="btn btn-info" 
+      (click)="undeleteRecord()" i18n>Undelete Record</button>
+  </ng-container>
+
+  <button class="btn btn-success ml-2" (click)="saveRecord()" 
+    [disabled]="record && record.deleted" i18n>Save Changes</button>
+</div>
+
+<div class="row">
+  <div class="col-lg-12">
+    <ngb-tabset [activeId]="editorTab">
+      <ngb-tab title="Enhanced MARC Editor" i18n-title id="rich">
+        <ng-template ngbTabContent>
+          <div class="alert alert-info mt-3" i18n>
+          Enhanced MARC Editor is not yet implemented.  See the
+          <ng-container *ngIf="record && record.id">
+            <a target="_blank"
+              href="/eg/staff/cat/catalog/record/{{record.id}}/marc_edit">
+              AngularJS MARC Editor.
+            </a>
+          </ng-container>
+          <ng-container *ngIf="!record || !record.id">
+            <a target="_blank" href="/eg/staff/cat/catalog/new_bib">
+              AngularJS MARC Editor.
+            </a>
+          </ng-container>
+          </div>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Flat Text Editor" i18n-title id="flat">
+        <ng-template ngbTabContent>
+          <eg-marc-flat-editor></eg-marc-flat-editor>
+        </ng-template>
+      </ngb-tab>
+    </ngb-tabset>
+  </div>
+</div>
+
+<div class="row d-flex p-2 m-2 flex-row-reverse">
+  <button class="btn btn-success" (click)="saveRecord()"
+    [disabled]="record && record.deleted" i18n>Save Changes</button>
+</div>
+
+
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
new file mode 100644
index 0000000000..cea199052a
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
@@ -0,0 +1,182 @@
+import {Component, Input, Output, OnInit, EventEmitter, ViewChild} from '@angular/core';
+import {IdlService} from '@eg/core/idl.service';
+import {EventService} from '@eg/core/event.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {MarcRecord} from './marcrecord';
+import {ComboboxEntry, ComboboxComponent
+  } from '@eg/share/combobox/combobox.component';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+
+
+/**
+ * MARC Record editor main interface.
+ */
+
+@Component({
+  selector: 'eg-marc-editor',
+  templateUrl: './editor.component.html'
+})
+
+export class MarcEditorComponent implements OnInit {
+
+    record: MarcRecord;
+    editorTab: 'rich' | 'flat';
+    sources: ComboboxEntry[];
+
+    @Input() set recordId(id: number) {
+        if (!id) { return; }
+        if (this.record && this.record.id === id) { return; }
+        this.fromId(id);
+    }
+
+    @Input() set recordXml(xml: string) {
+        if (xml) { this.fromXml(xml); }
+    }
+
+    // If true, saving records to the database is assumed to
+    // happen externally.  IOW, the record editor is just an
+    // in-place MARC modification interface.
+    inPlaceMode: boolean;
+
+    // In inPlaceMode, this is emitted in lieu of saving the record
+    // in th database.  When inPlaceMode is false, this is emitted after
+    // the record is successfully saved.
+    @Output() recordSaved: EventEmitter<string>;
+
+    @ViewChild('sourceSelector') sourceSelector: ComboboxComponent;
+    @ViewChild('confirmDelete') confirmDelete: ConfirmDialogComponent;
+    @ViewChild('confirmUndelete') confirmUndelete: ConfirmDialogComponent;
+    @ViewChild('cannotDelete') cannotDelete: ConfirmDialogComponent;
+
+    constructor(
+        private evt: EventService,
+        private idl: IdlService,
+        private net: NetService,
+        private auth: AuthService,
+        private org: OrgService,
+        private pcrud: PcrudService
+    ) {
+        this.sources = [];
+        this.recordSaved = new EventEmitter<string>();
+    }
+
+    ngOnInit() {
+        // Default to flat for now since it's all that's supported.
+        this.editorTab = 'flat';
+
+        this.pcrud.retrieveAll('cbs').subscribe(
+            src => this.sources.push({id: +src.id(), label: src.source()}),
+            _ => {},
+            () => {
+                this.sources = this.sources.sort((a, b) =>
+                    a.label.toLowerCase() < b.label.toLowerCase() ? -1 : 1
+                );
+            }
+        );
+    }
+
+    saveRecord(): Promise<any> {
+        const xml = this.record.toXml();
+
+        if (this.inPlaceMode) {
+            // Let the caller have the modified XML and move on.
+            this.recordSaved.emit(xml);
+            return Promise.resolve();
+        }
+
+        const source = this.sourceSelector.selected ?
+            this.sourceSelector.selected.label : null; // 'label' not a typo
+
+        if (this.record.id) { // Editing an existing record
+
+            const method = 'open-ils.cat.biblio.record.marc.replace';
+
+            return this.net.request('open-ils.cat', method,
+                this.auth.token(), this.record.id, xml, source
+            ).toPromise().then(response => {
+
+                const evt = this.evt.parse(response);
+                if (evt) {
+                    console.error(evt);
+                    // TODO: toast
+                }
+
+                // TODO: toast
+                this.recordSaved.emit(xml);
+                return response;
+            });
+
+        } else {
+            // TODO: create a new record
+        }
+    }
+
+    fromId(id: number): Promise<any> {
+        return this.pcrud.retrieve('bre', id)
+        .toPromise().then(bib => {
+            this.record = new MarcRecord(bib.marc());
+            this.record.id = id;
+            this.record.deleted = bib.deleted() === 't';
+            if (bib.source()) {
+                this.sourceSelector.applyEntryId(+bib.source());
+            }
+        });
+    }
+
+    fromXml(xml: string) {
+        this.record = new MarcRecord(xml);
+        this.record.id = null;
+    }
+
+    deleteRecord(): Promise<any> {
+
+        return this.confirmDelete.open().toPromise()
+        .then(yes => {
+            if (!yes) { return; }
+
+            return this.net.request('open-ils.cat',
+                'open-ils.cat.biblio.record_entry.delete',
+                this.auth.token(), this.record.id).toPromise()
+
+            .then(resp => {
+
+                const evt = this.evt.parse(resp);
+                if (evt) {
+                    if (evt.textcode === 'RECORD_NOT_EMPTY') {
+                        return this.cannotDelete.open().toPromise();
+                    } else {
+                        console.error(evt);
+                        return alert(evt);
+                    }
+                }
+                return this.fromId(this.record.id)
+                .then(_ => this.recordSaved.emit(this.record.toXml()));
+            });
+        });
+    }
+
+    undeleteRecord(): Promise<any> {
+
+        return this.confirmUndelete.open().toPromise()
+        .then(yes => {
+            if (!yes) { return; }
+
+            return this.net.request('open-ils.cat',
+                'open-ils.cat.biblio.record_entry.undelete',
+                this.auth.token(), this.record.id).toPromise()
+
+            .then(resp => {
+
+                const evt = this.evt.parse(resp);
+                if (evt) { console.error(evt); return alert(evt); }
+
+                return this.fromId(this.record.id)
+                .then(_ => this.recordSaved.emit(this.record.toXml()));
+            });
+        });
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.css
new file mode 100644
index 0000000000..12e912b8f1
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.css
@@ -0,0 +1,11 @@
+
+
+.flat-editor-content {
+    font-family: 'Lucida Console', Monaco, monospace; 
+    display: inline-block; 
+    /*
+    min-width: 1ch; 
+    margin: 0 -1px; 
+    */
+    padding: 0;
+}
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
new file mode 100644
index 0000000000..eaf54a92c1
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.html
@@ -0,0 +1,7 @@
+
+<div *ngIf="record">
+  <textarea class="form-control flat-editor-content" 
+    (blur)="record.absorbBreakerChanges()"
+    [(ngModel)]="record.breakerText" rows="{{rowCount()}}" spellcheck="false">
+  </textarea>
+</div>
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
new file mode 100644
index 0000000000..b5e2f41277
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts
@@ -0,0 +1,45 @@
+import {Component, Input, OnInit, Host} from '@angular/core';
+import {IdlService} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {MarcEditorComponent} from './editor.component';
+import {MarcRecord} from './marcrecord';
+
+/**
+ * MARC Record flat text (marc-breaker) editor.
+ */
+
+@Component({
+  selector: 'eg-marc-flat-editor',
+  templateUrl: './flat-editor.component.html',
+  styleUrls: ['flat-editor.component.css']
+})
+
+export class MarcFlatEditorComponent implements OnInit {
+
+    get record(): MarcRecord {
+        return this.editor.record;
+    }
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private store: ServerStoreService,
+        @Host() private editor: MarcEditorComponent
+    ) {
+    }
+
+    ngOnInit() {}
+
+    // When we have breaker text, limit the vertical expansion of the
+    // text area to the size of the data plus a little padding.
+    rowCount(): number {
+        if (this.record && this.record.breakerText) {
+            return this.record.breakerText.split(/\n/).length + 2;
+        }
+        return 40;
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
new file mode 100644
index 0000000000..a18eb0b7a4
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
@@ -0,0 +1,24 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {MarcEditorComponent} from './editor.component';
+import {MarcRichEditorComponent} from './rich-editor.component';
+import {MarcFlatEditorComponent} from './flat-editor.component';
+
+@NgModule({
+    declarations: [
+        MarcEditorComponent,
+        MarcRichEditorComponent,
+        MarcFlatEditorComponent
+    ],
+    imports: [
+        StaffCommonModule
+    ],
+    exports: [
+        MarcEditorComponent
+    ],
+    providers: [
+    ]
+})
+
+export class MarcEditModule { }
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
new file mode 100644
index 0000000000..1b0c488e46
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
@@ -0,0 +1,31 @@
+/**
+  * Simple wrapper class for our external MARC21.Record JS library.
+  */
+
+declare var MARC21;
+
+export class MarcRecord {
+
+    id: number; // Database ID when known.
+    deleted: boolean;
+    record: any; // MARC21.Record object
+    breakerText: string;
+
+    constructor(xml: string) {
+        this.record = new MARC21.Record({marcxml: xml});
+        this.breakerText = this.record.toBreaker();
+    }
+
+    toXml(): string {
+        return this.record.toXmlString();
+    }
+
+    toBreaker(): string {
+        return this.record.toBreaker();
+    }
+
+    absorbBreakerChanges() {
+        this.record = new MARC21.Record({marcbreaker: this.breakerText});
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
new file mode 100644
index 0000000000..7f8ac334e3
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
@@ -0,0 +1,28 @@
+import {Component, Input, Output, OnInit, AfterViewInit, EventEmitter,
+    OnDestroy} from '@angular/core';
+import {IdlService} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+
+/**
+ * MARC Record rich editor interface.
+ */
+
+@Component({
+  selector: 'eg-marc-rich-editor',
+  templateUrl: './rich-editor.component.html',
+  styleUrls: ['rich-editor.component.css']
+})
+
+export class MarcRichEditorComponent implements OnInit {
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService
+    ) {
+    }
+
+    ngOnInit() {}
+}
+
+
+