diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 1e49adb65..012d17361 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -15,7 +15,12 @@ import { ConstantsService } from './core/services/constants.service'; }) export class AppComponent { /** - * Initialises the translation unit. + * Master-component of all apps. + * + * Inits the translation service, the operator, the login data and the constants. + * + * Handles the altering of Array.toString() + * * @param autoupdateService * @param notifyService * @param translate @@ -35,5 +40,36 @@ export class AppComponent { const browserLang = translate.getBrowserLang(); // try to use the browser language if it is available. If not, uses english. translate.use(translate.getLangs().includes(browserLang) ? browserLang : 'en'); + // change default JS functions + this.overloadArrayToString(); + } + + /** + * Function to alter the normal Array.toString - function + * + * Will add a whitespace after a comma and shorten the output to + * three strings. + * + * TODO: There might be a better place for overloading functions than app.component + * TODO: Overloading can be extended to more functions. + */ + private overloadArrayToString(): void { + Array.prototype.toString = function(): string { + let string = ''; + const iterations = Math.min(this.length, 3); + + for (let i = 0; i <= iterations; i++) { + if (i < iterations) { + string += this[i]; + } + + if (i < iterations - 1) { + string += ', '; + } else if (i === iterations && this.length > iterations) { + string += ', ...'; + } + } + return string; + }; } } diff --git a/client/src/app/core/services/data-send.service.ts b/client/src/app/core/services/data-send.service.ts index 0b0d3c3f5..b9f3596ae 100644 --- a/client/src/app/core/services/data-send.service.ts +++ b/client/src/app/core/services/data-send.service.ts @@ -21,31 +21,46 @@ export class DataSendService { public constructor(private http: HttpClient) {} /** - * Save motion in the server - * - * @return Observable from + * Sends a post request with the model to the server. + * Usually for new Models */ - public saveModel(model: BaseModel): Observable { - if (!model.id) { - return this.http.post('rest/' + model.collectionString + '/', model).pipe( - tap( - response => { - // TODO: Message, Notify, Etc - console.log('New Model added. Response : ', response); - }, - error => console.log('error. ', error) - ) - ); - } else { - return this.http.patch('rest/' + model.collectionString + '/' + model.id, model).pipe( - tap( - response => { - console.log('Update model. Response : ', response); - }, - error => console.log('error. ', error) - ) - ); + public createModel(model: BaseModel): Observable { + return this.http.post('rest/' + model.collectionString + '/', model).pipe( + tap( + response => { + // TODO: Message, Notify, Etc + console.log('New Model added. Response :\n', response); + }, + error => console.error('createModel has returned an Error:\n', error) + ) + ); + } + + /** + * Function to change a model on the server. + * + * @param model the base model that is meant to be changed + * @param method the required http method. might be put or patch + */ + public updateModel(model: BaseModel, method: 'put' | 'patch'): Observable { + const restPath = `rest/${model.collectionString}/${model.id}`; + let httpMethod; + + if (method === 'patch') { + httpMethod = this.http.patch(restPath, model); + } else if (method === 'put') { + httpMethod = this.http.put(restPath, model); } + + return httpMethod.pipe( + tap( + response => { + // TODO: Message, Notify, Etc + console.log('Update model. Response :\n', response); + }, + error => console.error('updateModel has returned an Error:\n', error) + ) + ); } /** diff --git a/client/src/app/core/services/operator.service.ts b/client/src/app/core/services/operator.service.ts index 9b545c4b4..ba81fb38c 100644 --- a/client/src/app/core/services/operator.service.ts +++ b/client/src/app/core/services/operator.service.ts @@ -121,11 +121,11 @@ export class OperatorService extends OpenSlidesComponent { /** * Checks, if the operator has at least one of the given permissions. - * @param permissions The permissions to check, if at least one matches. + * @param checkPerms The permissions to check, if at least one matches. */ - public hasPerms(...permissions: Permission[]): boolean { - return permissions.some(permisison => { - return this.permissions.includes(permisison); + public hasPerms(...checkPerms: Permission[]): boolean { + return checkPerms.some(permission => { + return this.permissions.includes(permission); }); } diff --git a/client/src/app/shared/directives/perms.directive.ts b/client/src/app/shared/directives/perms.directive.ts index 3f4f4f474..866b05785 100644 --- a/client/src/app/shared/directives/perms.directive.ts +++ b/client/src/app/shared/directives/perms.directive.ts @@ -25,6 +25,20 @@ export class PermsDirective extends OpenSlidesComponent { */ private lastPermissionCheckResult = false; + /** + * Alternative to the permissions. Used in special case where a combination + * with *ngIf would be required. + * + * # Example: + * + * The div will render if the permission `user.can_manage` is set + * or if `this.ownPage` is `true` + * ```html + *
something
+ * ``` + */ + private alternative: boolean; + /** * Constructs the directive once. Observes the operator for it's groups so the * directive can perform changes dynamically @@ -61,6 +75,16 @@ export class PermsDirective extends OpenSlidesComponent { this.updateView(); } + /** + * Comes from the view. + * `;or:` turns into osPermsOr during runtime. + */ + @Input('osPermsOr') + public set osPermsAlt(value: boolean) { + this.alternative = value; + this.updateView(); + } + /** * Shows or hides certain content in the view. */ @@ -68,7 +92,7 @@ export class PermsDirective extends OpenSlidesComponent { const hasPerms = this.checkPermissions(); const permsChanged = hasPerms !== this.lastPermissionCheckResult; - if (hasPerms && permsChanged) { + if ((hasPerms && permsChanged) || this.alternative) { // clean up and add the template this.viewContainer.clear(); this.viewContainer.createEmbeddedView(this.template); diff --git a/client/src/app/shared/models/base/base-model.ts b/client/src/app/shared/models/base/base-model.ts index 20d59de10..ad7463812 100644 --- a/client/src/app/shared/models/base/base-model.ts +++ b/client/src/app/shared/models/base/base-model.ts @@ -60,6 +60,9 @@ export abstract class BaseModel extends OpenSlidesComponent }); } + /** + * update the values of the base model with new values + */ public patchValues(update: Partial): void { Object.assign(this, update); } diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 21ae88d6b..cab60a4ee 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -15,7 +15,8 @@ import { MatSnackBarModule, MatTableModule, MatPaginatorModule, - MatSortModule + MatSortModule, + MatTooltipModule } from '@angular/material'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatChipsModule } from '@angular/material'; @@ -79,6 +80,7 @@ library.add(fas); MatDialogModule, MatSnackBarModule, MatChipsModule, + MatTooltipModule, FontAwesomeModule, TranslateModule.forChild(), RouterModule, @@ -106,6 +108,7 @@ library.add(fas); MatDialogModule, MatSnackBarModule, MatChipsModule, + MatTooltipModule, NgxMatSelectSearchModule, FontAwesomeModule, TranslateModule, diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html index 501de114e..d59f4e741 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html @@ -23,8 +23,9 @@
-
@@ -39,7 +40,7 @@ - + diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss index e54d18361..6cf5de421 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss @@ -2,14 +2,6 @@ span { margin: 0; } -.save-button { - background-color: rgb(77, 243, 86); -} - -.deleteMotionButton { - color: red; -} - .motion-title { padding-left: 20px; line-height: 100%; diff --git a/client/src/app/site/motions/components/motion-list/motion-list.component.scss b/client/src/app/site/motions/components/motion-list/motion-list.component.scss index 47884887f..c0b255cb6 100644 --- a/client/src/app/site/motions/components/motion-list/motion-list.component.scss +++ b/client/src/app/site/motions/components/motion-list/motion-list.component.scss @@ -1,13 +1,3 @@ -.custom-table-header { - // display: none; - width: 100%; - height: 60px; - line-height: 60px; - text-align: right; - background: white; - border-bottom: 1px solid rgba(0, 0, 0, 0.12); -} - /** css hacks https://codepen.io/edge0703/pen/iHJuA */ .innerTable { display: inline-block; diff --git a/client/src/app/site/motions/services/category-repository.service.ts b/client/src/app/site/motions/services/category-repository.service.ts index 37ada08d9..40cfee6cd 100644 --- a/client/src/app/site/motions/services/category-repository.service.ts +++ b/client/src/app/site/motions/services/category-repository.service.ts @@ -40,7 +40,7 @@ export class CategoryRepositoryService extends BaseRepository { diff --git a/client/src/app/site/motions/services/motion-repository.service.ts b/client/src/app/site/motions/services/motion-repository.service.ts index b4cbfcc46..3ba109dd5 100644 --- a/client/src/app/site/motions/services/motion-repository.service.ts +++ b/client/src/app/site/motions/services/motion-repository.service.ts @@ -66,7 +66,7 @@ export class MotionRepositoryService extends BaseRepository * TODO: Remove the viewMotion and make it actually distignuishable from save() */ public create(motion: Motion): Observable { - return this.dataSend.saveModel(motion); + return this.dataSend.createModel(motion); } /** @@ -81,7 +81,7 @@ export class MotionRepositoryService extends BaseRepository public update(update: Partial, viewMotion: ViewMotion): Observable { const motion = viewMotion.motion; motion.patchValues(update); - return this.dataSend.saveModel(motion); + return this.dataSend.updateModel(motion, 'patch'); } /** diff --git a/client/src/app/site/site.component.ts b/client/src/app/site/site.component.ts index 3e7e0ed3e..0d43b4c7f 100644 --- a/client/src/app/site/site.component.ts +++ b/client/src/app/site/site.component.ts @@ -1,4 +1,5 @@ import { Component, OnInit, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; import { AuthService } from 'app/core/services/auth.service'; import { OperatorService } from 'app/core/services/operator.service'; @@ -21,7 +22,8 @@ export class SiteComponent extends BaseComponent implements OnInit { /** * HTML element of the side panel */ - @ViewChild('sideNav') public sideNav: MatSidenav; + @ViewChild('sideNav') + public sideNav: MatSidenav; /** * Get the username from the operator (should be known already) @@ -37,6 +39,7 @@ export class SiteComponent extends BaseComponent implements OnInit { * Constructor * * @param authService + * @param router * @param operator * @param vp * @param translate @@ -44,6 +47,7 @@ export class SiteComponent extends BaseComponent implements OnInit { */ public constructor( private authService: AuthService, + private router: Router, public operator: OperatorService, public vp: ViewportService, public translate: TranslateService, @@ -109,7 +113,11 @@ export class SiteComponent extends BaseComponent implements OnInit { } // TODO: Implement this - public editProfile(): void {} + public editProfile(): void { + if (this.operator.user) { + this.router.navigate([`./users/${this.operator.user.id}`]); + } + } // TODO: Implement this public changePassword(): void {} diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.html b/client/src/app/site/users/components/user-detail/user-detail.component.html new file mode 100644 index 000000000..5813c6dd6 --- /dev/null +++ b/client/src/app/site/users/components/user-detail/user-detail.component.html @@ -0,0 +1,152 @@ + + + + +
+
+ {{personalInfoForm.get('title').value}} + {{personalInfoForm.get('first_name').value}} + {{personalInfoForm.get('last_name').value}} +
+ +
+ {{user.fullName}} +
+
+ + + + +
+ +
+
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + + + + + + +
+ +
+ + + + + Please enter a valid email address + + +
+ +
+ + + + + + + + + +
+ +
+ + + + {{group}} + + +
+ +
+ + + + Generate + + +
+ +
+ + + + + +
+ +
+ + + + +
+ +
+ + + + Only for internal notes. + +
+ +
+ + + Is Present + + + + Is Active + + + + Is a committee + +
+
+ +
diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.scss b/client/src/app/site/users/components/user-detail/user-detail.component.scss new file mode 100644 index 000000000..6af3b7be9 --- /dev/null +++ b/client/src/app/site/users/components/user-detail/user-detail.component.scss @@ -0,0 +1,67 @@ +// hide certain stuff whem editing is disabled +.mat-form-field-disabled { + ::ng-deep { + .mat-input-element { + color: currentColor; + } + + .mat-select-value { + color: currentColor; + } + + .mat-form-field-underline { + display: none; + } + + .mat-hint { + display: none; + } + + .mat-select-value { + display: table-cell; + } + + button { + display: none; + } + } +} + +// angular material does not have this class. This is virtually set using ngClass +.mat-form-field-enabled { + .form100 { + ::ng-deep { + width: 100%; + } + } + + .form70 { + ::ng-deep { + width: 70%; + } + } + + .force-min-with { + min-width: 150px; + } + + .form30 { + ::ng-deep { + width: 30%; + } + } + + .form20 { + ::ng-deep { + width: 25%; + } + } + + .distance { + padding-right: 5%; + } +} + +mat-checkbox { + margin-right: 10px; +} diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.spec.ts b/client/src/app/site/users/components/user-detail/user-detail.component.spec.ts new file mode 100644 index 000000000..fbe8367bb --- /dev/null +++ b/client/src/app/site/users/components/user-detail/user-detail.component.spec.ts @@ -0,0 +1,24 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserDetailComponent } from './user-detail.component'; + +describe('UserDetailComponent', () => { + let component: UserDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [UserDetailComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.ts b/client/src/app/site/users/components/user-detail/user-detail.component.ts new file mode 100644 index 000000000..fbf49c6ec --- /dev/null +++ b/client/src/app/site/users/components/user-detail/user-detail.component.ts @@ -0,0 +1,336 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; + +import { ViewUser } from '../../models/view-user'; +import { UserRepositoryService } from '../../services/user-repository.service'; +import { Group } from '../../../../shared/models/users/group'; +import { DataStoreService } from '../../../../core/services/data-store.service'; +import { OperatorService } from '../../../../core/services/operator.service'; + +/** + * Users detail component for both new and existing users + */ +@Component({ + selector: 'os-user-detail', + templateUrl: './user-detail.component.html', + styleUrls: ['./user-detail.component.scss'] +}) +export class UserDetailComponent implements OnInit { + /** + * Info form object + */ + public personalInfoForm: FormGroup; + + /** + * if this is the own page + */ + public ownPage = false; + + /** + * Editing a user + */ + public editUser = false; + + /** + * True if a new user is created + */ + public newUser = false; + + /** + * True if the operator has manage permissions + */ + public canManage = false; + + /** + * ViewUser model + */ + public user: ViewUser; + + /** + * Should contain all Groups, loaded or observed from DataStore + */ + public groups: Group[]; + + /** + * Constructor for user + */ + public constructor( + private formBuilder: FormBuilder, + private route: ActivatedRoute, + private router: Router, + private repo: UserRepositoryService, + private DS: DataStoreService, + private op: OperatorService + ) { + this.user = new ViewUser(); + if (route.snapshot.url[0].path === 'new') { + this.newUser = true; + this.setEditMode(true); + } else { + this.route.params.subscribe(params => { + this.loadViewUser(params.id); + + // will fail after reload - observable required + this.ownPage = this.opOwnsPage(Number(params.id)); + + // observe operator to find out if we see our own page or not + this.op.getObservable().subscribe(newOp => { + if (newOp) { + this.ownPage = this.opOwnsPage(Number(params.id)); + } + }); + }); + } + this.createForm(); + } + + /** + * sets the ownPage variable if the operator owns the page + */ + public opOwnsPage(userId: number): boolean { + if (this.op.user && this.op.user.id === userId) { + return true; + } else { + return false; + } + } + + /** + * Should determine if the user (Operator) has the + * correct permission to perform the given action. + * + * actions might be: + * - seeName (title, 1st, last) (user.can_see_name or ownPage) + * - seeExtra (checkboxes, comment) (user.can_see_extra_data) + * - seePersonal (mail, username, about) (user.can_see_extra_data or ownPage) + * - manage (everything) (user.can_manage) + * - changePersonal (mail, username, about) (user.can_manage or ownPage) + * + * @param action the action the user tries to perform + */ + public isAllowed(action: string): boolean { + switch (action) { + case 'manage': + return this.op.hasPerms('users.can_manage'); + case 'seeName': + return this.op.hasPerms('users.can_see_name', 'users.can_manage') || this.ownPage; + case 'seeExtra': + return this.op.hasPerms('users.can_see_extra_data', 'users.can_manage'); + case 'seePersonal': + return this.op.hasPerms('users.can_see_extra_data', 'users.can_manage') || this.ownPage; + case 'changePersonal': + return this.op.hasPerms('user.cans_manage') || this.ownPage; + default: + return false; + } + } + + /** + * Loads a user from users repository + * @param id the required ID + */ + public loadViewUser(id: number): void { + this.repo.getViewModelObservable(id).subscribe(newViewUser => { + // repo sometimes delivers undefined values + // also ensures edition cannot be interrupted by autpupdate + if (newViewUser && !this.editUser) { + this.user = newViewUser; + // personalInfoForm is undefined during 'new' and directly after reloading + if (this.personalInfoForm) { + this.patchFormValues(); + } + } + }); + } + + /** + * initialize the form with default values + */ + public createForm(): void { + this.personalInfoForm = this.formBuilder.group({ + username: [''], + title: [''], + first_name: [''], + last_name: [''], + structure_level: [''], + number: [''], + about_me: [''], + groups_id: [''], + is_present: [true], + is_committee: [false], + email: ['', Validators.email], + last_email_send: [''], + comment: [''], + is_active: [true], + default_password: [''] + }); + + // per default disable the whole form: + + this.patchFormValues(); + } + + /** + * Loads values that require external references + * And allows async reading + */ + public patchFormValues(): void { + this.personalInfoForm.patchValue({ + username: this.user.username, + groups_id: this.user.groupIds, + title: this.user.title, + first_name: this.user.firstName, + last_name: this.user.lastName + }); + } + + /** + * Makes the form editable + * @param editable + */ + public makeFormEditable(editable: boolean): void { + if (this.personalInfoForm) { + const formControlNames = Object.keys(this.personalInfoForm.controls); + const allowedFormFields = []; + + if (this.isAllowed('manage')) { + // editable content with manage rights + allowedFormFields.push( + this.personalInfoForm.get('username'), + this.personalInfoForm.get('title'), + this.personalInfoForm.get('first_name'), + this.personalInfoForm.get('last_name'), + this.personalInfoForm.get('email'), + this.personalInfoForm.get('structure_level'), + this.personalInfoForm.get('number'), + this.personalInfoForm.get('groups_id'), + this.personalInfoForm.get('comment'), + this.personalInfoForm.get('is_present'), + this.personalInfoForm.get('is_active'), + this.personalInfoForm.get('is_committee'), + this.personalInfoForm.get('about_me') + ); + } else if (this.isAllowed('changePersonal')) { + // changeable personal data + // FIXME: Own E-Mail and Password is hidden (server?) + allowedFormFields.push( + this.personalInfoForm.get('username'), + this.personalInfoForm.get('email'), + this.personalInfoForm.get('about_me') + ); + } + + // treatment for the initial password field + if (!editable || this.newUser) { + allowedFormFields.push(this.personalInfoForm.get('default_password')); + } + + if (editable) { + allowedFormFields.forEach(formElement => { + formElement.enable(); + }); + } else { + formControlNames.forEach(formControlName => { + this.personalInfoForm.get(formControlName).disable(); + }); + } + } + } + + /** + * Handler for the generate Password button. + * Generates a password using 8 pseudo-random letters + * from the `characters` const. + * + * Removed the letter 'O' from the alphabet cause it's easy to confuse + * with the number '0'. + */ + public generatePassword(): void { + let pw = ''; + const characters = 'ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const amount = 8; + for (let i = 0; i < amount; i++) { + pw += characters.charAt(Math.floor(Math.random() * characters.length)); + } + this.personalInfoForm.patchValue({ + default_password: pw + }); + } + + /** + * Save / Submit a user + */ + public saveUser(): void { + if (this.newUser) { + this.repo.create(this.personalInfoForm.value).subscribe( + response => { + this.newUser = false; + this.router.navigate([`./users/${response.id}`]); + // this.setEditMode(false); + // this.loadViewUser(response.id); + }, + error => console.error('Creation of the user failed: ', error.error) + ); + } else { + this.repo.update(this.personalInfoForm.value, this.user).subscribe( + response => { + this.setEditMode(false); + this.loadViewUser(response.id); + }, + error => console.error('Update of the user failed: ', error.error) + ); + } + } + + /** + * sets editUser variable and editable form + * @param edit + */ + public setEditMode(edit: boolean): void { + this.editUser = edit; + this.makeFormEditable(edit); + } + + /** + * click on the edit button + */ + public editUserButton(): void { + if (this.editUser) { + this.saveUser(); + } else { + this.setEditMode(true); + } + } + + public cancelEditMotionButton(): void { + if (this.newUser) { + this.router.navigate(['./users/']); + } else { + this.setEditMode(false); + this.loadViewUser(this.user.id); + } + } + + /** + * click on the delete user button + */ + public deleteUserButton(): void { + this.repo.delete(this.user).subscribe(response => { + this.router.navigate(['./users/']); + }); + } + + /** + * Init function. + */ + public ngOnInit(): void { + this.makeFormEditable(this.editUser); + this.groups = this.DS.getAll(Group); + this.DS.changeObservable.subscribe(model => { + if (model instanceof Group) { + this.groups.push(model as Group); + } + }); + } +} diff --git a/client/src/app/site/users/user-list/user-list.component.html b/client/src/app/site/users/components/user-list/user-list.component.html similarity index 53% rename from client/src/app/site/users/user-list/user-list.component.html rename to client/src/app/site/users/components/user-list/user-list.component.html index 831b7b6ec..3ed6ced24 100644 --- a/client/src/app/site/users/user-list/user-list.component.html +++ b/client/src/app/site/users/components/user-list/user-list.component.html @@ -2,6 +2,15 @@ (ellipsisMenuItem)=onEllipsisItem($event)> +
+ + +
+ @@ -12,12 +21,30 @@ Group - {{user.groups}} {{user.structureLevel}} + +
+ + + {{user.groups}} + +
+ + + {{user.structureLevel}} + +
+
+ Presence - {{user.isActive}} + +
+ + Present +
+
diff --git a/client/src/app/site/users/components/user-list/user-list.component.scss b/client/src/app/site/users/components/user-list/user-list.component.scss new file mode 100644 index 000000000..660a93b47 --- /dev/null +++ b/client/src/app/site/users/components/user-list/user-list.component.scss @@ -0,0 +1,32 @@ +.groupsCell { + display: inline-block; + vertical-align: middle; + line-height: normal; + + fa-icon { + font-size: 80%; + } +} + +.os-listview-table { + .mat-column-name { + flex: 1 0 200px; + } + + .mat-column-group { + flex: 2 0 60px; + } + + .mat-column-presence { + flex: 0 0 60px; + + fa-icon { + font-size: 100%; + margin-right: 5px; + } + + div { + display: inherit; + } + } +} diff --git a/client/src/app/site/users/user-list/user-list.component.spec.ts b/client/src/app/site/users/components/user-list/user-list.component.spec.ts similarity index 100% rename from client/src/app/site/users/user-list/user-list.component.spec.ts rename to client/src/app/site/users/components/user-list/user-list.component.spec.ts diff --git a/client/src/app/site/users/user-list/user-list.component.ts b/client/src/app/site/users/components/user-list/user-list.component.ts similarity index 78% rename from client/src/app/site/users/user-list/user-list.component.ts rename to client/src/app/site/users/components/user-list/user-list.component.ts index 6be181ce8..71a81e97c 100644 --- a/client/src/app/site/users/user-list/user-list.component.ts +++ b/client/src/app/site/users/components/user-list/user-list.component.ts @@ -2,9 +2,10 @@ import { Component, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { TranslateService } from '@ngx-translate/core'; -import { ViewUser } from '../models/view-user'; -import { UserRepositoryService } from '../services/user-repository.service'; -import { ListViewBaseComponent } from '../../base/list-view-base'; +import { ViewUser } from '../../models/view-user'; +import { UserRepositoryService } from '../../services/user-repository.service'; +import { ListViewBaseComponent } from '../../../base/list-view-base'; +import { Router, ActivatedRoute } from '@angular/router'; /** * Component for the user list view. @@ -13,7 +14,7 @@ import { ListViewBaseComponent } from '../../base/list-view-base'; @Component({ selector: 'os-user-list', templateUrl: './user-list.component.html', - styleUrls: ['./user-list.component.css'] + styleUrls: ['./user-list.component.scss'] }) export class UserListComponent extends ListViewBaseComponent implements OnInit { /** @@ -46,7 +47,9 @@ export class UserListComponent extends ListViewBaseComponent implement public constructor( private repo: UserRepositoryService, protected titleService: Title, - protected translate: TranslateService + protected translate: TranslateService, + private router: Router, + private route: ActivatedRoute ) { super(titleService, translate); } @@ -86,13 +89,13 @@ export class UserListComponent extends ListViewBaseComponent implement * @param row selected row */ public selectUser(row: ViewUser): void { - console.log('clicked the row for user: ', row); + this.router.navigate([`./${row.id}`], { relativeTo: this.route }); } /** * Handles the click on the plus button */ public onPlusButton(): void { - console.log('new User'); + this.router.navigate(['./new'], { relativeTo: this.route }); } } diff --git a/client/src/app/site/users/models/view-user.ts b/client/src/app/site/users/models/view-user.ts index 739c32f8b..d16d07b1e 100644 --- a/client/src/app/site/users/models/view-user.ts +++ b/client/src/app/site/users/models/view-user.ts @@ -7,39 +7,96 @@ export class ViewUser extends BaseViewModel { private _user: User; private _groups: Group[]; - public get id(): number { - return this._user ? this._user.id : null; - } - public get user(): User { - return this._user; + return this._user ? this._user : null; } public get groups(): Group[] { return this._groups; } + public get id(): number { + return this.user ? this.user.id : null; + } + + public get username(): string { + return this.user ? this.user.username : null; + } + + public get title(): string { + return this.user ? this.user.title : null; + } + + public get firstName(): string { + return this.user ? this.user.first_name : null; + } + + public get lastName(): string { + return this.user ? this.user.last_name : null; + } + public get fullName(): string { return this.user ? this.user.full_name : null; } - /** - * TODO: Make boolean, use function over view component. - */ - public get isActive(): string { - return this.user && this.user.is_active ? 'active' : 'inactive'; + public get email(): string { + return this.user ? this.user.email : null; } public get structureLevel(): string { return this.user ? this.user.structure_level : null; } + public get participantNumber(): string { + return this.user ? this.user.number : null; + } + + public get groupIds(): number[] { + return this.user ? this.user.groups_id : null; + } + + /** + * Required by the input selector + */ + public set groupIds(ids: number[]) { + if (this.user) { + this.user.groups_id = ids; + } + } + + public get initialPassword(): string { + return this.user ? this.user.default_password : null; + } + + public get comment(): string { + return this.user ? this.user.comment : null; + } + + public get isPresent(): boolean { + return this.user ? this.user.is_present : null; + } + + public get isActive(): boolean { + return this.user ? this.user.is_active : null; + } + + public get isCommittee(): boolean { + return this.user ? this.user.is_committee : null; + } + + public get about(): string { + return this.user ? this.user.about_me : null; + } + public constructor(user?: User, groups?: Group[]) { super(); this._user = user; this._groups = groups; } + /** + * required by BaseViewModel. Don't confuse with the users title. + */ public getTitle(): string { return this.user ? this.user.toString() : null; } @@ -47,9 +104,7 @@ export class ViewUser extends BaseViewModel { /** * TODO: Implement */ - public replaceGroup(newGroup: Group): void { - console.log('replace group - not yet implemented, ', newGroup); - } + public replaceGroup(newGroup: Group): void {} public updateValues(update: BaseModel): void { if (update instanceof Group) { diff --git a/client/src/app/site/users/services/user-repository.service.ts b/client/src/app/site/users/services/user-repository.service.ts index 83adfa13e..735b16ffc 100644 --- a/client/src/app/site/users/services/user-repository.service.ts +++ b/client/src/app/site/users/services/user-repository.service.ts @@ -6,6 +6,7 @@ import { User } from '../../../shared/models/users/user'; import { Group } from '../../../shared/models/users/group'; import { Observable } from 'rxjs'; import { DataStoreService } from '../../../core/services/data-store.service'; +import { DataSendService } from '../../../core/services/data-send.service'; /** * Repository service for users @@ -19,17 +20,29 @@ export class UserRepositoryService extends BaseRepository { /** * Constructor calls the parent constructor */ - public constructor(DS: DataStoreService) { + public constructor(DS: DataStoreService, private dataSend: DataSendService) { super(DS, User, [Group]); } /** - * @ignore + * Updates a the selected user with the form values. * - * TODO: used over not-yet-existing detail view + * @param update the forms values + * @param viewUser */ - public update(user: Partial, viewUser: ViewUser): Observable { - return null; + public update(update: Partial, viewUser: ViewUser): Observable { + const updateUser = new User(); + // copy the ViewUser to avoid manipulation of parameters + updateUser.patchValues(viewUser.user); + updateUser.patchValues(update); + + // if the user deletes the username, reset + // prevents the server of generating ' +1' as username + if (updateUser.username === '') { + updateUser.username = viewUser.username; + } + + return this.dataSend.updateModel(updateUser, 'put'); } /** @@ -37,17 +50,38 @@ export class UserRepositoryService extends BaseRepository { * * TODO: used over not-yet-existing detail view */ - public delete(user: ViewUser): Observable { - return null; + public delete(viewUser: ViewUser): Observable { + return this.dataSend.delete(viewUser.user); } /** - * @ignore + * creates and saves a new user * * TODO: used over not-yet-existing detail view + * @param userData blank form value. Usually not yet a real user */ - public create(user: User): Observable { - return null; + public create(userData: Partial): Observable { + const newUser = new User(); + // collectionString of userData is still empty + newUser.patchValues(userData); + + // if the username is not presend, delete. + // The server will generate a one + if (!newUser.username) { + delete newUser.username; + } + + // title must not be "null" during creation + if (!newUser.title) { + delete newUser.title; + } + + // null values will not be accepted for group_id + if (!newUser.groups_id) { + delete newUser.groups_id; + } + + return this.dataSend.createModel(newUser); } public createViewModel(user: User): ViewUser { diff --git a/client/src/app/site/users/user-list/user-list.component.css b/client/src/app/site/users/user-list/user-list.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/app/site/users/users-routing.module.ts b/client/src/app/site/users/users-routing.module.ts index a7a2ad989..802e8256d 100644 --- a/client/src/app/site/users/users-routing.module.ts +++ b/client/src/app/site/users/users-routing.module.ts @@ -1,8 +1,22 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; -import { UserListComponent } from './user-list/user-list.component'; +import { UserListComponent } from './components/user-list/user-list.component'; +import { UserDetailComponent } from './components/user-detail/user-detail.component'; -const routes: Routes = [{ path: '', component: UserListComponent }]; +const routes: Routes = [ + { + path: '', + component: UserListComponent + }, + { + path: 'new', + component: UserDetailComponent + }, + { + path: ':id', + component: UserDetailComponent + } +]; @NgModule({ imports: [RouterModule.forChild(routes)], diff --git a/client/src/app/site/users/users.module.ts b/client/src/app/site/users/users.module.ts index fd25bf9d2..d40a610ab 100644 --- a/client/src/app/site/users/users.module.ts +++ b/client/src/app/site/users/users.module.ts @@ -3,10 +3,11 @@ import { CommonModule } from '@angular/common'; import { UsersRoutingModule } from './users-routing.module'; import { SharedModule } from '../../shared/shared.module'; -import { UserListComponent } from './user-list/user-list.component'; +import { UserListComponent } from './components/user-list/user-list.component'; +import { UserDetailComponent } from './components/user-detail/user-detail.component'; @NgModule({ imports: [CommonModule, UsersRoutingModule, SharedModule], - declarations: [UserListComponent] + declarations: [UserListComponent, UserDetailComponent] }) export class UsersModule {} diff --git a/client/src/assets/i18n/de.json b/client/src/assets/i18n/de.json index 9b925e745..d25288a23 100644 --- a/client/src/assets/i18n/de.json +++ b/client/src/assets/i18n/de.json @@ -1,12 +1,29 @@ { + "Abort": "", + "About Me": "", "Agenda": "Tagesordnung", "Assignments": "Wahlen", "Category": "", "Change Password": "Passwort ändern", + "Comment": "", "Content": "", "Copyright by": "Copyright by", + "Delete User": "", "DeleteMotion": "", + "Designates whether this user is in the room": { + "0": "" + }, + "Designates whether this user should be treated as a committee": { + "0": "" + }, + "Designates whether this user should be treated as active": { + " Unselect this instead of deleting the account": { + "0": "" + } + }, + "EMail": "", "Edit Profile": "Profil bearbeiten", + "Edit category details:": "", "English": "Englisch", "Export As": { "0": { @@ -17,11 +34,18 @@ }, "FILTER": "", "Files": "Dateien", + "First Name": "", "French": "Französisch", "German": "Deutsch", + "Groups": "", "Home": "Startseite", "Identifier": "", + "Initial Password": "", "Installed plugins": "", + "Is Active": "", + "Is Present": "", + "Is a committee": "", + "Last Name": "", "Legal Notice": "Impressum", "License": "", "Login": "", @@ -30,30 +54,42 @@ "Meta information": "", "Motion": "", "Motions": "Anträge", + "Name": "", + "None": "", "OK": "", "Offline mode: You can use OpenSlides but changes are not saved": { "0": "" }, + "Only for internal notes": { + "0": "" + }, "Origin": "", + "Participant Number": "", "Participants": "Teilnehmer", "Personal Note": "", "Personal note": "", + "Prefix": "", + "Present": "", "Privacy Policy": "Datenschutz", "Project": "", "Projector": "", "Reason": "", + "Required": "", "Reset State": "", "Reset recommendation": "", "SORT": "", "Selected Values": "", "Settings": "Einstellungen", "State": "", + "Structure Level": "", "Submitters": "", "Supporters": "", "The assembly may decide:": "", "The event manager hasn't set up a privacy policy yet": { "0": "" }, + "Title": "", + "Username": "", "Welcome to OpenSlides": "Willkommen bei OpenSlides", "by": "" } diff --git a/client/src/assets/i18n/en.json b/client/src/assets/i18n/en.json index 08175a42a..4754c498c 100644 --- a/client/src/assets/i18n/en.json +++ b/client/src/assets/i18n/en.json @@ -1,12 +1,29 @@ { + "Abort": "", + "About Me": "", "Agenda": "", "Assignments": "", "Category": "", "Change Password": "", + "Comment": "", "Content": "", "Copyright by": "", + "Delete User": "", "DeleteMotion": "", + "Designates whether this user is in the room": { + "0": "" + }, + "Designates whether this user should be treated as a committee": { + "0": "" + }, + "Designates whether this user should be treated as active": { + " Unselect this instead of deleting the account": { + "0": "" + } + }, + "EMail": "", "Edit Profile": "", + "Edit category details:": "", "English": "", "Export As": { "0": { @@ -17,11 +34,18 @@ }, "FILTER": "", "Files": "", + "First Name": "", "French": "", "German": "", + "Groups": "", "Home": "", "Identifier": "", + "Initial Password": "", "Installed plugins": "", + "Is Active": "", + "Is Present": "", + "Is a committee": "", + "Last Name": "", "Legal Notice": "", "License": "", "Login": "", @@ -30,30 +54,42 @@ "Meta information": "", "Motion": "", "Motions": "", + "Name": "", + "None": "", "OK": "", "Offline mode: You can use OpenSlides but changes are not saved": { "0": "" }, + "Only for internal notes": { + "0": "" + }, "Origin": "", + "Participant Number": "", "Participants": "", "Personal Note": "", "Personal note": "", + "Prefix": "", + "Present": "", "Privacy Policy": "", "Project": "", "Projector": "", "Reason": "", + "Required": "", "Reset State": "", "Reset recommendation": "", "SORT": "", "Selected Values": "", "Settings": "", "State": "", + "Structure Level": "", "Submitters": "", "Supporters": "", "The assembly may decide:": "", "The event manager hasn't set up a privacy policy yet": { "0": "" }, + "Title": "", + "Username": "", "Welcome to OpenSlides": "", "by": "" } diff --git a/client/src/assets/i18n/fr.json b/client/src/assets/i18n/fr.json index 08175a42a..4754c498c 100644 --- a/client/src/assets/i18n/fr.json +++ b/client/src/assets/i18n/fr.json @@ -1,12 +1,29 @@ { + "Abort": "", + "About Me": "", "Agenda": "", "Assignments": "", "Category": "", "Change Password": "", + "Comment": "", "Content": "", "Copyright by": "", + "Delete User": "", "DeleteMotion": "", + "Designates whether this user is in the room": { + "0": "" + }, + "Designates whether this user should be treated as a committee": { + "0": "" + }, + "Designates whether this user should be treated as active": { + " Unselect this instead of deleting the account": { + "0": "" + } + }, + "EMail": "", "Edit Profile": "", + "Edit category details:": "", "English": "", "Export As": { "0": { @@ -17,11 +34,18 @@ }, "FILTER": "", "Files": "", + "First Name": "", "French": "", "German": "", + "Groups": "", "Home": "", "Identifier": "", + "Initial Password": "", "Installed plugins": "", + "Is Active": "", + "Is Present": "", + "Is a committee": "", + "Last Name": "", "Legal Notice": "", "License": "", "Login": "", @@ -30,30 +54,42 @@ "Meta information": "", "Motion": "", "Motions": "", + "Name": "", + "None": "", "OK": "", "Offline mode: You can use OpenSlides but changes are not saved": { "0": "" }, + "Only for internal notes": { + "0": "" + }, "Origin": "", + "Participant Number": "", "Participants": "", "Personal Note": "", "Personal note": "", + "Prefix": "", + "Present": "", "Privacy Policy": "", "Project": "", "Projector": "", "Reason": "", + "Required": "", "Reset State": "", "Reset recommendation": "", "SORT": "", "Selected Values": "", "Settings": "", "State": "", + "Structure Level": "", "Submitters": "", "Supporters": "", "The assembly may decide:": "", "The event manager hasn't set up a privacy policy yet": { "0": "" }, + "Title": "", + "Username": "", "Welcome to OpenSlides": "", "by": "" } diff --git a/client/src/styles.scss b/client/src/styles.scss index ecb69e0ad..d75d803db 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -26,6 +26,19 @@ body { z-index: 100; } +.save-button { + // needs to be important or will be overwritten locally + background-color: rgb(77, 243, 86) !important; +} + +.red-warning-text { + color: red; +} + +.icon-text-distance { + margin-left: 5px; +} + .os-card { max-width: 90%; margin-top: 10px; @@ -33,6 +46,16 @@ body { margin-right: auto; } +//custom table header for search button, filtering and more. Used in ListViews +.custom-table-header { + width: 100%; + height: 60px; + line-height: 60px; + text-align: right; + background: white; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); +} + .os-listview-table { width: 100%;