From 7afc043013af6431b30c71359c5ce105ae42173e Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Fri, 5 Apr 2019 18:00:32 -0400 Subject: [PATCH] LP1823981 Angular Permission Group Tree Admin UI 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 Signed-off-by: Galen Charlton --- Open-ILS/src/eg2/src/app/core/org.service.ts | 11 + .../app/share/combobox/combobox.component.ts | 4 +- .../share/fm-editor/fm-editor.component.ts | 2 +- .../server/admin-server-splash.component.html | 2 +- .../staff/admin/server/admin-server.module.ts | 12 +- .../perm-group-map-dialog.component.html | 44 +++ .../server/perm-group-map-dialog.component.ts | 109 ++++++ .../server/perm-group-tree.component.html | 205 +++++++++++ .../admin/server/perm-group-tree.component.ts | 338 ++++++++++++++++++ .../app/staff/admin/server/routing.module.ts | 4 + 10 files changed, 725 insertions(+), 6 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-map-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-map-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-tree.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-tree.component.ts diff --git a/Open-ILS/src/eg2/src/app/core/org.service.ts b/Open-ILS/src/eg2/src/app/core/org.service.ts index c666957d2c..ba2b4e39f8 100644 --- a/Open-ILS/src/eg2/src/app/core/org.service.ts +++ b/Open-ILS/src/eg2/src/app/core/org.service.ts @@ -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. diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts index ce0dc3e2ae..85225faa41 100644 --- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts +++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts @@ -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 = diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts index dcb085bcd7..730084adc9 100644 --- a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts +++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts @@ -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 diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html index d3ed6ebf41..a6e3d2b2a9 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html @@ -80,7 +80,7 @@ + routerLink="/staff/admin/server/permission/grp_tree"> diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts index cbbadd38d6..cd628a39c2 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts @@ -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 index 0000000000..1c2422f3e5 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-map-dialog.component.html @@ -0,0 +1,44 @@ + + + + + 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 index 0000000000..2d472d70f7 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-map-dialog.component.ts @@ -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; + + // 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 index 0000000000..8a68159a9c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-tree.component.html @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

Permission Groups

+ +
+
+

Selected Permission Group

+ +
+ Select a permission group from the tree on the left. +
+ +
+
+ +
+
+
+
+
+ + + +
+
+ +
+
+ + + +
+
+
+
+ +
+
+ {{selected.callerData.name()}} +
+
+
+
+ +
+
+ {{selected.callerData.description()}} +
+
+
+
+ +
+
+ {{selected.callerData.perm_interval()}} +
+
+
+
+ +
+
+ {{selected.callerData.application_perm()}} +
+
+
+
+ +
+
+ {{selected.callerData.hold_priority()}} +
+
+
+
+ +
+
+ + {{selected.callerData.usergroup() == 't'}} +
+
+
+
+ + + +
+ + +
+ +
+ +
+ +
+
Permissions
+
Group
+
Depth
+
Grantable?
+
Delete?
+
+ +
+
+ {{map.perm().code()}} +
+ + +
{{map.depth()}}
+ +
+
+ +
+
+
+
+ +
{{map.grp().name()}}
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
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 index 0000000000..bd3aab29b3 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/perm-group-tree.component.ts @@ -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 { + + return this.pcrud.search('pgt', {parent: null}, + {flesh: -1, flesh_fields: {pgt: ['children']}} + ).pipe(map(pgtTree => this.ingestPgtTree(pgtTree))).toPromise(); + } + + async loadPermissions(): Promise { + // 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 { + 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); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts index 4f9b9ff366..20228783ef 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts @@ -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', @@ -14,6 +15,9 @@ const routes: Routes = [{ }, { path: 'config/print_template', component: PrintTemplateComponent +}, { + path: 'permission/grp_tree', + component: PermGroupTreeComponent }, { path: ':schema/:table', component: BasicAdminPageComponent -- 2.43.2