Merge pull request #3905 from FinnStutzenstein/commentfields

Motion comment section list
This commit is contained in:
Finn Stutzenstein 2018-10-09 14:03:30 +02:00 committed by GitHub
commit 24abdc7bd0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 655 additions and 6 deletions

View File

@ -13,6 +13,7 @@ import { WebsocketService } from './services/websocket.service';
import { AddHeaderInterceptor } from './http-interceptor';
import { DataSendService } from './services/data-send.service';
import { ViewportService } from './services/viewport.service';
import { PromptDialogComponent } from '../shared/components/prompt-dialog/prompt-dialog.component';
/** Global Core Module. Contains all global (singleton) services
*
@ -34,7 +35,8 @@ import { ViewportService } from './services/viewport.service';
useClass: AddHeaderInterceptor,
multi: true
}
]
],
entryComponents: [PromptDialogComponent]
})
export class CoreModule {
/** make sure CoreModule is imported only by one NgModule, the AppModule */

View File

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

View File

@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { OpenSlidesComponent } from 'app/openslides.component';
import { PromptDialogComponent } from '../../shared/components/prompt-dialog/prompt-dialog.component';
import { MatDialog } from '@angular/material';
/**
* A general service for prompting 'yes' or 'cancel' thinks from the user.
*/
@Injectable({
providedIn: 'root'
})
export class PromptService extends OpenSlidesComponent {
public constructor(private dialog: MatDialog) {
super();
}
/**
* Opens the dialog. Returns true, if the user accepts.
* @param title The title to display in the dialog
* @param content The content in the dialog
*/
public async open(title: string, content: string): Promise<any> {
const dialogRef = this.dialog.open(PromptDialogComponent, {
width: '250px',
data: { title: title, content: content }
});
return dialogRef.afterClosed().toPromise();
}
}

View File

@ -0,0 +1,6 @@
<h2 mat-dialog-title>{{ data.title | translate }}</h2>
<mat-dialog-content>{{ data.content | translate }}</mat-dialog-content>
<mat-dialog-actions>
<button mat-button [mat-dialog-close]="true" color="warn" translate>Yes</button>
<button mat-button [mat-dialog-close]="false" translate>Cancel</button>
</mat-dialog-actions>

View File

@ -0,0 +1,26 @@
import { async, TestBed } from '@angular/core/testing';
// import { PromptDialogComponent } from './prompt-dialog.component';
import { E2EImportsModule } from 'e2e-imports.module';
describe('PromptDialogComponent', () => {
// let component: PromptDialogComponent;
// let fixture: ComponentFixture<PromptDialogComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
// TODO: You cannot create this component in the standard way. Needs different testing.
beforeEach(() => {
/*fixture = TestBed.createComponent(PromptDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();*/
});
/*it('should create', () => {
expect(component).toBeTruthy();
});*/
});

View File

@ -0,0 +1,21 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
interface PromptDialogData {
title: string;
content: string;
}
/**
* A simple prompt dialog. Takes a title and content.
*/
@Component({
selector: 'os-prompt-dialog',
templateUrl: './prompt-dialog.component.html'
})
export class PromptDialogComponent {
public constructor(
public dialogRef: MatDialogRef<PromptDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: PromptDialogData
) {}
}

View File

@ -50,6 +50,7 @@ import { LegalNoticeContentComponent } from './components/legal-notice-content/l
import { PrivacyPolicyContentComponent } from './components/privacy-policy-content/privacy-policy-content.component';
import { SearchValueSelectorComponent } from './components/search-value-selector/search-value-selector.component';
import { OpenSlidesDateAdapter } from './date-adapter';
import { PromptDialogComponent } from './components/prompt-dialog/prompt-dialog.component';
library.add(fas);
@ -127,7 +128,8 @@ library.add(fas);
HeadBarComponent,
SearchValueSelectorComponent,
LegalNoticeContentComponent,
PrivacyPolicyContentComponent
PrivacyPolicyContentComponent,
PromptDialogComponent
],
declarations: [
PermsDirective,
@ -136,7 +138,8 @@ library.add(fas);
FooterComponent,
LegalNoticeContentComponent,
PrivacyPolicyContentComponent,
SearchValueSelectorComponent
SearchValueSelectorComponent,
PromptDialogComponent
],
providers: [{ provide: DateAdapter, useClass: OpenSlidesDateAdapter }]
})

