Add motion blocks

This commit is contained in:
Sean Engelhardt 2018-12-06 12:28:05 +01:00
parent 3b72e720b3
commit 03508c903f
26 changed files with 1030 additions and 38 deletions

View File

@ -10,6 +10,16 @@ interface ContentObject {
collection: string; collection: string;
} }
/**
* Determine visibility states for agenda items
* Coming from "OpenSlidesConfigVariables" property "agenda_hide_internal_items_on_projector"
*/
export const itemVisibilityChoices = [
{ key: 1, name: 'Public item' },
{ key: 2, name: 'Internal item' },
{ key: 3, name: 'Hidden item' }
];
/** /**
* Representations of agenda Item * Representations of agenda Item
* @ignore * @ignore

View File

@ -17,7 +17,12 @@ export class MotionBlock extends AgendaBaseModel {
return this.title; return this.title;
} }
/**
* Get the URL to the motion block
*
* @returns the URL as string
*/
public getDetailStateURL(): string { public getDetailStateURL(): string {
return 'TODO'; return `/motions/blocks/${this.id}`;
} }
} }

View File

@ -3,8 +3,7 @@
} }
.topic-title { .topic-title {
padding: 40px; padding: 40px 0 40px 25px;
padding-left: 25px;
line-height: 180%; line-height: 180%;
font-size: 120%; font-size: 120%;
color: #317796; // TODO: put in theme as $primary color: #317796; // TODO: put in theme as $primary

View File

@ -66,7 +66,7 @@
{{ updateForm.get('name').value }} {{ updateForm.get('name').value }}
</div> </div>
</div> </div>
<div class="header-size"> <div class="header-size os-amount-chip">
{{ motionsInCategory(category).length }} {{ motionsInCategory(category).length }}
</div> </div>
</div> </div>

View File

@ -31,13 +31,6 @@
.header-size { .header-size {
grid-column-start: 3; grid-column-start: 3;
border-radius: 50%;
width: 20px;
height: 20px;
padding: 3px;
background: lightgray;
color: #000;
text-align: center;
} }
} }

View File

@ -0,0 +1,124 @@
<os-head-bar
mainButtonIcon="edit"
[nav]="false"
[mainButton]="true"
[editMode]="editBlock"
(mainEvent)="toggleEditMode()"
(saveEvent)="saveBlock()"
>
<!-- Title -->
<div class="title-slot">
<h2 *ngIf="block && !editBlock">{{ 'Motion block' | translate }} {{ block.id }}</h2>
<form [formGroup]="blockEditForm" (ngSubmit)="saveBlock()" (keydown)="onKeyDown($event)" *ngIf="editBlock">
<mat-form-field>
<input
type="text"
matInput
osAutofocus
required
formControlName="title"
placeholder="{{ 'Title' | translate }}"
/>
</mat-form-field>
</form>
</div>
<!-- Menu -->
<div class="menu-slot">
<button type="button" mat-icon-button [matMenuTriggerFor]="motionBlockMenu">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</os-head-bar>
<!-- Title -->
<div *ngIf="block" class="block-title on-transition-fade">
<h2 *ngIf="!editBlock">{{ block.title }}</h2>
<h2 *ngIf="editBlock">{{ blockEditForm.get('title').value }}</h2>
</div>
<mat-card class="block-card">
<button mat-raised-button color="primary" (click)="onFollowRecButton()" [disabled]="isFollowingProhibited()">
<mat-icon>done_all</mat-icon>
<span translate>Follow recommendations for all motions</span>
</button>
<table class="os-headed-listview-table on-transition-fade" mat-table [dataSource]="dataSource" matSort>
<!-- title column -->
<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef mat-sort-header> <span translate>Motion</span> </mat-header-cell>
<mat-cell *matCellDef="let motion" (click)="onClickMotionTitle(motion)"> {{ motion.title }} </mat-cell>
</ng-container>
<!-- state column -->
<ng-container matColumnDef="state">
<mat-header-cell *matHeaderCellDef> <span translate>State</span> </mat-header-cell>
<mat-cell class="chip-container" *matCellDef="let motion">
<mat-basic-chip
disableRipple
[ngClass]="{
green: motion.state.css_class === 'success',
red: motion.state.css_class === 'danger',
grey: motion.state.css_class === 'default',
lightblue: motion.state.css_class === 'primary'
}"
>
{{ motion.state.name | translate }}
</mat-basic-chip>
</mat-cell>
</ng-container>
<!-- Recommendation column -->
<ng-container matColumnDef="recommendation">
<mat-header-cell *matHeaderCellDef> <span translate>Recommendation</span> </mat-header-cell>
<mat-cell class="chip-container" *matCellDef="let motion">
<mat-basic-chip disableRipple class="bluegrey">
{{
motion.recommendation
? (motion.recommendation.recommendation_label | translate)
: ('not set' | translate)
}}
</mat-basic-chip>
</mat-cell>
</ng-container>
<!-- Remove motion column -->
<ng-container matColumnDef="remove">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let motion">
<button
type="button"
mat-icon-button
color="warn"
matTooltip="{{ 'Remove from motion block' | translate }}"
(click)="onRemoveMotionButton(motion)"
>
<mat-icon>close</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
<mat-row *matRowDef="let row; columns: getColumnDefinition()"> </mat-row>
</table>
</mat-card>
<!-- The menu content -->
<mat-menu #motionBlockMenu="matMenu">
<button mat-menu-item [routerLink]="getSpeakerLink()">
<mat-icon>mic</mat-icon>
<span translate>List of speakers</span>
</button>
<button mat-menu-item>
<mat-icon>videocam</mat-icon>
<span translate>Project</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item class="red-warning-text" (click)="onDeleteBlockButton()">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
</button>
</mat-menu>

