LP#1840327: port standing penalty admin interface to Angular
authorMike Risher <mrisher@catalyte.io>
Tue, 20 Aug 2019 20:29:23 +0000 (20:29 +0000)
committerGalen Charlton <gmc@equinoxinitiative.org>
Fri, 6 Sep 2019 17:42:50 +0000 (13:42 -0400)
Convert standing penalty types admin UI from DOJO to Angular. Name
field is read only if the ID is below 100.  Doing this involved:

- creating a new standing penalty component
- using rowFlairCallback functionality in the grid, so that an icon
  and tooltip is shown for fields where the name cannot be edited
- making the ID show up in red text when it is below 100
- adding "readonly Override" functionality to fm-editor, so that
  some fields of a given type are read only, but others are not

Signed-off-by: Mike Risher <mrisher@catalyte.io>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html
Open-ILS/src/eg2/src/app/staff/admin/local/admin-local.module.ts
Open-ILS/src/eg2/src/app/staff/admin/local/routing.module.ts
Open-ILS/src/eg2/src/app/staff/admin/local/standing-penalty.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/standing-penalty.component.ts [new file with mode: 0644]

index a4fc62b..e5ed400 100644 (file)
@@ -67,6 +67,11 @@ export interface FmFieldOptions {
     // This only has an affect if the value is true.
     isReadonly?: boolean;
 
+    // If this function is defined, the function will be called
+    // at render time to see if the field should be marked readonly.
+    // This supersedes all other isReadonly specifiers.
+    isReadonlyOverride?: (field: string, record: IdlObject) => boolean;
+
     // Render the field using this custom template instead of chosing
     // from the default set of form inputs.
     customTemplate?: CustomFieldTemplate;
@@ -455,10 +460,16 @@ export class FmRecordEditorComponent
 
         let promise = null;
         const fieldOptions = this.fieldOptions[field.name] || {};
-
-        field.readOnly = this.mode === 'view'
-            || fieldOptions.isReadonly === true
-            || this.readonlyFieldsList.includes(field.name);
+                
+        if (this.mode === 'view') {
+            field.readOnly = true;
+        } else if (fieldOptions.isReadonlyOverride) {
+            field.readOnly =
+                !fieldOptions.isReadonlyOverride(field.name, this.record);
+        } else {
+            field.readOnly = fieldOptions.isReadonly === true
+                || this.readonlyFieldsList.includes(field.name);
+        }
 
         if (fieldOptions.isRequiredOverride) {
             field.isRequired = () => {
index 870deeb..02565df 100644 (file)
@@ -55,7 +55,7 @@
     <eg-link-table-link i18n-label label="Search Filter Groups" 
       url="/eg/staff/admin/local/actor/search_filter_group"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Standing Penalties" 
-      url="/eg/staff/admin/local/config/standing_penalty"></eg-link-table-link>
+      routerLink="/staff/admin/local/config/standing_penalty"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Statistical Categories Editor" 
       url="/eg/staff/admin/local/asset/stat_cat_editor"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Statistical Popularity Badges" 
index 7c8a6fa..9f70ab7 100644 (file)
@@ -6,12 +6,14 @@ import {AdminCommonModule} from '@eg/staff/admin/common.module';
 import {AdminLocalSplashComponent} from './admin-local-splash.component';
 import {AddressAlertComponent} from './address-alert.component';
 import {AdminCarouselComponent} from './admin-carousel.component';
+import {StandingPenaltyComponent} from './standing-penalty.component';
 
 @NgModule({
   declarations: [
       AdminLocalSplashComponent,
       AddressAlertComponent,
-      AdminCarouselComponent
+      AdminCarouselComponent,
+      StandingPenaltyComponent
   ],
   imports: [
     AdminCommonModule,
index 4eda585..39c6be7 100644 (file)
@@ -4,6 +4,7 @@ import {AdminLocalSplashComponent} from './admin-local-splash.component';
 import {BasicAdminPageComponent} from '@eg/staff/admin/basic-admin-page.component';
 import {AddressAlertComponent} from './address-alert.component';
 import {AdminCarouselComponent} from './admin-carousel.component';
+import {StandingPenaltyComponent} from './standing-penalty.component';
 
 const routes: Routes = [{
     path: 'splash',
@@ -19,6 +20,9 @@ const routes: Routes = [{
     path: 'container/carousel',
     component: AdminCarouselComponent
 }, {
+    path: 'config/standing_penalty',
+    component: StandingPenaltyComponent
+}, {
     path: ':schema/:table',
     component: BasicAdminPageComponent
 }];
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/standing-penalty.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/standing-penalty.component.html
new file mode 100644 (file)
index 0000000..351d1cb
--- /dev/null
@@ -0,0 +1,28 @@
+<eg-title i18n-prefix prefix="Standing Penalty Administration"></eg-title>
+<eg-staff-banner bannerText="Standing Penalty Types" i18n-bannerText>
+</eg-staff-banner>
+
+<eg-string #cspFlairTooltip i18n-text text="Limited Editing"></eg-string>
+<div class="w-100 mt-2 mb-2">
+  <eg-grid idlClass="csp"
+    [dataSource]="cspSource"
+    [rowFlairIsEnabled]="true"
+    [rowFlairCallback]="cspRowFlairCallback"
+    [cellClassCallback]="cspGridCellClassCallback"
+    [sortable]="true">
+    <eg-grid-toolbar-button 
+      label="New Standing Penalty Type" i18n-label (onClick)="createNew()">
+    </eg-grid-toolbar-button>
+    <eg-grid-toolbar-action label="Edit Selected" i18n-label (onClick)="editSelected($event)">
+    </eg-grid-toolbar-action>
+    <eg-grid-toolbar-action label="Delete Selected" i18n-label (onClick)="deleteSelected($event)">
+    </eg-grid-toolbar-action>
+  </eg-grid>
+</div>
+
+<eg-fm-record-editor #editDialog
+  idlClass="csp" 
+  [fieldOptions]="{name: {isReadonlyOverride: cspReadonlyOverride}}" 
+  [preloadLinkedValues]="true" 
+  readonlyFields="name">
+</eg-fm-record-editor>
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/standing-penalty.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/standing-penalty.component.ts
new file mode 100644 (file)
index 0000000..f1c4bd5
--- /dev/null
@@ -0,0 +1,160 @@
+import {Pager} from '@eg/share/util/pager';
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridColumn, GridRowFlairEntry} from '@eg/share/grid/grid';
+import {IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+@Component({
+    templateUrl: './standing-penalty.component.html'
+})
+
+export class StandingPenaltyComponent implements OnInit {
+    recId: number;
+    gridDataSource: GridDataSource;
+    initDone = false;
+    cspSource: GridDataSource = new GridDataSource();
+    @ViewChild('partsGrid') partsGrid: GridComponent;
+    @ViewChild('editDialog') editDialog: FmRecordEditorComponent;
+    @ViewChild('grid') grid: GridComponent;
+    @ViewChild('successString') successString: StringComponent;
+    @ViewChild('createString') createString: StringComponent;
+    @ViewChild('createErrString') createErrString: StringComponent;
+    @ViewChild('updateFailedString') updateFailedString: StringComponent;
+    @ViewChild('cspFlairTooltip') private cspFlairTooltip: StringComponent;
+    
+    cspRowFlairCallback: (row: any) => GridRowFlairEntry;
+
+    canCreate: boolean;
+    canDelete: boolean;
+    deleteSelected: (rows: IdlObject[]) => void;
+    
+    permissions: {[name: string]: boolean};
+
+    // Default sort field, used when no grid sorting is applied.
+    @Input() sortField: string;
+
+    @Input() idlClass: string = "csp";
+    // Size of create/edito dialog.  Uses large by default.
+    @Input() dialogSize: 'sm' | 'lg' = 'lg';
+    // Optional comma-separated list of read-only fields
+    // @Input() readonlyFields: string;
+
+    @Input() set recordId(id: number) {
+        this.recId = id;
+        // Only force new data collection when recordId()
+        // is invoked after ngInit() has already run.
+        if (this.initDone) {
+            this.partsGrid.reload();
+        }
+    }
+
+    constructor(
+        private pcrud: PcrudService,
+        private toast: ToastService
+    ) {
+        this.gridDataSource = new GridDataSource();
+    }
+
+    ngOnInit() {
+        this.initDone = true;
+        this.cspSource.getRows = (pager: Pager, sort: any[]) => {
+            const orderBy: any = {};
+            if (sort.length) {
+                // Sort specified from grid
+                orderBy[this.idlClass] = sort[0].name + ' ' + sort[0].dir;
+            } else if (this.sortField) {
+                // Default sort field
+                orderBy[this.idlClass] = this.sortField;
+            }
+        
+            const searchOps = {
+                offset: pager.offset,
+                limit: pager.limit,
+                order_by: orderBy
+            };
+            return this.pcrud.retrieveAll('csp', searchOps, {fleshSelectors: true});
+        }
+        
+        this.cspRowFlairCallback = (row: any): GridRowFlairEntry => {        
+            const flair = {icon: null, title: null};
+            if (row.id() < 100) {
+                flair.icon = 'not_interested';
+                flair.title = this.cspFlairTooltip.text;
+            }
+            return flair;
+        }
+    }
+
+    cspReadonlyOverride = (field: string, copy: IdlObject): boolean => {
+        if (copy.id() >= 100) {
+            return true;
+        }
+        return false;
+    }
+
+    cspGridCellClassCallback = (row: any, col: GridColumn): string => {
+        if (col.name === "id" && row.a[0] < 100) {
+            return "text-danger";
+        }
+        return "";
+    };
+
+    showEditDialog(standingPenalty: IdlObject): Promise<any> {
+        this.editDialog.mode = 'update';
+        this.editDialog.recId = standingPenalty["id"]();
+        return new Promise((resolve, reject) => {
+            this.editDialog.open({size: this.dialogSize}).subscribe(
+                result => {
+                    this.successString.current()
+                        .then(str => this.toast.success(str));
+                    this.grid.reload();
+                    resolve(result);
+                },
+                error => {
+                    this.updateFailedString.current()
+                        .then(str => this.toast.danger(str));
+                    reject(error);
+                }
+            );
+        });
+    }
+
+    editSelected(standingPenaltyFields: IdlObject[]) {
+        // Edit each IDL thing one at a time
+        const editOneThing = (standingPenalty: IdlObject) => {
+            if (!standingPenalty) { return; }
+
+            this.showEditDialog(standingPenalty).then(
+                () => editOneThing(standingPenaltyFields.shift()));
+        };
+
+        editOneThing(standingPenaltyFields.shift());
+    }
+
+    createNew() {
+        this.editDialog.mode = 'create';
+        // We reuse the same editor for all actions.  Be sure
+        // create action does not try to modify an existing record.
+        this.editDialog.recId = null;
+        this.editDialog.record = null;
+        this.editDialog.open({size: this.dialogSize}).subscribe(
+            ok => {
+                this.createString.current()
+                    .then(str => this.toast.success(str));
+                this.grid.reload();
+            },
+            rejection => {
+                if (!rejection.dismissed) {
+                    this.createErrString.current()
+                        .then(str => this.toast.danger(str));
+                }
+            }
+        );
+    }
+           
+}
+