LP1983628: Add editor for item notes
authorJane Sandberg <js7389@princeton.edu>
Wed, 1 Mar 2023 12:58:07 +0000 (04:58 -0800)
committerJason Boyer <JBoyer@equinoxOLI.org>
Mon, 22 May 2023 17:20:32 +0000 (13:20 -0400)
Test plan:
1. Open your favorite bib record in the staff catalog
2. On the item table tab, find a barcode and click "Edit"
3. Press the Item Notes button.
4. Add a note with a title and value.
5. Press Apply Changes
6. Press Apply All and Save
7. Press the Item Notes button again.
8. Without this commit, you will not have a way to edit
these notes.  With this commit, you will have an edit
button.
9. Confirm that you can Back out of the editor without
making changes
10. Confirm that you can make changes and they persist.

This commit also adds a test to confirm that this fm-editor
won't inadvertently fetch every single row in asset.copy
(as a linked field).

Signed-off-by: Jane Sandberg <js7389@princeton.edu>
Signed-off-by: Elaine Hardy <ehardy@georgialibraries.org>
Signed-off-by: Jason Boyer <JBoyer@equinoxOLI.org>

Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.spec.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.html
Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.ts
Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-edit/copy-notes-edit.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-edit/copy-notes-edit.component.spec.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-edit/copy-notes-edit.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts

index 77273ef..c259457 100644 (file)
@@ -4,7 +4,6 @@ import {NgForm} from '@angular/forms';
 import {IdlService, IdlObject} from '@eg/core/idl.service';
 import {Observable} from 'rxjs';
 import {map} from 'rxjs/operators';
-import {AuthService} from '@eg/core/auth.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {OrgService} from '@eg/core/org.service';
 import {DialogComponent} from '@eg/share/dialog/dialog.component';
