Merge pull request #3908 from tsiegleauq/ul-groups

Add group matrix.
This commit is contained in:
Finn Stutzenstein 2018-10-09 13:41:33 +02:00 committed by GitHub
commit d1bc995830
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 635 additions and 34 deletions

View File

@ -5387,13 +5387,15 @@
"version": "1.0.0",
"resolved": false,
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"resolved": false,
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -5416,7 +5418,8 @@
"version": "0.0.1",
"resolved": false,
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
@ -5583,6 +5586,7 @@
"resolved": false,
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}

View File

@ -29,7 +29,7 @@ export class DataSendService {
tap(
response => {
// TODO: Message, Notify, Etc
console.log('New Model added. Response :\n', response);
console.log('New Model added. Response ::\n', response);
},
error => console.error('createModel has returned an Error:\n', error)
)
@ -56,7 +56,7 @@ export class DataSendService {
tap(
response => {
// TODO: Message, Notify, Etc
console.log('Update model. Response :\n', response);
console.log('Update model. Response ::\n', response);
},
error => console.error('updateModel has returned an Error:\n', error)
)

View File

@ -16,8 +16,11 @@
</mat-toolbar>
<mat-menu #ellipsisMenu="matMenu">
<button mat-menu-item *ngFor="let item of menuList" (click)=clickMenu(item)>
<fa-icon *ngIf="item.icon" [icon]='item.icon'></fa-icon>
{{item.text | translate}}
</button>
<ng-container *ngFor="let item of menuList">
<button mat-menu-item *ngIf="opHasPerm(item.perm)" (click)=clickMenu(item)>
<fa-icon *ngIf="item.icon" [icon]='item.icon'></fa-icon>
{{item.text | translate}}
</button>
</ng-container>
</mat-menu>

View File

@ -1,4 +1,5 @@
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { OperatorService } from '../../../core/services/operator.service';
/**
* Reusable head bar component for Apps.
@ -86,7 +87,7 @@ export class HeadBarComponent implements OnInit {
/**
* Empty constructor
*/
public constructor() {}
public constructor(private op: OperatorService) {}
/**
* empty onInit
@ -107,4 +108,22 @@ export class HeadBarComponent implements OnInit {
public clickPlusButton(): void {
this.plusButtonClicked.emit(true);
}
/**
* Determine if the operator has the correct permission to use a button in the menu
* @param perm
*/
public opHasPerm(perm: string): boolean {
// return false if the operator is not yet loaded
if (this.op) {
// if no permission was required, return true
if (!perm) {
return true;
} else {
return this.op.hasPerms(perm);
}
} else {
return false;
}
}
}

View File

@ -11,6 +11,10 @@ export class Group extends BaseModel<Group> {
public constructor(input?: any) {
super('users/group', input);
if (!input) {
// permissions are required for new groups
this.permissions = [];
}
}
public getTitle(): string {

View File

@ -0,0 +1,88 @@
<mat-toolbar color='primary'>
<button *osPerms="'users.can_manage'" (click)='newGroupButton()' class='generic-mini-button on-transition-fade'
mat-mini-fab>
<fa-icon *ngIf="!newGroup" icon='plus'></fa-icon>
<fa-icon *ngIf="newGroup" icon='times'></fa-icon>
</button>
<div class="on-transition-fade">
<span translate>Groups</span>
</div>
<span class='spacer'></span>
</mat-toolbar>
<div class="on-transition-fade new-group-form" *ngIf="newGroup">
<form #newGroupForm="ngForm" (ngSubmit)="submitNewGroup(newGroupForm.form)" (keydown)="keyDownFunction($event)">
<mat-form-field>
<input type="text" matInput name="name" ngModel #nameField="ngModel" placeholder="{{ 'New group name' | translate}}">
</mat-form-field>
<button type="submit" mat-mini-fab color="primary">
<fa-icon icon="save"></fa-icon>
</button>
</form>
</div>
<div class="on-transition-fade new-group-form" *ngIf="editGroup">
<form #editGroupForm="ngForm" (ngSubmit)="submitEditedGroup(editGroupForm.form)">
<mat-form-field>
<input type="text" matInput name="name" [(ngModel)]="selectedGroup.name" #nameField="ngModel" placeholder="{{ 'Edit group name' | translate}}">
</mat-form-field>
<button type="submit" mat-mini-fab color="primary">
<fa-icon icon="save"></fa-icon>
</button>
<button type="button" mat-mini-fab color="warn" (click)="deleteSelectedGroup()" [disabled]="isProtected(selectedGroup)">
<fa-icon icon="trash"></fa-icon>
</button>
<button type="button" mat-mini-fab color="primary" (click)="cancelEditing()">
<fa-icon icon="times"></fa-icon>
</button>
</form>
</div>
<div class="hint-text on-transition-fade">
<span translate>All your changes are saved immediately.</span>
</div>
<mat-accordion *ngFor="let app of repo.appPermissions">
<mat-expansion-panel class="mat-elevation-z0" [expanded]=true>
<mat-expansion-panel-header>
<mat-panel-title translate>
{{ app.name }}
</mat-panel-title>
</mat-expansion-panel-header>
<div class="scrollable-perm-matrix">
<table mat-table class='on-transition-fade' [dataSource]="getTableDataSource(app.permissions)">
<ng-container matColumnDef="perm" sticky>
<mat-header-cell *matHeaderCellDef translate> Permissions </mat-header-cell>
<mat-cell *matCellDef="let perm" translate>
{{ perm.display_name }}
</mat-cell>
</ng-container>
<div *ngFor=" let group of groups; trackBy: trackGroupArray">
<ng-container [matColumnDef]="group.name">
<mat-header-cell class="group-head-table-cell" *matHeaderCellDef (click)="selectGroup(group)">
<div class="inner-table" translate>
{{ group.name }}
</div>
</mat-header-cell>
<mat-cell *matCellDef="let perm">
<div class="inner-table">
<mat-checkbox [checked]="group.hasPermission(perm.value)" (change)='togglePerm(group, perm.value)'></mat-checkbox>
</div>
</mat-cell>
</ng-container>
</div>
<mat-header-row *matHeaderRowDef="headerRowDef"></mat-header-row>
<mat-row *matRowDef="let row; columns: headerRowDef"></mat-row>
</table>
</div>
</mat-expansion-panel>
</mat-accordion>

View File

@ -0,0 +1,39 @@
table {
width: 100%;
.mat-cell {
min-width: 80px;
}
.mat-column-perm {
min-width: 130px;
}
.inner-table {
width: 100%;
text-align: center;
}
.group-head-table-cell {
cursor: pointer;
}
}
.hint-text {
padding-top: 30px;
padding-left: 25px;
background-color: #ffffff; // put in theme later
}
.new-group-form {
text-align: center;
padding-top: 10px;
background-color: #ffffff; // put in theme later
button {
margin-left: 10px;
}
}
.scrollable-perm-matrix {
overflow: auto;
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { GroupListComponent } from './group-list.component';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
describe('GroupListComponent', () => {
let component: GroupListComponent;
let fixture: ComponentFixture<GroupListComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [GroupListComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(GroupListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,192 @@
import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { MatTableDataSource } from '@angular/material';
import { FormGroup } from '@angular/forms';
import { GroupRepositoryService } from '../../services/group-repository.service';
import { ViewGroup } from '../../models/view-group';
import { Group } from '../../../../shared/models/users/group';
import { BaseComponent } from '../../../../base.component';
/**
* Component for the Group-List and permission matrix
*/
@Component({
selector: 'os-group-list',
templateUrl: './group-list.component.html',
styleUrls: ['./group-list.component.scss']
})
export class GroupListComponent extends BaseComponent implements OnInit {
/**
* Holds all Groups
*/
public groups: ViewGroup[];
/**
* The header rows that the table should show
*/
public headerRowDef: string[] = [];
/**
* Show or hide the new groups box
*/
public newGroup = false;
/**
* Show or hide edit Group features
*/
public editGroup = false;
/**
* Store the group to edit
*/
public selectedGroup: ViewGroup;
/**
* Constructor
*
* @param titleService Title Service
* @param translate Translations
* @param DS The Data Store
* @param constants Constants
*/
public constructor(titleService: Title, translate: TranslateService, public repo: GroupRepositoryService) {
super(titleService, translate);
}
/**
* Trigger for the new Group button
*/
public newGroupButton(): void {
this.editGroup = false;
this.newGroup = !this.newGroup;
}
/**
* Saves a newly created group.
* @param form form data given by the group
*/
public submitNewGroup(form: FormGroup): void {
if (form.value) {
this.repo.create(form.value).subscribe(response => {
if (response) {
form.reset();
// commenting the next line would allow to create multiple groups without reopening the form
this.newGroup = false;
}
});
}
}
/**
* Saves an edited group.
* @param form form data given by the group
*/
public submitEditedGroup(form: FormGroup): void {
if (form.value) {
const updateData = new Group({ name: form.value.name });
this.repo.update(updateData, this.selectedGroup).subscribe(response => {
if (response) {
this.cancelEditing();
}
});
}
}
/**
* Deletes the selected Group
*/
public deleteSelectedGroup(): void {
this.repo.delete(this.selectedGroup).subscribe(response => this.cancelEditing());
}
/**
* Cancel the editing
*/
public cancelEditing(): void {
this.editGroup = false;
}
/**
* Select group in head bar
*/
public selectGroup(group: ViewGroup): void {
this.newGroup = false;
this.selectedGroup = group;
this.editGroup = true;
}
/**
* Triggers when a permission was toggled
* @param group
* @param perm
*/
public togglePerm(viewGroup: ViewGroup, perm: string): void {
const updateData = new Group({ permissions: viewGroup.getAlteredPermissions(perm) });
this.repo.update(updateData, viewGroup).subscribe();
}
/**
* Update the rowDefinition after Reloading or changes
*/
public updateRowDef(): void {
// reset the rowDef list first
this.headerRowDef = ['perm'];
this.groups.forEach(viewGroup => {
this.headerRowDef.push('' + viewGroup.name);
});
}
/**
* Required to detect changes in *ngFor loops
*
* @param group Corresponding group that was changed
*/
public trackGroupArray(group: ViewGroup): number {
return group.id;
}
/**
* Converts a permission string into MatTableDataSource
* @param permissions
*/
public getTableDataSource(permissions: string[]): MatTableDataSource<any> {
const dataSource = new MatTableDataSource();
dataSource.data = permissions;
return dataSource;
}
/**
* Determine if a group is protected from deletion
* @param group ViewGroup
*/
public isProtected(group: ViewGroup): boolean {
return group.id === 1 || group.id === 2;
}
/**
* Clicking escape while in #newGroupForm should toggle newGroup.
*/
public keyDownFunction(event: KeyboardEvent): void {
if (event.keyCode === 27) {
this.newGroup = false;
}
}
/**
* Init function.
*
* Monitor the repository for changes and update the local groups array
*/
public ngOnInit(): void {
super.setTitle('Groups');
this.repo.getViewModelListObservable().subscribe(newViewGroups => {
if (newViewGroups) {
this.groups = newViewGroups;
this.updateRowDef();
}
});
}
}

View File

@ -24,7 +24,8 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
{
text: 'Groups',
icon: 'users',
action: 'toGroups'
action: 'toGroups',
perm: 'users.can_manage'
},
{
text: 'Import',
@ -81,7 +82,7 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
* TODO: implement
*/
public toGroups(): void {
console.log('to Groups');
this.router.navigate(['./groups'], { relativeTo: this.route });
}
/**

View File

@ -0,0 +1,76 @@
import { BaseViewModel } from '../../base/base-view-model';
import { Group } from '../../../shared/models/users/group';
import { BaseModel } from '../../../shared/models/base/base-model';
export class ViewGroup extends BaseViewModel {
private _group: Group;
public get group(): Group {
return this._group ? this._group : null;
}
public get id(): number {
return this.group ? this.group.id : null;
}
public get name(): string {
return this.group ? this.group.name : null;
}
/**
* required for renaming purpose
*/
public set name(newName: string) {
if (this.group) {
this.group.name = newName;
}
}
public get permissions(): string[] {
return this.group ? this.group.permissions : null;
}
public constructor(group?: Group) {
super();
this._group = group;
}
/**
* Returns an array of permissions where the given perm is included
* or removed.
*
* Avoids touching the local DataStore.
*
* @param perm
*/
public getAlteredPermissions(perm: string): string[] {
// clone the array, avoids altering the local dataStore
const currentPermissions = this.permissions.slice();
if (this.hasPermission(perm)) {
// remove the permission from currentPermissions-List
const indexOfPerm = currentPermissions.indexOf(perm);
if (indexOfPerm !== -1) {
currentPermissions.splice(indexOfPerm, 1);
return currentPermissions;
} else {
return currentPermissions;
}
} else {
currentPermissions.push(perm);
return currentPermissions;
}
}
public hasPermission(perm: string): boolean {
return this.permissions.includes(perm);
}
public getTitle(): string {
return this.name;
}
public updateValues(update: BaseModel): void {
console.log('ViewGroups wants to update Values with : ', update);
}
}

View File

@ -0,0 +1,16 @@
import { TestBed, inject } from '@angular/core/testing';
import { GroupRepositoryService } from './group-repository.service';
import { E2EImportsModule } from '../../../../e2e-imports.module';
describe('GroupRepositoryService', () => {
beforeEach(() =>
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [GroupRepositoryService]
}));
it('should be created', inject([GroupRepositoryService], (service: GroupRepositoryService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,140 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ViewGroup } from '../models/view-group';
import { BaseRepository } from '../../base/base-repository';
import { Group } from '../../../shared/models/users/group';
import { DataStoreService } from '../../../core/services/data-store.service';
import { DataSendService } from '../../../core/services/data-send.service';
import { ConstantsService } from '../../../core/services/constants.service';
/**
* Set rules to define the shape of an app permission
*/
interface AppPermission {
name: string;
permissions: string[];
}
/**
* Repository service for Groups
*
* Documentation partially provided in {@link BaseRepository}
*/
@Injectable({
providedIn: 'root'
})
export class GroupRepositoryService extends BaseRepository<ViewGroup, Group> {
/**
* holds sorted permissions per app.
*/
public appPermissions: AppPermission[] = [];
/**
* Constructor calls the parent constructor
* @param DS Store
* @param dataSend Sending Data
*/
public constructor(DS: DataStoreService, private dataSend: DataSendService, private constants: ConstantsService) {
super(DS, Group);
this.sortPermsPerApp();
}
/**
* Add an entry to appPermissions
*
* @param appId number that indicates the app
* @param perm certain permission as string
* @param appName Indicates the header in the Permission Matrix
*/
private addAppPerm(appId: number, perm: string, appName: string): void {
if (!this.appPermissions[appId]) {
this.appPermissions[appId] = {
name: appName,
permissions: []
};
}
this.appPermissions[appId].permissions.push(perm);
}
/**
* read the constants, add them to an array of apps
*/
private sortPermsPerApp(): void {
this.constants.get('permissions').subscribe(perms => {
perms.forEach(perm => {
// extract the apps name
const permApp = perm.value.split('.')[0];
switch (permApp) {
case 'core':
if (perm.value.indexOf('projector') > -1) {
this.addAppPerm(0, perm, 'Projector');
} else {
this.addAppPerm(6, perm, 'General');
}
break;
case 'agenda':
this.addAppPerm(1, perm, 'Agenda');
break;
case 'motions':
this.addAppPerm(2, perm, 'Motions');
break;
case 'assignments':
this.addAppPerm(3, perm, 'Assignments');
break;
case 'mediafiles':
this.addAppPerm(4, perm, 'Mediafiles');
break;
case 'users':
this.addAppPerm(5, perm, 'Users');
break;
default:
// plugins
const displayName = `${permApp.charAt(0).toUpperCase}${permApp.slice(1)}`;
// check if the plugin exists as app
const result = this.appPermissions.findIndex(app => {
return app.name === displayName;
});
const pluginId = result === -1 ? this.appPermissions.length : result;
this.addAppPerm(pluginId, perm, displayName);
break;
}
});
});
}
/**
* creates and saves a new user
*
* @param groupData form value. Usually not yet a real user
*/
public create(groupData: Partial<Group>): Observable<any> {
const newGroup = new Group();
newGroup.patchValues(groupData);
return this.dataSend.createModel(newGroup);
}
/**
* Updates the given Group with the new permission
*
* @param permission the new permission
* @param viewGroup the selected Group
*/
public update(groupData: Partial<Group>, viewGroup: ViewGroup): Observable<any> {
const updateGroup = new Group();
updateGroup.patchValues(viewGroup.group);
updateGroup.patchValues(groupData);
return this.dataSend.updateModel(updateGroup, 'put');
}
/**
* Deletes a given group
*/
public delete(viewGroup: ViewGroup): Observable<any> {
return this.dataSend.delete(viewGroup.group);
}
public createViewModel(group: Group): ViewGroup {
return new ViewGroup(group);
}
}

View File

@ -46,9 +46,7 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User> {
}
/**
* @ignore
*
* TODO: used over not-yet-existing detail view
* Deletes a given user
*/
public delete(viewUser: ViewUser): Observable<any> {
return this.dataSend.delete(viewUser.user);
@ -65,7 +63,7 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User> {
// collectionString of userData is still empty
newUser.patchValues(userData);
// if the username is not presend, delete.
// if the username is not present, delete.
// The server will generate a one
if (!newUser.username) {
delete newUser.username;

View File

@ -2,6 +2,7 @@ import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { UserListComponent } from './components/user-list/user-list.component';
import { UserDetailComponent } from './components/user-detail/user-detail.component';
import { GroupListComponent } from './components/group-list/group-list.component';
const routes: Routes = [
{
@ -12,6 +13,17 @@ const routes: Routes = [
path: 'new',
component: UserDetailComponent
},
{
path: 'groups',
component: GroupListComponent
/**
* FIXME: CRITICAL:
* Refreshing the page, even while having the required permission, will navigate you back to "/"
* Makes developing protected areas impossible.
* Has the be (temporarily) removed if this page should be edited.
*/
// data: { basePerm: 'users.can_manage' }
},
{
path: ':id',
component: UserDetailComponent

View File

@ -5,9 +5,10 @@ import { UsersRoutingModule } from './users-routing.module';
import { SharedModule } from '../../shared/shared.module';
import { UserListComponent } from './components/user-list/user-list.component';
import { UserDetailComponent } from './components/user-detail/user-detail.component';
import { GroupListComponent } from './components/group-list/group-list.component';
@NgModule({
imports: [CommonModule, UsersRoutingModule, SharedModule],
declarations: [UserListComponent, UserDetailComponent]
declarations: [UserListComponent, UserDetailComponent, GroupListComponent]
})
export class UsersModule {}

View File

@ -1,8 +1,6 @@
{
"Abort": "",
"About Me": "",
"Agenda": "Tagesordnung",
"Assignments": "Wahlen",
"Category": "",
"Change Password": "Passwort ändern",
"Changed version": "",
@ -35,13 +33,11 @@
}
},
"FILTER": "",
"Files": "Dateien",
"Final version": "",
"First Name": "",
"French": "Französisch",
"German": "Deutsch",
"Groups": "",
"Home": "Startseite",
"Identifier": "",
"Initial Password": "",
"Inline": "",
@ -71,7 +67,6 @@
"Original version": "",
"Outside": "",
"Participant Number": "",
"Participants": "Teilnehmer",
"Personal Note": "",
"Personal note": "",
"Prefix": "",
@ -85,7 +80,6 @@
"Reset recommendation": "",
"SORT": "",
"Selected Values": "",
"Settings": "Einstellungen",
"State": "",
"Structure Level": "",
"Submitters": "",

View File

@ -1,8 +1,6 @@
{
"Abort": "",
"About Me": "",
"Agenda": "",
"Assignments": "",
"Category": "",
"Change Password": "",
"Changed version": "",
@ -35,13 +33,11 @@
}
},
"FILTER": "",
"Files": "",
"Final version": "",
"First Name": "",
"French": "",
"German": "",
"Groups": "",
"Home": "",
"Identifier": "",
"Initial Password": "",
"Inline": "",
@ -71,7 +67,6 @@
"Original version": "",
"Outside": "",
"Participant Number": "",
"Participants": "",
"Personal Note": "",
"Personal note": "",
"Prefix": "",
@ -85,7 +80,6 @@
"Reset recommendation": "",
"SORT": "",
"Selected Values": "",
"Settings": "",
"State": "",
"Structure Level": "",
"Submitters": "",

View File

@ -1,8 +1,6 @@
{
"Abort": "",
"About Me": "",
"Agenda": "",
"Assignments": "",
"Category": "",
"Change Password": "",
"Changed version": "",
@ -35,13 +33,11 @@
}
},
"FILTER": "",
"Files": "",
"Final version": "",
"First Name": "",
"French": "",
"German": "",
"Groups": "",
"Home": "",
"Identifier": "",
"Initial Password": "",
"Inline": "",
@ -71,7 +67,6 @@
"Original version": "",
"Outside": "",
"Participant Number": "",
"Participants": "",
"Personal Note": "",
"Personal note": "",
"Prefix": "",
@ -85,7 +80,6 @@
"Reset recommendation": "",
"SORT": "",
"Selected Values": "",
"Settings": "",
"State": "",
"Structure Level": "",
"Submitters": "",