Merge pull request #3929 from FinnStutzenstein/motionComment

motion comments in the motion detail view
This commit is contained in:
Sean 2018-11-02 18:46:16 +01:00 committed by GitHub
commit a098e5c5c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 782 additions and 105 deletions

View File

@ -57,6 +57,10 @@ export class OperatorService extends OpenSlidesComponent {
this.updatePermissions(); this.updatePermissions();
} }
public get isAnonymous(): boolean {
return !this.user || this.user.id === 0;
}
/** /**
* Save, if quests are enabled. * 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. * Update the operators permissions and publish the operator afterwards.
*/ */

View File

@ -1,13 +1,57 @@
import { BaseModel } from '../base/base-model'; 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. * Representation of users personal note.
* @ignore * @ignore
*/ */
export class PersonalNote extends BaseModel<PersonalNote> { export class PersonalNote extends BaseModel<PersonalNote> implements PersonalNoteObject {
public id: number; public id: number;
public user_id: number; public user_id: number;
public notes: Object; public notes: PersonalNotesFormat;
public constructor(input: any) { public constructor(input: any) {
super('users/personal-note', input); super('users/personal-note', input);

View File

@ -108,14 +108,14 @@ export abstract class BaseRepository<V extends BaseViewModel, M extends BaseMode
/** /**
* helper function to return one viewModel * helper function to return one viewModel
*/ */
protected getViewModel(id: number): V { public getViewModel(id: number): V {
return this.viewModelStore[id]; return this.viewModelStore[id];
} }
/** /**
* helper function to return the viewModel as array * helper function to return the viewModel as array
*/ */
protected getViewModelList(): V[] { public getViewModelList(): V[] {
return Object.values(this.viewModelStore); return Object.values(this.viewModelStore);
} }

View File

@ -0,0 +1,45 @@
<ng-container *ngIf="vp.isMobile ; then mobileView; else desktopView"></ng-container>
<ng-template #title><ng-content select=".meta-text-block-title"></ng-content></ng-template>
<ng-template #content><ng-content select=".meta-text-block-content"></ng-content></ng-template>
<ng-template #actionRow><ng-content select=".meta-text-block-action-row"></ng-content></ng-template>
<ng-template #mobileView>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>{{ icon }}</mat-icon>
<ng-container *ngTemplateOutlet="title"></ng-container>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container *ngTemplateOutlet="content"></ng-container>
<mat-action-row *ngIf="showActionRow">
<ng-container *ngTemplateOutlet="actionRow"></ng-container>
</mat-action-row>
</mat-expansion-panel>
</ng-template>
<ng-template #desktopView>
<mat-card class="meta-text-block">
<mat-card-header>
<mat-card-title>
<div class="title-container">
<div>
<ng-container *ngTemplateOutlet="title"></ng-container>
</div>
<div>
<ng-container *ngTemplateOutlet="actionRow"></ng-container>
</div>
</div>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<ng-container *ngTemplateOutlet="content"></ng-container>
</mat-card-content>
</mat-card>
</ng-template>

View File

@ -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;
}
}

View File

@ -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<MetaTextBlockComponent>;
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();
});
});

View File

@ -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();
}
}

View File

@ -0,0 +1,32 @@
<os-meta-text-block *ngFor="let section of sections" [showActionRow]="canEditSection(section)" icon="comment">
<ng-container class="meta-text-block-title">
<span>{{ section.getTitle() }}</span>
</ng-container>
<ng-container class="meta-text-block-content">
<ng-container *ngIf="!isCommentEdited(section)">
<div *ngIf="comments[section.id]" [innerHTML]="comments[section.id].comment"></div>
<div class="no-content" *ngIf="!comments[section.id] || !comments[section.id].comment" translate>
No comment
</div>
</ng-container>
<form [formGroup]="commentForms[section.id]" *ngIf="isCommentEdited(section)">
<mat-form-field>
<textarea formControlName="comment" matInput placeholder="{{'Comment' | translate}}"></textarea>
</mat-form-field>
</form>
</ng-container>
<ng-container class="meta-text-block-action-row">
<button mat-icon-button *ngIf="!isCommentEdited(section)" (click)="editComment(section)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button *ngIf="isCommentEdited(section)" (click)="saveComment(section)">
<mat-icon>save</mat-icon>
</button>
<button mat-icon-button *ngIf="isCommentEdited(section)" (click)="cancelEditing(section)">
<mat-icon>close</mat-icon>
</button>
</ng-container>
</os-meta-text-block>

