LP#1938835: admin interface for customizing the staff portal
authorGalen Charlton <gmc@equinoxOLI.org>
Tue, 3 Aug 2021 22:59:51 +0000 (18:59 -0400)
committerMike Rylander <mrylander@gmail.com>
Thu, 24 Mar 2022 12:55:02 +0000 (08:55 -0400)
Sponsored-by: Pioneer Library System
Signed-off-by: Galen Charlton <gmc@equinoxOLI.org>
Signed-off-by: Mike Rylander <mrylander@gmail.com>
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/staff_portal_page/clone-portal-entries-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/clone-portal-entries-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.ts [new file with mode: 0644]

index 5ab2496..41f19dc 100644 (file)
@@ -64,6 +64,8 @@
       routerLink="/staff/admin/local/asset/copy_location_order"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Shelving Locations Editor" 
       routerLink="/staff/admin/local/asset/copy_location"></eg-link-table-link>
+    <eg-link-table-link i18n-label label="Staff Portal Page" 
+      routerLink="/staff/admin/local/config/ui_staff_portal_page_entry"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Standing Penalties" 
       routerLink="/staff/admin/local/config/standing_penalty"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Statistical Categories Editor" 
index 9f70ab7..2fff294 100644 (file)
@@ -6,6 +6,8 @@ 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 {ClonePortalEntriesDialogComponent} from './staff_portal_page/clone-portal-entries-dialog.component';
+import {AdminStaffPortalPageComponent} from './staff_portal_page/staff-portal-page.component';
 import {StandingPenaltyComponent} from './standing-penalty.component';
 
 @NgModule({
@@ -13,7 +15,9 @@ import {StandingPenaltyComponent} from './standing-penalty.component';
       AdminLocalSplashComponent,
       AddressAlertComponent,
       AdminCarouselComponent,
-      StandingPenaltyComponent
+      StandingPenaltyComponent,
+      ClonePortalEntriesDialogComponent,
+      AdminStaffPortalPageComponent
   ],
   imports: [
     AdminCommonModule,
index a2eb2ff..c87c517 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 {AdminStaffPortalPageComponent} from './staff_portal_page/staff-portal-page.component';
 import {StandingPenaltyComponent} from './standing-penalty.component';
 import {CourseTermMapComponent} from './course-reserves/course-term-map.component';
 
@@ -54,6 +55,9 @@ const routes: Routes = [{
     path: 'config/standing_penalty',
     component: StandingPenaltyComponent
 }, {
+    path: 'config/ui_staff_portal_page_entry',
+    component: AdminStaffPortalPageComponent
+}, {
     path: 'action/survey',
     loadChildren: () =>
       import('./survey/survey.module').then(m => m.SurveyModule)
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/clone-portal-entries-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/clone-portal-entries-dialog.component.html
new file mode 100644 (file)
index 0000000..322c036
--- /dev/null
@@ -0,0 +1,50 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h3 class="modal-title" i18n>Clone a Library's Portal Page Entries</h3>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <form #cloneForm="ngForm" role="form" class="form-validated common-form striped-odd">
+      <div class="form-group row mt-2">
+        <label for="source_library" class="col-sm-6 col-form-label" i18n>Source Library</label>
+        <div class="col-sm-6">
+          <eg-org-select
+            placeholder="Source Library..."
+            domId="source_library"
+            i18n-placeholder
+            [limitPerms]="['STAFF_LOGIN']"
+            (onChange)="result.source_library = $event.id(); cloneForm.form.markAsDirty()">
+          </eg-org-select>
+        </div>
+      </div>
+      <div class="form-group row mt-2">
+        <label for="target_library" class="col-sm-6 col-form-label" i18n>Target Library</label>
+        <div class="col-sm-6">
+          <eg-org-select
+            placeholder="Target Library..."
+            domId="target_library"
+            i18n-placeholder
+            [limitPerms]="['ADMIN_STAFF_PORTAL_PAGE']"
+            (onChange)="result.target_library = $event.id(); cloneForm.form.markAsDirty();">
+          </eg-org-select>
+        </div>
+      </div>
+      <div class="form-group row mt-2">
+        <label for="overwrite_target" class="col-sm-6 col-form-label" i18n>Clear Entries at Target Library?</label>
+        <div class="col-sm-6">
+          <input type="checkbox" id="overwrite_target" name="overwrite_target" [(ngModel)]="result.overwrite_target" />
+        </div>
+      </div>
+    </form>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-info"
+      [disabled]="!result.source_library || !result.target_library || (result.target_library === result.source_library)"
+      (click)="close(result)" i18n>Clone</button>
+    <button type="button" class="btn btn-warning"
+      (click)="close()" i18n>Close</button>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/clone-portal-entries-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/clone-portal-entries-dialog.component.ts
new file mode 100644 (file)
index 0000000..7773039
--- /dev/null
@@ -0,0 +1,34 @@
+import {Component, Input, ViewChild, TemplateRef, OnInit} from '@angular/core';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgForm, NG_VALIDATORS} from '@angular/forms';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+
+@Component({
+  selector: 'eg-clone-portal-entries-dialog',
+  templateUrl: './clone-portal-entries-dialog.component.html'
+})
+
+export class ClonePortalEntriesDialogComponent
+  extends DialogComponent implements OnInit {
+
+    result = { };
+
+    constructor(
+        private modal: NgbModal
+    ) {
+        super(modal);
+    }
+
+    ngOnInit() {
+        this.onOpen$.subscribe(() => this._initRecord());
+    }
+
+    private _initRecord() {
+        this.result = {
+            source_library: null,
+            target_library: null,
+            overwrite_target: false
+        };
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.html
new file mode 100644 (file)
index 0000000..8649e83
--- /dev/null
@@ -0,0 +1,111 @@
+<ng-template #successStrTmpl i18n>{{idlClassDef.label}} Update Succeeded</ng-template>
+<eg-string #successString [template]="successStrTmpl"></eg-string>
+
+<ng-template #updateFailedStrTmpl i18n>Update of {{idlClassDef.label}} failed</ng-template>
+<eg-string #updateFailedString [template]="updateFailedStrTmpl"></eg-string>
+
+<ng-template #deleteFailedStrTmpl i18n>Delete of {{idlClassDef.label}} failed or was not allowed</ng-template>
+<eg-string #deleteFailedString [template]="deleteFailedStrTmpl"></eg-string>
+
+<ng-template #deleteSuccessStrTmpl i18n>{{idlClassDef.label}} Successfully Deleted</ng-template>
+<eg-string #deleteSuccessString [template]="deleteSuccessStrTmpl"></eg-string>
+
+<ng-template #createStrTmpl i18n>{{idlClassDef.label}} Successfully Created</ng-template>
+<eg-string #createString [template]="createStrTmpl"></eg-string>
+
+<ng-template #createErrStrTmpl i18n>Failed to create new {{idlClassDef.label}}</ng-template>
+<eg-string #createErrString [template]="createErrStrTmpl"></eg-string>
+
+<eg-string #cloneSuccessString i18n-text text="Portal Page Entries Cloning Succeeded"></eg-string>
+<eg-string #cloneFailedString i18n-text text="Portal Page Entries Cloning Failed"></eg-string>
+
+<eg-confirm-dialog #delConfirm
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Delete?"
+  dialogBody="Delete staff portal page entry or entries?">
+</eg-confirm-dialog>
+
+<eg-clone-portal-entries-dialog #cloneDialog></eg-clone-portal-entries-dialog>
+
+<ng-container *ngIf="orgField || gridFilters">
+  <div class="row">
+    <div class="col-lg-6">
+      <ng-container *ngIf="orgField">
+        <eg-org-family-select
+          [limitPerms]="viewPerms" 
+          [selectedOrgId]="contextOrg.id()"
+          [ancestorSelectorChecked]="true"
+          [(ngModel)]="searchOrgs"
+          (ngModelChange)="grid.reload()">
+        </eg-org-family-select>
+      </ng-container>
+    </div>
+    <div class="col-lg-6 d-flex">
+      <div class="flex-1"></div><!-- push right -->
+      <ng-container *ngIf="gridFilters">
+        <span i18n>Filters Applied: {{gridFilters | json}}</span>
+        <a class="pl-2 font-italic" 
+          [attr.href]="clearGridFiltersUrl()" i18n>Clear Filters</a>
+      </ng-container>
+    </div>
+  </div>
+  <hr/>
+</ng-container>
+
+<!-- idlObject and fieldName applied programmatically -->
+<eg-translate #translator></eg-translate>
+
+<ng-container *ngIf="helpTemplate">
+  <ng-container *ngTemplateOutlet="helpTemplate"></ng-container>
+</ng-container>
+
+<ng-template #configFieldLink let-row="row" let-col="col">
+  <a i18n-title title="Link To {{col.label}}"
+    [attr.href]="configFieldLinkUrl(row, col)">{{configLinkLabel(row, col)}}</a>
+</ng-template>
+
+<eg-grid #grid idlClass="{{idlClass}}" [dataSource]="dataSource" hideFields="{{hideGridFields}}"
+    [sortable]="true" persistKey="{{persistKey}}" autoGeneratedColumnOrder="{{fieldOrder}}"
+    [filterable]="true"
+    (onRowActivate)="showEditDialog($event)"
+    [filterable]="true" [stickyHeader]="true">
+  <eg-grid-toolbar-button [disabled]="!canCreate" 
+    label="New {{idlClassDef.label}}" i18n-label (onClick)="createNew()">
+  </eg-grid-toolbar-button>
+  <eg-grid-toolbar-button [disabled]="!canCreate" 
+    label="Clone a Library's Portal Page Entries" i18n-label (onClick)="cloneEntries()">
+  </eg-grid-toolbar-button>
+  <eg-grid-toolbar-button [disabled]="translatableFields.length == 0" 
+    label="Apply Translations" i18n-label (onClick)="translate()">
+  </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>
+  <ng-container *ngFor="let cf of configFields">
+    <eg-grid-column name="{{cf.name}}" [cellTemplate]="configFieldLink">
+    </eg-grid-column>
+  </ng-container>
+</eg-grid>
+
+<ng-template #textTemplate let-field="field" let-record="record">
+  <textarea rows="3"
+    class="form-control"
+    id="{{idPrefix}}-{{field.name}}" name="{{field.name}}"
+    type="text" pattern="[\s\S]*\S[\s\S]*"
+    placeholder="{{field.label}}..." i18n-placeholder
+    [required]="field.isRequired()"
+    [ngModel]="record[field.name]()"
+    (ngModelChange)="record[field.name]($event)"></textarea>
+</ng-template>
+
+<eg-fm-record-editor #editDialog idlClass="{{idlClass}}" 
+    [fieldOptions]="fieldOptions"
+    [fieldOrder]="fieldOrder"
+    [defaultNewRecord]="defaultNewRecord"
+    [fieldOptions]="{entry_text:{customTemplate:{template:textTemplate}}}"
+    [preloadLinkedValues]="true"
+    [readonlyFields]="readonlyFields">
+</eg-fm-record-editor>
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.ts
new file mode 100644 (file)
index 0000000..13d285f
--- /dev/null
@@ -0,0 +1,114 @@
+import {Component, Input, ViewChild, OnInit} from '@angular/core';
+import {Location} from '@angular/common';
+import {FormatService} from '@eg/core/format.service';
+import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.component';
+import {ActivatedRoute} from '@angular/router';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {OrgService} from '@eg/core/org.service';
+import {PermService} from '@eg/core/perm.service';
+import {AuthService} from '@eg/core/auth.service';
+import {NetService} from '@eg/core/net.service';
+import {GridCellTextGenerator} from '@eg/share/grid/grid';
+import {StringComponent} from '@eg/share/string/string.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {ClonePortalEntriesDialogComponent} from './clone-portal-entries-dialog.component';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {merge, Observable, empty} from 'rxjs';
+
+@Component({
+    templateUrl: './staff-portal-page.component.html'
+})
+
+export class AdminStaffPortalPageComponent extends AdminPageComponent implements OnInit {
+
+    idlClass = 'cusppe';
+    fieldOrder = 'label,entry_type,target_url,entry_text,image_url,page_col,col_pos,owner,id';
+    classLabel: string;
+
+    refreshSelected: (idlThings: IdlObject[]) => void;
+    createNew: () => void;
+    cellTextGenerator: GridCellTextGenerator;
+
+    @ViewChild('refreshString', { static: true }) refreshString: StringComponent;
+    @ViewChild('refreshErrString', { static: true }) refreshErrString: StringComponent;
+    @ViewChild('cloneSuccessString', { static: true }) cloneSuccessString: StringComponent;
+    @ViewChild('cloneFailedString', { static: true }) cloneFailedString: StringComponent;
+    @ViewChild('cloneDialog', { static: true}) cloneDialog: ClonePortalEntriesDialogComponent;
+    @ViewChild('delConfirm', { static: true }) delConfirm: ConfirmDialogComponent;
+
+    constructor(
+        route: ActivatedRoute,
+        ngLocation: Location,
+        format: FormatService,
+        idl: IdlService,
+        org: OrgService,
+        auth: AuthService,
+        pcrud: PcrudService,
+        perm: PermService,
+        toast: ToastService,
+        private net: NetService
+    ) {
+        super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast);
+    }
+
+    ngOnInit() {
+        super.ngOnInit();
+
+        this.defaultNewRecord = this.idl.create(this.idlClass);
+        this.defaultNewRecord.owner(this.auth.user().ws_ou());
+    }
+
+    cloneEntries() {
+        this.cloneDialog.open().subscribe(
+            result => {
+                this._handleClone(result.source_library, result.target_library, result.overwrite_target);
+            }
+        );
+    }
+
+    deleteSelected(idlThings: IdlObject[]) {
+        this.delConfirm.open().subscribe(confirmed => {
+            if (!confirmed) { return; }
+            super.deleteSelected(idlThings);
+        });
+    }
+
+    _handleClone(src: number, tgt: number, overwrite: Boolean) {
+        const updates: IdlObject[] = [];
+
+        const delObs = (overwrite) ?
+            this.pcrud.search('cusppe', { owner: tgt }, {}, {}) :
+            empty();
+        const newObs = this.pcrud.search('cusppe', { owner: src }, {}, {});
+        merge(delObs, newObs).subscribe(
+            entry => {
+                if (entry.owner() === tgt) {
+                    entry.isdeleted(true);
+                } else {
+                    entry.owner(tgt);
+                    entry.id(null);
+                    entry.isnew(true);
+                }
+                updates.push(entry);
+            },
+            err => {},
+        ).add(() => {
+            this.pcrud.autoApply(updates).subscribe(
+                val => {},
+                err => {
+                    this.cloneFailedString.current()
+                        .then(str => this.toast.danger(str));
+                },
+                () => {
+                    this.cloneSuccessString.current()
+                        .then(str => this.toast.success(str));
+                    this.searchOrgs = {primaryOrgId: tgt}; // change the org filter to the
+                                                           // the one we just cloned into
+                    this.grid.reload();
+                }
+            );
+        });
+    }
+}