View File

@ -0,0 +1,52 @@
.block-title {
padding: 40px;
padding-left: 25px;
line-height: 180%;
font-size: 120%;
color: #317796; // TODO: put in theme as $primary
h2 {
margin: 0;
font-weight: normal;
}
}
.block-card {
margin: 0 20px 0 20px;
padding: 25px;
button {
.mat-icon {
margin-right: 5px;
}
}
}
.chip-container {
display: block;
height: 5em;
line-height: 5em;
}
.os-headed-listview-table {
// Title
.mat-column-title {
flex: 4 0 0;
}
// State
.mat-column-state {
flex: 2 0 0;
}
// Recommendation
.mat-column-recommendation {
flex: 2 0 0;
}
// Remove
.mat-column-remove {
flex: 1 0 0;
justify-content: flex-end !important;
}
}

View File

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

View File

@ -0,0 +1,209 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
import { ListViewBaseComponent } from 'app/site/base/list-view-base';
import { MotionBlockRepositoryService } from '../../services/motion-block-repository.service';
import { MotionRepositoryService } from '../../services/motion-repository.service';
import { MotionBlock } from 'app/shared/models/motions/motion-block';
import { ViewMotionBlock } from '../../models/view-motion-block';
import { ViewMotion } from '../../models/view-motion';
import { PromptService } from 'app/core/services/prompt.service';
/**
* Detail component to display one motion block
*/
@Component({
selector: 'os-motion-block-detail',
templateUrl: './motion-block-detail.component.html',
styleUrls: ['./motion-block-detail.component.scss']
})
export class MotionBlockDetailComponent extends ListViewBaseComponent<ViewMotion> implements OnInit {
/**
* Determines the block id from the given URL
*/
public block: ViewMotionBlock;
/**
* All motions in this block
*/
public motions: ViewMotion[];
/**
* Determine the edit mode
*/
public editBlock = false;
/**
* The form to edit blocks
*/
@ViewChild('blockEditForm')
public blockEditForm: FormGroup;
/**
* Constructor for motion block details
*
* @param titleService Setting the title
* @param translate translations
* @param matSnackBar showing errors
* @param router navigating
* @param route determine the blocks ID by the route
* @param repo the motion blocks repository
* @param motionRepo the motion repository
* @param promptService the displaying prompts before deleting
*/
public constructor(
titleService: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private router: Router,
private route: ActivatedRoute,
private repo: MotionBlockRepositoryService,
private motionRepo: MotionRepositoryService,
private promptService: PromptService
) {
super(titleService, translate, matSnackBar);
}
/**
* Init function.
* Sets the title, observes the block and the motions belonging in this block
*/
public ngOnInit(): void {
super.setTitle('Motion Block');
this.initTable();
this.blockEditForm = new FormGroup({
title: new FormControl('', Validators.required)
});
const blockId = +this.route.snapshot.params.id;
this.block = this.repo.getViewModel(blockId);
this.repo.getViewModelObservable(blockId).subscribe(newBlock => {
// necessary since the subscription can return undefined
if (newBlock) {
this.block = newBlock;
// set the blocks title in the form
this.blockEditForm.get('title').setValue(this.block.title);
this.repo.getViewMotionsByBlock(this.block.motionBlock).subscribe(newMotions => {
this.motions = newMotions;
this.dataSource.data = this.motions;
});
}
});
}
/**
* Get link to the list of speakers of the corresponding agenda item
*
* @returns the link to the list of speakers as string
*/
public getSpeakerLink(): string {
if (this.block) {
return `/agenda/${this.block.agenda_item_id}/speakers`;
}
}
/**
* Returns the columns that should be shown in the table
*
* @returns an array of strings building the column definition
*/
public getColumnDefinition(): string[] {
return ['title', 'state', 'recommendation', 'remove'];
}
/**
* Click handler for recommendation button
*/
public async onFollowRecButton(): Promise<void> {
const content = this.translate.instant(
`Are you sure you want to override the state of all motions of this motion block?`
);
if (await this.promptService.open(this.block.title, content)) {
for (const motion of this.motions) {
if (!motion.isInFinalState()) {
this.motionRepo.setState(motion, motion.recommendation_id);
}
}
}
}
/**
* Click handler for the motion title cell in the table
* Navigate to the motion that was clicked on
*
* @param motion the selected ViewMotion
*/
public onClickMotionTitle(motion: ViewMotion): void {
this.router.navigate([`/motions/${motion.id}`]);
}
/**
* Click handler to delete motion blocks
*/
public async onDeleteBlockButton(): Promise<void> {
const content = this.translate.instant('Are you sure you want to delete this motion block?');
if (await this.promptService.open(this.block.title, content)) {
await this.repo.delete(this.block);
this.router.navigate(['../'], { relativeTo: this.route });
}
}
/**
* Click handler for the delete button on the table
*
* @param motion the corresponding motion
*/
public async onRemoveMotionButton(motion: ViewMotion): Promise<void> {
const content = this.translate.instant('Are you sure you want to remove this motion from motion block?');
if (await this.promptService.open(motion.title, content)) {
this.repo.removeMotionFromBlock(motion);
}
}
/**
* Clicking escape while in editForm should deactivate edit mode.
*
* @param event The key that was pressed
*/
public onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
this.editBlock = false;
}
}
/**
* Determine if following the recommendations should be possible.
* Following a recommendation implies, that a valid recommendation exists.
*/
public isFollowingProhibited(): boolean {
if (this.motions) {
return this.motions.every(motion => motion.isInFinalState() || !motion.recommendation_id);
} else {
return false;
}
}
/**
* Save event handler
*/
public saveBlock(): void {
this.editBlock = false;
this.repo.update(this.blockEditForm.value as MotionBlock, this.block);
}
/**
* Click handler for the edit button
*/
public toggleEditMode(): void {
this.editBlock = !this.editBlock;
}
}