@@ -262,7 +261,6 @@ export class FmRecordEditorComponent
     constructor(
       private modal: NgbModal, // required for passing to parent
       private idl: IdlService,
-      private auth: AuthService,
       private toast: ToastService,
       private format: FormatService,
       private org: OrgService,
diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.spec.ts b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.spec.ts
new file mode 100644 (file)
index 0000000..5188408
--- /dev/null
@@ -0,0 +1,66 @@
+import { IdlService } from "@eg/core/idl.service";
+import { NgbModal } from "@ng-bootstrap/ng-bootstrap";
+import { ToastService } from "@eg/share/toast/toast.service";
+import { FmRecordEditorComponent } from "./fm-editor.component"
+import { FormatService } from "@eg/core/format.service";
+import { OrgService } from "@eg/core/org.service";
+import { PcrudService } from "@eg/core/pcrud.service";
+import { waitForAsync } from "@angular/core/testing";
+import { of } from "rxjs";
+
+describe('FmRecordEditorComponent', () => {
+    let component: FmRecordEditorComponent;
+    const mockPcrud = jasmine.createSpyObj<PcrudService>(['retrieve']);
+    beforeEach(() => {
+        const mockModal = jasmine.createSpyObj<NgbModal>(['open']);
+        const mockIdl = jasmine.createSpyObj<IdlService>(['pkeyMatches', 'getClassSelector', 'sortIdlFields'], {classes: {
+            'mock': {
+                label: 'Mock Class',
+                fields: [
+                    {datatype: 'link', name: 'linked_field', class: 'linked'}
+                ]
+            },
+            'linked': {pkey: 'id'}
+        }});
+        mockIdl.pkeyMatches.and.returnValue(true);
+        mockIdl.getClassSelector.and.returnValue('label');
+        const mockToast = jasmine.createSpyObj<ToastService>(['success']);
+        const mockFormat = jasmine.createSpyObj<FormatService>([], {wsOrgTimezone: 'America/Los_Angeles'});
+        const mockOrg = jasmine.createSpyObj<OrgService>(['get']);
+        mockPcrud.retrieve.and.callFake((fmClass, pkey) => {
+            if (fmClass === 'mock') {
+                return of({
+                    a: [],
+                    classname: 'mock',
+                    _isfieldmapper: true,
+                    'linked_field': () => 456
+                });
+            } else {
+                return of({
+                    id: () => 456,
+                    label: () => 'My Config Value'
+                });
+            }
+        });
+
+        component = new FmRecordEditorComponent(
+            mockModal, mockIdl, mockToast, mockFormat, mockOrg, mockPcrud
+        );
+
+    })
+    describe('hidden fields', () => {
+        it('fetches only one row of linked values', waitForAsync(() => {
+            component.idlClass = 'mock';
+            component.readonlyFields = 'linked_field';
+            component.mode = 'update';
+            component.displayMode = 'inline';
+            component.recordId = 123;
+            component.ngOnInit();
+            // wait for ngOnInit to do its work
+            setTimeout(() => {
+                expect(mockPcrud.retrieve).toHaveBeenCalledWith('mock', 123);
+                expect(mockPcrud.retrieve).toHaveBeenCalledWith('linked', 456);
+            }, 100)
+        }));
+    });
+});
index d15ee7a..93f5850 100644 (file)
     <button type="button" class="btn-close btn-close-white" 
       i18n-aria-label aria-label="Close" (click)="close()"></button>
   </div>
-  <div class="modal-body p-4 form-validated">
-
-    <ng-container *ngIf="mode === 'manage' && copy.notes().length">
-      <h4 i18n>Existing Notes</h4>
-      <div class="row mt-2 p-2" *ngFor="let note of copy.notes()">
-        <div class="col-lg-4">{{note.title()}}</div>
-        <div class="col-lg-5">{{note.value()}}</div>
-        <div class="col-lg-3">
-          <button class="btn btn-outline-danger" 
-            (click)="removeNote(note)" i18n>Remove</button>
-        </div>
-      </div>
-      <hr/>
+  <div class="modal-body">
+    <ng-container #editDialogContent *ngIf="mode === 'edit' && idToEdit; else manageDialogContent">
+      <eg-copy-notes-edit [recordId]="idToEdit" (doneWithEdits)="returnToManage()">
+      </eg-copy-notes-edit>
     </ng-container>
-
-    <h4 i18n>New Notes</h4>
-    <div class="row mt-2 p-2" *ngFor="let note of newNotes">
-      <div class="col-lg-4">{{note.title()}}</div>
-      <div class="col-lg-5">{{note.value()}}</div>
-      <div class="col-lg-3">
-        <button class="btn btn-outline-danger" (click)="removeNote(note)" i18n>
-          Remove
-        </button>
-      </div>
-    </div>
-
-    <div class="row mt-2 p-2 rounded border border-success">
-      <div class="col-lg-12">
-        <div class="row">
-          <div class="col-lg-6">
-            <input type="text" class="form-control" [(ngModel)]="curNoteTitle"
-              i18n-placeholder placeholder="Note title..."/>
-          </div>
-          <div class="col-lg-6">
-            <div class="form-check">
-              <input class="form-check-input" type="checkbox" 
-                [(ngModel)]="curNotePublic" id="pub-check">
-              <label class="form-label form-check-label" for="pub-check">Public Note</label>
+    <ng-template #manageDialogContent>
+      <div class="p-4 form-validated">
+        <ng-container *ngIf="mode === 'manage' && copy.notes().length">
+          <h4 i18n>Existing Notes</h4>
+          <div class="row mt-2 p-2" *ngFor="let note of copy.notes()">
+            <div class="col-lg-3">{{note.title()}}</div>
+            <div class="col-lg-5">{{note.value()}}</div>
+            <div class="col-lg-2">
+              <button class="btn btn-outline-info" (click)="editNote(note)" i18n>
+                Edit
+              </button>
+            </div>
+            <div class="col-lg-2">
+              <button class="btn btn-outline-danger"
+                (click)="removeNote(note)" i18n>Remove</button>
             </div>
           </div>
-        </div>
-        <div class="row mt-3">
-          <div class="col-lg-9">
-            <textarea class="form-control" [(ngModel)]="curNote"
-              i18n-placeholder placeholder="Enter note value..."></textarea>
+          <hr/>
+        </ng-container>
+
+        <h4 i18n>New Notes</h4>
+        <div class="row mt-2 p-2" *ngFor="let note of newNotes">
+          <div class="col-lg-3">{{note.title()}}</div>
+          <div class="col-lg-7">{{note.value()}}</div>
+          <div class="col-lg-2">
+            <button class="btn btn-outline-danger" (click)="removeNote(note)" i18n>
+              Remove
+            </button>
           </div>
-          <div class="col-lg-3">
-            <button class="btn btn-success" (click)="addNew()" i18n>Add Note</button>
+        </div>
+
+        <div class="row mt-2 p-2 rounded border border-success">
+          <div class="col-lg-12">
+            <div class="row">
+              <div class="col-lg-6">
+                <input type="text" class="form-control" [(ngModel)]="curNoteTitle"
+                  i18n-placeholder placeholder="Note title..."/>
+              </div>
+              <div class="col-lg-6">
+                <div class="form-check">
+                  <input class="form-check-input" type="checkbox"
+                    [(ngModel)]="curNotePublic" id="pub-check">
+                  <label class="form-label form-check-label" for="pub-check">Public Note</label>
+                </div>
+              </div>
+            </div>
+            <div class="row mt-3">
+              <div class="col-lg-9">
+                <textarea class="form-control" [(ngModel)]="curNote"
+                  i18n-placeholder placeholder="Enter note value..."></textarea>
+              </div>
+              <div class="col-lg-3">
+                <button class="btn btn-success" (click)="addNew()" i18n>Add Note</button>
+              </div>
+            </div>
           </div>
         </div>
       </div>
-    </div>
+    </ng-template>
   </div>
-  <div class="modal-footer">
+
+  <div class="modal-footer" *ngIf="mode !== 'edit'">
     <button type="button" class="btn btn-secondary" (click)="close()" i18n>Cancel</button>
     <button class="btn btn-success me-2" (click)="applyChanges()" i18n>Apply Changes</button>
   </div>
index cf8b8ba..ccfaec0 100644 (file)
@@ -32,7 +32,7 @@ export class CopyNotesDialogComponent
     // If there is only one copyId, then notes may be applied or removed.
     @Input() copyIds: number[] = [];
 
-    mode: string; // create | manage
+    mode: string; // create | manage | edit
 
     // If true, no attempt is made to save the new notes to the
     // database.  It's assumed this takes place in the calling code.
@@ -52,6 +52,8 @@ export class CopyNotesDialogComponent
 
     autoId = -1;
 
+    idToEdit: number;
+
     @ViewChild('successMsg', { static: true }) private successMsg: StringComponent;
     @ViewChild('errorMsg', { static: true }) private errorMsg: StringComponent;
 
@@ -112,6 +114,18 @@ export class CopyNotesDialogComponent
         });
     }
 
