LP2002435: Add optional undelete action to basic admin page
authorJane Sandberg <js7389@princeton.edu>
Fri, 3 Feb 2023 00:15:32 +0000 (16:15 -0800)
committerMichele Morgan <mmorgan@noblenet.org>
Fri, 17 Mar 2023 12:59:47 +0000 (08:59 -0400)
By default, this undelete action is only enabled on the shelving
location editor.

Also, only show admin page delete button if everything
selected is deleteable

Signed-off-by: Jane Sandberg <sandbergja@gmail.com>
Signed-off-by: Michele Morgan <mmorgan@noblenet.org>
Open-ILS/src/eg2/src/app/staff/admin/basic-admin-page.component.spec.ts
Open-ILS/src/eg2/src/app/staff/admin/basic-admin-page.component.ts
Open-ILS/src/eg2/src/app/staff/admin/local/routing.module.ts
Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html
Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.spec.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts

index 7a5cb9d..764c473 100644 (file)
@@ -34,6 +34,7 @@ class MockAdminPageComponent {
     @Input() idlClass: string;
     @Input() persistKeyPfx: string;
     @Input() readonlyFields: string;
+    @Input() enableUndelete: boolean;
 }
 
 describe('Component: BasicAdminPage', () => {
@@ -68,7 +69,8 @@ describe('Component: BasicAdminPage', () => {
         const data = [{
             schema: 'schema1',
             table: 'table1',
-            defaultNewRecord: { field1: 'value1' }
+            defaultNewRecord: { field1: 'value1' },
+            enableUndelete: true
         }];
         const parentRoute = { url: of('') };
         const snapshot = { parent: { url: [{ path: '' }] } };
@@ -103,4 +105,9 @@ describe('Component: BasicAdminPage', () => {
             By.directive(MockAdminPageComponent)).componentInstance;
         expect(adminPage.defaultNewRecord.a[0]).toEqual('value1');
     });
+    it('sets enableUndelete from routing data', () => {
+        const adminPage: MockAdminPageComponent = fixture.debugElement.query(
+            By.directive(MockAdminPageComponent)).componentInstance;
+        expect(adminPage.enableUndelete).toEqual(true);
+    });
 });
index 1f63253..cd6dbde 100644 (file)
@@ -20,6 +20,7 @@ import {tap, switchMap} from 'rxjs/operators';
         fieldOrder="{{fieldOrder}}"
         readonlyFields="{{readonlyFields}}"
         [defaultNewRecord]="defaultNewRecordIdl"
+        [enableUndelete]="enableUndelete"
         [disableOrgFilter]="disableOrgFilter"></eg-admin-page>
       </ng-container>
     `
@@ -38,6 +39,8 @@ export class BasicAdminPageComponent implements OnInit {
     // Tell the admin page to disable and hide the automagic org unit filter
     disableOrgFilter: boolean;
 
+    enableUndelete: boolean;
+
     private getParams$: Observable<ParamMap>;
     private getRouteData$: Observable<any>;
     private getParentUrl$: Observable<any>;
@@ -73,6 +76,7 @@ export class BasicAdminPageComponent implements OnInit {
                         this.table = data['table'];
                     }
                     this.disableOrgFilter = data['disableOrgFilter'];
+                    this.enableUndelete = data['enableUndelete'];
                     this.fieldOrder = data['fieldOrder'];
                     this.readonlyFields = data['readonlyFields'];
                     this.defaultNewRecord = data['defaultNewRecord'];
index 22e40f6..95139c7 100644 (file)
@@ -28,6 +28,7 @@ const routes: Routes = [{
     data: [{
         schema: 'asset',
         table: 'copy_location',
+        enableUndelete: true,
         readonlyFields: 'deleted',
         fieldOrder: 'owning_lib,name,opac_visible,circulate,holdable,hold_verify,checkin_alert,deleted,label_prefix,label_suffix,url,id'}]
 }, {
index 817a018..01b2016 100644 (file)
 <ng-template #deleteSuccessStrTmpl i18n>{{idlClassDef.label}} Successfully Deleted</ng-template>
 <eg-string #deleteSuccessString [template]="deleteSuccessStrTmpl"></eg-string>
 
+<ng-template #undeleteFailedStrTmpl i18n>Undelete of {{idlClassDef.label}} failed or was not allowed</ng-template>
+<eg-string #undeleteFailedString [template]="undeleteFailedStrTmpl"></eg-string>
+
+<ng-template #undeleteSuccessStrTmpl i18n>{{idlClassDef.label}} Successfully undeleted</ng-template>
+<eg-string #undeleteSuccessString [template]="undeleteSuccessStrTmpl"></eg-string>
+
 <ng-template #createStrTmpl i18n>{{idlClassDef.label}} Successfully Created</ng-template>
 <eg-string #createString [template]="createStrTmpl"></eg-string>
 
   </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 label="Delete Selected" i18n-label (onClick)="deleteSelected($event)"
+    [disableOnRows]="shouldDisableDelete">
+  </eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Undelete Selected" i18n-label (onClick)="undeleteSelected($event)"
+    [disableOnRows]="shouldDisableUndelete" *ngIf="enableUndelete">
   </eg-grid-toolbar-action>
   <ng-container *ngFor="let cf of configFields">
     <eg-grid-column name="{{cf.name}}" [cellTemplate]="configFieldLink">
diff --git a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.spec.ts b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.spec.ts
new file mode 100644 (file)
index 0000000..28139b5
--- /dev/null
@@ -0,0 +1,65 @@
+import { ActivatedRoute } from "@angular/router";
+import { AdminPageComponent } from "./admin-page.component";
+import { Location } from '@angular/common';
+import { FormatService } from "@eg/core/format.service";
+import { IdlService } from "@eg/core/idl.service";
+import { OrgService } from "@eg/core/org.service";
+import { AuthService } from "@eg/core/auth.service";
+import { PcrudService } from "@eg/core/pcrud.service";
+import { PermService } from "@eg/core/perm.service";
+import { ToastService } from "@eg/share/toast/toast.service";
+
+describe('CopyAttrsComponent', () => {
+    let component: AdminPageComponent;
+
+    const routeMock = jasmine.createSpyObj<ActivatedRoute>(['snapshot']);
+    const locationMock = jasmine.createSpyObj<Location>(['prepareExternalUrl']);
+    const formatMock = jasmine.createSpyObj<FormatService>(['transform']);
+    const idlMock = jasmine.createSpyObj<IdlService>(['classes']);
+    const orgMock = jasmine.createSpyObj<OrgService>(['get']);
+    const authMock = jasmine.createSpyObj<AuthService>(['user']);
+    const pcrudMock = jasmine.createSpyObj<PcrudService>(['retrieveAll']);
+    const permMock = jasmine.createSpyObj<PermService>(['hasWorkPermAt']);
+    const toastMock = jasmine.createSpyObj<ToastService>(['success']);
+    beforeEach(() => {
+        component = new AdminPageComponent(routeMock, locationMock, formatMock,
+            idlMock, orgMock, authMock, pcrudMock, permMock, toastMock);
+    })
+
+    describe('#shouldDisableDelete', () => {
+        it('returns true if one of the rows is already deleted', () => {
+            const rows = [
+                {isdeleted: () => true, a: [], classname: '', _isfieldmapper: true },
+                {isdeleted: () => false, a: [], classname: '', _isfieldmapper: true }
+            ];
+            expect(component.shouldDisableDelete(rows)).toBe(true);
+        });
+        it('returns true if no rows selected', () => {
+            expect(component.shouldDisableDelete([])).toBe(true);
+        })
+        it('returns false (i.e. you _should_ display delete) if no selected rows are deleted', () => {
+            const rows = [
+                {isdeleted: () => false, deleted: () => 'f', a: [], classname: '', _isfieldmapper: true }
+            ];
+            expect(component.shouldDisableDelete(rows)).toBe(false);
+        })
+    });
+    describe('#shouldDisableUndelete', () => {
+        it('returns true if none of the rows are deleted', () => {
+            const rows = [
+                {isdeleted: () => false, a: [], classname: '', _isfieldmapper: true },
+                {deleted: () => 'f', a: [], classname: '', _isfieldmapper: true }
+            ];
+            expect(component.shouldDisableUndelete(rows)).toBe(true);
+        });
+        it('returns true if no rows selected', () => {
+            expect(component.shouldDisableUndelete([])).toBe(true);
+        })
+        it('returns false (i.e. you _should_ display undelete) if all selected rows are deleted', () => {
+            const rows = [
+                {deleted: () => 't', a: [], classname: '', _isfieldmapper: true }
+            ];
+            expect(component.shouldDisableUndelete(rows)).toBe(false);
+        })
+    });
+});
index 7098c70..d9de7bd 100644 (file)
@@ -60,6 +60,9 @@ export class AdminPageComponent implements OnInit {
     // Disable the auto-matic org unit field filter
     @Input() disableOrgFilter: boolean;
 
+    // Give the grid an option to undelete any deleted rows
+    @Input() enableUndelete: boolean;
+
     // Include objects linking to org units which are ancestors
     // of the selected org unit.
     @Input() includeOrgAncestors: boolean;
@@ -106,6 +109,8 @@ export class AdminPageComponent implements OnInit {
     @ViewChild('updateFailedString', { static: true }) updateFailedString: StringComponent;
     @ViewChild('deleteFailedString', { static: true }) deleteFailedString: StringComponent;
     @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
+    @ViewChild('undeleteFailedString', { static: true }) undeleteFailedString: StringComponent;
+    @ViewChild('undeleteSuccessString', { static: true }) undeleteSuccessString: StringComponent;
     @ViewChild('translator', { static: true }) translator: TranslateComponent;
 
     idlClassDef: any;
@@ -340,6 +345,21 @@ export class AdminPageComponent implements OnInit {
         editOneThing(idlThings.shift());
     }
 
+    undeleteSelected(idlThings: IdlObject[]) {
+        idlThings.forEach(idlThing => idlThing.deleted(false));
+        this.pcrud.update(idlThings).subscribe(
+            val => {
+                this.undeleteSuccessString.current()
+                    .then(str => this.toast.success(str));
+            },
+            err => {
+                this.undeleteFailedString.current()
+                    .then(str => this.toast.danger(str));
+            },
+            ()  => this.grid.reload()
+        );
+    }
+
     deleteSelected(idlThings: IdlObject[]) {
         idlThings.forEach(idlThing => idlThing.isdeleted(true));
         this.pcrud.autoApply(idlThings).subscribe(
@@ -355,6 +375,36 @@ export class AdminPageComponent implements OnInit {
         );
     }
 
+    shouldDisableDelete(rows: IdlObject[]): boolean {
+        if (rows.length === 0) {
+            return true;
+        } else {
+            const deletedRows = rows.filter((row) => {
+                if (row.deleted && row.deleted() === 't') {
+                    return true;
+                } else if (row.isdeleted) {
+                    return row.isdeleted();
+                }
+            });
+            return deletedRows.length > 0;
+        }
+    }
+
+    shouldDisableUndelete(rows: IdlObject[]): boolean {
+        if (rows.length === 0) {
+            return true;
+        } else {
+            const deletedRows = rows.filter((row) => {
+                if (row.deleted && row.deleted() === 't') {
+                    return true;
+                } else if (row.isdeleted) {
+                    return row.isdeleted();
+                }
+            });
+            return deletedRows.length !== rows.length;
+        }
+    }
+
     createNew() {
         this.editDialog.mode = 'create';
         // We reuse the same editor for all actions.  Be sure