View File

@ -0,0 +1,77 @@
<os-head-bar [nav]="false" [mainButton]="true" (mainEvent)="onPlusButton()">
<!-- Title -->
<div class="title-slot"><h2 translate>Motion blocks</h2></div>
</os-head-bar>
<!-- Creating a new motion block -->
<mat-card class="os-card" *ngIf="blockToCreate">
<mat-card-title translate>Create new motion block</mat-card-title>
<mat-card-content>
<form [formGroup]="createBlockForm">
<!-- Title -->
<p>
<mat-form-field>
<input
formControlName="title"
matInput
placeholder="{{ 'Title' | translate }}"
required
/>
<mat-error *ngIf="createBlockForm.get('title').hasError('required')" translate>
A name is required
</mat-error>
</mat-form-field>
</p>
<!-- Parent item -->
<p>
<os-search-value-selector
ngDefaultControl
listname="{{ 'Parent Item' | translate }}"
[form]="createBlockForm"
[formControl]="createBlockForm.get('agenda_parent_id')"
[multiple]="false"
[includeNone]="true"
[InputListValues]="items"
></os-search-value-selector>
</p>
<!-- Visibility -->
<mat-form-field>
<mat-select formControlName="agenda_type" placeholder="{{ 'Agenda visibility' | translate }}">
<mat-option *ngFor="let type of itemVisibility" [value]="type.key">
<span>{{ type.name | translate }}</span>
</mat-option>
</mat-select>
</mat-form-field>
</form>
</mat-card-content>
<!-- Save and Cancel buttons -->
<mat-card-actions>
<button mat-button (click)="onSaveNewButton()"><span translate>Save</span></button>
<button mat-button (click)="blockToCreate = null"><span translate>Cancel</span></button>
</mat-card-actions>
</mat-card>
<!-- Table -->
<mat-card class="os-card">
<table class="os-headed-listview-table on-transition-fade" mat-table [dataSource]="dataSource" matSort>
<!-- title column -->
<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef mat-sort-header> <span translate>Name</span> </mat-header-cell>
<mat-cell *matCellDef="let block"> {{ block.title }} </mat-cell>
</ng-container>
<!-- amount column -->
<ng-container matColumnDef="amount">
<mat-header-cell *matHeaderCellDef> <span translate>Motions</span> </mat-header-cell>
<mat-cell *matCellDef="let block">
<span class="os-amount-chip">{{ getMotionAmount(block.motionBlock) }}</span>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
<mat-row (click)="onSelectRow(row)" *matRowDef="let row; columns: getColumnDefinition()"> </mat-row>
</table>
</mat-card>

View File

@ -0,0 +1,11 @@
.os-headed-listview-table {
// Title
.mat-column-title {
flex: 3 0 0;
}
// Amount
.mat-column-amount {
flex: 1 0 0;
}
}

View File

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

View File