View File

@ -0,0 +1,108 @@
<os-head-bar appName="Motion comment sections" [plusButton]=true (plusButtonClicked)=onPlusButton()>
</os-head-bar>
<div class="head-spacer"></div>
<mat-card *ngIf="commentSectionToCreate">
<mat-card-title translate>Create new comment section</mat-card-title>
<mat-card-content>
<form [formGroup]="createForm" (keydown)="keyDownFunction($event)">
<p>
<mat-form-field>
<input formControlName="name" matInput placeholder="{{'Name' | translate}}" required>
<mat-hint *ngIf="!createForm.controls.name.valid">
<span translate>Required</span>
</mat-hint>
</mat-form-field>
</p>
<p>
<os-search-value-selector ngDefaultControl [form]="createForm" [formControl]="this.createForm.get('read_groups_id')"
[multiple]="true" listname="Groups with read permissions" [InputListValues]="this.groups"></os-search-value-selector>
</p>
<p>
<os-search-value-selector ngDefaultControl [form]="createForm" [formControl]="this.createForm.get('write_groups_id')"
[multiple]="true" listname="Groups with write permissions" [InputListValues]="this.groups"></os-search-value-selector>
</p>
</form>
</mat-card-content>
<mat-card-actions>
<button mat-button (click)="create()" translate>Create</button>
<button mat-button (click)="commentSectionToCreate = null" translate>Abort</button>
</mat-card-actions>
</mat-card>
<mat-accordion class="os-card">
<mat-expansion-panel *ngFor="let section of this.commentSections" (opened)="openId = section.id"
(closed)="panelClosed(section)" [expanded]="openId === section.id" multiple="false">
<mat-expansion-panel-header>
<mat-panel-title>
<div class="header-container">
<div class="name">
{{ section.name }}
</div>
<div class="read">
<fa-icon icon="eye"></fa-icon>
{{ section.read_groups }}
<ng-container *ngIf="section.read_groups.length === 0">
&ndash;
</ng-container>
</div>
<div class="write">
<fa-icon icon="pen"></fa-icon>
{{ section.write_groups }}
<ng-container *ngIf="section.write_groups.length === 0">
&ndash;
</ng-container>
</div>
</div>
</mat-panel-title>
</mat-expansion-panel-header>
<form [formGroup]="updateForm" *ngIf="editId === section.id" (keydown)="keyDownFunction($event, section)">
<span translate>Edit section details:</span>
<p>
<mat-form-field>
<input formControlName="name" matInput placeholder="{{'Name' | translate}}">
<mat-hint *ngIf="!updateForm.controls.name.valid">
<span translate>Required</span>
</mat-hint>
</mat-form-field>
</p>
<p>
<os-search-value-selector ngDefaultControl [form]="updateForm" [formControl]="this.updateForm.get('read_groups_id')"
[multiple]="true" listname="Groups with read permissions" [InputListValues]="this.groups"></os-search-value-selector>
</p>
<p>
<os-search-value-selector ngDefaultControl [form]="updateForm" [formControl]="this.updateForm.get('write_groups_id')"
[multiple]="true" listname="Groups with write permissions" [InputListValues]="this.groups"></os-search-value-selector>
</p>
</form>
<ng-container *ngIf="editId !== section.id">
<h3 translate>Name</h3>
<div class="spacer-left">{{ section.name }}</div>
<h3 translate>Groups with read permissions</h3>
<ul *ngFor="let group of section.read_groups">
<li>{{ group.getTitle() }}</li>
</ul>
<div class="spacer-left" *ngIf="section.read_groups.length === 0" translate>No groups selected</div>
<h3 translate>Groups with write permissions</h3>
<ul *ngFor="let group of section.write_groups">
<li>{{ group.getTitle() }}</li>
</ul>
<div class="spacer-left" *ngIf="section.write_groups.length === 0" translate>No groups selected</div>
</ng-container>
<mat-action-row>
<button *ngIf="editId !== section.id" mat-button class="on-transition-fade" (click)="onEditButton(section)"
mat-icon-button>
<fa-icon icon='pen'></fa-icon>
</button>
<button *ngIf="editId === section.id" mat-button class="on-transition-fade" (click)="editId = null"
mat-icon-button>
<fa-icon icon='times'></fa-icon>
</button>
<button *ngIf="editId === section.id" mat-button class="on-transition-fade" (click)="onSaveButton(section)"
mat-icon-button>
<fa-icon icon='save'></fa-icon>
</button>
<button mat-button class='on-transition-fade' (click)=onDeleteButton(section) mat-icon-button>
<fa-icon icon='trash'></fa-icon>
</button>
</mat-action-row>
</mat-expansion-panel>
</mat-accordion>

