From 430dbc1dff7648c132ae22d2da5a0252bd2684cb Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Tue, 16 Oct 2018 12:41:46 +0200 Subject: [PATCH] motion comments and personal note in the motion detail view --- .../src/app/core/services/operator.service.ts | 27 ++++ .../app/shared/models/users/personal-note.ts | 48 +++++- client/src/app/site/base/base-repository.ts | 4 +- .../meta-text-block.component.html | 45 ++++++ .../meta-text-block.component.scss | 39 +++++ .../meta-text-block.component.spec.ts | 26 ++++ .../meta-text-block.component.ts | 24 +++ .../motion-comments.component.html | 32 ++++ .../motion-comments.component.scss | 3 + .../motion-comments.component.spec.ts | 27 ++++ .../motion-comments.component.ts | 145 ++++++++++++++++++ .../motion-detail.component.html | 32 +--- .../motion-detail.component.scss | 42 ----- .../personal-note.component.html | 32 ++++ .../personal-note.component.scss | 3 + .../personal-note.component.spec.ts | 27 ++++ .../personal-note/personal-note.component.ts | 115 ++++++++++++++ .../statute-paragraph-list.component.html | 2 +- .../statute-paragraph-list.component.scss | 5 - .../models/view-motion-comment-section.ts | 4 +- .../app/site/motions/models/view-motion.ts | 33 ++-- client/src/app/site/motions/motions.module.ts | 18 ++- .../services/personal-note.service.spec.ts | 17 ++ .../motions/services/personal-note.service.ts | 130 ++++++++++++++++ client/src/styles.scss | 5 + openslides/motions/views.py | 2 +- 26 files changed, 782 insertions(+), 105 deletions(-) create mode 100644 client/src/app/site/motions/components/meta-text-block/meta-text-block.component.html create mode 100644 client/src/app/site/motions/components/meta-text-block/meta-text-block.component.scss create mode 100644 client/src/app/site/motions/components/meta-text-block/meta-text-block.component.spec.ts create mode 100644 client/src/app/site/motions/components/meta-text-block/meta-text-block.component.ts create mode 100644 client/src/app/site/motions/components/motion-comments/motion-comments.component.html create mode 100644 client/src/app/site/motions/components/motion-comments/motion-comments.component.scss create mode 100644 client/src/app/site/motions/components/motion-comments/motion-comments.component.spec.ts create mode 100644 client/src/app/site/motions/components/motion-comments/motion-comments.component.ts create mode 100644 client/src/app/site/motions/components/personal-note/personal-note.component.html create mode 100644 client/src/app/site/motions/components/personal-note/personal-note.component.scss create mode 100644 client/src/app/site/motions/components/personal-note/personal-note.component.spec.ts create mode 100644 client/src/app/site/motions/components/personal-note/personal-note.component.ts create mode 100644 client/src/app/site/motions/services/personal-note.service.spec.ts create mode 100644 client/src/app/site/motions/services/personal-note.service.ts diff --git a/client/src/app/core/services/operator.service.ts b/client/src/app/core/services/operator.service.ts index ebc356184..bb254e671 100644 --- a/client/src/app/core/services/operator.service.ts +++ b/client/src/app/core/services/operator.service.ts @@ -57,6 +57,10 @@ export class OperatorService extends OpenSlidesComponent { this.updatePermissions(); } + public get isAnonymous(): boolean { + return !this.user || this.user.id === 0; + } + /** * Save, if quests are enabled. */ @@ -132,6 +136,29 @@ export class OperatorService extends OpenSlidesComponent { }); } + /** + * Returns true, if the operator is in at least one group or he is in the admin group. + * @param groups The groups to check + */ + public isInGroup(...groups: Group[]): boolean { + return this.isInGroupIds(...groups.map(group => group.id)); + } + + /** + * Returns true, if the operator is in at least one group or he is in the admin group. + * @param groups The group ids to check + */ + public isInGroupIds(...groupIds: number[]): boolean { + if (!this.user) { + return groupIds.includes(1); // any anonymous is in the default group. + } + if (this.user.groups_id.includes(2)) { + // An admin has all perms and is technically in every group. + return true; + } + return groupIds.some(id => this.user.groups_id.includes(id)); + } + /** * Update the operators permissions and publish the operator afterwards. */ diff --git a/client/src/app/shared/models/users/personal-note.ts b/client/src/app/shared/models/users/personal-note.ts index bb229fced..61bf6f5bc 100644 --- a/client/src/app/shared/models/users/personal-note.ts +++ b/client/src/app/shared/models/users/personal-note.ts @@ -1,13 +1,57 @@ import { BaseModel } from '../base/base-model'; +/** + * The content every personal note has. + */ +export interface PersonalNoteContent { + /** + * Users can star content to mark as favorite. + */ + star: boolean; + + /** + * Users can save their notes. + */ + note: string; +} + +/** + * All notes are assigned to their object (given by collection string and id) + */ +export interface PersonalNotesFormat { + [collectionString: string]: { + [id: number]: PersonalNoteContent; + }; +} + +/** + * The base personal note object. + */ +export interface PersonalNoteObject { + /** + * Every personal note object has an id. + */ + id: number; + + /** + * The user for the object. + */ + user_id: number; + + /** + * The actual notes arranged in a specific format. + */ + notes: PersonalNotesFormat; +} + /** * Representation of users personal note. * @ignore */ -export class PersonalNote extends BaseModel { +export class PersonalNote extends BaseModel implements PersonalNoteObject { public id: number; public user_id: number; - public notes: Object; + public notes: PersonalNotesFormat; public constructor(input: any) { super('users/personal-note', input); diff --git a/client/src/app/site/base/base-repository.ts b/client/src/app/site/base/base-repository.ts index c98d9ba57..4978500ce 100644 --- a/client/src/app/site/base/base-repository.ts +++ b/client/src/app/site/base/base-repository.ts @@ -108,14 +108,14 @@ export abstract class BaseRepository + + + + + + + + + + {{ icon }} + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ +
+
+ + + +
+
+ diff --git a/client/src/app/site/motions/components/meta-text-block/meta-text-block.component.scss b/client/src/app/site/motions/components/meta-text-block/meta-text-block.component.scss new file mode 100644 index 000000000..35766086a --- /dev/null +++ b/client/src/app/site/motions/components/meta-text-block/meta-text-block.component.scss @@ -0,0 +1,39 @@ +mat-panel-title { + mat-icon { + margin-right: 35px; + } +} + +.meta-text-block { + padding: 0px; + margin: 20px; + margin-right: 0; + min-width: 10hv; + min-width: 200px; + + mat-card-header { + display: inherit; + padding: 10px; + margin: 0; + background-color: #eee; + + mat-card-title { + margin: 0; + + .title-container { + display: flex; + justify-content: space-between; + + :host ::ng-deep button { + width: 30px; + height: 30px; + line-height: 30px; + } + } + } + } + + mat-card-content { + padding: 15px; + } +} diff --git a/client/src/app/site/motions/components/meta-text-block/meta-text-block.component.spec.ts b/client/src/app/site/motions/components/meta-text-block/meta-text-block.component.spec.ts new file mode 100644 index 000000000..1d8e68eca --- /dev/null +++ b/client/src/app/site/motions/components/meta-text-block/meta-text-block.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MetaTextBlockComponent } from './meta-text-block.component'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('MetaTextBlockComponent', () => { + let component: MetaTextBlockComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MetaTextBlockComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MetaTextBlockComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/components/meta-text-block/meta-text-block.component.ts b/client/src/app/site/motions/components/meta-text-block/meta-text-block.component.ts new file mode 100644 index 000000000..21ebd3130 --- /dev/null +++ b/client/src/app/site/motions/components/meta-text-block/meta-text-block.component.ts @@ -0,0 +1,24 @@ +import { Component, Input } from '@angular/core'; + +import { BaseComponent } from '../../../../base.component'; +import { ViewportService } from '../../../../core/services/viewport.service'; + +/** + * Component for the motion comments view + */ +@Component({ + selector: 'os-meta-text-block', + templateUrl: './meta-text-block.component.html', + styleUrls: ['./meta-text-block.component.scss'] +}) +export class MetaTextBlockComponent extends BaseComponent { + @Input() + public showActionRow: boolean; + + @Input() + public icon: string; + + public constructor(public vp: ViewportService) { + super(); + } +} diff --git a/client/src/app/site/motions/components/motion-comments/motion-comments.component.html b/client/src/app/site/motions/components/motion-comments/motion-comments.component.html new file mode 100644 index 000000000..7d16d76d5 --- /dev/null +++ b/client/src/app/site/motions/components/motion-comments/motion-comments.component.html @@ -0,0 +1,32 @@ + + + {{ section.getTitle() }} + + + + +
+
+ No comment +
+
+
+ + + +
+
+ + + + + + +
+ diff --git a/client/src/app/site/motions/components/motion-comments/motion-comments.component.scss b/client/src/app/site/motions/components/motion-comments/motion-comments.component.scss new file mode 100644 index 000000000..bc76a27e2 --- /dev/null +++ b/client/src/app/site/motions/components/motion-comments/motion-comments.component.scss @@ -0,0 +1,3 @@ +mat-form-field { + width: 100%; +} diff --git a/client/src/app/site/motions/components/motion-comments/motion-comments.component.spec.ts b/client/src/app/site/motions/components/motion-comments/motion-comments.component.spec.ts new file mode 100644 index 000000000..861dd5610 --- /dev/null +++ b/client/src/app/site/motions/components/motion-comments/motion-comments.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MotionCommentsComponent } from './motion-comments.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; +import { MetaTextBlockComponent } from '../meta-text-block/meta-text-block.component'; + +describe('MotionCommentsComponent', () => { + let component: MotionCommentsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MetaTextBlockComponent, MotionCommentsComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MotionCommentsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/components/motion-comments/motion-comments.component.ts b/client/src/app/site/motions/components/motion-comments/motion-comments.component.ts new file mode 100644 index 000000000..fb823085d --- /dev/null +++ b/client/src/app/site/motions/components/motion-comments/motion-comments.component.ts @@ -0,0 +1,145 @@ +import { Component, Input } from '@angular/core'; + +import { BaseComponent } from '../../../../base.component'; +import { ViewportService } from '../../../../core/services/viewport.service'; +import { MotionCommentSectionRepositoryService } from '../../services/motion-comment-section-repository.service'; +import { ViewMotionCommentSection } from '../../models/view-motion-comment-section'; +import { OperatorService } from '../../../../core/services/operator.service'; +import { FormGroup, FormBuilder } from '@angular/forms'; +import { MotionComment } from '../../../../shared/models/motions/motion-comment'; +import { ViewMotion } from '../../models/view-motion'; +import { HttpService } from '../../../../core/services/http.service'; + +/** + * Component for the motion comments view + */ +@Component({ + selector: 'os-motion-comments', + templateUrl: './motion-comments.component.html', + styleUrls: ['./motion-comments.component.scss'] +}) +export class MotionCommentsComponent extends BaseComponent { + /** + * An array of all sections the operator can see. + */ + public sections: ViewMotionCommentSection[] = []; + + /** + * An object of forms for one comment mapped to the section id. + */ + public commentForms: { [id: number]: FormGroup } = {}; + + /** + * This object holds all comments for each section for the given motion. + */ + public comments: { [id: number]: MotionComment } = {}; + + /** + * The motion, which these comments belong to. + */ + private _motion: ViewMotion; + + @Input() + public set motion(motion: ViewMotion) { + this._motion = motion; + this.updateComments(); + } + + public get motion(): ViewMotion { + return this._motion; + } + + /** + * Watches for changes in sections and the operator. If one of them changes, the sections are reloaded + * and the comments updated. + */ + public constructor( + private commentRepo: MotionCommentSectionRepositoryService, + private http: HttpService, + private formBuilder: FormBuilder, + public vp: ViewportService, + private operator: OperatorService + ) { + super(); + + this.commentRepo.getViewModelListObservable().subscribe(sections => this.setSections(sections)); + this.operator.getObservable().subscribe(() => this.setSections(this.commentRepo.getViewModelList())); + } + + /** + * sets the `sections` member with sections, if the operator has reading permissions. + * @param allSections A list of all sections available + */ + private setSections(allSections: ViewMotionCommentSection[]): void { + this.sections = allSections.filter(section => this.operator.isInGroupIds(...section.read_groups_id)); + this.updateComments(); + } + + /** + * Returns true if the operator has write permissions for the given section, so he can edit the comment. + * @param section The section to judge about + */ + public canEditSection(section: ViewMotionCommentSection): boolean { + return this.operator.isInGroupIds(...section.write_groups_id); + } + + /** + * Update the comments. Comments are saved in the `comments` object associated with their section id. + */ + private updateComments(): void { + this.comments = {}; + if (!this.motion || !this.sections) { + return; + } + this.sections.forEach(section => { + this.comments[section.id] = this.motion.getCommentForSection(section); + }); + } + + /** + * Puts the comment into edit mode. + * @param section The section for the comment. + */ + public editComment(section: ViewMotionCommentSection): void { + const comment = this.comments[section.id]; + const form = this.formBuilder.group({ + comment: [comment ? comment.comment : ''] + }); + this.commentForms[section.id] = form; + } + + /** + * Saves the comment. Makes a request to the server. + * @param section The section for the comment to save + */ + public async saveComment(section: ViewMotionCommentSection): Promise { + const commentText = this.commentForms[section.id].get('comment').value; + try { + await this.http + .post(`rest/motions/motion/${this.motion.id}/manage_comments/`, { + section_id: section.id, + comment: commentText + }); + this.cancelEditing(section); + } catch (e) { + console.log(e); + // TODO: Errorhandling + } + } + + /** + * Cancles the editing for a comment. + * @param section The section for the comment + */ + public cancelEditing(section: ViewMotionCommentSection): void { + delete this.commentForms[section.id]; + } + + /** + * Returns true, if the comment is edited. + * @param section The section for the comment. + */ + public isCommentEdited(section: ViewMotionCommentSection): boolean { + return Object.keys(this.commentForms).includes('' + section.id); + } +} 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 eabda516a..3cc37d37c 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 @@ -83,16 +83,8 @@ - - - - - speaker_notes - Personal note - - - TEST - + + @@ -119,24 +111,8 @@ - -
- - - - Personal Note -
- add - more_vert -
- -
-
- - Hier könnte ihre Werbung stehen. 1 2 3 4 5 6 Hier könnte ihre Werbung stehen. 1 2 3 4 5 6 - -
-
+ +
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 b15b62ece..7e4ac8114 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 @@ -47,12 +47,6 @@ span { font-size: 70%; } -mat-panel-title { - mat-icon { - margin-right: 35px; //on line with text - } -} - .meta-info-block { form { div + div { @@ -153,42 +147,6 @@ mat-panel-title { .meta-info-desktop { padding-left: 20px; } - - .personal-note { - mat-card { - padding: 0px; - margin: 20px; - min-width: 10hv; - min-width: 200px; - - .mat-card-header-text { - width: 100%; - } - - mat-card-header { - display: inherit; - padding: 15px; - margin: 0; - background-color: #eee; - - .title-right { - float: right; - mat-icon { - padding-left: 10px; - } - } - - mat-card-title { - font-weight: bold; - display: inline; - } - } - - mat-card-content { - padding: 30px 15px 15px 15px; - } - } - } } .desktop-right { diff --git a/client/src/app/site/motions/components/personal-note/personal-note.component.html b/client/src/app/site/motions/components/personal-note/personal-note.component.html new file mode 100644 index 000000000..d80e4ca6e --- /dev/null +++ b/client/src/app/site/motions/components/personal-note/personal-note.component.html @@ -0,0 +1,32 @@ + + + Personal note + + + + +
+
+ No personal note +
+
+
+ + + +
+
+ + + + + + +
+ diff --git a/client/src/app/site/motions/components/personal-note/personal-note.component.scss b/client/src/app/site/motions/components/personal-note/personal-note.component.scss new file mode 100644 index 000000000..bc76a27e2 --- /dev/null +++ b/client/src/app/site/motions/components/personal-note/personal-note.component.scss @@ -0,0 +1,3 @@ +mat-form-field { + width: 100%; +} diff --git a/client/src/app/site/motions/components/personal-note/personal-note.component.spec.ts b/client/src/app/site/motions/components/personal-note/personal-note.component.spec.ts new file mode 100644 index 000000000..e26b30989 --- /dev/null +++ b/client/src/app/site/motions/components/personal-note/personal-note.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PersonalNoteComponent } from './personal-note.component'; +import { E2EImportsModule } from 'e2e-imports.module'; +import { MetaTextBlockComponent } from '../meta-text-block/meta-text-block.component'; + +describe('PersonalNoteComponent', () => { + let component: PersonalNoteComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MetaTextBlockComponent, PersonalNoteComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PersonalNoteComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/components/personal-note/personal-note.component.ts b/client/src/app/site/motions/components/personal-note/personal-note.component.ts new file mode 100644 index 000000000..d7c6240ad --- /dev/null +++ b/client/src/app/site/motions/components/personal-note/personal-note.component.ts @@ -0,0 +1,115 @@ +import { Component, Input, OnDestroy } from '@angular/core'; + +import { BaseComponent } from '../../../../base.component'; +import { ViewMotion } from '../../models/view-motion'; +import { PersonalNoteService } from '../../services/personal-note.service'; +import { Subscription } from 'rxjs'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { PersonalNoteContent } from '../../../../shared/models/users/personal-note'; + +/** + * Component for the motion comments view + */ +@Component({ + selector: 'os-personal-note', + templateUrl: './personal-note.component.html', + styleUrls: ['./personal-note.component.scss'] +}) +export class PersonalNoteComponent extends BaseComponent implements OnDestroy { + /** + * The motion, which the personal note belong to. + */ + private _motion: ViewMotion; + + /** + * Sets the motion. If the motion updates (changes, and so on), the subscription + * for the personal note will be established. + */ + @Input() + public set motion(motion: ViewMotion) { + this._motion = motion; + if (this.personalNoteSubscription) { + this.personalNoteSubscription.unsubscribe(); + } + if (motion && motion.motion) { + this.personalNoteSubscription = this.personalNoteService + .getPersonalNoteObserver(motion.motion) + .subscribe(pn => { + this.personalNote = pn; + }); + } + } + + public get motion(): ViewMotion { + return this._motion; + } + + /** + * The edit form for the note + */ + public personalNoteForm: FormGroup; + + /** + * Saves, if the users edits the note. + */ + public isEditMode = false; + + /** + * The personal note. + */ + public personalNote: PersonalNoteContent; + + /** + * The subscription for the personal note. + */ + private personalNoteSubscription: Subscription; + + public constructor(private personalNoteService: PersonalNoteService, formBuilder: FormBuilder) { + super(); + this.personalNoteForm = formBuilder.group({ + note: [''] + }); + } + + /** + * Sets up the form. + */ + public editPersonalNote(): void { + this.personalNoteForm.reset(); + this.personalNoteForm.patchValue({ + note: this.personalNote ? this.personalNote.note : '' + }); + this.isEditMode = true; + } + + /** + * Saves the personal note. If it does not exists, it will be created. + */ + public async savePersonalNote(): Promise { + let content: PersonalNoteContent; + if (this.personalNote) { + content = Object.assign({}, this.personalNote); + content.note = this.personalNoteForm.get('note').value; + } else { + content = { + note: this.personalNoteForm.get('note').value, + star: false + }; + } + try { + await this.personalNoteService.savePersonalNote(this.motion.motion, content); + this.isEditMode = false; + } catch (e) { + console.log(e); + } + } + + /** + * Remove the subscription if this component isn't needed anymore. + */ + public ngOnDestroy(): void { + if (this.personalNoteSubscription) { + this.personalNoteSubscription.unsubscribe(); + } + } +} diff --git a/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.html b/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.html index aa8f55ba6..0be0fa5e6 100644 --- a/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.html +++ b/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.html @@ -105,7 +105,7 @@ - +
No statute paragraphs yet...
diff --git a/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.scss b/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.scss index 9ad75da1d..5f11a3ab3 100644 --- a/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.scss +++ b/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.scss @@ -10,8 +10,3 @@ mat-card { margin-bottom: 20px; } - -.noContent { - text-align: center; - color: gray; /* TODO: remove this and replace with theme */ -} diff --git a/client/src/app/site/motions/models/view-motion-comment-section.ts b/client/src/app/site/motions/models/view-motion-comment-section.ts index 4e3be0a12..2d72a88e6 100644 --- a/client/src/app/site/motions/models/view-motion-comment-section.ts +++ b/client/src/app/site/motions/models/view-motion-comment-section.ts @@ -73,9 +73,7 @@ export class ViewMotionCommentSection extends BaseViewModel { } // TODO: Implement updating of groups - public updateGroup(group: Group): void { - console.log(this._section, group); - } + public updateGroup(group: Group): void {} /** * Duplicate this motion into a copy of itself diff --git a/client/src/app/site/motions/models/view-motion.ts b/client/src/app/site/motions/models/view-motion.ts index d91f9fd44..8a49d6e7c 100644 --- a/client/src/app/site/motions/models/view-motion.ts +++ b/client/src/app/site/motions/models/view-motion.ts @@ -5,6 +5,8 @@ import { Workflow } from '../../../shared/models/motions/workflow'; import { WorkflowState } from '../../../shared/models/motions/workflow-state'; import { BaseModel } from '../../../shared/models/base/base-model'; import { BaseViewModel } from '../../base/base-view-model'; +import { ViewMotionCommentSection } from './view-motion-comment-section'; +import { MotionComment } from '../../../shared/models/motions/motion-comment'; export enum LineNumberingMode { None, @@ -146,29 +148,13 @@ export class ViewMotion extends BaseViewModel { } public set supporters(users: User[]) { - const userIDArr: number[] = []; - users.forEach(user => { - userIDArr.push(user.id); - }); this._supporters = users; - this._motion.supporters_id = userIDArr; + this._motion.supporters_id = users.map(user => user.id); } public set submitters(users: User[]) { - // For the newer backend with weight: - // const submitterArr: MotionSubmitter[] = [] - // users.forEach(user => { - // const motionSub = new MotionSubmitter(); - // submitterArr.push(motionSub); - // }); - // this._motion.submitters = submitterArr; this._submitters = users; - const submitterIDArr: number[] = []; - // for the older backend: - users.forEach(user => { - submitterIDArr.push(user.id); - }); - this._motion.submitters_id = submitterIDArr; + this._motion.submitters_id = users.map(user => user.id); } public constructor( @@ -204,6 +190,17 @@ export class ViewMotion extends BaseViewModel { return this.title; } + /** + * Returns the motion comment for the given section. Null, if no comment exist. + * @param section The section to search the comment for. + */ + public getCommentForSection(section: ViewMotionCommentSection): MotionComment { + if (!this.motion) { + return null; + } + return this.motion.comments.find(comment => comment.section_id === section.id); + } + /** * Updates the local objects if required * @param update diff --git a/client/src/app/site/motions/motions.module.ts b/client/src/app/site/motions/motions.module.ts index fa15aead1..006e7e2d4 100644 --- a/client/src/app/site/motions/motions.module.ts +++ b/client/src/app/site/motions/motions.module.ts @@ -11,6 +11,9 @@ import { StatuteParagraphListComponent } from './components/statute-paragraph-li import { MotionChangeRecommendationComponent } from './components/motion-change-recommendation/motion-change-recommendation.component'; import { MotionDetailOriginalChangeRecommendationsComponent } from './components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component'; import { MotionDetailDiffComponent } from './components/motion-detail-diff/motion-detail-diff.component'; +import { MotionCommentsComponent } from './components/motion-comments/motion-comments.component'; +import { MetaTextBlockComponent } from './components/meta-text-block/meta-text-block.component'; +import { PersonalNoteComponent } from './components/personal-note/personal-note.component'; @NgModule({ imports: [CommonModule, MotionsRoutingModule, SharedModule], @@ -21,10 +24,19 @@ import { MotionDetailDiffComponent } from './components/motion-detail-diff/motio MotionCommentSectionListComponent, StatuteParagraphListComponent, MotionChangeRecommendationComponent, - MotionCommentSectionListComponent, MotionDetailOriginalChangeRecommendationsComponent, - MotionDetailDiffComponent + MotionDetailDiffComponent, + MotionCommentsComponent, + MetaTextBlockComponent, + PersonalNoteComponent ], - entryComponents: [MotionChangeRecommendationComponent] + entryComponents: [ + MotionChangeRecommendationComponent, + StatuteParagraphListComponent, + MotionCommentsComponent, + MotionCommentSectionListComponent, + MetaTextBlockComponent, + PersonalNoteComponent + ] }) export class MotionsModule {} diff --git a/client/src/app/site/motions/services/personal-note.service.spec.ts b/client/src/app/site/motions/services/personal-note.service.spec.ts new file mode 100644 index 000000000..64b29de59 --- /dev/null +++ b/client/src/app/site/motions/services/personal-note.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { PersonalNoteService } from './personal-note.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('PersonalNoteService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [PersonalNoteService] + }); + }); + + it('should be created', inject([PersonalNoteService], (service: PersonalNoteService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/site/motions/services/personal-note.service.ts b/client/src/app/site/motions/services/personal-note.service.ts new file mode 100644 index 000000000..3711203a3 --- /dev/null +++ b/client/src/app/site/motions/services/personal-note.service.ts @@ -0,0 +1,130 @@ +import { Injectable } from '@angular/core'; +import { Observable, BehaviorSubject } from 'rxjs'; +import { DataStoreService } from '../../../core/services/data-store.service'; +import { OperatorService } from '../../../core/services/operator.service'; +import { PersonalNote, PersonalNoteObject, PersonalNoteContent } from '../../../shared/models/users/personal-note'; +import { BaseModel } from '../../../shared/models/base/base-model'; +import { HttpService } from '../../../core/services/http.service'; + +/** + * All subjects are organized by the collection string and id of the model. + */ +interface PersonalNoteSubjects { + [collectionString: string]: { + [id: number]: BehaviorSubject; + }; +} + +/** + * Handles personal notes. + * + * Get updated by subscribing to `getPersonalNoteObserver`. Save personal notes by calling + * `savePersonalNote`. + */ +@Injectable({ + providedIn: 'root' +}) +export class PersonalNoteService { + /** + * The personal note object for the operator + */ + private personalNoteObject: PersonalNoteObject; + + /** + * All subjects for all observers. + */ + private subjects: PersonalNoteSubjects = {}; + + /** + * Watches for changes in the personal note model. + */ + public constructor(private operator: OperatorService, private DS: DataStoreService, private http: HttpService) { + operator.getObservable().subscribe(() => this.updatePersonalNoteObject()); + this.DS.changeObservable.subscribe(model => { + if (model instanceof PersonalNote) { + this.updatePersonalNoteObject(); + } + }); + } + + /** + * Updates the personal note object and notifies the subscribers. + */ + private updatePersonalNoteObject(): void { + if (this.operator.isAnonymous) { + return; + } + + // Get the note for the operator. + const operatorId = this.operator.user.id; + const objects = this.DS.filter(PersonalNote, pn => pn.user_id === operatorId); + this.personalNoteObject = objects.length === 0 ? null : objects[0]; + + this.updateSubscribers(); + } + + /** + * Update all subscribers. + */ + private updateSubscribers(): void { + Object.keys(this.subjects).forEach(collectionString => { + Object.keys(this.subjects[collectionString]).forEach(id => { + this.subjects[collectionString][id].next(this.getPersonalNoteContent(collectionString, +id)); + }); + }); + } + + /** + * Gets the content from a note by the collection string and id. + */ + private getPersonalNoteContent(collectionString: string, id: number): PersonalNoteContent { + if ( + !this.personalNoteObject || + !this.personalNoteObject.notes || + !this.personalNoteObject.notes[collectionString] || + !this.personalNoteObject.notes[collectionString][id] + ) { + return null; + } + return this.personalNoteObject.notes[collectionString][id]; + } + + /** + * Returns an observalbe for a given BaseModel. + * @param model The model to observe the personal note from. + */ + public getPersonalNoteObserver(model: BaseModel): Observable { + if (!this.subjects[model.collectionString]) { + this.subjects[model.collectionString] = {}; + } + if (!this.subjects[model.collectionString][model.id]) { + const subject = new BehaviorSubject( + this.getPersonalNoteContent(model.collectionString, model.id) + ); + this.subjects[model.collectionString][model.id] = subject; + } + return this.subjects[model.collectionString][model.id]; + } + + /** + * Saves the personal note for the given model. + * @param model The model the content belongs to + * @param content The new content. + */ + public async savePersonalNote(model: BaseModel, content: PersonalNoteContent): Promise { + const pnObject: Partial = this.personalNoteObject || {}; + if (!pnObject.notes) { + pnObject.notes = {}; + } + if (!pnObject.notes[model.collectionString]) { + pnObject.notes[model.collectionString] = {}; + } + + pnObject.notes[model.collectionString][model.id] = content; + if (!pnObject.id) { + await this.http.post('rest/users/personal-note/', pnObject); + } else { + await this.http.put(`rest/users/personal-note/${pnObject.id}/`, pnObject); + } + } +} diff --git a/client/src/styles.scss b/client/src/styles.scss index cb0e1033d..663cd61ad 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -65,6 +65,11 @@ body { margin-left: 5px; } +.no-content { + text-align: center; + color: gray; +} + .os-card { max-width: 90%; margin-top: 10px; diff --git a/openslides/motions/views.py b/openslides/motions/views.py index e9699d709..2ce47613b 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -300,7 +300,7 @@ class MotionViewSet(ModelViewSet): @detail_route(methods=['POST', 'DELETE']) def manage_comments(self, request, pk=None): """ - Create, update and delete motin comments. + Create, update and delete motion comments. Send a post request with {'section_id': , 'comment': ''} to create a new comment or update an existing comment. Send a delete request with just {'section_id': } to delete the comment.