diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 447dc2b8f..7820ec676 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -44,6 +44,9 @@ Motions: tags, states and recommendations [#4037, #4132, #4702]. - Added timestampes for motions [#4134]. - New config option to set reason as required field [#4232]. + - Added subcategories and altered behaviour of motion numbering in + categories: Motions of subcategories are also numbered, and parents of + amendments needs to be in the numbered category or any subcategory [#4756]. User: - Added new admin group which grants all permissions. Users of existing group diff --git a/client/src/app/core/repositories/motions/category-repository.service.ts b/client/src/app/core/repositories/motions/category-repository.service.ts index 2e059d37f..8041431cc 100644 --- a/client/src/app/core/repositories/motions/category-repository.service.ts +++ b/client/src/app/core/repositories/motions/category-repository.service.ts @@ -5,14 +5,13 @@ import { TranslateService } from '@ngx-translate/core'; import { BaseRepository } from '../base-repository'; import { Category } from 'app/shared/models/motions/category'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; -import { ConfigService } from 'app/core/ui-services/config.service'; import { DataSendService } from '../../core-services/data-send.service'; import { DataStoreService } from '../../core-services/data-store.service'; import { HttpService } from '../../core-services/http.service'; import { ViewCategory, CategoryTitleInformation } from 'app/site/motions/models/view-category'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; - -type SortProperty = 'prefix' | 'name'; +import { Motion } from 'app/shared/models/motions/motion'; +import { TreeIdNode } from 'app/core/ui-services/tree.service'; /** * Repository Services for Categories @@ -28,8 +27,6 @@ type SortProperty = 'prefix' | 'name'; providedIn: 'root' }) export class CategoryRepositoryService extends BaseRepository { - private sortProperty: SortProperty; - /** * Creates a CategoryRepository * Converts existing and incoming category to ViewCategories @@ -48,16 +45,11 @@ export class CategoryRepositoryService extends BaseRepository('motions_category_sorting').subscribe(conf => { - this.sortProperty = conf; - this.setConfigSortFn(); - }); + this.setSortFunction((a, b) => a.weight - b.weight); } public getTitle = (titleInformation: CategoryTitleInformation) => { @@ -78,10 +70,9 @@ export class CategoryRepositoryService extends BaseRepository { - await this.httpService.post(`/rest/motions/category/${category.id}/numbering/`, { motions: motionIds }); + public async numberMotionsInCategory(category: ViewCategory): Promise { + await this.httpService.post(`/rest/motions/category/${category.id}/numbering/`); } /** @@ -91,25 +82,25 @@ export class CategoryRepositoryService extends BaseRepository { - await this.httpService.post(`/rest/motions/category/${category.id}/sort/`, { motions: motionIds }); + await this.httpService.post(`/rest/motions/category/${category.id}/sort_motions/`, { motions: motionIds }); } /** - * Triggers an update for the sort function responsible for the default sorting of data items + * Sends the changed nodes to the server. + * + * @param data The reordered data from the sorting */ - public setConfigSortFn(): void { - this.setSortFunction((a: ViewCategory, b: ViewCategory) => { - if (a[this.sortProperty] && b[this.sortProperty]) { - return this.languageCollator.compare(a[this.sortProperty], b[this.sortProperty]); - } else if (this.sortProperty === 'prefix') { - if (a.prefix) { - return 1; - } else if (b.prefix) { - return -1; - } else { - return this.languageCollator.compare(a.name, b.name); - } - } - }); + public async sortCategories(data: TreeIdNode[]): Promise { + await this.httpService.post('/rest/motions/category/sort_categories/', data); + } + + /** + * Filter the DataStore by Motions and returns the amount of motions in the given category + * + * @param category the category + * @returns the number of motions inside the category + */ + public getMotionAmountByCategory(category: ViewCategory): number { + return this.DS.filter(Motion, motion => motion.category_id === category.id).length; } } diff --git a/client/src/app/shared/models/motions/category.ts b/client/src/app/shared/models/motions/category.ts index 540473db9..a05c020b9 100644 --- a/client/src/app/shared/models/motions/category.ts +++ b/client/src/app/shared/models/motions/category.ts @@ -10,6 +10,9 @@ export class Category extends BaseModel { public id: number; public name: string; public prefix: string; + public parent_id?: number; + public weight: number; + public level: number; public constructor(input?: any) { super(Category.COLLECTIONSTRING, input); diff --git a/client/src/app/site/motions/models/view-category.ts b/client/src/app/site/motions/models/view-category.ts index e80bf2bc5..c0545771d 100644 --- a/client/src/app/site/motions/models/view-category.ts +++ b/client/src/app/site/motions/models/view-category.ts @@ -18,10 +18,16 @@ export interface CategoryTitleInformation { export class ViewCategory extends BaseViewModel implements CategoryTitleInformation, Searchable { public static COLLECTIONSTRING = Category.COLLECTIONSTRING; + private _parent?: ViewCategory; + public get category(): Category { return this._model; } + public get parent(): ViewCategory | null { + return this._parent; + } + public get name(): string { return this.category.name; } @@ -30,26 +36,39 @@ export class ViewCategory extends BaseViewModel implements CategoryTit return this.category.prefix; } - /** - * TODO: Where is this used? Try to avoid this. - */ - public set prefix(prefix: string) { - this._model.prefix = prefix; + public get weight(): number { + return this.category.weight; + } + + public get parent_id(): number { + return this.category.parent_id; + } + + public get level(): number { + return this.category.level; } /** * TODO: Where is this used? Try to avoid this. */ - public set name(name: string) { + /*public set prefix(prefix: string) { + this._model.prefix = prefix; + }*/ + + /** + * TODO: Where is this used? Try to avoid this. + */ + /*public set name(name: string) { this._model.name = name; - } + }*/ public get prefixedName(): string { return this.prefix ? this.prefix + ' - ' + this.name : this.name; } - public constructor(category: Category) { + public constructor(category: Category, parent?: ViewCategory) { super(Category.COLLECTIONSTRING, category); + this._parent = parent; } public formatForSearch(): SearchRepresentation { @@ -64,5 +83,9 @@ export class ViewCategory extends BaseViewModel implements CategoryTit * Updates the local objects if required * @param update */ - public updateDependencies(update: BaseViewModel): void {} + public updateDependencies(update: BaseViewModel): void { + if (update instanceof ViewCategory && update.id === this.parent_id) { + this._parent = update; + } + } } diff --git a/client/src/app/site/motions/modules/category/category-routing.module.ts b/client/src/app/site/motions/modules/category/category-routing.module.ts index 7a7d3eb52..be6726f70 100644 --- a/client/src/app/site/motions/modules/category/category-routing.module.ts +++ b/client/src/app/site/motions/modules/category/category-routing.module.ts @@ -1,12 +1,16 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { CategoryListComponent } from './components/category-list/category-list.component'; -import { CategorySortComponent } from './components/category-sort/category-sort.component'; +import { CategoryMotionsSortComponent } from './components/category-motions-sort/category-motions-sort.component'; import { WatchSortingTreeGuard } from 'app/shared/utils/watch-sorting-tree.guard'; +import { CategoryDetailComponent } from './components/category-detail/category-detail.component'; +import { CategoriesSortComponent } from './components/categories-sort/categories-sort.component'; const routes: Routes = [ { path: '', component: CategoryListComponent, pathMatch: 'full' }, - { path: ':id', component: CategorySortComponent, canDeactivate: [WatchSortingTreeGuard] } + { path: ':id/sort', component: CategoryMotionsSortComponent, canDeactivate: [WatchSortingTreeGuard] }, + { path: 'sort', component: CategoriesSortComponent, canDeactivate: [WatchSortingTreeGuard] }, + { path: ':id', component: CategoryDetailComponent } ]; @NgModule({ diff --git a/client/src/app/site/motions/modules/category/category.module.ts b/client/src/app/site/motions/modules/category/category.module.ts index c97e6d1e4..3746983f1 100644 --- a/client/src/app/site/motions/modules/category/category.module.ts +++ b/client/src/app/site/motions/modules/category/category.module.ts @@ -4,10 +4,17 @@ import { CommonModule } from '@angular/common'; import { CategoryRoutingModule } from './category-routing.module'; import { SharedModule } from 'app/shared/shared.module'; import { CategoryListComponent } from './components/category-list/category-list.component'; -import { CategorySortComponent } from './components/category-sort/category-sort.component'; +import { CategoryMotionsSortComponent } from './components/category-motions-sort/category-motions-sort.component'; +import { CategoryDetailComponent } from './components/category-detail/category-detail.component'; +import { CategoriesSortComponent } from './components/categories-sort/categories-sort.component'; @NgModule({ - declarations: [CategoryListComponent, CategorySortComponent], + declarations: [ + CategoryListComponent, + CategoryDetailComponent, + CategoryMotionsSortComponent, + CategoriesSortComponent + ], imports: [CommonModule, CategoryRoutingModule, SharedModule] }) export class CategoryModule {} diff --git a/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.html b/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.html new file mode 100644 index 000000000..35b089157 --- /dev/null +++ b/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.html @@ -0,0 +1,23 @@ + + +
+

Sort categories

+
+
+ + + + + {{ item.getTitle() }} + + + diff --git a/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.scss b/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.scss new file mode 100644 index 000000000..fb1df8fba --- /dev/null +++ b/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.scss @@ -0,0 +1,22 @@ +// TODO: partial duplicate of sorting-list +.line { + display: table; + width: 100%; + font-size: 14px; + min-height: 50px; + margin-bottom: 5px; + + .left { + display: table-cell; + vertical-align: middle; + max-width: 100%; + } + + .right { + display: table-cell; + padding-right: 10px; + vertical-align: middle; + width: auto; + white-space: nowrap; + } +} diff --git a/client/src/app/site/motions/modules/category/components/category-sort/category-sort.component.spec.ts b/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.spec.ts similarity index 58% rename from client/src/app/site/motions/modules/category/components/category-sort/category-sort.component.spec.ts rename to client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.spec.ts index 5e3682989..723b00ef3 100644 --- a/client/src/app/site/motions/modules/category/components/category-sort/category-sort.component.spec.ts +++ b/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.spec.ts @@ -1,21 +1,21 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { CategorySortComponent } from './category-sort.component'; import { E2EImportsModule } from 'e2e-imports.module'; +import { CategoriesSortComponent } from './categories-sort.component'; -describe('CategorySortComponent', () => { - let component: CategorySortComponent; - let fixture: ComponentFixture; +describe('CategoriesSortComponent', () => { + let component: CategoriesSortComponent; + let fixture: ComponentFixture; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [E2EImportsModule], - declarations: [CategorySortComponent] + declarations: [CategoriesSortComponent] }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(CategorySortComponent); + fixture = TestBed.createComponent(CategoriesSortComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.ts b/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.ts new file mode 100644 index 000000000..3382c7e4d --- /dev/null +++ b/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.ts @@ -0,0 +1,101 @@ +import { Component, ViewChild } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { MatSnackBar } from '@angular/material'; + +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; + +import { BaseViewComponent } from 'app/site/base/base-view'; +import { CanComponentDeactivate } from 'app/shared/utils/watch-sorting-tree.guard'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component'; +import { ViewCategory } from 'app/site/motions/models/view-category'; +import { CategoryRepositoryService } from 'app/core/repositories/motions/category-repository.service'; + +/** + * Sort view for the call list. + */ +@Component({ + selector: 'os-categories-sort', + templateUrl: './categories-sort.component.html', + styleUrls: ['./categories-sort.component.scss'] +}) +export class CategoriesSortComponent extends BaseViewComponent implements CanComponentDeactivate { + /** + * Reference to the sorting tree. + */ + @ViewChild('osSortedTree') + private osSortTree: SortingTreeComponent; + + /** + * All motions sorted first by weight, then by id. + */ + public categoriesObservable: Observable; + + /** + * Boolean to check if the tree has changed. + */ + public hasChanged = false; + + /** + * Updates the motions member, and sorts it. + * @param title + * @param translate + * @param matSnackBar + * @param categoryRepo + * @param promptService + */ + public constructor( + title: Title, + translate: TranslateService, + matSnackBar: MatSnackBar, + private categoryRepo: CategoryRepositoryService, + private promptService: PromptService + ) { + super(title, translate, matSnackBar); + + this.categoriesObservable = this.categoryRepo.getViewModelListObservable(); + } + + /** + * Function to save changes on click. + */ + public async onSave(): Promise { + await this.categoryRepo + .sortCategories(this.osSortTree.getTreeData()) + .then(() => this.osSortTree.setSubscription(), this.raiseError); + } + + /** + * Function to restore the old state. + */ + public async onCancel(): Promise { + if (await this.canDeactivate()) { + this.osSortTree.setSubscription(); + } + } + + /** + * Function to get an info if changes has been made. + * + * @param hasChanged Boolean received from the tree to see that changes has been made. + */ + public receiveChanges(hasChanged: boolean): void { + this.hasChanged = hasChanged; + } + + /** + * Function to open a prompt dialog, + * so the user will be warned if he has made changes and not saved them. + * + * @returns The result from the prompt dialog. + */ + public async canDeactivate(): Promise { + if (this.hasChanged) { + const title = this.translate.instant('Do you really want to exit this page?'); + const content = this.translate.instant('You made changes.'); + return await this.promptService.open(title, content); + } + return true; + } +} diff --git a/client/src/app/site/motions/modules/category/components/category-detail/category-detail.component.html b/client/src/app/site/motions/modules/category/components/category-detail/category-detail.component.html new file mode 100644 index 000000000..f62b9d52b --- /dev/null +++ b/client/src/app/site/motions/modules/category/components/category-detail/category-detail.component.html @@ -0,0 +1,117 @@ + + +
+

+ {{ selectedCategory.prefixedName }} +

+
+ + + +
+ + +
+

+ {{ getLevelDashes(category) }} + {{ category.prefixedName }} +

+ + + + Motion + + {{ motion.getTitle() }} + + + + + + State + + + {{ getStateLabel(motion) }} + + + + + + + Recommendation + + + {{ getRecommendationLabel(motion) }} + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + +

+ {{ 'Edit details for' | translate }} {{ selectedCategory.prefixedName }} +

+
+
+ + + + + + +
+
+
+ + +
+
diff --git a/client/src/app/site/motions/modules/category/components/category-detail/category-detail.component.scss b/client/src/app/site/motions/modules/category/components/category-detail/category-detail.component.scss new file mode 100644 index 000000000..2f5db7e35 --- /dev/null +++ b/client/src/app/site/motions/modules/category/components/category-detail/category-detail.component.scss @@ -0,0 +1,57 @@ +@import '~assets/styles/tables.scss'; + +.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; + min-height: 0; // default is inherit, will appear on the top edge of the cell +} + +.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; + } +} + +.edit-form { + overflow: hidden; +} diff --git a/client/src/app/site/motions/modules/category/components/category-detail/category-detail.component.spec.ts b/client/src/app/site/motions/modules/category/components/category-detail/category-detail.component.spec.ts new file mode 100644 index 000000000..5f999131e --- /dev/null +++ b/client/src/app/site/motions/modules/category/components/category-detail/category-detail.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CategoryDetailComponent } from './category-detail.component'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('CategoryDetailComponent', () => { + let component: CategoryDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [CategoryDetailComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CategoryDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/modules/category/components/category-detail/category-detail.component.ts b/client/src/app/site/motions/modules/category/components/category-detail/category-detail.component.ts new file mode 100644 index 000000000..29e5730bc --- /dev/null +++ b/client/src/app/site/motions/modules/category/components/category-detail/category-detail.component.ts @@ -0,0 +1,241 @@ +import { ActivatedRoute, Router } from '@angular/router'; +import { Component, OnInit, ViewChild, TemplateRef } from '@angular/core'; +import { FormGroup, Validators, FormBuilder } from '@angular/forms'; +import { MatSnackBar, MatDialog, MatTableDataSource } from '@angular/material'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service'; +import { OperatorService } from 'app/core/core-services/operator.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { ViewMotion } from 'app/site/motions/models/view-motion'; +import { ViewCategory } from 'app/site/motions/models/view-category'; +import { CategoryRepositoryService } from 'app/core/repositories/motions/category-repository.service'; +import { BaseViewComponent } from 'app/site/base/base-view'; + +/** + * Detail component to display one motion block + */ +@Component({ + selector: 'os-category-detail', + templateUrl: './category-detail.component.html', + styleUrls: ['./category-detail.component.scss'] +}) +export class CategoryDetailComponent extends BaseViewComponent implements OnInit { + /** + * The one selected category + */ + public selectedCategory: ViewCategory; + + /** + * All categories with the selected one and all children. + */ + public categories: ViewCategory[]; + + /** + * Datasources for `categories`. Holds all motions for one category. + */ + public readonly dataSources: { [id: number]: MatTableDataSource } = {}; + + /** + * The form to edit the selected category + */ + @ViewChild('editForm') + public editForm: FormGroup; + + /** + * Reference to the template for edit-dialog + */ + @ViewChild('editDialog') + private editDialog: TemplateRef; + + /** + * helper for permission checks + * + * @returns true if the user may alter motions + */ + public get canEdit(): boolean { + return this.operator.hasPerms('motions.can_manage'); + } + + /** + * Constructor for motion block details + * + * @param titleService Setting the title + * @param translate translations + * @param matSnackBar showing errors + * @param operator the current user + * @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, + protected translate: TranslateService, + matSnackBar: MatSnackBar, + private route: ActivatedRoute, + private operator: OperatorService, + private router: Router, + private repo: CategoryRepositoryService, + private motionRepo: MotionRepositoryService, + private promptService: PromptService, + private fb: FormBuilder, + private dialog: MatDialog + ) { + super(titleService, translate, matSnackBar); + } + + /** + * Init function. + * Sets the title, observes the block and the motions belonging in this block + */ + public ngOnInit(): void { + super.setTitle('Category'); + const selectedCategoryId = parseInt(this.route.snapshot.params.id, 10); + + this.subscriptions.push( + this.repo.getViewModelListObservable().subscribe(categories => { + // Extract all categories, that is the selected one and all child categories + const selectedCategoryIndex = categories.findIndex(category => category.id === selectedCategoryId); + + if (selectedCategoryIndex < 0) { + return; + } + + // Find index of last child. THis can be easily done by searching, becuase this + // is the flat sorted tree + this.selectedCategory = categories[selectedCategoryIndex]; + let lastChildIndex: number; + for ( + lastChildIndex = selectedCategoryIndex + 1; + lastChildIndex < categories.length && + categories[lastChildIndex].level > this.selectedCategory.level; + lastChildIndex++ + ) {} + this.categories = categories.slice(selectedCategoryIndex, lastChildIndex); + + // setup datasources: + const allMotions = this.motionRepo + .getViewModelList() + .sort((a, b) => a.category_weight - b.category_weight); + this.categories.forEach(category => { + if (!this.dataSources[category.id]) { + const dataSource = new MatTableDataSource(); + dataSource.data = allMotions.filter(motion => motion.category_id === category.id); + this.dataSources[category.id] = dataSource; + } + }); + }), + this.motionRepo.getViewModelListObservable().subscribe(motions => { + motions = motions + .filter(motion => !!motion.category_id) + .sort((a, b) => a.category_weight - b.category_weight); + Object.keys(this.dataSources).forEach(_id => { + const id = +_id; + this.dataSources[id].data = motions.filter(motion => motion.category_id === id); + }); + }) + ); + } + + /** + * 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', 'anchor']; + } + + /** + * Click handler to delete a category + */ + public async onDeleteButton(): Promise { + const title = this.translate.instant('Are you sure you want to delete this category?'); + const content = this.selectedCategory.prefixedName; + if (await this.promptService.open(title, content)) { + await this.repo.delete(this.selectedCategory); + this.router.navigate(['../'], { relativeTo: this.route }); + } + } + + /** + * 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.dialog.closeAll(); + } + if (event.key === 'Enter') { + this.save(); + } + } + + /** + * Save event handler + */ + public save(): void { + this.repo + .update(this.editForm.value, this.selectedCategory) + .then(() => this.dialog.closeAll()) + .catch(this.raiseError); + } + + /** + * Click handler for the edit button + */ + public toggleEditMode(): void { + this.editForm = this.fb.group({ + prefix: [this.selectedCategory.prefix], + name: [this.selectedCategory.name, Validators.required] + }); + + this.dialog.open(this.editDialog, { + width: '400px', + maxWidth: '90vw', + maxHeight: '90vh', + disableClose: true + }); + } + + /** + * Fetch a motion's current recommendation label + * + * @param motion + * @returns the current recommendation label (with extension) + */ + public getRecommendationLabel(motion: ViewMotion): string { + return this.motionRepo.getExtendedRecommendationLabel(motion); + } + + /** + * Fetch a motion's current state label + * + * @param motion + * @returns the current state label (with extension) + */ + public getStateLabel(motion: ViewMotion): string { + return this.motionRepo.getExtendedStateLabel(motion); + } + + public getLevelDashes(category: ViewCategory): string { + const relativeLevel = category.level - this.selectedCategory.level; + return '–'.repeat(relativeLevel); + } + + /** + * Triggers a numbering of the motions + */ + public async numberMotions(): Promise { + const title = this.translate.instant('Are you sure you want to renumber all motions of this category?'); + const content = this.selectedCategory.getTitle(); + if (await this.promptService.open(title, content)) { + await this.repo.numberMotionsInCategory(this.selectedCategory).then(null, this.raiseError); + } + } +} diff --git a/client/src/app/site/motions/modules/category/components/category-list/category-list.component.html b/client/src/app/site/motions/modules/category/components/category-list/category-list.component.html index e7818d7dc..848042a96 100644 --- a/client/src/app/site/motions/modules/category/components/category-list/category-list.component.html +++ b/client/src/app/site/motions/modules/category/components/category-list/category-list.component.html @@ -1,133 +1,91 @@ - +

Categories

+ + +
-
- - Create new category + + + New motion block -
- - - - - - - - - Required - - + + +

+ + + +

+ + +

+ + + + A name is required + + +

- -
+ - - - - - - -
-
-
- {{ category.prefixedName }} -
-
-
- {{ motionsInCategory(category).length }} -
-
-
-
- - -
-
-
- - - - - - - Required - - -
-
- - - -
-
-
- -
- Motions: -
-
    -
  • {{ motion.getListTitle() }}
  • -
-
-
-
+ + + + + Title + + +
{{ category.prefixedName }}
+
- + + + + + Motions + + + {{ getMotionAmount(category) }} + + + + + + + + + + + + + +
+ + + + diff --git a/client/src/app/site/motions/modules/category/components/category-list/category-list.component.scss b/client/src/app/site/motions/modules/category/components/category-list/category-list.component.scss index f2b05fc13..12df1e302 100644 --- a/client/src/app/site/motions/modules/category/components/category-list/category-list.component.scss +++ b/client/src/app/site/motions/modules/category/components/category-list/category-list.component.scss @@ -1,45 +1,26 @@ -.header-container { - display: grid; - grid-template-rows: auto; - grid-template-columns: 75% 25%; - width: 100%; +@import '~assets/styles/tables.scss'; - > div { - grid-row-start: 1; - grid-row-end: span 1; - grid-column-end: span 2; +.os-headed-listview-table { + // Title + .mat-column-title { + flex: 9 0 0; } - .header-name { - grid-column-start: 1; - color: lightslategray; + // Amount + .mat-column-amount { + flex: 1 0 60px; } - .header-size { - grid-column-start: 2; + // Menu + .mat-column-menu { + flex: 0 0 40px; } } -mat-expansion-panel { - max-width: 770px; - margin: auto; +::ng-deep .mat-form-field { + width: 50%; } -.full-width-form { - display: flex; - width: 100%; - align-content: space-between; - flex: 2; -} - -.short-input { - width: 20%; -} -.long-input { - width: 75%; -} -.inline-form-submit { - justify-content: end; - display: block; - flex: 1; +.mat-cell > div { + z-index: 1 !important; } diff --git a/client/src/app/site/motions/modules/category/components/category-list/category-list.component.spec.ts b/client/src/app/site/motions/modules/category/components/category-list/category-list.component.spec.ts index 084792b09..ef4928662 100644 --- a/client/src/app/site/motions/modules/category/components/category-list/category-list.component.spec.ts +++ b/client/src/app/site/motions/modules/category/components/category-list/category-list.component.spec.ts @@ -1,7 +1,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { CategoryListComponent } from './category-list.component'; import { E2EImportsModule } from 'e2e-imports.module'; +import { CategoryListComponent } from './category-list.component'; describe('CategoryListComponent', () => { let component: CategoryListComponent; diff --git a/client/src/app/site/motions/modules/category/components/category-list/category-list.component.ts b/client/src/app/site/motions/modules/category/components/category-list/category-list.component.ts index e3bfbc9ba..f876cc39d 100644 --- a/client/src/app/site/motions/modules/category/components/category-list/category-list.component.ts +++ b/client/src/app/site/motions/modules/category/components/category-list/category-list.component.ts @@ -1,226 +1,156 @@ import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { FormGroup, FormBuilder, Validators } from '@angular/forms'; -import { MatSnackBar } from '@angular/material'; import { Title } from '@angular/platform-browser'; +import { MatSnackBar } from '@angular/material'; import { TranslateService } from '@ngx-translate/core'; -import { BaseViewComponent } from 'app/site/base/base-view'; +import { ListViewBaseComponent } from 'app/site/base/list-view-base'; +import { OperatorService } from 'app/core/core-services/operator.service'; +import { StorageService } from 'app/core/core-services/storage.service'; import { Category } from 'app/shared/models/motions/category'; import { CategoryRepositoryService } from 'app/core/repositories/motions/category-repository.service'; -import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service'; -import { PromptService } from 'app/core/ui-services/prompt.service'; import { ViewCategory } from 'app/site/motions/models/view-category'; -import { ViewMotion } from 'app/site/motions/models/view-motion'; /** - * List view for the categories. + * Table for categories */ @Component({ selector: 'os-category-list', templateUrl: './category-list.component.html', styleUrls: ['./category-list.component.scss'] }) -export class CategoryListComponent extends BaseViewComponent implements OnInit { +export class CategoryListComponent extends ListViewBaseComponent + implements OnInit { /** - * Hold the category to create - */ - public categoryToCreate: Category | null; - - /** - * Determine which category is opened - */ - public editId: number | null; - - /** - * Source of the data - */ - public categories: ViewCategory[]; - - /** - * For new categories + * Holds the create form */ public createForm: FormGroup; /** - * The current focussed form + * Flag, if the creation panel is open */ - public updateForm: FormGroup; + public isCreatingNewCategory = false; + + /** + * helper for permission checks + * + * @returns true if the user may alter motions or their metadata + */ + public get canEdit(): boolean { + return this.operator.hasPerms('motions.can_manage'); + } /** * The usual component constructor * @param titleService * @param translate * @param matSnackBar + * @param route + * @param storage * @param repo * @param formBuilder * @param promptService + * @param operator */ public constructor( titleService: Title, - protected translate: TranslateService, // protected required for ng-translate-extract + translate: TranslateService, matSnackBar: MatSnackBar, + route: ActivatedRoute, + storage: StorageService, private repo: CategoryRepositoryService, - private motionRepo: MotionRepositoryService, private formBuilder: FormBuilder, - private promptService: PromptService + private operator: OperatorService ) { - super(titleService, translate, matSnackBar); + super(titleService, translate, matSnackBar, repo, route, storage); this.createForm = this.formBuilder.group({ prefix: [''], - name: ['', Validators.required] - }); - - this.updateForm = this.formBuilder.group({ - prefix: [''], - name: ['', Validators.required] + name: ['', Validators.required], + parent_id: [''] }); } /** - * Event on key-down in form. Submits the current form if the 'enter' button is pressed - * - * @param event - * @param viewCategory - */ - public keyDownFunction(event: KeyboardEvent, viewCategory?: ViewCategory): void { - if (event.key === 'Enter') { - if (viewCategory) { - this.onSaveButton(viewCategory); - } else { - this.onCreateButton(); - } - } - } - - /** - * Init function. - * - * Sets the title and gets/observes categories from DataStore + * Observe the agendaItems for changes. */ public ngOnInit(): void { super.setTitle('Categories'); - this.repo.getViewModelListObservable().subscribe(newViewCategories => { - this.categories = newViewCategories; - }); + this.initTable(); } /** - * Add a new Category. + * 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', 'anchor']; + } + + /** + * return the amount of motions in a category + * + * @param category the category to determine the amount of motions for + * @returns a number that indicates how many motions are in the given category + */ + public getMotionAmount(category: ViewCategory): number { + return this.repo.getMotionAmountByCategory(category); + } + + /** + * Click handler for the plus button */ public onPlusButton(): void { - if (!this.categoryToCreate) { - this.categoryToCreate = new Category(); + if (!this.isCreatingNewCategory) { this.createForm.reset(); + this.isCreatingNewCategory = true; } } /** - * Creates a new category. Executed after hitting save. + * Click handler for the save button. + * Sends the category to create to the repository and resets the form. */ - public onCreateButton(): void { + public onCreate(): void { if (this.createForm.valid) { - const cat: Partial = { name: this.createForm.get('name').value }; - if (this.createForm.get('prefix').value) { - cat.prefix = this.createForm.get('prefix').value; + try { + this.repo.create(this.createForm.value); + this.createForm.reset(); + this.isCreatingNewCategory = false; + } catch (e) { + this.raiseError(e); } - this.categoryToCreate.patchValues(cat); + } + // set a form control as "touched" to trigger potential error messages + this.createForm.get('name').markAsTouched(); + } - this.repo.create(this.categoryToCreate).then(() => (this.categoryToCreate = null), this.raiseError); + /** + * clicking Shift and Enter will save automatically + * clicking Escape will cancel the process + * + * @param event has the code + */ + public onKeyDown(event: KeyboardEvent): void { + if (event.key === 'Enter') { + this.onCreate(); + } + if (event.key === 'Escape') { + this.onCancel(); } } /** - * Category specific edit button - * @param viewCategory individual cat + * Cancels the current form action */ - public onEditButton(viewCategory: ViewCategory): void { - this.editId = viewCategory.id; - this.updateForm.reset(); - this.updateForm.patchValue({ - prefix: viewCategory.category.prefix, - name: viewCategory.name - }); + public onCancel(): void { + this.isCreatingNewCategory = false; } - /** - * Saves a category - * TODO: Some feedback - * - * @param viewCategory - */ - public async onSaveButton(viewCategory: ViewCategory): Promise { - if (this.updateForm.dirty && this.updateForm.valid) { - const cat: Partial = { name: this.updateForm.get('name').value }; - cat.prefix = this.updateForm.get('prefix').value || ''; - await this.repo.update(cat, viewCategory); - this.updateForm.markAsPristine(); - } - } - - /** - * Trigger after cancelling an edit. The updateForm is reset to an original - * value, which might belong to a different category - */ - public onCancelButton(): void { - this.updateForm.markAsPristine(); - } - - /** - * is executed, when the delete button is pressed - * - * @param viewCategory The category to delete - */ - public async onDeleteButton(viewCategory: ViewCategory): Promise { - const title = this.translate.instant('Are you sure you want to delete this category?'); - const content = viewCategory.getTitle(); - if (await this.promptService.open(title, content)) { - this.repo.delete(viewCategory).then(() => this.onCancelButton(), this.raiseError); - } - } - - /** - * Returns the motions corresponding to a category - * - * @param category target - * @returns all motions in the category - */ - public motionsInCategory(category: Category): ViewMotion[] { - return this.motionRepo.getSortedViewModelList().filter(m => m.category_id === category.id); - } - - /** - * Function to get a sorted list of all motions in a specific category. - * Sorting by `category_weight`. - * - * @param category the target category in where the motions are. - * - * @returns all motions in the given category sorted by their category_weight. - */ - public getSortedMotionListInCategory(category: Category): ViewMotion[] { - return this.motionsInCategory(category).sort((a, b) => a.category_weight - b.category_weight); - } - - /** - * Fetch the correct URL for a detail sort view - * - * @param viewCategory - */ - public getSortUrl(viewCategory: ViewCategory): string { - return `/motions/category/${viewCategory.id}`; - } - - /** - * Set/reset the initial values and the referenced category of the update form - * - * @param category - */ - public setValues(category: ViewCategory): void { - this.editId = category.id; - this.updateForm.setValue({ - prefix: category.prefix, - name: category.name - }); + public getMargin(category: ViewCategory): string { + return `${category.level * 20}px`; } } diff --git a/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.html b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.html new file mode 100644 index 000000000..34f853210 --- /dev/null +++ b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.html @@ -0,0 +1,27 @@ + + +
+

Sort motions in {{ category.prefixedName }}

+
+
+ + +
+ Drag and drop motions to reorder the category. Then click the button to renumber. +
+
+ {{ sortSelector.multiSelectedIndex.length }}  + selected + +
+ +
diff --git a/client/src/app/site/motions/modules/category/components/category-sort/category-sort.component.scss b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.scss similarity index 100% rename from client/src/app/site/motions/modules/category/components/category-sort/category-sort.component.scss rename to client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.scss diff --git a/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.spec.ts b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.spec.ts new file mode 100644 index 000000000..6c8304e3f --- /dev/null +++ b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CategoryMotionsSortComponent } from './category-motions-sort.component'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('CategoryMotionsSortComponent', () => { + let component: CategoryMotionsSortComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [CategoryMotionsSortComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CategoryMotionsSortComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/modules/category/components/category-sort/category-sort.component.ts b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.ts similarity index 86% rename from client/src/app/site/motions/modules/category/components/category-sort/category-sort.component.ts rename to client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.ts index f5f963309..a4bf42af0 100644 --- a/client/src/app/site/motions/modules/category/components/category-sort/category-sort.component.ts +++ b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.ts @@ -21,11 +21,11 @@ import { ViewMotion } from 'app/site/motions/models/view-motion'; * as displayed in this view */ @Component({ - selector: 'os-category-sort', - templateUrl: './category-sort.component.html', - styleUrls: ['./category-sort.component.scss'] + selector: 'os-category-motions-sort', + templateUrl: './category-motions-sort.component.html', + styleUrls: ['./category-motions-sort.component.scss'] }) -export class CategorySortComponent extends BaseViewComponent implements OnInit, CanComponentDeactivate { +export class CategoryMotionsSortComponent extends BaseViewComponent implements OnInit, CanComponentDeactivate { /** * The current category. Determined by the route */ @@ -68,16 +68,6 @@ export class CategorySortComponent extends BaseViewComponent implements OnInit, return this.motionsSubject.asObservable(); } - /** - * @returns the name and (if present) prefix of the category - */ - public get categoryName(): string { - if (!this.category) { - return ''; - } - return this.category.prefix ? `${this.category.name} (${this.category.prefix})` : this.category.name; - } - /** * The Sort Component */ @@ -140,24 +130,6 @@ export class CategorySortComponent extends BaseViewComponent implements OnInit, this.motionsCopy = motions; } - /** - * Triggers a (re-)numbering of the motions after a configmarion dialog - * - * @param category - */ - public async onNumberMotions(): Promise { - if (this.sortSelector) { - const title = this.translate.instant('Are you sure you want to renumber all motions of this category?'); - const content = this.category.getTitle(); - if (await this.promptService.open(title, content)) { - const sortedMotionIds = this.sortSelector.array.map(selectable => selectable.id); - await this.repo - .numberMotionsInCategory(this.category.category, sortedMotionIds) - .then(null, this.raiseError); - } - } - } - /** * Listener for the sorting event in the `sorting-list`. * diff --git a/client/src/app/site/motions/modules/category/components/category-sort/category-sort.component.html b/client/src/app/site/motions/modules/category/components/category-sort/category-sort.component.html deleted file mode 100644 index e2bf477a0..000000000 --- a/client/src/app/site/motions/modules/category/components/category-sort/category-sort.component.html +++ /dev/null @@ -1,49 +0,0 @@ - - - -

Sort motions

-
- - -

{{ categoryName }}

-
- - Drag and drop motions to reorder the category. Then click the button to renumber. - -
- -
- {{ sortSelector.multiSelectedIndex.length }}  - selected - -
- - - - - - local_offer - {{ tag.getTitle() }} - - - {{ motion.id }} - - -
diff --git a/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.html b/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.html index fff2dc94c..0039ad6d0 100644 --- a/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.html +++ b/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.html @@ -76,7 +76,7 @@ - + diff --git a/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.ts b/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.ts index 0744edb65..f5cb8328d 100644 --- a/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.ts +++ b/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.ts @@ -128,16 +128,6 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent - A name is required + A title is required

@@ -86,23 +86,6 @@ - - - - Menu - - - - - - @@ -115,13 +98,3 @@
- - - - - - - diff --git a/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.ts b/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.ts index 9e26fe912..f27e56cac 100644 --- a/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.ts +++ b/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.ts @@ -14,7 +14,6 @@ import { MotionBlock } from 'app/shared/models/motions/motion-block'; import { MotionBlockRepositoryService } from 'app/core/repositories/motions/motion-block-repository.service'; import { MotionBlockSortService } from 'app/site/motions/services/motion-block-sort.service'; import { OperatorService } from 'app/core/core-services/operator.service'; -import { PromptService } from 'app/core/ui-services/prompt.service'; import { StorageService } from 'app/core/core-services/storage.service'; import { ViewItem } from 'app/site/agenda/models/view-item'; import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block'; @@ -89,7 +88,6 @@ export class MotionBlockListComponent private repo: MotionBlockRepositoryService, private agendaRepo: ItemRepositoryService, private formBuilder: FormBuilder, - private promptService: PromptService, private itemRepo: ItemRepositoryService, private operator: OperatorService, sortService: MotionBlockSortService @@ -124,9 +122,6 @@ export class MotionBlockListComponent if (this.operator.hasPerms('core.can_manage_projector')) { columns = ['projector'].concat(columns); } - if (this.operator.hasPerms('motions.can_manage')) { - columns = columns.concat(['menu']); - } return columns; } @@ -140,19 +135,6 @@ export class MotionBlockListComponent return this.repo.getMotionAmountByBlock(motionBlock); } - /** - * Click handler to delete motion blocks - * - * @param motionBlock the block to delete - */ - public async onDelete(motionBlock: ViewMotionBlock): Promise { - const title = this.translate.instant('Are you sure you want to delete this motion block?'); - const content = motionBlock.title; - if (await this.promptService.open(title, content)) { - await this.repo.delete(motionBlock); - } - } - /** * Helper function reset the form and set the default values */ diff --git a/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.html b/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.html index d550c11ca..e60f5a69c 100644 --- a/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.html +++ b/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.html @@ -206,6 +206,8 @@ sort Call list + +