View File

@ -0,0 +1,49 @@
.head-spacer {
width: 100%;
height: 60px;
line-height: 60px;
text-align: right;
background: white;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
}
mat-card {
margin-bottom: 20px;
}
.header-container {
display: grid;
grid-template-rows: auto;
grid-template-columns: 33.333% 33.333% 33.333%;
width: 100%;
> div {
grid-row-start: 1;
grid-row-end: span 1;
grid-column-end: span 1;
}
.title {
grid-column-start: 1;
}
.read {
grid-column-start: 2;
}
.write {
grid-column-start: 3;
}
}
h3 {
display: block;
margin-top: 12px; //distance between heading and text
margin-bottom: 3px; //distance between heading and text
font-size: 90%;
color: gray;
}
.spacer-left {
margin-left: 40px;
}

View File

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

View File

@ -0,0 +1,170 @@
import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { BaseComponent } from '../../../../base.component';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { MotionCommentSection } from '../../../../shared/models/motions/motion-comment-section';
import { ViewMotionCommentSection } from '../../models/view-motion-comment-section';
import { MotionCommentSectionRepositoryService } from '../../services/motion-comment-section-repository.service';
import { PromptService } from '../../../../core/services/prompt.service';
import { BehaviorSubject } from 'rxjs';
import { Group } from '../../../../shared/models/users/group';
import { DataStoreService } from '../../../../core/services/data-store.service';
/**
* List view for the categories.
*/
@Component({
selector: 'os-motion-comment-section-list',
templateUrl: './motion-comment-section-list.component.html',
styleUrls: ['./motion-comment-section-list.component.scss']
})
export class MotionCommentSectionListComponent extends BaseComponent implements OnInit {
public commentSectionToCreate: MotionCommentSection | null;
/**
* Source of the Data
*/
public commentSections: ViewMotionCommentSection[] = [];
/**
* The current focussed formgroup
*/
public updateForm: FormGroup;
public createForm: FormGroup;
public openId: number | null;
public editId: number | null;
public groups: BehaviorSubject<Array<Group>>;
/**
* The usual component constructor
* @param titleService
* @param translate
* @param repo
* @param formBuilder
*/
public constructor(
protected titleService: Title,
protected translate: TranslateService,
private repo: MotionCommentSectionRepositoryService,
private formBuilder: FormBuilder,
private promptService: PromptService,
private DS: DataStoreService
) {
super(titleService, translate);
const form = {
name: ['', Validators.required],
read_groups_id: [[]],
write_groups_id: [[]]
};
this.createForm = this.formBuilder.group(form);
this.updateForm = this.formBuilder.group(form);
}
/**
* Event on Key Down in update or create form. Do not provide the viewSection for the create form.
*/
public keyDownFunction(event: KeyboardEvent, viewSection?: ViewMotionCommentSection): void {
if (event.keyCode === 13) {
if (viewSection) {
this.onSaveButton(viewSection);
} else {
this.create();
}
}
}
/**
* Init function.
*
* Sets the title and gets/observes categories from DataStore
*/
public ngOnInit(): void {
super.setTitle('Comment Sections');
this.groups = new BehaviorSubject(this.DS.getAll(Group));
this.DS.changeObservable.subscribe(model => {
if (model instanceof Group) {
this.groups.next(this.DS.getAll(Group));
}
});
this.repo.getViewModelListObservable().subscribe(newViewSections => {
this.commentSections = newViewSections;
});
}
/**
* Add a new Section.
*/
public onPlusButton(): void {
if (!this.commentSectionToCreate) {
this.commentSectionToCreate = new MotionCommentSection();
this.createForm.setValue({
name: '',
read_groups_id: [],
write_groups_id: []
});
}
}
public create(): void {
if (this.createForm.valid) {
this.commentSectionToCreate.patchValues(this.createForm.value as MotionCommentSection);
this.repo.create(this.commentSectionToCreate).subscribe(resp => {
this.commentSectionToCreate = null;
});
}
}
/**
* Executed on edit button
* @param viewSection
*/
public onEditButton(viewSection: ViewMotionCommentSection): void {
this.editId = viewSection.id;
this.updateForm.setValue({
name: viewSection.name,
read_groups_id: viewSection.read_groups_id,
write_groups_id: viewSection.write_groups_id
});
}
/**
* Saves the categories
*/
public onSaveButton(viewSection: ViewMotionCommentSection): void {
if (this.updateForm.valid) {
this.repo.update(this.updateForm.value as Partial<MotionCommentSection>, viewSection).subscribe(resp => {
this.openId = this.editId = null;
});
}
}
/**
* is executed, when the delete button is pressed
*/
public async onDeleteButton(viewSection: ViewMotionCommentSection): Promise<any> {
const content = this.translate.instant('Delete') + ` ${viewSection.name}?`;
if (await this.promptService.open('Are you sure?', content)) {
this.repo.delete(viewSection).subscribe(resp => {
this.openId = this.editId = null;
});
}
}
/**
* Is executed when a mat-extension-panel is closed
* @param viewSection the category in the panel
*/
public panelClosed(viewSection: ViewMotionCommentSection): void {
this.openId = null;
if (this.editId) {
this.onSaveButton(viewSection);
}
}
}