@ -0,0 +1,169 @@
import { Component, OnInit } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material';
import { BehaviorSubject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { ListViewBaseComponent } from 'app/site/base/list-view-base';
import { MotionBlock } from 'app/shared/models/motions/motion-block';
import { Item, itemVisibilityChoices } from 'app/shared/models/agenda/item';
import { DataStoreService } from 'app/core/services/data-store.service';
import { MotionBlockRepositoryService } from '../../services/motion-block-repository.service';
import { ViewMotionBlock } from '../../models/view-motion-block';
/**
* Table for the motion blocks
*/
@Component({
selector: 'os-motion-block-list',
templateUrl: './motion-block-list.component.html',
styleUrls: ['./motion-block-list.component.scss']
})
export class MotionBlockListComponent extends ListViewBaseComponent<ViewMotionBlock> implements OnInit {
/**
* Holds the create form
*/
public createBlockForm: FormGroup;
/**
* The new motion block to create
*/
public blockToCreate: MotionBlock | null;
/**
* Holds the agenda items to select the parent item
*/
public items: BehaviorSubject<Item[]>;
/**
* Determine the default agenda visibility
*/
public defaultVisibility: number;
/**
* Determine visibility states for the agenda that will be created implicitly
*/
public itemVisibility = itemVisibilityChoices;
/**
* Constructor for the motion block list view
*
* @param titleService sets the title
* @param translate translations
* @param matSnackBar display errors in the snack bar
* @param router routing to children
* @param route determine the local route
* @param repo the motion block repository
* @param DS the dataStore
* @param formBuilder creates forms
*/
public constructor(
titleService: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private router: Router,
private route: ActivatedRoute,
private repo: MotionBlockRepositoryService,
private DS: DataStoreService,
private formBuilder: FormBuilder
) {
super(titleService, translate, matSnackBar);
this.createBlockForm = this.formBuilder.group({
title: ['', Validators.required],
agenda_type: ['', Validators.required],
agenda_parent_id: ['']
});
}
/**
* Observe the agendaItems for changes.
*/
public ngOnInit(): void {
super.setTitle('Motion Blocks');
this.initTable();
this.items = new BehaviorSubject(this.DS.getAll(Item));
this.DS.changeObservable.subscribe(model => {
if (model instanceof Item) {
this.items.next(this.DS.getAll(Item));
}
});
this.repo.getViewModelListObservable().subscribe(newMotionblocks => {
this.dataSource.data = newMotionblocks;
});
this.repo.getDefaultAgendaVisibility().subscribe(visibility => (this.defaultVisibility = visibility));
}
/**
* Returns the columns that should be shown in the table
*
* @returns an array of strings building the column definition
*/
public getColumnDefinition(): string[] {
return ['title', 'amount'];
}
/**
* Action while clicking on a row. Navigate to the detail page of given block
*
* @param block the given motion block
*/
public onSelectRow(block: ViewMotionBlock): void {
this.router.navigate([`${block.id}`], { relativeTo: this.route });
}
/**
* return the amount of motions in a motion block
*
* @param motionBlock the block to determine the amount of motions for
* @returns a number that indicates how many motions are in the given block
*/
public getMotionAmount(motionBlock: MotionBlock): number {
return this.repo.getMotionAmountByBlock(motionBlock);
}
/**
* Helper function reset the form and set the default values
*/
public resetForm(): void {
this.createBlockForm.reset();
this.createBlockForm.get('agenda_type').setValue(this.defaultVisibility);
}
/**
* Click handler for the plus button
*/
public onPlusButton(): void {
if (!this.blockToCreate) {
this.resetForm();
this.blockToCreate = new MotionBlock();
}
}
/**
* Click handler for the save button.
* Sends the block to create to the repository and resets the form.
*/
public onSaveNewButton(): void {
if (this.createBlockForm.valid) {
const blockPatch = this.createBlockForm.value;
if (!blockPatch.agenda_parent_id) {
delete blockPatch.agenda_parent_id;
}
this.blockToCreate.patchValues(blockPatch);
this.repo.create(this.blockToCreate);
this.resetForm();
this.blockToCreate = null;
}
// set a form control as "touched" to trigger potential error messages
this.createBlockForm.get('title').markAsTouched();
}
}

View File

@ -274,8 +274,12 @@
<div *ngIf="motion && !editMotion"> <div *ngIf="motion && !editMotion">
<h4 translate>Category</h4> <h4 translate>Category</h4>
<mat-menu #categoryMenu='matMenu'> <mat-menu #categoryMenu='matMenu'>
<button *ngFor='let category of categoryObserver.value' mat-menu-item <button
(click)=setCategory(category.id)>{{ category }} mat-menu-item
*ngFor="let category of categoryObserver.value"
(click)="setCategory(category.id)"
>
{{ category }}
</button> </button>
<button mat-menu-item (click)=setCategory(null)> <button mat-menu-item (click)=setCategory(null)>
--- ---
@ -286,6 +290,27 @@
</mat-basic-chip> </mat-basic-chip>
</div> </div>
<!-- Block -->
<div *ngIf="motion && !editMotion">
<h4 translate>Motion block</h4>
<mat-menu #blockMenu='matMenu'>
<button
mat-menu-item
*ngFor="let block of blockObserver.value"
(click)="setBlock(block.id)"
>
{{ block }}
</button>
<button mat-menu-item (click)="setBlock(null)">
---
</button>
</mat-menu>
<mat-basic-chip [matMenuTriggerFor]='blockMenu' class="grey">
{{ motion.motion_block ? motion.motion_block : ('not set' | translate) }}
</mat-basic-chip>
</div>
<!-- Workflow --> <!-- Workflow -->
<div *ngIf="editMotion"> <div *ngIf="editMotion">
<div *osPerms="['motions.can_manage', 'motions.can_manage_metadata']"> <div *osPerms="['motions.can_manage', 'motions.can_manage_metadata']">

View File

@ -31,6 +31,7 @@ import { take, takeWhile, multicast, skipWhile } from 'rxjs/operators';
import { LocalPermissionsService } from '../../services/local-permissions.service'; import { LocalPermissionsService } from '../../services/local-permissions.service';
import { ViewCreateMotion } from '../../models/view-create-motion'; import { ViewCreateMotion } from '../../models/view-create-motion';
import { CreateMotion } from '../../models/create-motion'; import { CreateMotion } from '../../models/create-motion';
import { MotionBlock } from 'app/shared/models/motions/motion-block';
/** /**
* Component for the motion detail view * Component for the motion detail view
@ -178,6 +179,11 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
*/ */
public supporterObserver: BehaviorSubject<User[]>; public supporterObserver: BehaviorSubject<User[]>;
/**
* Subject for the motion blocks
*/
public blockObserver: BehaviorSubject<MotionBlock[]>;
/** /**
* Determine if the name of supporters are visible * Determine if the name of supporters are visible
*/ */
@ -249,6 +255,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
this.supporterObserver = new BehaviorSubject(DS.getAll(User)); this.supporterObserver = new BehaviorSubject(DS.getAll(User));
this.categoryObserver = new BehaviorSubject(DS.getAll(Category)); this.categoryObserver = new BehaviorSubject(DS.getAll(Category));
this.workflowObserver = new BehaviorSubject(DS.getAll(Workflow)); this.workflowObserver = new BehaviorSubject(DS.getAll(Workflow));
this.blockObserver = new BehaviorSubject(DS.getAll(MotionBlock));
// Make sure the subjects are updated, when a new Model for the type arrives // Make sure the subjects are updated, when a new Model for the type arrives
this.DS.changeObservable.subscribe(newModel => { this.DS.changeObservable.subscribe(newModel => {
@ -259,6 +266,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
this.categoryObserver.next(DS.getAll(Category)); this.categoryObserver.next(DS.getAll(Category));
} else if (newModel instanceof Workflow) { } else if (newModel instanceof Workflow) {
this.workflowObserver.next(DS.getAll(Workflow)); this.workflowObserver.next(DS.getAll(Workflow));
} else if (newModel instanceof MotionBlock) {
this.blockObserver.next(DS.getAll(MotionBlock));
} }
}); });
// load config variables // load config variables
@ -762,6 +771,15 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
this.repo.setCatetory(this.motion, id); this.repo.setCatetory(this.motion, id);
} }
/**
* Add the current motion to a motion block
*
* @param id Motion block id
*/
public setBlock(id: number): void {
this.repo.setBlock(this.motion, id);
}
/** /**
* Observes the repository for changes in the motion recommender * Observes the repository for changes in the motion recommender
*/ */

