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 14:42:03 +0000 (10:42 -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 [new file with mode: 0644]
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

diff --git a/Open-ILS/src/eg2/src/app/staff/admin/basic-admin-page.component.spec.ts b/Open-ILS/src/eg2/src/app/staff/admin/basic-admin-page.component.spec.ts
new file mode 100644 (file)
index 0000000..764c473
--- /dev/null
@@ -0,0 +1,113 @@
+import { Component, Input } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { ActivatedRoute, ParamMap } from '@angular/router';
+import { IdlObject, IdlService } from '@eg/core/idl.service';
+import { of } from 'rxjs';
+import { BasicAdminPageComponent } from './basic-admin-page.component';
+
+@Component({
+    selector: 'eg-title',
+    template: ''
+})
+class MockTitleComponent {
+    @Input() prefix: string;
+}
+
+@Component({
+    selector: 'eg-staff-banner',
+    template: ''
+})
+class MockStaffBannerComponent {
+    @Input() bannerText: string;
+}
+
+@Component({
+    selector: 'eg-admin-page',
+    template: ''
+})
+class MockAdminPageComponent {
+    @Input() configLinkBasePath: string;
+    @Input() defaultNewRecord: IdlObject;
+    @Input() disableOrgFilter: boolean;
+    @Input() fieldOrder: string;
+    @Input() idlClass: string;
+    @Input() persistKeyPfx: string;
+    @Input() readonlyFields: string;
+    @Input() enableUndelete: boolean;
+}
+
+describe('Component: BasicAdminPage', () => {
+    let component: BasicAdminPageComponent;
+    let fixture: ComponentFixture<BasicAdminPageComponent>;
+    let idlServiceStub: Partial<IdlService>;
+    let routeStub: any;
+
+    beforeEach(() => {
+        idlServiceStub = {
+            create: (cls: string, seed?: []) => {
+                return {
+                    a: seed || [],
+                    classname: cls,
+                    _isfieldmapper: true,
+
+                    field1(value: any): any {
+                        this.a[0] = value;
+                        return this.a[0];
+                    }
+                };
+            },
+            classes: [{ tbl1: { table: 'schema1.table1' } }]
+        };
+
+        const emptyParamMap: ParamMap = {
+            has: (name: string) => false,
+            get: (name: string) => null,
+            getAll: (name: string) => [],
+            keys: []
+        };
+        const data = [{
+            schema: 'schema1',
+            table: 'table1',
+            defaultNewRecord: { field1: 'value1' },
+            enableUndelete: true
+        }];
+        const parentRoute = { url: of('') };
+        const snapshot = { parent: { url: [{ path: '' }] } };
+        routeStub = {
+            paramMap: of(emptyParamMap),
+            data: of(data),
+            parent: parentRoute,
+            snapshot
+        };
+
+        TestBed.configureTestingModule({
+            imports: [],
+            providers: [
+                { provide: IdlService, useValue: idlServiceStub },
+                { provide: ActivatedRoute, useValue: routeStub }
+            ],
+            declarations: [
+                BasicAdminPageComponent,
+                MockTitleComponent,
+                MockStaffBannerComponent,
+                MockAdminPageComponent
+            ]
+        });
+        fixture = TestBed.createComponent(BasicAdminPageComponent);
+        component = fixture.componentInstance;
+        component.idlClass = 'tbl1';
+        fixture.detectChanges();
+    });
+
+    it('sets default new record from routing data', () => {
+        const adminPage: MockAdminPageComponent = fixture.debugElement.query(
+            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 91b2040..a6d81d2 100644 (file)
@@ -19,6 +19,7 @@ import {tap, switchMap} from 'rxjs/operators';
         configLinkBasePath="{{configLinkBasePath}}"
         fieldOrder="{{fieldOrder}}"
         readonlyFields="{{readonlyFields}}"
+        [enableUndelete]="enableUndelete"
         [disableOrgFilter]="disableOrgFilter"></eg-admin-page>
       </ng-container>
     `
@@ -36,6 +37,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>;
@@ -69,9 +72,10 @@ export class BasicAdminPageComponent implements OnInit {
                     if (!this.table) {
                         this.table = data['table'];
                     }
-                this.disableOrgFilter = data['disableOrgFilter'];
-                this.fieldOrder = data['fieldOrder'];
-                this.readonlyFields = data['readonlyFields'];
+                    this.disableOrgFilter = data['disableOrgFilter'];
+                    this.enableUndelete = data['enableUndelete'];
+                    this.fieldOrder = data['fieldOrder'];
+                    this.readonlyFields = data['readonlyFields'];
                 }
 
             }));
index 5267c04..b78565f 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 58b940b..82d570b 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