LP1906058: Course-term map interface only allow reasonable mappings
authorJane Sandberg <sandbej@linnbenton.edu>
Fri, 23 Jul 2021 17:15:13 +0000 (10:15 -0700)
committerGalen Charlton <gmc@equinoxOLI.org>
Sun, 15 Aug 2021 15:42:26 +0000 (11:42 -0400)
Different libraries will use different sets of course terms.  In an
academic library context, for example, a consortium may include
libraries with very different start and end dates to their terms/semesters,
different numbers of terms/year, etc.

This commit changes the interface to only allow users to associate
a course with a term from the same library or one of its ancestors.

To test:
1. Create many courses and course terms and many different OUs.
2. On the course list, click "Terms taught".  Associate some courses
and course terms.  Make sure that you aren't able to associate your
course with any course terms that would not be reasonable for the
course's library.
3. Edit a course, and choose the Course terms tab.  Continue to associate
courses and terms, and make sure the mappings are reasonable.

Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
Signed-off-by: Beth Willis <willis@noblenet.org>
Signed-off-by: Galen Charlton <gmc@equinoxOLI.org>
Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-page.component.html
Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-page.component.ts
Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-reserves.module.ts
Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-term-map-grid.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-term-map-grid.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-term-map.component.ts

index 9d57bc0..4936a97 100644 (file)
   <li [ngbNavItem]="'courseTerms'">
     <a ngbNavLink i18n>Course terms</a>
     <ng-template ngbNavContent>
-      <eg-admin-page idlClass="acmtcm" readonlyFields="id"
-        [defaultNewRecord]="defaultNewAcmtcm"
-        hideGridFields="id" [dataSource]="termsDataSource">
-      </eg-admin-page>
+      <eg-course-term-map-grid [courseId]="courseId"></eg-course-term-map-grid>
     </ng-template>
   </li>
 </ul>
index 07d5cbf..1bbc487 100644 (file)
@@ -1,14 +1,12 @@
 import {Component, ViewChild, OnInit} from '@angular/core';
 import {ActivatedRoute} from '@angular/router';
 import {PcrudService} from '@eg/core/pcrud.service';
-import {IdlObject, IdlService} from '@eg/core/idl.service';
-import {GridDataSource} from '@eg/share/grid/grid';
+import {IdlObject} from '@eg/core/idl.service';
 import {StringComponent} from '@eg/share/string/string.component';
 import {ToastService} from '@eg/share/toast/toast.service';
 import {CourseService} from '@eg/staff/share/course.service';
 import {CourseAssociateUsersComponent} from './course-associate-users.component';
 import {CourseAssociateMaterialComponent} from './course-associate-material.component';