View File

@ -72,7 +72,16 @@
<ng-container matColumnDef="state"> <ng-container matColumnDef="state">
<mat-header-cell *matHeaderCellDef mat-sort-header>State</mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header>State</mat-header-cell>
<mat-cell *matCellDef="let motion"> <mat-cell *matCellDef="let motion">
<div *ngIf="motion.category" class="small"><mat-icon>device_hub</mat-icon>{{ motion.category }}</div> <div class="innerTable">
<div class="small" *ngIf="motion.category">
<mat-icon>device_hub</mat-icon>
{{ motion.category }}
</div>
<div class="small" *ngIf="motion.motion_block">
<mat-icon>widgets</mat-icon>
{{ motion.motion_block.title }}
</div>
</div>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
@ -117,6 +126,15 @@
<mat-icon>device_hub</mat-icon> <mat-icon>device_hub</mat-icon>
<span translate>Categories</span> <span translate>Categories</span>
</button> </button>
<button mat-menu-item routerLink="blocks">
<!-- possible icons:
dashboard
widgets
view_module
-->
<mat-icon>widgets</mat-icon>
<span translate>Motion blocks</span>
</button>
<button mat-menu-item routerLink="statute-paragraphs" *ngIf="statutesEnabled"> <button mat-menu-item routerLink="statute-paragraphs" *ngIf="statutesEnabled">
<mat-icon>account_balance</mat-icon> <mat-icon>account_balance</mat-icon>
<span translate>Statute</span> <span translate>Statute</span>
@ -138,7 +156,7 @@
<mat-icon>sort</mat-icon> <mat-icon>sort</mat-icon>
<span translate>Move to agenda item</span> <span translate>Move to agenda item</span>
</button> </button>
<button mat-menu-item (click)="multiselectWrapper(multiselectService.setStatus(selectedRows))"> <button mat-menu-item (click)="multiselectWrapper(multiselectService.setStateOfMultiple(selectedRows))">
<mat-icon>label</mat-icon> <mat-icon>label</mat-icon>
<span translate>Set status</span> <span translate>Set status</span>
</button> </button>