+    editNote(note: IdlObject) {
+        this.idToEdit = note.id();
+        this.mode = 'edit';
+    }
+
+    returnToManage() {
+        this.getCopies().then(() => {
+            this.idToEdit = null;
+            this.mode = 'manage';
+        })
+    }
+
     removeNote(note: IdlObject) {
         this.newNotes = this.newNotes.filter(t => t.id() !== note.id());
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-edit/copy-notes-edit.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-edit/copy-notes-edit.component.html
new file mode 100644 (file)
index 0000000..ebbc5ad
--- /dev/null
@@ -0,0 +1,12 @@
+<button class="btn btn-info label-with-material-icon" (click)="doneWithEdits.emit()">
+    <span class="material-icons" aria-hidden="true">arrow_back</span>
+    <span i18n>Back</span>
+</button>
+<eg-fm-record-editor #fmRecordEditor
+    (recordSaved)="doneWithEdits.emit()"
+    idlClass="acpn" mode="update"
+    hiddenFields="create_date,owning_copy,id,creator"
+    [hideBanner]="true"
+    displayMode="inline" fieldOrder="title,value,pub" [recordId]="recordId">
+</eg-fm-record-editor>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-edit/copy-notes-edit.component.spec.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-edit/copy-notes-edit.component.spec.ts
new file mode 100644 (file)
index 0000000..f74afc2
--- /dev/null
@@ -0,0 +1,37 @@
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CopyNotesEditComponent } from './copy-notes-edit.component';
+
+describe('CopyNotesEditComponent', () => {
+  let component: CopyNotesEditComponent;
+  let fixture: ComponentFixture<CopyNotesEditComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [ CopyNotesEditComponent ],
+      schemas: [ CUSTOM_ELEMENTS_SCHEMA ]
+    })
+    .compileComponents();
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(CopyNotesEditComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+  describe('back button', () => {
+    it('emits an event on click', () => {
+      spyOn(component.doneWithEdits, 'emit');
+      const generatedElement: HTMLElement = fixture.nativeElement;
+      const buttonElement: HTMLButtonElement = generatedElement.querySelector('button');
+      buttonElement.dispatchEvent(new Event('click'));
+      fixture.detectChanges();
+      expect(component.doneWithEdits.emit).toHaveBeenCalled();
+    })
+  })
+});
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-edit/copy-notes-edit.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-edit/copy-notes-edit.component.ts
new file mode 100644 (file)
index 0000000..268a2ab
--- /dev/null
@@ -0,0 +1,13 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+
+@Component({
+  selector: 'eg-copy-notes-edit',
+  templateUrl: './copy-notes-edit.component.html',
+})
+export class CopyNotesEditComponent {
+
+  constructor() { }
+
+  @Input() recordId: number;
+  @Output() doneWithEdits: EventEmitter<any> = new EventEmitter();
+}
index fd1ec0f..bb89890 100644 (file)
@@ -15,6 +15,8 @@ import {TransferItemsComponent} from './transfer-items.component';
 import {TransferHoldingsComponent} from './transfer-holdings.component';
 import {BatchItemAttrComponent} from './batch-item-attr.component';
 import {CopyAlertManagerDialogComponent} from './copy-alert-manager.component';
+import {CopyNotesEditComponent} from './copy-notes-edit/copy-notes-edit.component';
+import { FmRecordEditorModule } from '@eg/share/fm-editor/fm-editor.module';
 
 @NgModule({
     declarations: [
@@ -24,6 +26,7 @@ import {CopyAlertManagerDialogComponent} from './copy-alert-manager.component';
       CopyAlertsDialogComponent,
       CopyTagsDialogComponent,
       CopyNotesDialogComponent,
+      CopyNotesEditComponent,
       ReplaceBarcodeDialogComponent,
       DeleteHoldingDialogComponent,
       ConjoinedItemsDialogComponent,
@@ -34,7 +37,8 @@ import {CopyAlertManagerDialogComponent} from './copy-alert-manager.component';
     ],
     imports: [
         StaffCommonModule,
-        BillingModule
+        BillingModule,
+        FmRecordEditorModule
     ],
     exports: [
       MarkDamagedDialogComponent,