LP1823981 Angular Permission Group Tree Admin UI
authorBill Erickson <berickxx@gmail.com>
Fri, 5 Apr 2019 22:00:32 +0000 (18:00 -0400)
committerGalen Charlton <gmc@equinoxinitiative.org>
Fri, 6 Sep 2019 15:35:05 +0000 (11:35 -0400)
Migrate the Admin => Server Admin => Permission Groups admin page to
Angular.

As an added feature, the interface now displays inherited permissions
alongside linked permissions for each group.  Inherited permissions
are read-only and act to indicate to the user when a group already has
a certain permission and therefore may not need a new one added.

Additionally, a new filter option is available in the linked permissions
interface for filtering the displayed linked permissions by code or
description.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
Open-ILS/src/eg2/src/app/core/org.service.ts
Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts
Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-map-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-map-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-tree.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-tree.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts

index c666957..ba2b4e3 100644 (file)
@@ -44,6 +44,17 @@ export class OrgService {
         return this.orgList;
     }
 
+    // Returns a list of org unit type objects
+    typeList(): IdlObject[] {
+        const types = [];
+        this.list().forEach(org => {
+            if ((types.filter(t => t.id() === org.ou_type().id())).length === 0) {
+                types.push(org.ou_type());
+            }
+        });
+        return types;
+    }
+
     /**
      * Returns a list of org units that match the selected criteria.
      * All filters must match for an org to be included in the result set.
index ce0dc3e..85225fa 100644 (file)
@@ -67,7 +67,7 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
     // Entry ID of the default entry to select (optional)
     // onChange() is NOT fired when applying the default value,
     // unless startIdFiresOnChange is set to true.
-    @Input() startId: any;
+    @Input() startId: any = null;
     @Input() startIdFiresOnChange: boolean;
 
     @Input() idlClass: string;
@@ -187,7 +187,7 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
     // Apply a default selection where needed
     applySelection() {
 
-        if (this.startId &&
+        if (this.startId !== null &&
             this.entrylist && !this.defaultSelectionApplied) {
 
             const entry =
index dcb085b..730084a 100644 (file)
@@ -39,7 +39,7 @@ export interface FmFieldOptions {
 
     // Render the field as a combobox using these values, regardless
     // of the field's datatype.
-    customValues?: {[field: string]: ComboboxEntry[]};
+    customValues?: ComboboxEntry[];
 
     // Provide / override the "selector" value for the linked class.
     // This is the field the combobox will search for typeahead.  If no
index d3ed6eb..a6e3d2b 100644 (file)
@@ -80,7 +80,7 @@
     <eg-link-table-link i18n-label label="Organizational Units"  
       url="/eg/staff/admin/server/legacy/actor/org_unit"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Permission Groups"  
-      url="/eg/staff/admin/server/legacy/permission/grp_tree"></eg-link-table-link>
+      routerLink="/staff/admin/server/permission/grp_tree"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Permissions"  
       routerLink="/staff/admin/server/permission/perm_list"></eg-link-table-link>
     <!-- Probably should move this to local admin once it's migrated -->
index cbbadd3..cd628a3 100644 (file)
@@ -1,18 +1,26 @@
 import {NgModule} from '@angular/core';
 import {TreeModule} from '@eg/share/tree/tree.module';
-import {StaffCommonModule} from '@eg/staff/common.module';
 import {AdminServerRoutingModule} from './routing.module';
 import {AdminCommonModule} from '@eg/staff/admin/common.module';
 import {AdminServerSplashComponent} from './admin-server-splash.component';
 import {OrgUnitTypeComponent} from './org-unit-type.component';
 import {PrintTemplateComponent} from './print-template.component';
 import {SampleDataService} from '@eg/share/util/sample-data.service';
+import {PermGroupTreeComponent} from './perm-group-tree.component';
+import {PermGroupMapDialogComponent} from './perm-group-map-dialog.component';
+
+/* As it stands, all components defined under admin/server are
+imported / declared in the admin/server base module.  This could
+cause the module to baloon in size.  Consider moving non-auto-
+generated UI's into lazy-loadable sub-mobules. */
 
 @NgModule({
   declarations: [
       AdminServerSplashComponent,
       OrgUnitTypeComponent,
-      PrintTemplateComponent
+      PrintTemplateComponent,
+      PermGroupTreeComponent,
+      PermGroupMapDialogComponent
   ],
   imports: [
     AdminCommonModule,
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-map-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-map-dialog.component.html
new file mode 100644 (file)
index 0000000..1c2422f
--- /dev/null
@@ -0,0 +1,44 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Add New Permission Group Mapping</h4>
+    <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">
+    <div class="row">
+      <div class="col-lg-5" i18n>Permission Group</div>
+      <div class="col-lg-7">{{permGroup.name()}}</div>
+    </div>
+    <div class="row mt-1 pt-1">
+      <div class="col-lg-5" i18n>New Permission</div>
+      <div class="col-lg-7">
+        <eg-combobox [asyncDataSource]="permEntries"
+          (onChange)="perm = $event ? $event.id : null">
+        </eg-combobox>
+      </div>
+    </div>
+    <div class="row mt-1 pt-1">
+      <div class="col-lg-5" i18n>Depth</div>
+      <div class="col-lg-7">
+        <select [(ngModel)]="depth" class="p-1">
+          <option *ngFor="let d of orgDepths" value="{{d}}">{{d}}</option>
+        </select>
+      </div>
+    </div>
+    <div class="row mt-1 pt-1">
+      <div class="col-lg-5" i18n>Grantable</div>
+      <div class="col-lg-7">
+        <input type="checkbox" [(ngModel)]="grantable"/>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success" 
+      (click)="create()" i18n>Create</button>
+    <button type="button" class="btn btn-warning" 
+      (click)="close()" i18n>Cancel</button>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-map-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-map-dialog.component.ts
new file mode 100644 (file)
index 0000000..2d472d7
--- /dev/null
@@ -0,0 +1,109 @@
+import {Component, Input, ViewChild, TemplateRef, OnInit} from '@angular/core';
+import {Observable, from, empty, throwError} from 'rxjs';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+@Component({
+  selector: 'eg-perm-group-map-dialog',
+  templateUrl: './perm-group-map-dialog.component.html'
+})
+
+/**
+ * Ask the user which part is the lead part then merge others parts in.
+ */
+export class PermGroupMapDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() permGroup: IdlObject;
+
+    @Input() permissions: IdlObject[];
+
+    // List of grp-perm-map objects that relate to the selected permission
+    // group or are linked to a parent group.
+    @Input() permMaps: IdlObject[];
+
+    @Input() orgDepths: number[];
+
+    // Note we have all of the permissions on hand, but rendering the
+    // full list of permissions can caus sluggishness.  Render async instead.
+    permEntries: (term: string) => Observable<ComboboxEntry>;
+
+    // Permissions the user may apply to the current group.
+    trimmedPerms: IdlObject[];
+
+    depth: number;
+    grantable: boolean;
+    perm: number;
+
+    constructor(
+        private idl: IdlService,
+        private pcrud: PcrudService,
+        private modal: NgbModal) {
+        super(modal);
+    }
+
+    ngOnInit() {
+        this.depth = 0;
+        this.grantable = false;
+
+        this.permissions = this.permissions
+            .sort((a, b) => a.code() < b.code() ? -1 : 1);
+
+        this.onOpen$.subscribe(() => this.trimPermissions());
+
+
+        this.permEntries = (term: string) => {
+            if (term === null || term === undefined) { return empty(); }
+            term = ('' + term).toLowerCase();
+
+            // Find entries whose code or description match the search term
+
+            const entries: ComboboxEntry[] =  [];
+            this.trimmedPerms.forEach(p => {
+                if (p.code().toLowerCase().includes(term) ||
+                    p.description().toLowerCase().includes(term)) {
+                    entries.push({id: p.id(), label: p.code()});
+                }
+            });
+
+            return from(entries);
+        };
+    }
+
+    trimPermissions() {
+        this.trimmedPerms = [];
+
+        this.permissions.forEach(p => {
+
+            // Prevent duplicate permissions, for-loop for early exit.
+            for (let idx = 0; idx < this.permMaps.length; idx++) {
+                const map = this.permMaps[idx];
+                if (map.perm().id() === p.id() &&
+                    map.grp().id() === this.permGroup.id()) {
+                    return;
+                }
+            }
+
+            this.trimmedPerms.push(p);
+        });
+    }
+
+    create() {
+        const map = this.idl.create('pgpm');
+
+        map.grp(this.permGroup.id());
+        map.perm(this.perm);
+        map.grantable(this.grantable ? 't' : 'f');
+        map.depth(this.depth);
+
+        this.pcrud.create(map).subscribe(
+            newMap => this.close(newMap),
+            err => throwError(err)
+        );
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-tree.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-tree.component.html
new file mode 100644 (file)
index 0000000..8a68159
--- /dev/null
@@ -0,0 +1,205 @@
+<eg-staff-banner bannerText="Permission Group Configuration" i18n-bannerText>
+</eg-staff-banner>
+
+<eg-string #createString i18n-text text="Permission Group Mapping Added">
+</eg-string>
+<eg-string #successString i18n-text text="Permission Group Update Succeeded">
+</eg-string>
+<eg-string #errorString i18n-text text="Permission Group Update Failed">
+</eg-string>
+
+<eg-string #createMapString i18n-text text="Permission Group Mapping Added">
+</eg-string>
+<eg-string #successMapString i18n-text text="Permission Group Mapping Update Succeeded">
+  </eg-string>
+<eg-string #errorMapString i18n-text text="Permission Group Mapping Update Failed">
+</eg-string>
+
+<eg-fm-record-editor #editDialog idlClass="pgt" readonlyFields="parent"
+   [fieldOptions]="fmEditorOptions()">
+</eg-fm-record-editor>
+
+<eg-confirm-dialog #delConfirm                                                 
+  i18n-dialogTitle i18n-dialogBody                                             
+  dialogTitle="Confirm Delete"                                                 
+  dialogBody="Delete Permission Group {{selected ? selected.label : ''}}?">    
+</eg-confirm-dialog>
+
+
+<eg-perm-group-map-dialog #addMappingDialog 
+  [permGroup]="selected ? selected.callerData : null"
+  [orgDepths]="orgDepths"
+  [permMaps]="groupPermMaps()" [permissions]="permissions">
+</eg-perm-group-map-dialog>
+
+<div class="row">
+  <div class="col-lg-3">
+    <h3 i18n>Permission Groups</h3>
+    <eg-tree [tree]="tree" (nodeClicked)="nodeClicked($event)"></eg-tree>
+  </div>
+  <div class="col-lg-9">
+    <h3 i18n class="mb-3">Selected Permission Group</h3>
+    <ng-container *ngIf="!selected">
+      <div class="alert alert-info font-italic" i18n>
+        Select a permission group from the tree on the left.
+      </div>
+      <ng-container *ngIf="loading">
+          <div class="row">
+            <div class="col-lg-6">
+              <eg-progress-inline></eg-progress-inline>
+            </div>
+          </div>
+        </ng-container>
+    </ng-container>
+    <div *ngIf="selected" class="common-form striped-even">
+      <ngb-tabset>
+        <ngb-tab title="Group Details" i18n-title id="detail">
+          <ng-template ngbTabContent>
+            <div class="row">
+              <div class="col-lg-3">
+                <label i18n>Actions for Selected: </label>
+              </div>
+              <div class="col-lg-9">
+                <button class="btn btn-info mr-2" (click)="edit()" i18n>Edit</button>
+                <button class="btn btn-info mr-2" (click)="addChild()" i18n>Add Child</button>
+                <button class="btn btn-warning mr-2" (click)="remove()" i18n>Delete</button>
+              </div>
+            </div>
+            <div class="row">
+              <div class="col-lg-4">
+                <label i18n>Name: </label>
+              </div>
+              <div class="col-lg-8 font-weight-bold">
+                {{selected.callerData.name()}}
+              </div>
+            </div>
+            <div class="row">
+              <div class="col-lg-4">
+                <label i18n>Description: </label>
+              </div>
+              <div class="col-lg-8 font-weight-bold">
+                {{selected.callerData.description()}}
+              </div>
+            </div>
+            <div class="row">
+              <div class="col-lg-4">
+                <label i18n>User Expiration Interval: </label>
+              </div>
+              <div class="col-lg-8 font-weight-bold">
+                {{selected.callerData.perm_interval()}}
+              </div>
+            </div>
+            <div class="row">
+              <div class="col-lg-4">
+                <label i18n>Application Permission: </label>
+              </div>
+              <div class="col-lg-8 font-weight-bold">
+                {{selected.callerData.application_perm()}}
+              </div>
+            </div>
+            <div class="row">
+              <div class="col-lg-4">
+                <label i18n>Hold Priority: </label>
+              </div>
+              <div class="col-lg-8 font-weight-bold">
+                {{selected.callerData.hold_priority()}}
+              </div>
+            </div>
+            <div class="row">
+              <div class="col-lg-4">
+                <label i18n>User Group?: </label>
+              </div>
+              <div class="col-lg-8 font-weight-bold">
+                <!-- TODO: replace with <eg-bool/> when merged -->
+                {{selected.callerData.usergroup() == 't'}}
+              </div>
+            </div>
+          </ng-template>
+        </ngb-tab>
+        <ngb-tab title="Group Permissions" i18n-title id="perm">
+          <ng-template ngbTabContent>
+
+            <div class="row d-flex m-2 mb-3">
+              <button class="btn btn-success" (click)="applyChanges()" i18n
+                [disabled]='!changesPending()'>
+                Apply Changes
+              </button>
+              <button class="btn btn-info ml-3" (click)="openAddDialog()" i18n>
+                Add New Mapping
+              </button>
+              <div class="col-lg-3">
+                <input class="form-control ml-2" [(ngModel)]="filterText"
+                  i18n-placeholder placeholder="Filter..."/>
+              </div>
+              <button class="btn btn-outline-secondary ml-1" 
+                (click)="filterText=''" i18n>
+                Clear
+              </button>
+            </div>
+            
+            <div class="row font-weight-bold">
+              <div class="col-lg-5" i18n>Permissions</div>
+              <div class="col-lg-4" i18n>Group</div>
+              <div class="col-lg-1" i18n>Depth</div>
+              <div class="col-lg-1" i18n>Grantable?</div>
+              <div class="col-lg-1" i18n>Delete?</div>
+            </div>
+
+            <div class="row" *ngFor="let map of groupPermMaps()"
+                [ngClass]="{'bg-danger': map.isdeleted()}">
+              <div class="col-lg-5">
+                <span title="{{map.perm().description()}}">{{map.perm().code()}}</span>
+              </div>
+              <ng-container *ngIf="permIsInherited(map); else nativeMap">
+                <div class="col-lg-4">
+                  <a href="javascript:;" (click)="selectGroup(map.grp().id())">
+                    {{map.grp().name()}}
+                  </a>
+                </div>
+                <div class="col-lg-1 text-center">{{map.depth()}}</div>
+                <!-- TODO migrate to <eg-bool/> once merged-->
+                <div class="col-lg-1 d-flex flex-column justify-content-center">
+                  <div class="d-flex justify-content-center p-3 rounded border border-secondary">
+                    <eg-bool [value]="map.grantable() == 't'"></eg-bool>
+                  </div>
+                </div>
+                <div class="col-lg-1"> </div>
+              </ng-container>
+              <ng-template #nativeMap>
+                <div class="col-lg-4">{{map.grp().name()}}</div>
+                <div class="col-lg-1">
+                  <select [ngModel]="map.depth()" class="p-1"
+                    (ngModelChange)="map.depth($event); map.ischanged(true)">
+                    <option *ngFor="let d of orgDepths" value="{{d}}">{{d}}</option>
+                  </select>
+                </div>
+                <div class="col-lg-1 d-flex flex-column justify-content-center">
+                  <div class="d-flex justify-content-center p-3 rounded border border-info">                
+                    <input type="checkbox" class="align-middle"
+                      i18n-title title="Grantable?"
+                      [ngModel]="map.grantable() == 't'"
+                      (ngModelChange)="map.grantable($event ? 't' : 'f'); map.ischanged(true)"/>
+                  </div>
+                </div>
+                <div class="col-lg-1 d-flex flex-column justify-content-center">
+                  <div class="d-flex justify-content-center p-3 rounded border border-danger">
+                    <input type="checkbox" class="align-middle"
+                      i18n-title title="Delete Mapping"
+                      [ngModel]="map.isdeleted()"
+                      (ngModelChange)="map.isdeleted($event)"/>
+                  </div>
+                </div>
+              </ng-template>
+            </div>
+            <div class="row d-flex m-2 mb-3">
+              <button class="btn btn-success" (click)="applyChanges()" i18n
+                [disabled]='!changesPending()'>
+                Apply Changes
+              </button>
+            </div>
+          </ng-template>
+        </ngb-tab>
+      </ngb-tabset>
+    </div>
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-tree.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-tree.component.ts
new file mode 100644 (file)
index 0000000..bd3aab2
--- /dev/null
@@ -0,0 +1,338 @@
+import {Component, ViewChild, OnInit} from '@angular/core';
+import {map} from 'rxjs/operators';
+import {Tree, TreeNode} from '@eg/share/tree/tree';
+import {IdlService, IdlObject} 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 {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {FmRecordEditorComponent, FmFieldOptions} from '@eg/share/fm-editor/fm-editor.component';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {PermGroupMapDialogComponent} from './perm-group-map-dialog.component';
+
+/** Manage permission groups and group permissions */
+
+@Component({
+    templateUrl: './perm-group-tree.component.html'
+})
+
+export class PermGroupTreeComponent implements OnInit {
+
+    tree: Tree;
+    selected: TreeNode;
+    permissions: IdlObject[];
+    permIdMap: {[id: number]: IdlObject};
+    permEntries: ComboboxEntry[];
+    permMaps: IdlObject[];
+    orgDepths: number[];
+    filterText: string;
+
+    // Have to fetch quite a bit of data for this UI.
+    loading: boolean;
+
+    @ViewChild('editDialog') editDialog: FmRecordEditorComponent;
+    @ViewChild('delConfirm') delConfirm: ConfirmDialogComponent;
+    @ViewChild('successString') successString: StringComponent;
+    @ViewChild('createString') createString: StringComponent;
+    @ViewChild('errorString') errorString: StringComponent;
+    @ViewChild('successMapString') successMapString: StringComponent;
+    @ViewChild('createMapString') createMapString: StringComponent;
+    @ViewChild('errorMapString') errorMapString: StringComponent;
+    @ViewChild('addMappingDialog') addMappingDialog: PermGroupMapDialogComponent;
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private toast: ToastService
+    ) {
+        this.permissions = [];
+        this.permEntries = [];
+        this.permMaps = [];
+        this.permIdMap = {};
+    }
+
+
+    async ngOnInit() {
+        this.loading = true;
+        await this.loadPgtTree();
+        await this.loadPermissions();
+        await this.loadPermMaps();
+        this.setOrgDepths();
+        this.loading = false;
+        return Promise.resolve();
+    }
+
+    setOrgDepths() {
+        const depths = this.org.typeList().map(t => Number(t.depth()));
+        const depths2 = [];
+        depths.forEach(d => {
+            if (!depths2.includes(d)) {
+                depths2.push(d);
+            }
+        });
+        this.orgDepths = depths2.sort();
+    }
+
+    groupPermMaps(): IdlObject[] {
+        if (!this.selected) { return []; }
+
+        let maps = this.inheritedPermissions();
+        maps = maps.concat(
+            this.permMaps.filter(m => +m.grp().id() === +this.selected.id));
+
+        maps = this.applyFilter(maps);
+
+        return maps.sort((m1, m2) =>
+            m1.perm().code() < m2.perm().code() ? -1 : 1);
+    }
+
+    // Chop the filter text into separate words and return true if all
+    // of the words appear somewhere in the combined permission code
+    // plus description text.
+    applyFilter(maps: IdlObject[]) {
+        if (!this.filterText) { return maps; }
+        const parts = this.filterText.toLowerCase().split(' ');
+
+        maps = maps.filter(m => {
+            const target = m.perm().code().toLowerCase()
+                + ' ' + m.perm().description().toLowerCase();
+
+            for (let i = 0; i < parts.length; i++) {
+                const part = parts[i];
+                if (part && target.indexOf(part) === -1) {
+                    return false;
+                }
+            }
+
+            return true;
+        });
+
+        return maps;
+    }
+
+    async loadPgtTree(): Promise<any> {
+
+        return this.pcrud.search('pgt', {parent: null},
+            {flesh: -1, flesh_fields: {pgt: ['children']}}
+        ).pipe(map(pgtTree => this.ingestPgtTree(pgtTree))).toPromise();
+    }
+
+    async loadPermissions(): Promise<any> {
+        // ComboboxEntry's for perms uses code() for id instead of
+        // the database ID, because the application_perm field on
+        // "pgt" is text instead of a link.  So the value it expects
+        // is the code, not the ID.
+        return this.pcrud.retrieveAll('ppl', {order_by: {ppl: ['name']}})
+        .pipe(map(perm => {
+            this.permissions.push(perm);
+            this.permEntries.push({id: perm.code(), label: perm.code()});
+            this.permissions.forEach(p => this.permIdMap[+p.id()] = p);
+        })).toPromise();
+    }
+
+    async loadPermMaps(): Promise<any> {
+        this.permMaps = [];
+        return this.pcrud.retrieveAll('pgpm', {},
+            {fleshSelectors: true, authoritative: true})
+        .pipe(map((m => this.permMaps.push(m)))).toPromise();
+    }
+
+    fmEditorOptions(): {[fieldName: string]: FmFieldOptions} {
+        return {
+            application_perm: {
+                customValues: this.permEntries
+            }
+        };
+    }
+
+    // Translate the org unt type tree into a structure EgTree can use.
+    ingestPgtTree(pgtTree: IdlObject) {
+
+        const handleNode = (pgtNode: IdlObject): TreeNode => {
+            if (!pgtNode) { return; }
+
+            const treeNode = new TreeNode({
+                id: pgtNode.id(),
+                label: pgtNode.name(),
+                callerData: pgtNode
+            });
+
+            pgtNode.children()
+                .sort((c1, c2) => c1.name() < c2.name() ? -1 : 1)
+                .forEach(childNode =>
+                treeNode.children.push(handleNode(childNode))
+            );
+
+            return treeNode;
+        };
+
+        const rootNode = handleNode(pgtTree);
+        this.tree = new Tree(rootNode);
+    }
+
+    groupById(id: number): IdlObject {
+        return this.tree.findNode(id).callerData;
+    }
+
+    permById(id: number): IdlObject {
+        return this.permIdMap[id];
+    }
+
+    // Returns true if the perm map belongs to an ancestore of the
+    // currently selected group.
+    permIsInherited(m: IdlObject): boolean {
+        // We know the provided map came from this.groupPermMaps() which
+        // only returns maps for the selected group plus parent groups.
+        return m.grp().id() !== this.selected.callerData.id();
+    }
+
+    // List of perm maps that owned by perm groups which are ancestors
+    // of the selected group
+    inheritedPermissions(): IdlObject[] {
+        let maps: IdlObject[] = [];
+
+        let treeNode = this.tree.findNode(this.selected.callerData.parent());
+        while (treeNode) {
+            maps = maps.concat(
+                this.permMaps.filter(m => +m.grp().id() === +treeNode.id));
+            treeNode = this.tree.findNode(treeNode.callerData.parent());
+        }
+
+        return maps;
+    }
+
+
+    nodeClicked($event: any) {
+        this.selected = $event;
+
+        // When the user selects a different perm tree node,
+        // reset the edit state for our perm maps.
+
+        this.permMaps.forEach(m => {
+            m.isnew(false);
+            m.ischanged(false);
+            m.isdeleted(false);
+        });
+    }
+
+    edit() {
+        this.editDialog.mode = 'update';
+        this.editDialog.setRecord(this.selected.callerData);
+
+        this.editDialog.open({size: 'lg'}).subscribe(
+            success => {
+                this.successString.current().then(str => this.toast.success(str));
+            },
+            failed => {
+                this.errorString.current()
+                    .then(str => this.toast.danger(str));
+            }
+        );
+    }
+
+    remove() {
+        this.delConfirm.open().subscribe(
+            confirmed => {
+                if (!confirmed) { return; }
+
+                this.pcrud.remove(this.selected.callerData)
+                .subscribe(
+                    ok2 => {},
+                    err => {
+                        this.errorString.current()
+                          .then(str => this.toast.danger(str));
+                    },
+                    ()  => {
+                        // Avoid updating until we know the entire
+                        // pcrud action/transaction completed.
+                        this.tree.removeNode(this.selected);
+                        this.selected = null;
+                        this.successString.current().then(str => this.toast.success(str));
+                    }
+                );
+            }
+        );
+    }
+
+    addChild() {
+        const parentTreeNode = this.selected;
+        const parentType = parentTreeNode.callerData;
+
+        const newType = this.idl.create('pgt');
+        newType.parent(parentType.id());
+
+        this.editDialog.setRecord(newType);
+        this.editDialog.mode = 'create';
+
+        this.editDialog.open({size: 'lg'}).subscribe(
+            result => { // pgt object
+
+                // Add our new node to the tree
+                const newNode = new TreeNode({
+                    id: result.id(),
+                    label: result.name(),
+                    callerData: result
+                });
+                parentTreeNode.children.push(newNode);
+                this.createString.current().then(str => this.toast.success(str));
+            },
+            failed => {
+                this.errorString.current()
+                    .then(str => this.toast.danger(str));
+            }
+        );
+    }
+
+    changesPending(): boolean {
+        return this.modifiedMaps().length > 0;
+    }
+
+    modifiedMaps(): IdlObject[] {
+        return this.permMaps.filter(
+            m => m.isnew() || m.ischanged() || m.isdeleted()
+        );
+    }
+
+    applyChanges() {
+
+        const maps: IdlObject[] = this.modifiedMaps()
+            .map(m => this.idl.clone(m)); // Clone for de-fleshing
+
+        maps.forEach(m => {
+            m.grp(m.grp().id());
+            m.perm(m.perm().id());
+        });
+
+        this.pcrud.autoApply(maps).subscribe(
+            one => console.debug('Modified one mapping: ', one),
+            err => {
+                console.error(err);
+                this.errorMapString.current().then(msg => this.toast.danger(msg));
+            },
+            ()  => {
+                this.successMapString.current().then(msg => this.toast.success(msg));
+                this.loadPermMaps();
+            }
+        );
+    }
+
+    openAddDialog() {
+        this.addMappingDialog.open().subscribe(
+            modified => {
+                this.createMapString.current().then(msg => this.toast.success(msg));
+                this.loadPermMaps();
+            }
+        );
+    }
+
+    selectGroup(id: number) {
+        const node: TreeNode = this.tree.findNode(id);
+        this.tree.selectNode(node);
+        this.nodeClicked(node);
+    }
+}
+
index 4f9b9ff..2022878 100644 (file)
@@ -4,6 +4,7 @@ import {AdminServerSplashComponent} from './admin-server-splash.component';
 import {BasicAdminPageComponent} from '@eg/staff/admin/basic-admin-page.component';
 import {OrgUnitTypeComponent} from './org-unit-type.component';
 import {PrintTemplateComponent} from './print-template.component';
+import {PermGroupTreeComponent} from './perm-group-tree.component';
 
 const routes: Routes = [{
     path: 'splash',
@@ -15,6 +16,9 @@ const routes: Routes = [{
     path: 'config/print_template',
     component: PrintTemplateComponent
 }, {
+    path: 'permission/grp_tree',
+    component: PermGroupTreeComponent
+}, {
     path: ':schema/:table',
     component: BasicAdminPageComponent
 }];