View File

@ -191,7 +191,7 @@
<mat-option *ngFor="let state of motionCopy.nextStates" [value]="state.id">{{state}}</mat-option>
<mat-divider></mat-divider>
<mat-option>
<fa-icon icon='exclamation-triangle'></fa-icon> <span translate>Reset State</span>
<fa-icon icon='exclamation-triangle'></fa-icon><span translate>Reset State</span>
</mat-option>
</mat-select>
</mat-form-field>

View File

@ -42,6 +42,10 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
{
text: 'Categories',
action: 'toCategories'
},
{
text: 'Motion comment sections',
action: 'toMotionCommentSections'
}
];
@ -130,6 +134,13 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
this.router.navigate(['./category'], { relativeTo: this.route });
}
/**
* navigate to 'motion/comment-section'
*/
public toMotionCommentSections(): void {
this.router.navigate(['./comment-section'], { relativeTo: this.route });
}
/**
* Download all motions As PDF and DocX
*

View File

@ -0,0 +1,90 @@
import { TranslateService } from '@ngx-translate/core';
import { BaseViewModel } from '../../base/base-view-model';
import { MotionCommentSection } from '../../../shared/models/motions/motion-comment-section';
import { Group } from '../../../shared/models/users/group';
import { BaseModel } from '../../../shared/models/base/base-model';
/**
* Motion comment section class for the View
*
* Stores a motion comment section including all (implicit) references
* Provides "safe" access to variables and functions in {@link MotionCommentSection}
* @ignore
*/
export class ViewMotionCommentSection extends BaseViewModel {
private _section: MotionCommentSection;
private _read_groups: Group[];
private _write_groups: Group[];
public edit = false;
public open = false;
public get section(): MotionCommentSection {
return this._section;
}
public get id(): number {
return this.section ? this.section.id : null;
}
public get name(): string {
return this.section ? this.section.name : null;
}
public get read_groups_id(): number[] {
return this.section ? this.section.read_groups_id : [];
}
public get write_groups_id(): number[] {
return this.section ? this.section.write_groups_id : [];
}
public get read_groups(): Group[] {
return this._read_groups;
}
public get write_groups(): Group[] {
return this._write_groups;
}
public set name(name: string) {
this._section.name = name;
}
public constructor(section: MotionCommentSection, read_groups: Group[], write_groups: Group[]) {
super();
this._section = section;
this._read_groups = read_groups;
this._write_groups = write_groups;
}
public getTitle(translate?: TranslateService): string {
return this.name;
}
/**
* Updates the local objects if required
* @param section
*/
public updateValues(update: BaseModel): void {
if (update instanceof MotionCommentSection) {
this._section = update as MotionCommentSection;
}
if (update instanceof Group) {
this.updateGroup(update as Group);
}
}
// TODO: Implement updating of groups
public updateGroup(group: Group): void {
console.log(this._section, group);
}
/**
* Duplicate this motion into a copy of itself
*/
public copy(): ViewMotionCommentSection {
return new ViewMotionCommentSection(this._section, this._read_groups, this._write_groups);
}
}

View File

