LP1852782 Angular MARC enriched editor (first batch)
authorBill Erickson <berickxx@gmail.com>
Thu, 14 Nov 2019 21:54:21 +0000 (16:54 -0500)
committerBill Erickson <berickxx@gmail.com>
Fri, 21 Feb 2020 16:44:38 +0000 (11:44 -0500)
Main rich MARC editor component.  Includes fixed fields editor, context
menus for value selection, undo/redo, help display, keyboard shortcuts.

Also includes a standalone context menu component.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
28 files changed:
Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
Open-ILS/src/eg2/src/app/share/common-widgets.module.ts
Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css
Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/staff.component.html
Open-ILS/src/eg2/src/styles.css

index 3d98604..0283957 100644 (file)
@@ -52,6 +52,8 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
 
     @Input() allowFreeText = false;
 
+    @Input() inputSize: number = null;
+
     // Add a 'required' attribute to the input
     isRequired: boolean;
     @Input() set required(r: boolean) {
index 01b16bd..e1f85cd 100644 (file)
@@ -14,6 +14,7 @@ import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
 import {OrgSelectComponent} from '@eg/share/org-select/org-select.component';
 import {DateRangeSelectComponent} from '@eg/share/daterange-select/daterange-select.component';
 import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select.component';
+import {ContextMenuModule} from '@eg/share/context-menu/context-menu.module';
 
 
 @NgModule({
@@ -23,14 +24,15 @@ import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select
     DateSelectComponent,
     OrgSelectComponent,
     DateRangeSelectComponent,
-    DateTimeSelectComponent,
+    DateTimeSelectComponent
   ],
   imports: [
     CommonModule,
     FormsModule,
     ReactiveFormsModule,
     NgbModule,
-    EgCoreModule
+    EgCoreModule,
+    ContextMenuModule
   ],
   exports: [
     CommonModule,
@@ -43,7 +45,8 @@ import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select
     OrgSelectComponent,
     DateRangeSelectComponent,
     DateTimeSelectComponent,
-  ],
+    ContextMenuModule
+  ]
 })
 
 export class CommonWidgetsModule { }
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css
new file mode 100644 (file)
index 0000000..3323d2a
--- /dev/null
@@ -0,0 +1,27 @@
+
+.eg-context-menu {
+  /* These fonts were applied specifically for the MARC editor
+   * context menus.  Might want to make these optional. */
+  font-family: 'Lucida Console', Monaco, monospace;
+
+  /* put a hard limit on the popover width */
+  max-width: 550px;
+}
+
+.eg-context-menu .popover-body {
+  max-height: 400px;
+
+  /* Text exceeding the max-height / max-width will results in scrolls.
+   * In most cases, this should not happen. */
+  overflow-y: auto;
+  overflow-x: auto;
+}
+
+.eg-context-menu .popover-body .menu-entry {
+  /* force the menu to expand horizontally to display the text */
+  white-space: nowrap;
+}
+
+.eg-context-menu .popover-body .menu-entry:hover {
+  background-color: #f8f9fa; 
+}
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html
new file mode 100644 (file)
index 0000000..0d6c0a0
--- /dev/null
@@ -0,0 +1,8 @@
+
+<ng-template #menuTemplate>
+  <!-- apply (click) to div so user can click anywhere in the row -->
+  <div *ngFor="let entry of menuEntries; first as isFirst" 
+   (click)="entryClicked(entry)" class="menu-entry {{entryClasses}}">
+    <a>{{entry.label}}</a>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts
new file mode 100644 (file)
index 0000000..a5dfdce
--- /dev/null
@@ -0,0 +1,38 @@
+import {Component, Input, Output, EventEmitter, OnInit, ViewChild, 
+    AfterViewInit, TemplateRef, ViewEncapsulation} from '@angular/core';
+import {ContextMenuService, ContextMenu, ContextMenuEntry} from './context-menu.service';
+
+@Component({
+  selector: 'eg-context-menu-container',
+  templateUrl: './context-menu-container.component.html',
+  styleUrls: ['context-menu-container.component.css'],
+  /* Our CSS affects the style of the popover, which may 
+   * be beyond our reach for standard view encapsulation */
+  encapsulation: ViewEncapsulation.None
+})
+
+export class ContextMenuContainerComponent implements OnInit, AfterViewInit {
+
+    menuEntries: ContextMenuEntry[] = [];
+    @ViewChild('menuTemplate', {static: false}) menuTemplate: TemplateRef<any>;
+
+    constructor(private menuService: ContextMenuService) {}
+
+    ngOnInit() {
+
+        this.menuService.showMenuRequest.subscribe(
+            (menu: ContextMenu) => {
+
+            this.menuEntries = menu.entries
+        });
+    }
+
+    ngAfterViewInit() {
+        this.menuService.menuTemplate = this.menuTemplate;
+    }
+
+    entryClicked(entry: ContextMenuEntry) {
+        this.menuService.menuItemSelected.emit(entry);
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts
new file mode 100644 (file)
index 0000000..591d9d0
--- /dev/null
@@ -0,0 +1,90 @@
+import {Input, Output, EventEmitter, Directive} from '@angular/core';
+import {NgbPopover} from '@ng-bootstrap/ng-bootstrap';
+import {ContextMenuService, ContextMenu, ContextMenuEntry} from './context-menu.service';
+
+
+/* Import all of this stuff so we can pass it to our parent 
+ * class via its constructor */
+import {
+    Inject, Injector, Renderer2, ElementRef, TemplateRef, ViewContainerRef,
+    ComponentFactoryResolver, NgZone, ChangeDetectorRef, ApplicationRef
+} from '@angular/core';
+import {DOCUMENT} from '@angular/common';
+import {NgbPopoverConfig} from '@ng-bootstrap/ng-bootstrap';
+/* --- */
+
+@Directive({
+  selector: '[egContextMenu]',
+  exportAs: 'egContextMenu'
+})
+export class ContextMenuDirective extends NgbPopover {
+
+    triggers = 'contextmenu';
+    popoverClass = 'eg-context-menu';
+
+    menuEntries: ContextMenuEntry[] = [];
+    menu: ContextMenu;
+
+    @Input() set egContextMenu(menuEntries: ContextMenuEntry[]) {
+        this.menuEntries = menuEntries;
+    }
+
+    @Output() menuItemSelected: EventEmitter<ContextMenuEntry>;
+
+    // Only one active menu is allowed at a time.
+    static activeDirective: ContextMenuDirective;
+    static menuId = 0;
+
+    constructor(
+        p1: ElementRef<HTMLElement>, p2: Renderer2, p3: Injector,
+        p4: ComponentFactoryResolver, p5: ViewContainerRef, p6: NgbPopoverConfig,
+        p7: NgZone, @Inject(DOCUMENT) p8: any, p9: ChangeDetectorRef,
+        p10: ApplicationRef, private menuService: ContextMenuService) {
+
+        // relay injected services to parent
+        super(p1, p2, p3, p4, p5, p6, p7, p8, p9, p10);
+
+        this.menuItemSelected = new EventEmitter<ContextMenuEntry>();
+
+        this.menuService.menuItemSelected.subscribe(
+            (entry: ContextMenuEntry) => {
+
+            // Only broadcast entry selection to my listeners if I'm 
+            // hosting the menu where the selection occurred.
+
+            if (this.menu && this.menu.id === this.menuService.activeMenu.id) {
+                this.menuItemSelected.emit(entry);
+            }
+        });
+    }
+
+    open() {
+
+        // In certain scenarios (e.g. right-clicking on another context
+        // menu) an open popover will stay open.  Force it closed here.
+        if (ContextMenuDirective.activeDirective) {
+            ContextMenuDirective.activeDirective.close();
+            ContextMenuDirective.activeDirective = null;
+            this.menuService.activeMenu == null;
+        }
+
+        if (!this.menuEntries ||
+             this.menuEntries.length === 0) { 
+             return;
+        }
+
+        this.menu = new ContextMenu();
+        this.menu.id = ContextMenuDirective.menuId++;
+        this.menu.entries = this.menuEntries;
+
+        this.menuService.activeMenu = this.menu;
+        this.menuService.showMenuRequest.emit(this.menu);
+        this.ngbPopover = this.menuService.menuTemplate;
+
+        ContextMenuDirective.activeDirective = this;
+
+        super.open();
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts
new file mode 100644 (file)
index 0000000..fb25e61
--- /dev/null
@@ -0,0 +1,24 @@
+import {NgModule} from '@angular/core';
+import {CommonModule} from '@angular/common';
+import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
+import {ContextMenuService} from './context-menu.service';
+import {ContextMenuDirective} from './context-menu.directive';
+import {ContextMenuContainerComponent} from './context-menu-container.component';
+
+@NgModule({
+  declarations: [
+    ContextMenuDirective,
+    ContextMenuContainerComponent
+  ],
+  imports: [
+    CommonModule,
+    NgbModule
+  ],
+  exports: [
+    ContextMenuDirective,
+    ContextMenuContainerComponent
+  ]
+})
+
+export class ContextMenuModule { }
+
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts
new file mode 100644 (file)
index 0000000..bfedf42
--- /dev/null
@@ -0,0 +1,32 @@
+import {Injectable, EventEmitter, TemplateRef} from '@angular/core';
+import {tap} from 'rxjs/operators';
+
+/* Relay requests to/from the context menu directive and its 
+ * template container component */
+
+export interface ContextMenuEntry {
+    value: string;
+    label: string;
+}
+
+export class ContextMenu {
+    id: number;
+    entries: ContextMenuEntry[];
+}
+
+@Injectable({providedIn: 'root'})
+export class ContextMenuService {
+    
+    showMenuRequest: EventEmitter<ContextMenu>;
+    menuItemSelected: EventEmitter<ContextMenuEntry>;
+
+    menuTemplate: TemplateRef<any>;
+    activeMenu: ContextMenu;
+    
+    constructor() {
+        this.showMenuRequest = new EventEmitter<ContextMenu>();
+        this.menuItemSelected = new EventEmitter<ContextMenuEntry>();
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css
new file mode 100644 (file)
index 0000000..e21bb84
--- /dev/null
@@ -0,0 +1,22 @@
+
+div[contenteditable] {
+   /* provide plenty of input space */
+   min-width: 2em;
+   /* match BS form-control border color */
+   border: 1px solid rgb(206, 212, 218);
+   /* match BS form-control input height */
+   min-height: calc(1.5em + .75rem + 2px);
+}
+
+.sf-delimiter { 
+  /* match angjs color */
+  color: rgb(0, 0, 255)!important; 
+  /* snuggle up to my subfield code */
+  margin-right: -0.5rem; 
+}
+
+.sf-code { 
+  /* match angjs color */
+  color: rgb(0, 0, 255)!important; 
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
new file mode 100644 (file)
index 0000000..359128c
--- /dev/null
@@ -0,0 +1,35 @@
+
+<ng-container *ngIf="bigText">
+  <div contenteditable
+    id='{{randId}}' 
+    spellcheck="false"
+    class="d-inline-block text-dark text-break {{moreClasses}}"
+    [attr.tabindex]="fieldText ? -1 : ''"
+    [egContextMenu]="contextMenuEntries()"
+    (menuItemSelected)="contextMenuChange($event.value)"
+    (keydown)="inputKeyDown($event)"
+    (focus)="selectText()"
+    (blur)="inputBlurred()"
+    (input)="bigTextValueChange()">
+  </div>
+</ng-container>
+
+<ng-container *ngIf="!bigText">
+  <input 
+    id='{{randId}}' 
+    spellcheck="false"
+    class="text-dark rounded-0 form-control {{moreClasses}}"
+    [size]="inputSize()" 
+    [maxlength]="maxLength || ''"
+    [disabled]="fieldText" 
+    [attr.tabindex]="fieldText ? -1 : ''"
+    [egContextMenu]="contextMenuEntries()"
+    (menuItemSelected)="contextMenuChange($event.value)"
+    (keydown)="inputKeyDown($event)"
+    (focus)="selectText()"
+    (blur)="inputBlurred()"
+    [ngModel]="getContent()"
+    (ngModelChange)="setContent($event)"
+  />
+</ng-container>
+
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
new file mode 100644 (file)
index 0000000..5f4b1ea
--- /dev/null
@@ -0,0 +1,534 @@
+import {ElementRef, Component, Input, Output, OnInit, OnDestroy, 
+    EventEmitter, AfterViewInit, Renderer2} from '@angular/core';
+import {Subscription} from 'rxjs';
+import {filter} from 'rxjs/operators';
+import {MarcRecord, MarcField, MarcSubfield} from './marcrecord';
+import {MarcEditContext, FieldFocusRequest, MARC_EDITABLE_FIELD_TYPE, 
+    TextUndoRedoAction} from './editor-context';
+import {ContextMenuEntry} from '@eg/share/context-menu/context-menu.service';
+import {TagTableService} from './tagtable.service';
+
+/**
+ * MARC Editable Content Component
+ */
+
+@Component({
+  selector: 'eg-marc-editable-content',
+  templateUrl: './editable-content.component.html',
+  styleUrls: ['./editable-content.component.css']
+})
+
+export class EditableContentComponent 
+    implements OnInit, AfterViewInit, OnDestroy {
+
+    @Input() context: MarcEditContext;
+    @Input() field: MarcField;
+    @Input() fieldType: MARC_EDITABLE_FIELD_TYPE = null;
+
+    // read-only field text.  E.g. 'LDR'
+    @Input() fieldText: string = null;
+
+    // array of subfield code and subfield value
+    @Input() subfield: MarcSubfield;
+
+    @Input() fixedFieldCode: string;
+
+    // space-separated list of additional CSS classes to append
+    @Input() moreClasses: string;
+
+    get record(): MarcRecord { return this.context.record; }
+
+    bigText = false;
+    randId = Math.floor(Math.random() * 100000);
+    editInput: any; // <input/> or <div contenteditable/>
+    maxLength: number = null;
+
+    // Track the load-time content so we know what text value to 
+    // track on our undo stack.
+    undoBackToText: string;
+
+    focusSub: Subscription;
+    undoRedoSub: Subscription;
+    isLeader: boolean; // convenience
+
+    // Cache of fixed field menu options
+    ffValues: ContextMenuEntry[] = [];
+
+    // Track the fixed field value locally since extracting the value
+    // in real time from the record, which adds padding to the text,
+    // causes usability problems.
+    ffValue: string;
+
+    constructor(
+        private renderer: Renderer2,
+        private tagTable: TagTableService) {}
+
+    ngOnInit() {
+        this.setupFieldType();
+    }
+
+    ngOnDestroy() {
+        if (this.focusSub) { this.focusSub.unsubscribe(); }
+        if (this.undoRedoSub) { this.undoRedoSub.unsubscribe(); }
+    }
+
+    watchForFocusRequests() {
+        this.focusSub = this.context.fieldFocusRequest.pipe(
+            filter((req: FieldFocusRequest) => this.focusRequestIsMe(req)))
+        .subscribe((req: FieldFocusRequest) => this.selectText(req));
+    }
+
+    watchForUndoRedoRequests() {
+        this.undoRedoSub = this.context.textUndoRedoRequest.pipe(
+            filter((action: TextUndoRedoAction) => this.focusRequestIsMe(action.position)))
+        .subscribe((action: TextUndoRedoAction) => this.processUndoRedo(action));
+    }
+
+    focusRequestIsMe(req: FieldFocusRequest): boolean {
+        if (req.target !== this.fieldType) { return false; }
+
+        if (this.field) {
+            if (req.fieldId !== this.field.fieldId) { return false; }
+        } else if (req.target === 'ldr') {
+            return this.isLeader;
+        } else if (req.target === 'ffld' && 
+            req.ffCode !== this.fixedFieldCode) {
+            return false;
+        }
+
+        if (req.sfOffset !== undefined && 
+            req.sfOffset !== this.subfield[2]) {
+            // this is not the subfield you are looking for.
+            return false;
+        }
+
+        return true;
+    }
+
+    selectText(req?: FieldFocusRequest) {
+        if (this.bigText) {
+            this.focusBigText();
+        } else {
+            this.editInput.select();
+        }
+
+        if (!req) {
+            // Focus request may have come from keyboard navigation, 
+            // clicking, etc.  Model the event as a focus request
+            // so it can be tracked the same.
+            req = {
+                fieldId: this.field ? this.field.fieldId : -1,
+                target: this.fieldType,
+                sfOffset: this.subfield ? this.subfield[2] : undefined,
+                ffCode: this.fixedFieldCode
+            };
+        }
+
+        this.context.lastFocused = req;
+    }
+
+    setupFieldType() {
+        const content = this.getContent();
+        this.undoBackToText = content;
+
+        switch (this.fieldType) {
+            case 'ldr':
+                this.isLeader = true;
+                if (content) { this.maxLength = content.length; }
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'tag':
+                this.maxLength = 3;
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'cfld':
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'ffld':
+                this.applyFFOptions();
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'ind1':
+            case 'ind2':
+                this.maxLength = 1;
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'sfc':
+                this.maxLength = 1;
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'sfv':
+                this.bigText = true;
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+        }
+    }
+
+    applyFFOptions() {
+        return this.tagTable.getFfFieldMeta(
+            this.fixedFieldCode, this.record.recordType())
+        .then(fieldMeta => {
+            if (fieldMeta) {
+                this.maxLength = fieldMeta.length || 1;
+            }
+        });
+
+        // Fixed field options change when the record type changes.
+        this.context.recordChange.subscribe(_ => this.applyFFOptions());
+    }
+
+    // These are served dynamically to handle cases where a tag or
+    // subfield is modified in place.
+    contextMenuEntries(): ContextMenuEntry[] {
+        if (this.isLeader) { return; }
+
+        switch(this.fieldType) {
+            case 'tag': 
+                return this.tagTable.getFieldTags();
+
+            case 'sfc': 
+                return this.tagTable.getSubfieldCodes(this.field.tag);
+
+            case 'sfv': 
+                return this.tagTable.getSubfieldValues(
+                    this.field.tag, this.subfield[0]);
+
+            case 'ind1':
+            case 'ind2':
+                return this.tagTable.getIndicatorValues(
+                    this.field.tag, this.fieldType);
+
+            case 'ffld': 
+                return this.tagTable.getFfValues(
+                    this.fixedFieldCode, this.record.recordType());
+        }
+
+        return null;
+    }
+
+    getContent(): string {
+        if (this.fieldText) { return this.fieldText; } // read-only
+
+        switch (this.fieldType) {
+            case 'ldr': return this.record.leader; 
+            case 'cfld': return this.field.data;
+            case 'tag': return this.field.tag;
+            case 'sfc': return this.subfield[0];
+            case 'sfv': return this.subfield[1];
+            case 'ind1': return this.field.ind1;
+            case 'ind2': return this.field.ind2;
+
+            case 'ffld': 
+                // When actively editing a fixed field, track its value
+                // in a local variable instead of pulling the value
+                // from record.extractFixedField(), which applies
+                // additional formattting, causing usability problems
+                // (e.g. unexpected spaces).  Once focus is gone, the
+                // view will be updated with the correctly formatted
+                // value.
+
+                if ( this.ffValue === undefined ||
+                    !this.context.lastFocused ||
+                    !this.focusRequestIsMe(this.context.lastFocused)) {
+
+                    this.ffValue = 
+                        this.record.extractFixedField(this.fixedFieldCode);
+                }
+                return this.ffValue;
+        }
+        return 'X';
+    }
+
+    setContent(value: string, propagatBigText?: boolean, skipUndoTrack?: boolean) {
+
+        if (this.fieldText) { return; } // read-only text
+
+        switch (this.fieldType) {
+            case 'ldr': this.record.leader = value; break;
+            case 'cfld': this.field.data = value; break;
+            case 'tag': this.field.tag = value; break;
+            case 'sfc': this.subfield[0] = value; break;
+            case 'sfv': this.subfield[1] = value; break;
+            case 'ind1': this.field.ind1 = value; break;
+            case 'ind2': this.field.ind2 = value; break;
+            case 'ffld': 
+                // Track locally and propagate to the record.
+                this.ffValue = value;
+                this.record.setFixedField(this.fixedFieldCode, value);
+                break;
+        }
+
+        if (propagatBigText && this.bigText) {
+            // Propagate new content to the bigtext div.
+            // Should only be used when a content change occurrs via
+            // external means (i.e. not from a direct edit of the div).
+            this.editInput.innerText = value;
+        }
+
+        if (!skipUndoTrack) {
+            this.trackTextChangeForUndo(value);
+        }
+    }
+
+    trackTextChangeForUndo(value: string) {
+
+        // Human-driven changes invalidate the redo stack.
+        this.context.redoStack = [];
+
+        const lastUndo = this.context.undoStack[0];
+
+        if (lastUndo
+            && lastUndo instanceof TextUndoRedoAction
+            && lastUndo.textContent === this.undoBackToText
+            && this.focusRequestIsMe(lastUndo.position)) {
+            // Most recent undo entry was a text change event within the
+            // current atomic editing (focused) session for the input.
+            // Nothing else to track.
+            return;
+        }
+
+        const undo = new TextUndoRedoAction();
+        undo.position = this.context.lastFocused;
+        undo.textContent =  this.undoBackToText;
+
+        this.context.undoStack.unshift(undo);
+    }
+
+    // Apply the undo or redo action and track its opposite
+    // action on the necessary stack
+    processUndoRedo(action: TextUndoRedoAction) {
+
+        // Undoing a text change
+        const recoverContent = this.getContent();
+        this.setContent(action.textContent, true, true);
+
+        action.textContent = recoverContent;
+        const moveTo = action.isRedo ? 
+            this.context.undoStack : this.context.redoStack;
+
+        moveTo.unshift(action);
+    }
+
+    inputBlurred() {
+        // If the text content changed during this focus session,
+        // track the new value as the value the next session of 
+        // text edits should return to upon undo.
+        this.undoBackToText = this.getContent();
+    }
+
+    // Propagate editable div content into our record
+    bigTextValueChange() {
+        this.setContent(this.editInput.innerText);
+    }
+
+    ngAfterViewInit() {
+        this.editInput = // numeric id requires [id=...] query selector
+            this.renderer.selectRootElement(`[id='${this.randId}']`);
+
+        // Initialize the editable div
+        this.editInput.innerText = this.getContent();
+    }
+
+    inputSize(): number {
+        if (this.maxLength) {
+            return this.maxLength + 1;
+        }
+        // give at least 2+ chars space and grow with the content
+        return Math.max(2, (this.getContent() || '').length) * 1.1;
+    }
+
+    focusBigText() {
+        const targetNode = this.editInput.firstChild;
+
+        if (!targetNode) {
+            // Div contains no text content, nothing to select
+            return;
+        }
+
+        const range = document.createRange();
+        range.setStart(targetNode, 0);
+        range.setEnd(targetNode, targetNode.length);
+
+        const selection = window.getSelection();
+        selection.removeAllRanges();
+        selection.addRange(range);
+    }
+
+    // Route keydown events to the appropriate handler
+    inputKeyDown(evt: KeyboardEvent) {
+
+        switch(evt.key) {
+            case 'y': 
+                if (evt.ctrlKey) { // redo
+                    this.context.requestRedo();
+                    evt.preventDefault();
+                }
+                return;
+
+            case 'z': 
+                if (evt.ctrlKey) { // undo
+                    this.context.requestUndo();
+                    evt.preventDefault();
+                }
+                return;
+
+            case 'F6': 
+                if (evt.shiftKey) {
+                    // shift+F6 => add 006
+                    this.context.add00X('006');
+                    evt.preventDefault();
+                    evt.stopPropagation();
+                }
+                return;
+
+            case 'F7': 
+                if (evt.shiftKey) {
+                    // shift+F7 => add 007
+                    this.context.add00X('007');
+                    evt.preventDefault();
+                    evt.stopPropagation();
+                }
+                return;
+
+            case 'F8': 
+                if (evt.shiftKey) {
+                    // shift+F8 => add/replace 008
+                    this.context.insertReplace008();
+                    evt.preventDefault();
+                    evt.stopPropagation();
+                }
+                return;
+        }
+
+        // None of the remaining key combos are supported by the LDR
+        // or fixed field editor.
+        if (this.fieldType === 'ldr' || this.fieldType === 'ffld') { return; }
+
+        switch (evt.key) {
+
+            case 'Enter': 
+                if (evt.ctrlKey) {
+                    // ctrl+enter == insert stub field after focused field
+                    // ctrl+shift+enter == insert stub field before focused field
+                    this.context.insertStubField(this.field, evt.shiftKey);
+                }
+
+                evt.preventDefault(); // Bare newlines not allowed.
+                break;
+
+            case 'Delete':
+
+                if (evt.ctrlKey) { 
+                    // ctrl+delete == delete whole field
+                    this.context.deleteField(this.field);
+                    evt.preventDefault();
+
+                } else if (evt.shiftKey && this.subfield) {
+                    // shift+delete == delete subfield
+
+                    this.context.deleteSubfield(this.field, this.subfield);
+                    evt.preventDefault();
+                }
+
+                break;
+
+            case 'ArrowDown': 
+
+                if (evt.ctrlKey) {
+                    // ctrl+down == copy current field down one
+                    this.context.insertField(
+                        this.field, this.record.cloneField(this.field));
+                } else {
+                    // avoid dupe focus requests
+                    this.context.focusNextTag(this.field);
+                }
+
+                evt.preventDefault();
+                break;
+
+            case 'ArrowUp':
+
+                if (evt.ctrlKey) {
+                    // ctrl+up == copy current field up one
+                    this.context.insertField(
+                        this.field, this.record.cloneField(this.field), true);
+                } else {
+                    // avoid dupe focus requests
+                    this.context.focusPreviousTag(this.field);
+                }
+
+                // up == move focus to tag of previous field
+                evt.preventDefault();
+                break;
+
+            case 'd': // thunk
+            case 'i':
+                if (evt.ctrlKey) {
+                    // ctrl+i / ctrl+d == insert subfield
+                    const pos = this.subfield ? this.subfield[2] + 1 : 0;
+                    this.context.insertStubSubfield(this.field, pos);
+                    evt.preventDefault();
+                }
+                break;
+        }
+    }
+
+    insertField(before: boolean) {
+
+        const newField = this.record.newField(
+            {tag: '999', subfields: [[' ', '', 0]]});
+
+        if (before) {
+            this.record.insertFieldsBefore(this.field, newField);
+        } else {
+            this.record.insertFieldsAfter(this.field, newField);
+        }
+
+        this.context.requestFieldFocus(
+            {fieldId: newField.fieldId, target: 'tag'});
+    }
+
+    deleteField() {
+        this.context.focusNextTag(this.field) || 
+            this.context.focusPreviousTag(this.field);
+
+        this.record.deleteFields(this.field);
+    }
+
+    deleteSubfield() {
+        // If subfields remain, focus the previous subfield.
+        // otherwise focus our tag.
+        const sfpos = this.subfield[2] - 1;
+
+        this.field.deleteExactSubfields(this.subfield);
+
+        const focus: FieldFocusRequest = 
+            {fieldId: this.field.fieldId, target: 'tag'};
+
+        if (sfpos >= 0) { 
+            focus.target = 'sfv';
+            focus.sfOffset = sfpos; 
+        }
+
+        this.context.requestFieldFocus(focus);
+    }
+
+    contextMenuChange(value: string) {
+        this.setContent(value, true);
+    }
+}
+
+
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
new file mode 100644 (file)
index 0000000..168fbda
--- /dev/null
@@ -0,0 +1,351 @@
+import {EventEmitter} from '@angular/core';
+import {MarcRecord, MarcField, MarcSubfield} from './marcrecord';
+import {NgbPopover} from '@ng-bootstrap/ng-bootstrap';
+
+/* Per-instance MARC editor context. */
+
+const STUB_DATA_00X = '                                        ';
+
+export type MARC_EDITABLE_FIELD_TYPE = 
+    'ldr' | 'tag' | 'cfld' | 'ind1' | 'ind2' | 'sfc' | 'sfv' | 'ffld';
+
+export interface FieldFocusRequest {
+    fieldId: number;
+    target: MARC_EDITABLE_FIELD_TYPE;
+    sfOffset?: number; // focus a specific subfield by its offset
+    ffCode?: string; // fixed field code
+}
+
+export class UndoRedoAction {
+    // Which point in the record was modified.
+    position: FieldFocusRequest;
+
+    // Which stack do we toss this on once it's been applied?
+    isRedo: boolean;
+}
+
+export class TextUndoRedoAction extends UndoRedoAction {
+    textContent: string;
+}
+
+export class StructUndoRedoAction extends UndoRedoAction {
+    /* Add or remove a part of the record (field, subfield, etc.) */
+
+    // Does this action track an addition or deletion.
+    wasAddition: boolean;
+
+    // Field to add/delete or field to modify for subfield adds/deletes
+    field: MarcField;
+
+    // If this is a subfield modification.
+    subfield: MarcSubfield;
+
+    // Position preceding the modified position to mark the position
+    // of deletion recovery.
+    prevPosition: FieldFocusRequest;
+
+    // Location of the cursor at time of initial action.
+    prevFocus: FieldFocusRequest;
+}
+
+
+export class MarcEditContext {
+
+    recordChange: EventEmitter<MarcRecord>;
+    fieldFocusRequest: EventEmitter<FieldFocusRequest>;
+    textUndoRedoRequest: EventEmitter<TextUndoRedoAction>;
+    recordType: 'biblio' | 'authority' = 'biblio';
+
+    lastFocused: FieldFocusRequest = null;
+
+    undoStack: UndoRedoAction[] = [];
+    redoStack: UndoRedoAction[] = [];
+
+    private _record: MarcRecord;
+    set record(r: MarcRecord) {
+        if (r !== this._record) {
+            this._record = r;
+            this._record.stampFieldIds();
+            this.recordChange.emit(r);
+        }
+    }
+
+    get record(): MarcRecord {
+        return this._record;
+    }
+
+    constructor() {
+        this.recordChange = new EventEmitter<MarcRecord>();
+        this.fieldFocusRequest = new EventEmitter<FieldFocusRequest>();
+        this.textUndoRedoRequest = new EventEmitter<TextUndoRedoAction>();
+    }
+
+    requestFieldFocus(req: FieldFocusRequest) {
+        // timeout allows for new components to be built before the
+        // focus request is emitted.
+        setTimeout(() => this.fieldFocusRequest.emit(req));
+    }
+
+    resetUndos() {
+        this.undoStack = [];
+        this.redoStack = [];
+    }
+
+    requestUndo() {
+        const undo = this.undoStack.shift();
+        if (undo) {
+            undo.isRedo = false;
+            this.distributeUndoRedo(undo);
+        }
+    }
+
+    requestRedo() {
+        const redo = this.redoStack.shift();
+        if (redo) {
+            redo.isRedo = true;
+            this.distributeUndoRedo(redo);
+        }
+    }
+
+    distributeUndoRedo(action: UndoRedoAction) {
+        if (action instanceof TextUndoRedoAction) {
+            // Let the editable content component handle it.
+            this.textUndoRedoRequest.emit(action);
+        } else {
+            // Manage structural changes within
+            this.handleStructuralUndoRedo(action as StructUndoRedoAction);
+        }
+    }
+
+    handleStructuralUndoRedo(action: StructUndoRedoAction) {
+
+        if (action.wasAddition) {
+            // Remove the added field
+
+            if (action.subfield) {
+                const prevPos = action.subfield[2] - 1;
+                action.field.deleteExactSubfields(action.subfield);
+                this.focusSubfield(action.field, prevPos);
+
+            } else {
+                this.record.deleteFields(action.field);
+            }
+
+            // When deleting chunks, always return focus to the
+            // pre-insert position.
+            this.requestFieldFocus(action.prevFocus);
+
+        } else {
+            // Re-insert the removed field and focus it.
+            
+            if (action.subfield) { 
+
+                this.insertSubfield(action.field, action.subfield, true);
+                this.focusSubfield(action.field, action.subfield[2]);
+
+            } else {
+                
+                const fieldId = action.position.fieldId;
+                const prevField = 
+                    this.record.getField(action.prevPosition.fieldId);
+
+                this.record.insertFieldsAfter(prevField, action.field);
+                
+                // Recover the original fieldId, which gets re-stamped
+                // in this.record.insertFields* calls.
+                action.field.fieldId = fieldId;
+                
+                // Focus the newly recovered field.
+                this.requestFieldFocus(action.position);
+            }
+
+            // When inserting chunks, track the location where the
+            // insert was requested so we can return the cursor so we
+            // can return the cursor to the scene of the crime if the
+            // undo is re-done or vice versa.  This is primarily useful
+            // when performing global inserts like add00X, which can be
+            // done without the 00X field itself having focus.
+            action.prevFocus = this.lastFocused;
+        }
+
+        action.wasAddition = !action.wasAddition;
+
+        const moveTo = action.isRedo ? this.undoStack : this.redoStack;
+
+        moveTo.unshift(action);
+    }
+
+    trackStructuralUndo(field: MarcField, isAddition: boolean, subfield?: MarcSubfield) {
+
+        // Human-driven changes invalidate the redo stack.
+        this.redoStack = [];
+
+        const position: FieldFocusRequest = {fieldId: field.fieldId, target: 'tag'};
+
+        let prevPos: FieldFocusRequest = null;
+
+        if (subfield) {
+            position.target = 'sfc';
+            position.sfOffset = subfield[2];
+
+        } else {
+            // No need to track the previous field for subfield mods.
+
+            const prevField = this.record.getPreviousField(field.fieldId);
+            if (prevField) {
+                prevPos = {fieldId: prevField.fieldId, target: 'tag'};
+            }
+        }
+
+        const action = new StructUndoRedoAction();
+        action.field = field;
+        action.subfield = subfield;
+        action.wasAddition = isAddition;
+        action.position = position;
+        action.prevPosition = prevPos;
+
+        // For bulk adds (e.g. add a whole row) the field focused at
+        // time of action will be different than the added field.
+        action.prevFocus = this.lastFocused;
+
+        this.undoStack.unshift(action);
+    }
+
+    deleteField(field: MarcField) { 
+        this.trackStructuralUndo(field, false);
+
+        this.focusNextTag(field) || this.focusPreviousTag(field);
+
+        this.record.deleteFields(field);
+    }
+
+    add00X(tag: string) {
+
+        const field: MarcField = 
+            this.record.newField({tag : tag, data : STUB_DATA_00X});
+
+        this.record.insertOrderedFields(field);
+
+        this.trackStructuralUndo(field, true);
+
+        this.focusTag(field);
+    }
+
+    insertReplace008() {
+
+        // delete all of the 008s
+        [].concat(this.record.field('008', true)).forEach(f => {
+            this.trackStructuralUndo(f, false);
+            this.record.deleteFields(f);
+        });
+
+        const field = this.record.newField({
+            tag : '008', data : this.record.generate008()});
+
+        this.record.insertOrderedFields(field);
+
+        this.trackStructuralUndo(field, true);
+
+        this.focusTag(field);
+    }
+
+    // Add stub field before or after the context field
+    insertStubField(field: MarcField, before?: boolean) {
+
+        const newField = this.record.newField(
+            {tag: '999', subfields: [[' ', '', 0]]});
+
+        this.insertField(field, newField, before);
+    }
+
+    insertField(contextField: MarcField, newField: MarcField, before?: boolean) {
+
+        if (before) {
+            this.record.insertFieldsBefore(contextField, newField);
+            this.focusPreviousTag(contextField);
+
+        } else {
+            this.record.insertFieldsAfter(contextField, newField);
+            this.focusNextTag(contextField);
+        }
+
+        this.trackStructuralUndo(newField, true);
+    }
+
+    // Adds a new empty subfield to the provided field at the
+    // requested subfield position
+    insertSubfield(field: MarcField, 
+        subfield: MarcSubfield, skipTracking?: boolean) {
+        const position = subfield[2];
+
+        // array index 3 contains that position of the subfield
+        // in the MARC field.  When splicing a new subfield into
+        // the set, be sure the any that come after the new one
+        // have their positions bumped to reflect the shift.
+        field.subfields.forEach(
+            sf => {if (sf[2] >= position) { sf[2]++; }});
+
+        field.subfields.splice(position, 0, subfield);
+
+        if (!skipTracking) {
+            this.focusSubfield(field, position);
+            this.trackStructuralUndo(field, true, subfield);
+        }
+    }
+
+    insertStubSubfield(field: MarcField, position: number) {
+        const newSf: MarcSubfield = [' ', '', position];
+        this.insertSubfield(field, newSf);
+    }
+    
+    // Focus the requested subfield by its position.  If its 
+    // position is less than zero, focus the field's tag instead.
+    focusSubfield(field: MarcField, position: number) {
+
+        const focus: FieldFocusRequest = {fieldId: field.fieldId, target: 'tag'};
+
+        if (position >= 0) { 
+            // Focus the code instead of the value, because attempting to
+            // focus an empty (editable) div results in nothing getting focus.
+            focus.target = 'sfc';
+            focus.sfOffset = position; 
+        }
+
+        this.requestFieldFocus(focus);
+    }
+
+    deleteSubfield(field: MarcField, subfield: MarcSubfield) {
+        const sfpos = subfield[2] - 1; // previous subfield
+
+        this.trackStructuralUndo(field, false, subfield);
+
+        field.deleteExactSubfields(subfield);
+
+        this.focusSubfield(field, sfpos);
+    }
+
+    focusTag(field: MarcField) {
+        this.requestFieldFocus({fieldId: field.fieldId, target: 'tag'});
+    }
+
+    // Returns true if the field has a next tag to focus
+    focusNextTag(field: MarcField) {
+        const nextField = this.record.getNextField(field.fieldId);
+        if (nextField) { 
+            this.focusTag(nextField); 
+            return true;
+        }
+        return false;
+    }
+
+    // Returns true if the field has a previous tag to focus
+    focusPreviousTag(field: MarcField): boolean {
+        const prevField = this.record.getPreviousField(field.fieldId);
+        if (prevField) {
+            this.focusTag(prevField);
+            return true;
+        }
+        return false;
+    }
+}
+
index 55c5af6..dea8581 100644 (file)
 
 <div class="row d-flex p-2 m-2">
   <div class="flex-1"></div>
+
+  <h3 class="mr-2">
+    <span class="badge badge-light p-2" i18n>
+      Record Type {{record ? record.recordType() : ''}}
+    </span>
+  </h3>
+    
   <div class="mr-2">
     <eg-combobox #sourceSelector
       [entries]="sources"
 
 <div class="row">
   <div class="col-lg-12">
-    <ngb-tabset [activeId]="editorTab">
-      <ngb-tab title="Enhanced MARC Editor" i18n-title id="rich" *ngIf="!inPlaceMode">
+    <ngb-tabset [activeId]="editorTab" (tabChange)="tabChange($event)">
+      <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 *ngIf="context && context.record">
+            <eg-marc-rich-editor [context]="context"></eg-marc-rich-editor>
           </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-container *ngIf="context && context.record">
+            <eg-marc-flat-editor [context]="context"></eg-marc-flat-editor>
+          </ng-container>
         </ng-template>
       </ngb-tab>
     </ngb-tabset>
index 44da299..1381616 100644 (file)
@@ -6,11 +6,14 @@ import {AuthService} from '@eg/core/auth.service';
 import {OrgService} from '@eg/core/org.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {ToastService} from '@eg/share/toast/toast.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
 import {StringComponent} from '@eg/share/string/string.component';
 import {MarcRecord} from './marcrecord';
 import {ComboboxEntry, ComboboxComponent
   } from '@eg/share/combobox/combobox.component';
 import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {MarcEditContext} from './editor-context';
+import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 
 interface MarcSavedEvent {
     marcXml: string;
@@ -28,9 +31,11 @@ interface MarcSavedEvent {
 
 export class MarcEditorComponent implements OnInit {
 
-    record: MarcRecord;
     editorTab: 'rich' | 'flat';
     sources: ComboboxEntry[];
+    context: MarcEditContext;
+
+    @Input() recordType: 'biblio' | 'authority' = 'biblio';
 
     @Input() set recordId(id: number) {
         if (!id) { return; }
@@ -39,7 +44,13 @@ export class MarcEditorComponent implements OnInit {
     }
 
     @Input() set recordXml(xml: string) {
-        if (xml) { this.fromXml(xml); }
+        if (xml) {
+            this.fromXml(xml);
+        }
+    }
+
+    get record(): MarcRecord {
+        return this.context.record;
     }
 
     // Tell us which record source to select by default.
@@ -70,15 +81,20 @@ export class MarcEditorComponent implements OnInit {
         private auth: AuthService,
         private org: OrgService,
         private pcrud: PcrudService,
-        private toast: ToastService
+        private toast: ToastService,
+        private store: ServerStoreService
     ) {
         this.sources = [];
         this.recordSaved = new EventEmitter<MarcSavedEvent>();
+        this.context = new MarcEditContext();
     }
 
     ngOnInit() {
-        // Default to flat for now since it's all that's supported.
-        this.editorTab = 'flat';
+
+        this.context.recordType = this.recordType;
+
+        this.store.getItem('cat.marcedit.flateditor').then(
+            useFlat => this.editorTab = useFlat ? 'flat' : 'rich');
 
         this.pcrud.retrieveAll('cbs').subscribe(
             src => this.sources.push({id: +src.id(), label: src.source()}),
@@ -95,6 +111,20 @@ export class MarcEditorComponent implements OnInit {
         );
     }
 
+    // Remember the last used tab as the preferred tab.
+    tabChange(evt: NgbTabChangeEvent) {
+
+        // Avoid undo persistence across tabs since that could result
+        // in changes getting lost.
+        this.context.resetUndos();
+
+        if (evt.nextId === 'flat') {
+            this.store.setItem('cat.marcedit.flateditor', true);
+        } else {
+            this.store.removeItem('cat.marcedit.flateditor');
+        }
+    }
+
     saveRecord(): Promise<any> {
         const xml = this.record.toXml();
 
@@ -140,7 +170,7 @@ export class MarcEditorComponent implements OnInit {
     fromId(id: number): Promise<any> {
         return this.pcrud.retrieve('bre', id)
         .toPromise().then(bib => {
-            this.record = new MarcRecord(bib.marc());
+            this.context.record = new MarcRecord(bib.marc());
             this.record.id = id;
             this.record.deleted = bib.deleted() === 't';
             if (bib.source()) {
@@ -150,7 +180,7 @@ export class MarcEditorComponent implements OnInit {
     }
 
     fromXml(xml: string) {
-        this.record = new MarcRecord(xml);
+        this.context.record = new MarcRecord(xml);
         this.record.id = null;
     }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css
new file mode 100644 (file)
index 0000000..88b31f2
--- /dev/null
@@ -0,0 +1,20 @@
+
+:host >>> .popover {
+  font-family: 'Lucida Console', Monaco, monospace;
+  max-width: 550px;
+}
+
+:host >>> .popover-body {
+  max-height: 400px;
+  overflow-y: auto;
+  overflow-x: auto;
+}
+
+:host >>> .popover-body .menu-entry {
+  white-space: nowrap;
+}
+
+:host >>> .popover-body .menu-entry:hover {
+  background-color: #f8f9fa; /* bootstrap color */
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html
new file mode 100644 (file)
index 0000000..e2e8976
--- /dev/null
@@ -0,0 +1,16 @@
+
+<ng-container *ngIf="fieldMeta">
+
+  <div class="d-flex">
+    <div class="flex-4">
+      <span id='label-{{randId}}' class="text-left font-weight-bold">
+        {{fieldLabel}}
+      </span>
+    </div>
+      <div class="flex-5">
+        <eg-marc-editable-content [context]="context"
+          [fixedFieldCode]="fieldCode" fieldType="ffld" moreClasses="p-1">
+        </eg-marc-editable-content>
+      </div>
+  </div>
+</ng-container>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts
new file mode 100644 (file)
index 0000000..a81255d
--- /dev/null
@@ -0,0 +1,46 @@
+import {Component, Input, Output, OnInit, EventEmitter} from '@angular/core';
+import {IdlObject} from '@eg/core/idl.service';
+import {MarcRecord} from './marcrecord';
+import {MarcEditContext} from './editor-context';
+import {TagTableService} from './tagtable.service';
+
+/**
+ * MARC Fixed Field Editing Component
+ */
+
+@Component({
+  selector: 'eg-fixed-field',
+  templateUrl: './fixed-field.component.html',
+  styleUrls: ['fixed-field.component.css']
+})
+
+export class FixedFieldComponent implements OnInit {
+
+    @Input() fieldCode: string;
+    @Input() fieldLabel: string;
+    @Input() context: MarcEditContext;
+
+    get record(): MarcRecord { return this.context.record; }
+
+    fieldMeta: IdlObject;
+    randId = Math.floor(Math.random() * 10000000);
+
+    constructor(private tagTable: TagTableService) {}
+
+    ngOnInit() {
+        this.init().then(_ =>
+            this.context.recordChange.subscribe(__ => this.init()));
+    }
+
+    init(): Promise<any> {
+        if (!this.record) { return Promise.resolve(); }
+
+        // If no field metadata is found for this fixed field code and
+        // record type combo, the field will be hidden in the UI.
+        return this.tagTable.getFfFieldMeta(
+            this.fieldCode, this.record.recordType())
+        .then(fieldMeta => this.fieldMeta = fieldMeta);
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html
new file mode 100644 (file)
index 0000000..97d866f
--- /dev/null
@@ -0,0 +1,281 @@
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Type" fieldLabel="Type"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="ELvl" fieldLabel="ELvl"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Source" fieldLabel="Source"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Audn" fieldLabel="Audn"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Ctrl" fieldLabel="Ctrl"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Lang" fieldLabel="Lang"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="BLvl" fieldLabel="BLvl"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Form" fieldLabel="Form"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Conf" fieldLabel="Conf"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Biog" fieldLabel="Biog"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="MRec" fieldLabel="MRec"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Ctry" fieldLabel="Ctry"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="s_l" fieldLabel="s_l"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Cont" fieldLabel="Cont"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="GPub" fieldLabel="GPub"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="LitF" fieldLabel="LitF"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Indx" fieldLabel="Indx"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Alph" fieldLabel="Alph"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Desc" fieldLabel="Desc"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Ills" fieldLabel="Ills"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Fest" fieldLabel="Fest"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="DtSt" fieldLabel="DtSt"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Date1" fieldLabel="Date1"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Date2" fieldLabel="Date2"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SrTp" fieldLabel="SrTp"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Regl" fieldLabel="Regl"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Orig" fieldLabel="Orig"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Freq" fieldLabel="Freq"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="EntW" fieldLabel="EntW"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="TrAr" fieldLabel="TrAr"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Part" fieldLabel="Part"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="LTxt" fieldLabel="LTxt"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="FMus" fieldLabel="FMus"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="AccM" fieldLabel="AccM"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Comp" fieldLabel="Comp"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SpFm" fieldLabel="SpFm"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Relf" fieldLabel="Relf"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Proj" fieldLabel="Proj"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="CrTp" fieldLabel="CrTp"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="TMat" fieldLabel="TMat"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Time" fieldLabel="Time"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Tech" fieldLabel="Tech"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="File" fieldLabel="File"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Type_tbmfhd" fieldLabel="Type_tbmfhd"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="ELvl_tbmfhd" fieldLabel="ELvl_tbmfhd"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Item_tbmfhd" fieldLabel="Item_tbmfhd"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="GeoDiv" fieldLabel="GeoDiv"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Roman" fieldLabel="Roman"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="CatLang" fieldLabel="CatLang"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Kind" fieldLabel="Kind"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Rules" fieldLabel="Rules"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Subj" fieldLabel="Subj"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Series" fieldLabel="Series"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SerNum" fieldLabel="SerNum"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="NameUse" fieldLabel="NameUse"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SubjUse" fieldLabel="SubjUse"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SerUse" fieldLabel="SerUse"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="TypeSubd" fieldLabel="TypeSubd"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="GovtAgn" fieldLabel="GovtAgn"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="RefStatus" fieldLabel="RefStatus"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="UpdStatus" fieldLabel="UpdStatus"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Name" fieldLabel="Name"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Status" fieldLabel="Status"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="ModRec" fieldLabel="ModRec"></eg-fixed-field>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts
new file mode 100644 (file)
index 0000000..d029816
--- /dev/null
@@ -0,0 +1,31 @@
+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';
+import {MarcRecord} from './marcrecord';
+import {MarcEditContext} from './editor-context';
+import {TagTableService} from './tagtable.service';
+
+/**
+ * MARC Fixed Fields Editor Component
+ */
+
+@Component({
+  selector: 'eg-fixed-fields-editor',
+  templateUrl: './fixed-fields-editor.component.html'
+})
+
+export class FixedFieldsEditorComponent implements OnInit {
+
+    @Input() context: MarcEditContext;
+    get record(): MarcRecord { return this.context.record; }
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private tagTable: TagTableService
+    ) {}
+
+    ngOnInit() {}
+}
+
index b5e2f41..465a738 100644 (file)
@@ -1,9 +1,9 @@
-import {Component, Input, OnInit, Host} from '@angular/core';
+import {Component, Input, OnInit} 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';
+import {MarcEditContext} from './editor-context';
 
 /**
  * MARC Record flat text (marc-breaker) editor.
@@ -17,19 +17,22 @@ import {MarcRecord} from './marcrecord';
 
 export class MarcFlatEditorComponent implements OnInit {
 
+    @Input() context: MarcEditContext;
     get record(): MarcRecord {
-        return this.editor.record;
+        return this.context.record;
     }
 
     constructor(
         private idl: IdlService,
         private org: OrgService,
-        private store: ServerStoreService,
-        @Host() private editor: MarcEditorComponent
-    ) {
-    }
+        private store: ServerStoreService
+    ) {}
 
-    ngOnInit() {}
+    ngOnInit() {
+        // Be sure changes made in the enriched editor are
+        // reflected here.
+        this.record.breakerText = this.record.toBreaker();
+    }
 
     // When we have breaker text, limit the vertical expansion of the
     // text area to the size of the data plus a little padding.
index a18eb0b..c7bbaba 100644 (file)
@@ -1,22 +1,32 @@
 import {NgModule} from '@angular/core';
 import {StaffCommonModule} from '@eg/staff/common.module';
+import {CommonWidgetsModule} from '@eg/share/common-widgets.module';
 import {MarcEditorComponent} from './editor.component';
 import {MarcRichEditorComponent} from './rich-editor.component';
 import {MarcFlatEditorComponent} from './flat-editor.component';
+import {FixedFieldsEditorComponent} from './fixed-fields-editor.component';
+import {FixedFieldComponent} from './fixed-field.component';
+import {TagTableService} from './tagtable.service';
+import {EditableContentComponent} from './editable-content.component';
 
 @NgModule({
     declarations: [
         MarcEditorComponent,
         MarcRichEditorComponent,
-        MarcFlatEditorComponent
+        MarcFlatEditorComponent,
+        FixedFieldsEditorComponent,
+        FixedFieldComponent,
+        EditableContentComponent
     ],
     imports: [
-        StaffCommonModule
+        StaffCommonModule,
+        CommonWidgetsModule
     ],
     exports: [
         MarcEditorComponent
     ],
     providers: [
+        TagTableService
     ]
 })
 
index df1a492..61b7169 100644 (file)
@@ -1,12 +1,29 @@
-/**
-  * Simple wrapper class for our external MARC21.Record JS library.
-  */
+import {EventEmitter} from '@angular/core';
+
+/* Wrapper class for our external MARC21.Record JS library. */
 
 declare var MARC21;
 
 // MARC breaker delimiter
 const DELIMITER = '$';
 
+export interface MarcSubfield  // code, value, position
+    extends Array<string|number>{0: string; 1: string; 2: number}
+
+// Only contains the attributes/methods we need so far.
+export interface MarcField {
+    fieldId?: number;
+    data?: string;
+    tag?: string;
+    ind1?: string;
+    ind2?: string;
+    subfields?: MarcSubfield[]; 
+
+    isControlfield(): boolean;
+
+    deleteExactSubfields(...subfield: MarcSubfield[]): number;
+}
+
 export class MarcRecord {
 
     id: number; // Database ID when known.
@@ -14,9 +31,30 @@ export class MarcRecord {
     record: any; // MARC21.Record object
     breakerText: string;
 
+    // Let clients know some fixed field shuffling may have occured.
+    // Emits the fixed field code.
+    fixedFieldChange: EventEmitter<string>;
+
+    get leader(): string {
+        return this.record.leader;
+    }
+
+    set leader(l: string) {
+        this.record.leader = l;
+    }
+
+    get fields(): MarcField[] {
+       return this.record.fields;
+    }
+
+    set fields(f: MarcField[]) {
+        this.record.fields = f;
+    }
+
     constructor(xml: string) {
         this.record = new MARC21.Record({marcxml: xml, delimiter: DELIMITER});
         this.breakerText = this.record.toBreaker();
+        this.fixedFieldChange = new EventEmitter<string>();
     }
 
     toXml(): string {
@@ -27,9 +65,112 @@ export class MarcRecord {
         return this.record.toBreaker();
     }
 
+    recordType(): string {
+        return this.record.recordType();
+    }
+
     absorbBreakerChanges() {
         this.record = new MARC21.Record(
             {marcbreaker: this.breakerText, delimiter: DELIMITER});
     }
+
+    extractFixedField(fieldCode: string): string {
+        return this.record.extractFixedField(fieldCode);
+    }
+
+    setFixedField(fieldCode: string, fieldValue: string): string {
+        const response = this.record.setFixedField(fieldCode, fieldValue);
+        this.fixedFieldChange.emit(fieldCode);
+        return response;
+    }
+
+    // Give each field an identifier so it may be referenced later.
+    stampFieldIds() {
+        this.fields.forEach(f => this.stampFieldId(f));
+    }
+
+    stampFieldId(field: MarcField) {
+        if (!field.fieldId) {
+            field.fieldId = Math.floor(Math.random() * 10000000);
+        }
+    }
+
+    field(spec: string, wantArray?: boolean): MarcField | MarcField[] {
+        return this.record.field(spec, wantArray);
+    }
+
+    insertFieldsBefore(field: MarcField, ...newFields: MarcField[]) {
+        this.record.insertFieldsBefore.apply(
+            this.record, [field].concat(newFields));
+        this.stampFieldIds();
+    }
+
+    insertFieldsAfter(field: MarcField, ...newFields: MarcField[]) {
+        this.record.insertFieldsAfter.apply(
+            this.record, [field].concat(newFields));
+        this.stampFieldIds();
+    }
+
+    insertOrderedFields(...newFields: MarcField[]) {
+        this.record.insertOrderedFields.apply(this.record, newFields);
+        this.stampFieldIds();
+    }
+
+    generate008(): MarcField {
+        return this.record.generate008();
+    }
+
+
+    deleteFields(...fields: MarcField[]) {
+        this.record.deleteFields.apply(this.record, fields);
+    }
+
+    getField(id: number): MarcField {
+        return this.fields.filter(f => f.fieldId === id)[0];
+    }
+
+    getPreviousField(id: number): MarcField {
+        for (let idx = 0; idx < this.fields.length; idx++) {
+            if (this.fields[idx].fieldId === id) {
+                return this.fields[idx - 1];
+            }
+        }
+    }
+
+    getNextField(id: number): MarcField {
+        for (let idx = 0; idx < this.fields.length; idx++) {
+            if (this.fields[idx].fieldId === id) {
+                return this.fields[idx + 1];
+            }
+        }
+    }
+
+    // Turn an field-ish object into a proper MARC.Field
+    newField(props: any): MarcField {
+        const field = new MARC21.Field(props);
+        this.stampFieldId(field);
+        return field;
+    }
+
+    cloneField(field: any): MarcField {
+        const props: any = {tag: field.tag};
+
+        if (field.isControlfield()) {
+            props.data = field.data;
+
+        } else {
+            props.ind1 = field.ind1;
+            props.ind2 = field.ind2;
+            props.subfields = this.cloneSubfields(field.subfields);
+        }
+
+        return this.newField(props);
+    }
+
+    cloneSubfields(subfields: MarcSubfield[]): MarcSubfield[] {
+        const root = [];
+        subfields.forEach(sf => root.push([].concat(sf)));
+        return root;
+    }
 }
 
index e69de29..5c90f28 100644 (file)
@@ -0,0 +1,8 @@
+
+.fixed-fields-container {
+  /*
+   * wait for https://bugs.launchpad.net/evergreen/+bug/1735568 approval
+  background-color: lightcyan;
+  border-bottom: 1px solid gray;
+  */
+}
index e69de29..cf21442 100644 (file)
@@ -0,0 +1,139 @@
+
+<ng-container *ngIf="!dataLoaded">
+  <div class="row mt-5">
+    <div class="offset-lg-3 col-lg-6">
+      <eg-progress-inline></eg-progress-inline>
+    </div>
+  </div>
+</ng-container>
+
+<ng-container *ngIf="dataLoaded">
+  <div class="mt-3 text-monospace"
+    (contextmenu)="$event.preventDefault()">
+    <div class="row pb-2 mb-2 border-bottom border-muted">
+      <div class="col-lg-9 fixed-fields-container">
+        <eg-fixed-fields-editor [context]="context"></eg-fixed-fields-editor>
+      </div>
+      <div class="col-lg-3">
+        <div><button class="btn btn-outline-dark"
+          (click)="showHelp = !showHelp" i18n>Help</button></div>
+        <div class="mt-2"><button class="btn btn-outline-dark"
+          [disabled]="true"
+          (click)="validate()" i18n>Validate</button></div>
+        <div class="mt-2">
+          <button type="button" class="btn btn-outline-info" 
+            [disabled]="undoCount() < 1" (click)="undo()">
+            Undo <span class="badge badge-info">{{undoCount()}}</span>
+          </button>
+          <button type="button" class="btn btn-outline-info ml-2" 
+            [disabled]="redoCount() < 1" (click)="redo()">
+            Redo <span class="badge badge-info">{{redoCount()}}</span>
+          </button>
+        </div>
+        <div class="mt-2">
+          <div class="form-check">
+            <input class="form-check-input" type="checkbox" 
+              [disabled]="true"
+              [(ngModel)]="stackSubfields" id="stack-subfields-{{randId}}">
+            <label class="form-check-label" for="stack-subfields-{{randId}}">
+              Stack Subfields
+            </label>
+          </div>
+        </div>
+      </div>
+      <div class="col-lg-1">
+      </div>
+    </div>
+    <div *ngIf="showHelp" class="row m-2">
+      <div class="col-lg-4">
+        <ul>
+          <li>Undo: CTRL-z</li>
+          <li>Redo: CTRL-y</li>
+          <li>Add Row: CTRL+Enter</li>
+          <li>Insert Row: CTRL+Shift+Enter</li>
+        </ul>
+      </div>
+      <div class="col-lg-4">
+        <ul>
+         <li>Copy Current Row Above: CTRL+Up</li>
+         <li>Copy Current Row Below: CTRL+Down</li>
+         <li>Add Subfield: CTRL+D or CTRL+I</li>
+         <li>Remove Row: CTRL+Del</li>
+        </ul>
+      </div>
+      <div class="col-lg-4">
+        <ul>
+         <li>Remove Subfield: Shift+Del</li>
+         <li>Create/Replace 006: Shift+F6</li>
+         <li>Create/Replace 007: Shift+F7</li>
+         <li>Create/Replace 008: Shift+F8</li>
+        </ul>
+      </div>
+    </div>
+
+    <!-- LEADER -->
+    <div class="row pt-0 pb-0 pl-3 form-horizontal">
+      <eg-marc-editable-content [context]="context" fieldType="tag" 
+        fieldText="LDR" i18n-fieldText moreClasses="p-1">
+      </eg-marc-editable-content>
+
+      <eg-marc-editable-content [context]="context" fieldType="ldr"
+         moreClasses="p-1 pr-2">
+      </eg-marc-editable-content>
+    </div>
+
+    <!-- CONTROL FIELDS -->
+    <div class="row pt-0 pb-0 pl-3 form-horizontal" 
+      *ngFor="let field of controlFields()">
+
+      <eg-marc-editable-content [context]="context" fieldType="tag"
+        [field]="field" moreClasses="p-1">
+      </eg-marc-editable-content>
+
+      <eg-marc-editable-content [context]="context" fieldType="cfld"
+        [field]="field" moreClasses="p-1">
+      </eg-marc-editable-content>
+    </div>
+
+    <!-- data fields -->
+    <div class="row pt-0 pb-0 pl-3 form-horizontal" 
+      *ngFor="let field of dataFields()">
+
+      <!-- TAG -->
+      <eg-marc-editable-content [context]="context" fieldType="tag"
+        [field]="field" moreClasses="p-1">
+      </eg-marc-editable-content>
+
+      <!-- INDICATOR 1 -->
+      <eg-marc-editable-content [context]="context" fieldType="ind1" 
+        [field]="field" moreClasses="p-1">
+      </eg-marc-editable-content>
+
+      <!-- INDICATOR 2 -->
+      <eg-marc-editable-content [context]="context" fieldType="ind2" 
+        [field]="field" moreClasses="p-1">
+      </eg-marc-editable-content>
+
+      <!-- SUBFIELDS -->
+      <ng-container *ngFor="let subfield of field.subfields">
+
+        <!-- SUBFIELD DECORATOR/DELIMITER -->
+        <eg-marc-editable-content fieldText="‡" i18n-fieldText
+          moreClasses="sf-delimiter border-right-0 bg-transparent p-1 pr-0">
+        </eg-marc-editable-content>
+
+        <!-- SUBFIELD CHARACTER -->
+        <eg-marc-editable-content [context]="context" fieldType="sfc" 
+          [field]="field" [subfield]="subfield" 
+          moreClasses="sf-code border-left-0 p-1 pl-0">
+        </eg-marc-editable-content>
+
+        <!-- SUBFIELD VALUE -->
+        <eg-marc-editable-content [context]="context" fieldType="sfv"
+          [field]="field" [subfield]="subfield" moreClasses="p-1 pt-2">
+        </eg-marc-editable-content>
+      </ng-container>
+    </div>
+  </div>
+</ng-container>
+
index 7f8ac33..1c50c57 100644 (file)
@@ -2,6 +2,10 @@ 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';
+import {TagTableService} from './tagtable.service';
+import {MarcRecord, MarcField} from './marcrecord';
+import {MarcEditContext} from './editor-context';
+
 
 /**
  * MARC Record rich editor interface.
@@ -15,13 +19,60 @@ import {OrgService} from '@eg/core/org.service';
 
 export class MarcRichEditorComponent implements OnInit {
 
+    @Input() context: MarcEditContext;
+    get record(): MarcRecord { return this.context.record; }
+
+    dataLoaded: boolean;
+    showHelp: boolean;
+    randId = Math.floor(Math.random() * 100000);
+    stackSubfields: boolean;
+
     constructor(
         private idl: IdlService,
-        private org: OrgService
-    ) {
+        private org: OrgService,
+        private tagTable: TagTableService
+    ) {}
+
+    ngOnInit() {
+        this.init().then(_ =>
+            this.context.recordChange.subscribe(__ => this.init()));
+    }
+
+    init(): Promise<any> {
+        this.dataLoaded = false;
+
+        if (!this.record) { return Promise.resolve(); }
+
+        return Promise.all([
+            this.tagTable.loadTagTable({marcRecordType: this.context.recordType}),
+            this.tagTable.getFfPosTable(this.record.recordType()),
+            this.tagTable.getFfValueTable(this.record.recordType())
+        ]).then(_ => this.dataLoaded = true);
+    }
+
+    undoCount(): number {
+        return this.context.undoStack.length;
     }
 
-    ngOnInit() {}
+    redoCount(): number {
+        return this.context.redoStack.length;
+    }
+
+    undo() {
+        this.context.requestUndo();
+    }
+
+    redo() {
+        this.context.requestRedo();
+    }
+
+    controlFields(): MarcField[] {
+        return this.record.fields.filter(f => f.isControlfield());
+    }
+
+    dataFields(): MarcField[] {
+        return this.record.fields.filter(f => !f.isControlfield());
+    }
 }
 
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
new file mode 100644 (file)
index 0000000..ec5abd0
--- /dev/null
@@ -0,0 +1,274 @@
+import {Injectable, EventEmitter} from '@angular/core';
+import {map, tap} from 'rxjs/operators';
+import {StoreService} from '@eg/core/store.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {AuthService} from '@eg/core/auth.service';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {EventService} from '@eg/core/event.service';
+import {ContextMenuEntry} from '@eg/share/context-menu/context-menu.service';
+
+interface TagTableSelector {
+    marcFormat?: string;
+    marcRecordType?: string;
+}
+
+const defaultTagTableSelector: TagTableSelector = {
+    marcFormat     : 'marc21',
+    marcRecordType : 'biblio'
+}
+
+@Injectable()
+export class TagTableService {
+
+    // Current set of tags in list and map form.
+    tagMap: {[tag: string]: any} = {};
+    ffPosMap: {[rtype: string]: any[]} = {};
+    ffValueMap: {[rtype: string]: any} = {};
+
+    extractedValuesCache: 
+        {[valueType: string]: {[which: string]: any}} = {};
+
+    constructor(
+        private store: StoreService,
+        private auth: AuthService,
+        private net: NetService,
+        private pcrud: PcrudService,
+        private evt: EventService
+    ) {
+
+        this.extractedValuesCache = {
+            fieldtags: {},
+            indicators: {},
+            sfcodes: {},
+            sfvalues: {},
+            ffvalues: {}
+        };
+    }
+
+    // Various data needs munging for display.  Cached the modified
+    // values since they are refernced repeatedly by the UI code.
+    fromCache(dataType: string, which?: string, which2?: string): ContextMenuEntry[] {
+        const part1 = this.extractedValuesCache[dataType][which];
+        if (which2) {
+            if (part1) {
+                return part1[which2];
+            }
+        } else {
+            return part1;
+        }
+    }
+
+    toCache(dataType: string, which: string, 
+        which2: string, values: ContextMenuEntry[]): ContextMenuEntry[] {
+        const base = this.extractedValuesCache[dataType];
+        const part1 = base[which];
+
+        if (which2) {
+            if (!base[which]) { base[which] = {}; }
+            base[which][which2] = values;
+        } else {
+            base[which] = values;
+        }
+
+        return values;
+    }
+
+    getFfPosTable(rtype: string): Promise<any> {
+        const storeKey = 'FFPosTable_' + rtype;
+
+        if (this.ffPosMap[rtype]) {
+            return Promise.resolve(this.ffPosMap[rtype]);
+        }
+
+        this.ffPosMap[rtype] = this.store.getLocalItem(storeKey);
+
+        if (this.ffPosMap[rtype]) {
+            return Promise.resolve(this.ffPosMap[rtype]);
+        }
+
+        return this.net.request(
+            'open-ils.fielder', 'open-ils.fielder.cmfpm.atomic',
+            {query: {tag: {'!=' : '006'}, rec_type: rtype}}
+
+        ).toPromise().then(table => {
+            this.store.setLocalItem(storeKey, table);
+            return this.ffPosMap[rtype] = table;
+        });
+    }
+
+    getFfValueTable(rtype: string): Promise<any> {
+
+        const storeKey = 'FFValueTable_' + rtype;
+
+        if (this.ffValueMap[rtype]) {
+            return Promise.resolve(this.ffValueMap[rtype]);
+        }
+
+        this.ffValueMap[rtype] = this.store.getLocalItem(storeKey);
+
+        if (this.ffValueMap[rtype]) {
+            return Promise.resolve(this.ffValueMap[rtype]);
+        }
+
+        return this.net.request(
+            'open-ils.cat',
+            'open-ils.cat.biblio.fixed_field_values.by_rec_type', rtype
+
+        ).toPromise().then(table => {
+            this.store.setLocalItem(storeKey, table);
+            return this.ffValueMap[rtype] = table;
+        });
+    }
+
+    loadTagTable(selector?: TagTableSelector): Promise<any> {
+
+        if (selector) {
+            if (!selector.marcFormat) {
+                selector.marcFormat = defaultTagTableSelector.marcFormat;
+            }
+            if (!selector.marcRecordType) {
+                selector.marcRecordType = 
+                    defaultTagTableSelector.marcRecordType;
+            }
+        } else {
+            selector = defaultTagTableSelector;
+        }
+
+        const cacheKey = 'FFValueTable_' + selector.marcRecordType;
+
+        this.tagMap = this.store.getLocalItem(cacheKey);
+
+        if (this.tagMap) {
+            return Promise.resolve(this.tagMap);
+        }
+
+        return this.fetchTagTable(selector).then(_ => {
+            this.store.setLocalItem(cacheKey, this.tagMap);
+            return this.tagMap;
+        });
+    }
+
+    fetchTagTable(selector?: TagTableSelector): Promise<any> {
+        this.tagMap = [];
+        return this.net.request(
+            'open-ils.cat',
+            'open-ils.cat.tag_table.all.retrieve.local',
+            this.auth.token(), selector.marcFormat, selector.marcRecordType
+        ).pipe(tap(tagData => {
+            this.tagMap[tagData.tag] = tagData;
+        })).toPromise();
+    }
+
+    getSubfieldCodes(tag: string): ContextMenuEntry[] { 
+        if (!tag || !this.tagMap[tag]) { return null; }
+
+        const cached = this.fromCache('sfcodes', tag);
+
+        const list = this.tagMap[tag].subfields.map(sf => ({
+            value: sf.code, 
+            label: `${sf.code}: ${sf.description}`
+        })) 
+        .sort((a, b) => a.label < b.label ? -1 : 1);
+
+        return this.toCache('sfcodes', tag, null, list);
+    }
+
+    getFieldTags(): ContextMenuEntry[] {
+
+        const cached = this.fromCache('fieldtags');
+        if (cached) { return cached; }
+        
+        return Object.keys(this.tagMap)
+        .filter(tag => Boolean(this.tagMap[tag]))
+        .map(tag => ({
+            value: tag,
+            label: `${tag}: ${this.tagMap[tag].name}`
+        }))
+        .sort((a, b) => a.label < b.label ? -1 : 1);
+    }
+
+    getSubfieldValues(tag: string, sfCode: string): ContextMenuEntry[] {
+        if (!tag || !this.tagMap[tag]) { return []; }
+
+        const cached = this.fromCache('sfvalues', tag, sfCode)
+        if (cached) { return cached; }
+
+        const list: ContextMenuEntry[] = [];
+
+        this.tagMap[tag].subfields
+        .filter(sf =>
+            sf.code === sfCode && sf.hasOwnProperty('value_list'))
+        .forEach(sf => {
+            sf.value_list.forEach(value => {
+
+                let label = value.description || value.code;
+                let code = value.code || label;
+                if (code !== label) { label = `${code}: ${label}`; }
+
+                list.push({value: code, label: label});
+            })
+        });
+
+        return this.toCache('sfvalues', tag, sfCode, list);
+    }
+
+    getIndicatorValues(tag: string, which: 'ind1' | 'ind2'): ContextMenuEntry[] {
+        if (!tag || !this.tagMap[tag]) { return }
+
+        const cached = this.fromCache('indicators', tag, which);
+        if (cached) { return cached; }
+
+        let values = this.tagMap[tag][which];
+        if (!values) { return; }
+
+        values = values.map(value => ({
+            value: value.code,
+            label: `${value.code}: ${value.description}`
+        }))
+        .sort((a, b) => a.label < b.label ? -1 : 1);
+
+        return this.toCache('indicators', tag, which, values);
+    }
+
+
+    getFfFieldMeta(fieldCode: string, recordType: string): Promise<IdlObject> {
+        return this.getFfPosTable(recordType).then(table => {
+
+            // Note the AngJS MARC editor stores the full POS table
+            // for all record types in every copy of the table, hence
+            // the seemingly extraneous check in recordType.
+            return table.filter(
+                field =>
+                    field.fixed_field === fieldCode
+                 && field.rec_type === recordType
+            )[0];
+        });
+    }
+
+
+    // Assumes getFfPosTable and getFfValueTable have already been
+    // invoked for the request record type.
+    getFfValues(fieldCode: string, recordType: string): ContextMenuEntry[] {
+
+        const cached = this.fromCache('ffvalues', recordType, fieldCode);
+        if (cached) { return cached; }
+
+        let values = this.ffValueMap[recordType];
+
+        if (!values || !values[fieldCode]) { return null; }
+
+        // extract the canned set of possible values for our
+        // fixed field.  Ignore those whose value exceeds the
+        // specified field length.
+        values = values[fieldCode]
+            .filter(val => val[0].length <= val[2])
+            .map(val => ({value: val[0], label: `${val[0]}: ${val[1]}`}))
+            .sort((a, b) => a.label < b.label ? -1 : 1);
+
+        return this.toCache('ffvalues', recordType, fieldCode, values);
+    }
+}
+
+
+
index 6cc1bc0..7865747 100644 (file)
@@ -25,3 +25,5 @@
 <!-- global print handler component -->
 <eg-print></eg-print>
 
+<!-- context menu DOM insertion point -->
+<eg-context-menu-container></eg-context-menu-container>
index ef97e2a..9c120cc 100644 (file)
@@ -219,3 +219,4 @@ body>.dropdown-menu {z-index: 2100;}
   background-color: #c9efe4;
   color: black;
 }
+