View File

@ -0,0 +1,3 @@
mat-form-field {
width: 100%;
}

View File

@ -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<MotionCommentsComponent>;
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();
});
});

View File

@ -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<void> {
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);
}
}

View File

@ -83,16 +83,8 @@
</div> </div>
</mat-expansion-panel> </mat-expansion-panel>
<!-- Personal Note --> <os-motion-comments [motion]="motion"></os-motion-comments>
<mat-expansion-panel> <os-personal-note [motion]="motion"></os-personal-note>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon icon>speaker_notes</mat-icon>
<span translate>Personal note</span>
</mat-panel-title>
</mat-expansion-panel-header>
TEST
</mat-expansion-panel>
<!-- Content --> <!-- Content -->
<mat-expansion-panel #contentPanel [expanded]='true'> <mat-expansion-panel #contentPanel [expanded]='true'>
@ -119,24 +111,8 @@
<ng-container *ngTemplateOutlet="metaInfoTemplate"></ng-container> <ng-container *ngTemplateOutlet="metaInfoTemplate"></ng-container>
</div> </div>
<!-- Personal Note --> <os-motion-comments [motion]="motion"></os-motion-comments>
<div class="personal-note"> <os-personal-note [motion]="motion"></os-personal-note>
<mat-card>
<mat-card-header>
<mat-card-title>
<span translate>Personal Note</span>
<div class="title-right">
<mat-icon>add</mat-icon>
<mat-icon>more_vert</mat-icon>
</div>
</mat-card-title>
</mat-card-header>
<mat-card-content>
Hier könnte ihre Werbung stehen. 1 2 3 4 5 6 Hier könnte ihre Werbung stehen. 1 2 3 4 5 6
</mat-card-content>
</mat-card>
</div>
</div> </div>
<div class="desktop-right "> <div class="desktop-right ">

View File