View File

@ -35,7 +35,6 @@
/** State */ /** State */
.mat-column-state { .mat-column-state {
flex: 0 0 160px; flex: 0 0 160px;
justify-content:flex-end !important;
mat-icon { mat-icon {
font-size: 150%; font-size: 150%;

View File

@ -0,0 +1,39 @@
import { BaseViewModel } from 'app/site/base/base-view-model';
import { MotionBlock } from 'app/shared/models/motions/motion-block';
/**
* ViewModel for motion blocks.
* @ignore
*/
export class ViewMotionBlock extends BaseViewModel {
private _motionBlock: MotionBlock;
public get motionBlock(): MotionBlock {
return this._motionBlock;
}
public get id(): number {
return this.motionBlock ? this.motionBlock.id : null;
}
public get title(): string {
return this.motionBlock ? this.motionBlock.title : null;
}
public get agenda_item_id(): number {
return this.motionBlock ? this.motionBlock.agenda_item_id : null;
}
public constructor(motionBlock: MotionBlock) {
super();
this._motionBlock = motionBlock;
}
public updateValues(update: MotionBlock): void {
this._motionBlock = update;
}
public getTitle(): string {
return this.title
}
}

View File

@ -347,6 +347,13 @@ export class ViewMotion extends BaseViewModel {
return !!this.statute_paragraph_id; return !!this.statute_paragraph_id;
} }
/**
* Determine if the motion is in its final workflow state
*/
public isInFinalState(): boolean {
return this.nextStates.length === 0;
}
/** /**
* It's a paragraph-based amendments if only one paragraph is to be changed, * It's a paragraph-based amendments if only one paragraph is to be changed,
* specified by amendment_paragraphs-array * specified by amendment_paragraphs-array

View File

@ -8,6 +8,8 @@ import { StatuteParagraphListComponent } from './components/statute-paragraph-li
import { SpeakerListComponent } from '../agenda/components/speaker-list/speaker-list.component'; import { SpeakerListComponent } from '../agenda/components/speaker-list/speaker-list.component';
import { CallListComponent } from './components/call-list/call-list.component'; import { CallListComponent } from './components/call-list/call-list.component';
import { AmendmentCreateWizardComponent } from './components/amendment-create-wizard/amendment-create-wizard.component'; import { AmendmentCreateWizardComponent } from './components/amendment-create-wizard/amendment-create-wizard.component';
import { MotionBlockListComponent } from './components/motion-block-list/motion-block-list.component';
import { MotionBlockDetailComponent } from './components/motion-block-detail/motion-block-detail.component';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: MotionListComponent }, { path: '', component: MotionListComponent },
@ -15,6 +17,8 @@ const routes: Routes = [
{ path: 'comment-section', component: MotionCommentSectionListComponent }, { path: 'comment-section', component: MotionCommentSectionListComponent },
{ path: 'statute-paragraphs', component: StatuteParagraphListComponent }, { path: 'statute-paragraphs', component: StatuteParagraphListComponent },
{ path: 'call-list', component: CallListComponent }, { path: 'call-list', component: CallListComponent },
{ path: 'blocks', component: MotionBlockListComponent },
{ path: 'blocks/:id', component: MotionBlockDetailComponent },
{ path: 'new', component: MotionDetailComponent }, { path: 'new', component: MotionDetailComponent },
{ path: ':id', component: MotionDetailComponent }, { path: ':id', component: MotionDetailComponent },
{ path: ':id/speakers', component: SpeakerListComponent }, { path: ':id/speakers', component: SpeakerListComponent },

View File

@ -16,6 +16,8 @@ import { MetaTextBlockComponent } from './components/meta-text-block/meta-text-b
import { PersonalNoteComponent } from './components/personal-note/personal-note.component'; import { PersonalNoteComponent } from './components/personal-note/personal-note.component';
import { CallListComponent } from './components/call-list/call-list.component'; import { CallListComponent } from './components/call-list/call-list.component';
import { AmendmentCreateWizardComponent } from './components/amendment-create-wizard/amendment-create-wizard.component'; import { AmendmentCreateWizardComponent } from './components/amendment-create-wizard/amendment-create-wizard.component';
import { MotionBlockListComponent } from './components/motion-block-list/motion-block-list.component';
import { MotionBlockDetailComponent } from './components/motion-block-detail/motion-block-detail.component';
@NgModule({ @NgModule({
imports: [CommonModule, MotionsRoutingModule, SharedModule], imports: [CommonModule, MotionsRoutingModule, SharedModule],
@ -32,7 +34,9 @@ import { AmendmentCreateWizardComponent } from './components/amendment-create-wi
MetaTextBlockComponent, MetaTextBlockComponent,
PersonalNoteComponent, PersonalNoteComponent,
CallListComponent, CallListComponent,
AmendmentCreateWizardComponent AmendmentCreateWizardComponent,
MotionBlockListComponent,
MotionBlockDetailComponent
], ],
entryComponents: [ entryComponents: [
MotionChangeRecommendationComponent, MotionChangeRecommendationComponent,

View File

@ -0,0 +1,15 @@
import { TestBed } from '@angular/core/testing';
import { MotionBlockRepositoryService } from './motion-block-repository.service';
import { E2EImportsModule } from 'e2e-imports.module';
describe('MotionBlockRepositoryService', () => {
beforeEach(() => TestBed.configureTestingModule({
imports: [E2EImportsModule]
}));
it('should be created', () => {
const service: MotionBlockRepositoryService = TestBed.get(MotionBlockRepositoryService);
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,127 @@
import { Injectable } from '@angular/core';
import { MotionBlock } from 'app/shared/models/motions/motion-block';
import { ViewMotionBlock } from '../models/view-motion-block';
import { BaseRepository } from 'app/site/base/base-repository';
import { DataStoreService } from 'app/core/services/data-store.service';
import { CollectionStringModelMapperService } from 'app/core/services/collectionStringModelMapper.service';
import { DataSendService } from 'app/core/services/data-send.service';
import { Identifiable } from 'app/shared/models/base/identifiable';
import { Motion } from 'app/shared/models/motions/motion';
import { ViewMotion } from '../models/view-motion';
import { Observable } from 'rxjs';
import { MotionRepositoryService } from './motion-repository.service';
import { map } from 'rxjs/operators';
import { ConfigService } from 'app/core/services/config.service';
/**
* Repository service for motion blocks
*/
@Injectable({
providedIn: 'root'
})
export class MotionBlockRepositoryService extends BaseRepository<ViewMotionBlock, MotionBlock> {
/**
* Constructor for the motion block repository
*
* @param DS Data Store
* @param mapperService Mapping collection strings to classes
* @param dataSend Send models to the server
* @param motionRepo Accessing the motion repository
* @param config To access config variables
*/
public constructor(
DS: DataStoreService,
mapperService: CollectionStringModelMapperService,
private dataSend: DataSendService,
private motionRepo: MotionRepositoryService,
private config: ConfigService
) {
super(DS, mapperService, MotionBlock);
}
/**
* Updates a given motion block
*
* @param update a partial motion block containing the update data
* @param viewBlock the motion block to update
*/
public async update(update: Partial<MotionBlock>, viewBlock: ViewMotionBlock): Promise<void> {
const updateMotionBlock = new MotionBlock();
updateMotionBlock.patchValues(viewBlock.motionBlock);
updateMotionBlock.patchValues(update);
return await this.dataSend.updateModel(updateMotionBlock);
}
/**
* Deletes a motion block from the server
*
* @param newBlock the motion block to delete
*/
public async delete(newBlock: ViewMotionBlock): Promise<void> {
return await this.dataSend.deleteModel(newBlock.motionBlock);
}
/**
* Creates a new motion block to the server
*
* @param newBlock The new block to create
* @returns the ID of the created model as promise
*/
public async create(newBlock: MotionBlock): Promise<Identifiable> {
return await this.dataSend.createModel(newBlock);
}
/**
* Converts a given motion block into a ViewModel
*
* @param block a motion block
* @returns a new ViewMotionBlock
*/
protected createViewModel(block: MotionBlock): ViewMotionBlock {
return new ViewMotionBlock(block);
}
/**
* Removes the motion block id from the given motion
*
* @param viewMotion The motion to alter
*/
public removeMotionFromBlock(viewMotion: ViewMotion): void {
const updateMotion = viewMotion.motion;
updateMotion.motion_block_id = null;
this.motionRepo.update(updateMotion, viewMotion);
}
/**
* Filter the DataStore by Motions and returns the
*
* @param block the motion block
* @returns the number of motions inside a motion block
*/
public getMotionAmountByBlock(block: MotionBlock): number {
return this.DS.filter(Motion, motion => motion.motion_block_id === block.id).length;
}
/**
* Get agenda visibility from the config
*
* @return An observable to the default agenda visibility
*/
public getDefaultAgendaVisibility(): Observable<number> {
return this.config.get('agenda_new_items_default_visibility').pipe(map(key => +key));
}
/**
* Observe the motion repository and return the motions belonging to the given
* block as observable
*
* @param block a motion block
* @returns an observable to view motions
*/
public getViewMotionsByBlock(block: MotionBlock): Observable<ViewMotion[]> {
return this.motionRepo
.getViewModelListObservable()
.pipe(map(viewMotions => viewMotions.filter(viewMotion => viewMotion.motion_block_id === block.id)));
}
}

View File

@ -82,7 +82,7 @@ export class MotionMultiselectService {
* *
* @param motions The motions to change * @param motions The motions to change
*/ */
public async setStatus(motions: ViewMotion[]): Promise<void> { public async setStateOfMultiple(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('This will set the state of all selected motions to:'); const title = this.translate.instant('This will set the state of all selected motions to:');
const choices = this.workflowRepo.getAllWorkflowStates().map(workflowState => ({ const choices = this.workflowRepo.getAllWorkflowStates().map(workflowState => ({
id: workflowState.id, id: workflowState.id,

View File

@ -26,6 +26,7 @@ import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree
import { TreeService } from 'app/core/services/tree.service'; import { TreeService } from 'app/core/services/tree.service';
import { ViewMotionAmendedParagraph } from '../models/view-motion-amended-paragraph'; import { ViewMotionAmendedParagraph } from '../models/view-motion-amended-paragraph';
import { CreateMotion } from '../models/create-motion'; import { CreateMotion } from '../models/create-motion';
import { MotionBlock } from 'app/shared/models/motions/motion-block';
/** /**
* Repository Services for motions (and potentially categories) * Repository Services for motions (and potentially categories)
@ -41,7 +42,6 @@ import { CreateMotion } from '../models/create-motion';
providedIn: 'root' providedIn: 'root'
}) })
export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion> { export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion> {
/** /**
* Creates a MotionRepository * Creates a MotionRepository
* *
@ -64,7 +64,7 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
private readonly diff: DiffService, private readonly diff: DiffService,
private treeService: TreeService private treeService: TreeService
) { ) {
super(DS, mapperService, Motion, [Category, User, Workflow, Item]); super(DS, mapperService, Motion, [Category, User, Workflow, Item, MotionBlock]);
} }
/** /**
@ -81,11 +81,12 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
const supporters = this.DS.getMany(User, motion.supporters_id); const supporters = this.DS.getMany(User, motion.supporters_id);
const workflow = this.DS.get(Workflow, motion.workflow_id); const workflow = this.DS.get(Workflow, motion.workflow_id);
const item = this.DS.get(Item, motion.agenda_item_id); const item = this.DS.get(Item, motion.agenda_item_id);
const block = this.DS.get(MotionBlock, motion.motion_block_id);
let state: WorkflowState = null; let state: WorkflowState = null;
if (workflow) { if (workflow) {
state = workflow.getStateById(motion.state_id); state = workflow.getStateById(motion.state_id);
} }
return new ViewMotion(motion, category, submitters, supporters, workflow, state, item); return new ViewMotion(motion, category, submitters, supporters, workflow, state, item, block);
} }
/** /**
@ -179,6 +180,18 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
await this.update(motion, viewMotion); await this.update(motion, viewMotion);
} }
/**
* Add the motion to a motion block
*
* @param viewMotion the motion to add
* @param blockId the ID of the motion block
*/
public async setBlock(viewMotion: ViewMotion, blockId: number): Promise<void> {
const motion = viewMotion.motion;
motion.motion_block_id = blockId;
await this.update(motion, viewMotion);
}
/** /**
* Sends the changed nodes to the server. * Sends the changed nodes to the server.
* *

View File

@ -16,6 +16,29 @@
@import '~angular-tree-component/dist/angular-tree-component.css'; @import '~angular-tree-component/dist/angular-tree-component.css';
// Shared scss definitions
%os-table {
width: 100%;
/** size of the mat row */
mat-row {
height: 60px;
}
mat-row:hover {
cursor: pointer;
background-color: rgba(0, 0, 0, 0.025);
}
mat-row.selected {
cursor: pointer;
background-color: rgba(0, 0, 0, 0.055);
}
mat-row.lg {
height: 90px;
}
}
* { * {
font-family: Roboto, Arial, Helvetica, sans-serif; font-family: Roboto, Arial, Helvetica, sans-serif;
} }
@ -98,29 +121,16 @@ body {
} }
.os-listview-table { .os-listview-table {
width: 100%; @extend %os-table;
/** hide mat header row */ /** hide mat header row */
.mat-header-row { .mat-header-row {
display: none; display: none;
} }
/** size of the mat row */
mat-row {
height: 60px;
} }
mat-row:hover { .os-headed-listview-table {
cursor: pointer; @extend %os-table;
background-color: rgba(0, 0, 0, 0.025);
}
mat-row.selected {
cursor: pointer;
background-color: rgba(0, 0, 0, 0.055);
}
mat-row.lg {
height: 90px;
}
} }
.card-plus-distance { .card-plus-distance {
@ -204,6 +214,18 @@ mat-panel-title mat-icon {
margin: 8px 8px 8px 0; margin: 8px 8px 8px 0;
} }
// to display quantities. Use in span or div
.os-amount-chip {
border-radius: 50%;
width: 20px;
height: 20px;
line-height: 20px;
padding: 3px;
background: lightgray;
color: #000;
text-align: center;
}
.mat-chip:focus, .mat-chip:focus,
.mat-basic-chip:focus { .mat-basic-chip:focus {
outline: none; outline: none;