@ -3,10 +3,12 @@ import { Routes, RouterModule } from '@angular/router';
import { MotionListComponent } from './components/motion-list/motion-list.component';
import { MotionDetailComponent } from './components/motion-detail/motion-detail.component';
import { CategoryListComponent } from './components/category-list/category-list.component';
import { MotionCommentSectionListComponent } from './components/motion-comment-section-list/motion-comment-section-list.component';
const routes: Routes = [
{ path: '', component: MotionListComponent },
{ path: 'category', component: CategoryListComponent },
{ path: 'comment-section', component: MotionCommentSectionListComponent },
{ path: 'new', component: MotionDetailComponent },
{ path: ':id', component: MotionDetailComponent }
];

View File

@ -6,9 +6,10 @@ import { SharedModule } from '../../shared/shared.module';
import { MotionListComponent } from './components/motion-list/motion-list.component';
import { MotionDetailComponent } from './components/motion-detail/motion-detail.component';
import { CategoryListComponent } from './components/category-list/category-list.component';
import { MotionCommentSectionListComponent } from './components/motion-comment-section-list/motion-comment-section-list.component';
@NgModule({
imports: [CommonModule, MotionsRoutingModule, SharedModule],
declarations: [MotionListComponent, MotionDetailComponent, CategoryListComponent]
declarations: [MotionListComponent, MotionDetailComponent, CategoryListComponent, MotionCommentSectionListComponent]
})
export class MotionsModule {}

View File

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

View File

@ -0,0 +1,61 @@
import { Injectable } from '@angular/core';
import { DataSendService } from '../../../core/services/data-send.service';
import { Observable } from 'rxjs';
import { DataStoreService } from '../../../core/services/data-store.service';
import { BaseRepository } from '../../base/base-repository';
import { ViewMotionCommentSection } from '../models/view-motion-comment-section';
import { MotionCommentSection } from '../../../shared/models/motions/motion-comment-section';
import { Group } from '../../../shared/models/users/group';
/**
* Repository Services for Categories
*
* The repository is meant to process domain objects (those found under
* shared/models), so components can display them and interact with them.
*
* Rather than manipulating models directly, the repository is meant to
* inform the {@link DataSendService} about changes which will send
* them to the Server.
*/
@Injectable({
providedIn: 'root'
})
export class MotionCommentSectionRepositoryService extends BaseRepository<
ViewMotionCommentSection,
MotionCommentSection
> {
/**
* Creates a CategoryRepository
* Converts existing and incoming category to ViewCategories
* Handles CRUD using an observer to the DataStore
* @param DataSend
*/
public constructor(protected DS: DataStoreService, private dataSend: DataSendService) {
super(DS, MotionCommentSection, [Group]);
}
protected createViewModel(section: MotionCommentSection): ViewMotionCommentSection {
const read_groups = this.DS.getMany(Group, section.read_groups_id);
const write_groups = this.DS.getMany(Group, section.write_groups_id);
return new ViewMotionCommentSection(section, read_groups, write_groups);
}
public create(section: MotionCommentSection): Observable<any> {
return this.dataSend.createModel(section);
}
public update(section: Partial<MotionCommentSection>, viewSection?: ViewMotionCommentSection): Observable<any> {
let updateSection: MotionCommentSection;
if (viewSection) {
updateSection = viewSection.section;
} else {
updateSection = new MotionCommentSection();
}
updateSection.patchValues(section);
return this.dataSend.updateModel(updateSection, 'put');
}
public delete(viewSection: ViewMotionCommentSection): Observable<any> {
return this.dataSend.delete(viewSection.section);
}
}

View File

@ -23,7 +23,6 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
AppModule,
CommonModule,
SharedModule,
HttpClientModule,
TranslateModule.forRoot({
loader: {

View File

@ -5,6 +5,7 @@ from django.utils.translation import ugettext as _
from ..poll.serializers import default_votes_validator
from ..utils.auth import get_group_model
from ..utils.autoupdate import inform_changed_data
from ..utils.rest_api import (
CharField,
DecimalField,
@ -314,6 +315,12 @@ class MotionCommentSectionSerializer(ModelSerializer):
'read_groups',
'write_groups',)
def create(self, validated_data):
""" Call inform_changed_data on creation, so the cache includes the groups. """
section = super().create(validated_data)
inform_changed_data(section)
return section
class MotionCommentSerializer(ModelSerializer):
"""