@ -47,12 +47,6 @@ span {
font-size: 70%; font-size: 70%;
} }
mat-panel-title {
mat-icon {
margin-right: 35px; //on line with text
}
}
.meta-info-block { .meta-info-block {
form { form {
div + div { div + div {
@ -153,42 +147,6 @@ mat-panel-title {
.meta-info-desktop { .meta-info-desktop {
padding-left: 20px; 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 { .desktop-right {

View File

@ -0,0 +1,32 @@
<os-meta-text-block showActionRow="true" icon="speaker_notes">
<ng-container class="meta-text-block-title">
<span translate>Personal note</span>
</ng-container>
<ng-container class="meta-text-block-content">
<ng-container *ngIf="!isEditMode">
<div *ngIf="personalNote" [innerHTML]="personalNote.note"></div>
<div class="no-content" *ngIf="!personalNote" translate>
No personal note
</div>
</ng-container>
<form [formGroup]="personalNoteForm" *ngIf="isEditMode">
<mat-form-field>
<textarea formControlName="note" matInput placeholder="{{'Personal note' | translate}}"></textarea>
</mat-form-field>
</form>
</ng-container>
<ng-container class="meta-text-block-action-row">
<button mat-icon-button *ngIf="!isEditMode" (click)="editPersonalNote()">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button *ngIf="isEditMode" (click)="savePersonalNote()">
<mat-icon>save</mat-icon>
</button>
<button mat-icon-button *ngIf="isEditMode" (click)="isEditMode = false">
<mat-icon>close</mat-icon>
</button>
</ng-container>
</os-meta-text-block>

View File

@ -0,0 +1,3 @@
mat-form-field {
width: 100%;
}

View File

@ -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<PersonalNoteComponent>;
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();
});
});

View File

@ -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<void> {
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();
}
}
}

View File

@ -105,7 +105,7 @@
</mat-accordion> </mat-accordion>
<mat-card *ngIf="statuteParagraphs.length === 0"> <mat-card *ngIf="statuteParagraphs.length === 0">
<mat-card-content> <mat-card-content>
<div class="noContent" translate>No statute paragraphs yet...</div> <div class="no-content" translate>No statute paragraphs yet...</div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

View File

@ -10,8 +10,3 @@
mat-card { mat-card {
margin-bottom: 20px; margin-bottom: 20px;
} }
.noContent {
text-align: center;
color: gray; /* TODO: remove this and replace with theme */
}

View File

@ -73,9 +73,7 @@ export class ViewMotionCommentSection extends BaseViewModel {
} }
// TODO: Implement updating of groups // TODO: Implement updating of groups
public updateGroup(group: Group): void { public updateGroup(group: Group): void {}
console.log(this._section, group);
}
/** /**
* Duplicate this motion into a copy of itself * Duplicate this motion into a copy of itself

View File

@ -5,6 +5,8 @@ import { Workflow } from '../../../shared/models/motions/workflow';
import { WorkflowState } from '../../../shared/models/motions/workflow-state'; import { WorkflowState } from '../../../shared/models/motions/workflow-state';
import { BaseModel } from '../../../shared/models/base/base-model'; import { BaseModel } from '../../../shared/models/base/base-model';
import { BaseViewModel } from '../../base/base-view-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 { export enum LineNumberingMode {
None, None,
@ -146,29 +148,13 @@ export class ViewMotion extends BaseViewModel {
} }
public set supporters(users: User[]) { public set supporters(users: User[]) {
const userIDArr: number[] = [];
users.forEach(user => {
userIDArr.push(user.id);
});
this._supporters = users; this._supporters = users;
this._motion.supporters_id = userIDArr; this._motion.supporters_id = users.map(user => user.id);
} }
public set submitters(users: User[]) { 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; this._submitters = users;
const submitterIDArr: number[] = []; this._motion.submitters_id = users.map(user => user.id);
// for the older backend:
users.forEach(user => {
submitterIDArr.push(user.id);
});
this._motion.submitters_id = submitterIDArr;
} }
public constructor( public constructor(
@ -204,6 +190,17 @@ export class ViewMotion extends BaseViewModel {
return this.title; 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 * Updates the local objects if required
* @param update * @param update

View File

@ -11,6 +11,9 @@ import { StatuteParagraphListComponent } from './components/statute-paragraph-li
import { MotionChangeRecommendationComponent } from './components/motion-change-recommendation/motion-change-recommendation.component'; 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 { 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 { 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({ @NgModule({
imports: [CommonModule, MotionsRoutingModule, SharedModule], imports: [CommonModule, MotionsRoutingModule, SharedModule],
@ -21,10 +24,19 @@ import { MotionDetailDiffComponent } from './components/motion-detail-diff/motio
MotionCommentSectionListComponent, MotionCommentSectionListComponent,
StatuteParagraphListComponent, StatuteParagraphListComponent,
MotionChangeRecommendationComponent, MotionChangeRecommendationComponent,
MotionCommentSectionListComponent,
MotionDetailOriginalChangeRecommendationsComponent, MotionDetailOriginalChangeRecommendationsComponent,
MotionDetailDiffComponent MotionDetailDiffComponent,
MotionCommentsComponent,
MetaTextBlockComponent,
PersonalNoteComponent
], ],
entryComponents: [MotionChangeRecommendationComponent] entryComponents: [
MotionChangeRecommendationComponent,
StatuteParagraphListComponent,
MotionCommentsComponent,
MotionCommentSectionListComponent,
MetaTextBlockComponent,
PersonalNoteComponent
]
}) })
export class MotionsModule {} export class MotionsModule {}

View File

@ -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();
}));
});

View File

@ -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<PersonalNoteContent>;
};
}
/**
* 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<PersonalNoteContent> {
if (!this.subjects[model.collectionString]) {
this.subjects[model.collectionString] = {};
}
if (!this.subjects[model.collectionString][model.id]) {
const subject = new BehaviorSubject<PersonalNoteContent>(
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<void> {
const pnObject: Partial<PersonalNoteObject> = 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);
}
}
}

View File

@ -65,6 +65,11 @@ body {
margin-left: 5px; margin-left: 5px;
} }
.no-content {
text-align: center;
color: gray;
}
.os-card { .os-card {
max-width: 90%; max-width: 90%;
margin-top: 10px; margin-top: 10px;

View File

@ -300,7 +300,7 @@ class MotionViewSet(ModelViewSet):
@detail_route(methods=['POST', 'DELETE']) @detail_route(methods=['POST', 'DELETE'])
def manage_comments(self, request, pk=None): 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': <id>, 'comment': '<comment>'} to create Send a post request with {'section_id': <id>, 'comment': '<comment>'} to create
a new comment or update an existing comment. a new comment or update an existing comment.
Send a delete request with just {'section_id': <id>} to delete the comment. Send a delete request with just {'section_id': <id>} to delete the comment.