-import {Pager} from '@eg/share/util/pager';
 
 @Component({
     selector: 'eg-course-page',
@@ -32,13 +30,8 @@ export class CoursePageComponent implements OnInit {
     @ViewChild('archiveSuccessString', { static: true })
         archiveSuccessString: StringComponent;
 
-    // Course Tab
-    termsDataSource: GridDataSource = new GridDataSource();
-    defaultNewAcmtcm: IdlObject;
-
     constructor(
         private course: CourseService,
-        private idl: IdlService,
         private pcrud: PcrudService,
         private route: ActivatedRoute,
         private toast: ToastService
@@ -50,24 +43,6 @@ export class CoursePageComponent implements OnInit {
         this.course.getCourses([this.courseId]).then(course => {
             this.currentCourse = course[0];
         });
-
-        this.defaultNewAcmtcm = this.idl.create('acmtcm');
-        this.defaultNewAcmtcm.course(this.courseId);
-
-        this.termsDataSource.getRows = (pager: Pager, sort: any[]) => {
-            const orderBy: any = {};
-            if (sort.length) {
-                orderBy.acmtcm = sort[0].name + ' ' + sort[0].dir;
-            }
-            const searchOps = {
-                offset: pager.offset,
-                limit: pager.limit,
-                order_by: orderBy
-            };
-
-            return this.pcrud.search('acmtcm', {course: this.courseId},
-                searchOps, {fleshSelectors: true});
-        };
     }
 
     // Edit Tab
index 32a44a5..149a39a 100644 (file)
@@ -11,6 +11,7 @@ import {ItemLocationSelectModule} from '@eg/share/item-location-select/item-loca
 import {MarcSimplifiedEditorModule} from '@eg/staff/share/marc-edit/simplified-editor/simplified-editor.module';
 import {PatronModule} from '@eg/staff/share/patron/patron.module';
 import {CourseTermMapComponent} from './course-term-map.component';
+import {CourseTermMapGridComponent} from './course-term-map-grid.component';
 
 @NgModule({
   declarations: [
@@ -18,7 +19,8 @@ import {CourseTermMapComponent} from './course-term-map.component';
     CoursePageComponent,
     CourseAssociateMaterialComponent,
     CourseAssociateUsersComponent,
-    CourseTermMapComponent
+    CourseTermMapComponent,
+    CourseTermMapGridComponent
   ],
   imports: [
     StaffCommonModule,
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-term-map-grid.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-term-map-grid.component.html
new file mode 100644 (file)
index 0000000..0b9adf5
--- /dev/null
@@ -0,0 +1,24 @@
+<eg-grid #grid idlClass="acmtcm" [dataSource]="gridDataSource" [filterable]="true">
+  <eg-grid-toolbar-button
+    label="Attach course to a term"
+    i18n-label (onClick)="createNew()"></eg-grid-toolbar-button>
+  <eg-grid-toolbar-action label="Detach course(s) from term(s)" i18n-label
+    [action]="deleteSelected"></eg-grid-toolbar-action>
+</eg-grid>
+
+<eg-fm-record-editor #editDialog idlClass="acmtcm"
+  [fieldOptions]="{term:{customTemplate:{template:termTemplate}}}"
+  fieldOrder="course,term"
+  hiddenFieldsList="id"
+  [defaultNewRecord]="defaultNewAcmtcm"
+  [readonlyFieldsList]="readonlyFields">
+</eg-fm-record-editor>
+
+<ng-template #termTemplate let-field="field" let-record="record">
+  <eg-combobox [required]="true"
+    [asyncSupportsEmptyTermClick]="true"
+    [asyncDataSource]="termEntryGenerator(record['course']())"
+    [selectedId]="record['term']()"
+    (onChange)="($event) ? record['term']($event.id) : ''">
+  </eg-combobox>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-term-map-grid.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-term-map-grid.component.ts
new file mode 100644 (file)
index 0000000..8d35453
--- /dev/null
@@ -0,0 +1,98 @@
+import {Component, Input, OnInit, ViewChild} from '@angular/core';
+import {Observable} from 'rxjs';
+import {map, switchMap} from 'rxjs/operators';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {Pager} from '@eg/share/util/pager';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+
+@Component({
+    selector: 'eg-course-term-map-grid',
+    templateUrl: './course-term-map-grid.component.html',
+})
+export class CourseTermMapGridComponent implements OnInit {
+    @Input() courseId: number;
+    @ViewChild('grid') private grid: GridComponent;
+    @ViewChild('editDialog') private editDialog: FmRecordEditorComponent;
+
+    readonlyFields: string;
+
+    defaultNewAcmtcm: IdlObject;
+    gridDataSource: GridDataSource;
+    createNew: () => void;
+    deleteSelected: (rows: IdlObject[]) => void;
+    termEntryGenerator: (course: number) => (query: string) => Observable<ComboboxEntry>;
+    termEntries: (query: string) => Observable<ComboboxEntry>;
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private pcrud: PcrudService,
+    ) {
+        this.gridDataSource = new GridDataSource();
+        this.defaultNewAcmtcm = this.idl.create('acmtcm');
+    }
+
+    ngOnInit() {
+
+        if (this.courseId) {
+            this.defaultNewAcmtcm.course(this.courseId);
+            this.readonlyFields = 'course';
+        }
+
+        this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
+            const orderBy: any = {};
+
+            const searchOps = {
+                offset: pager.offset,
+                limit: pager.limit,
+                order_by: orderBy
+            };
+
+            const criteria = this.courseId ? {course: this.courseId} : {};
+
+            return this.pcrud.search('acmtcm',
+                criteria, searchOps, {fleshSelectors: true});
+        };
+
+        // Produce a bespoke callback for the combobox search, which
+        // limits the results to course terms that make sense for the
+        // selected course.  This prevents users from associating a
+        // course at their library to a term from a completely different
+        // academic calendar.
+        this.termEntryGenerator = (courseId: number) => {
+            return (query: string) => {
+                return this.pcrud.retrieve('acmc', courseId).pipe(switchMap(fullCourseObject => {
+                    return this.pcrud.search(
+                        'acmt', {
+                            name: {'ilike': `%${query}`},
+                            owning_lib: this.org.ancestors(fullCourseObject.owning_lib(), true)
+                        },
+                        {order_by: {'acmt': 'name'}}
+                       );
+                }), map(courseTerm => {
+                    return {id: courseTerm.id(), label: courseTerm.name()};
+                }));
+            };
+        };
+
+        this.createNew = () => {
+            this.editDialog.mode = 'create';
+            this.editDialog.open({size: 'lg'})
+                .subscribe(() => this.grid.reload());
+        };
+
+        this.deleteSelected = (termMaps: IdlObject[]) => {
+            termMaps.forEach(termMap => termMap.isdeleted(true));
+            this.pcrud.autoApply(termMaps).subscribe(
+                val => console.debug('deleted: ' + val),
+                err => {},
+                ()  => this.grid.reload()
+            );
+        };
+    }
+}
index bfca8a7..ecbd3c1 100644 (file)
@@ -1,4 +1,5 @@
 import {Component} from '@angular/core';
+import {ActivatedRoute} from '@angular/router';
 
 /**
  * Very basic page for editing course/term map
@@ -18,11 +19,17 @@ import {Component} from '@angular/core';
                 </a>
             </div>
         </div>
-        <eg-admin-page persistKeyPfx="local" idlClass="acmtcm"
-            [disableOrgFilter]="true"></eg-admin-page>
+        <eg-course-term-map-grid [courseId]="courseId"></eg-course-term-map-grid>
     `
 })
 
 export class CourseTermMapComponent {
+    public courseId: number;
+
+    constructor(private route: ActivatedRoute) {
+        const filters = this.route.snapshot.queryParamMap.get('gridFilters');
+        this.courseId = JSON.parse(filters)['course'] || 1;
+    }
+
 
 }