From 101d6c96cdc5c9517500cc543c1214042490f41a Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Fri, 16 Nov 2018 10:25:17 +0100 Subject: [PATCH] using angular2 tree for the nested motion call list --- client/package.json | 1 + .../sorting-tree/sorting-tree.component.html | 3 + .../sorting-tree.component.spec.ts | 60 +++++ .../sorting-tree/sorting-tree.component.ts | 250 ++++++++++++++++++ client/src/app/shared/shared.module.ts | 17 +- .../call-list/call-list.component.html | 13 +- .../call-list/call-list.component.ts | 46 ++-- .../services/motion-repository.service.ts | 19 +- client/src/styles.scss | 15 ++ openslides/motions/views.py | 7 +- 10 files changed, 388 insertions(+), 43 deletions(-) create mode 100644 client/src/app/shared/components/sorting-tree/sorting-tree.component.html create mode 100644 client/src/app/shared/components/sorting-tree/sorting-tree.component.spec.ts create mode 100644 client/src/app/shared/components/sorting-tree/sorting-tree.component.ts diff --git a/client/package.json b/client/package.json index d9bf99a33..33559ea44 100644 --- a/client/package.json +++ b/client/package.json @@ -31,6 +31,7 @@ "@ngx-translate/core": "^11.0.1", "@ngx-translate/http-loader": "^4.0.0", "@tinymce/tinymce-angular": "^2.3.1", + "angular-tree-component": "^8.0.0", "core-js": "^2.5.4", "file-saver": "^2.0.0-rc.3", "material-design-icons": "^3.0.1", diff --git a/client/src/app/shared/components/sorting-tree/sorting-tree.component.html b/client/src/app/shared/components/sorting-tree/sorting-tree.component.html new file mode 100644 index 000000000..e3d6ee7c0 --- /dev/null +++ b/client/src/app/shared/components/sorting-tree/sorting-tree.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/client/src/app/shared/components/sorting-tree/sorting-tree.component.spec.ts b/client/src/app/shared/components/sorting-tree/sorting-tree.component.spec.ts new file mode 100644 index 000000000..578d20938 --- /dev/null +++ b/client/src/app/shared/components/sorting-tree/sorting-tree.component.spec.ts @@ -0,0 +1,60 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; +import { SortingTreeComponent } from './sorting-tree.component'; +import { Component, ViewChild } from '@angular/core'; +import { Displayable } from 'app/shared/models/base/displayable'; +import { Identifiable } from 'app/shared/models/base/identifiable'; +import { BehaviorSubject } from 'rxjs'; + +/** + * A test model for the sorting + */ +class TestModel implements Identifiable, Displayable { + public constructor(public id: number, public name: string, public weight: number, public parent_id: number | null){} + + public getTitle(): string { + return this.name; + } + + public getListTitle(): string { + return this.getTitle(); + } +} + +describe('SortingTreeComponent', () => { + @Component({ + selector: 'os-host-component', + template: '' + }) + class TestHostComponent { + @ViewChild(SortingTreeComponent) + public sortingTreeCompononent: SortingTreeComponent; + } + + let hostComponent: TestHostComponent; + let hostFixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [TestHostComponent] + }).compileComponents(); + })); + + beforeEach(() => { + hostFixture = TestBed.createComponent(TestHostComponent); + hostComponent = hostFixture.componentInstance; + }); + + it('should create', () => { + const models: TestModel[] = []; + for (let i = 0; i < 10; i++) { + models.push(new TestModel(i, `TOP${i}`, i, null)); + } + const modelSubject = new BehaviorSubject(models); + hostComponent.sortingTreeCompononent.modelsObservable = modelSubject.asObservable(); + + hostFixture.detectChanges(); + expect(hostComponent.sortingTreeCompononent).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/sorting-tree/sorting-tree.component.ts b/client/src/app/shared/components/sorting-tree/sorting-tree.component.ts new file mode 100644 index 000000000..5adf12d4d --- /dev/null +++ b/client/src/app/shared/components/sorting-tree/sorting-tree.component.ts @@ -0,0 +1,250 @@ +import { Component, OnInit, ViewChild, Input, EventEmitter, Output, OnDestroy } from '@angular/core'; +import { transferArrayItem } from '@angular/cdk/drag-drop'; + +import { ITreeOptions, TreeModel, TreeNode } from 'angular-tree-component'; +import { auditTime } from 'rxjs/operators'; +import { Subscription, Observable } from 'rxjs'; + +import { Identifiable } from 'app/shared/models/base/identifiable'; +import { Displayable } from 'app/shared/models/base/displayable'; + +/** + * An representation of our nodes. Saves the displayed name, the id and children to build a full tree. + */ +interface OSTreeNode { + name: string; + id: number; + children?: OSTreeNode[]; +} + +/** + * The data representation for the sort event. + */ +export interface OSTreeSortEvent { + /** + * Gives all nodes to be inserted below the parent_id. + */ + nodes: OSTreeNode[]; + + /** + * Provides the parent id for the nodes array. Do not provide it, if it's the + * full tree, e.g. when inserting a node into the first layer of the tree. The + * name is not camelCase, because this format can be send to the server as is. + */ + parent_id?: number; +} + +@Component({ + selector: 'os-sorting-tree', + templateUrl: './sorting-tree.component.html' +}) +export class SortingTreeComponent implements OnInit, OnDestroy { + /** + * The property key to get the parent id. + */ + @Input() + public parentIdKey: keyof T; + + /** + * The property key used for the weight attribute. + */ + @Input() + public weightKey: keyof T; + + /** + * An observable to recieve the models to display. + */ + @Input() + public set modelsObservable(models: Observable) { + if (!models) { + return; + } + if (this.modelSubscription) { + this.modelSubscription.unsubscribe(); + } + this.modelSubscription = models.pipe(auditTime(100)).subscribe(m => { + this.nodes = this.makeTree(m); + setTimeout(() => this.tree.treeModel.expandAll()); + }); + } + + /** + * Saves the current subscription to the model oberservable. + */ + private modelSubscription: Subscription = null; + + /** + * An event emitter for expanding an collapsing the whole tree. The parent component + * can emit true or false to expand or collapse the tree. + */ + @Input() + public set expandCollapseAll(value: EventEmitter) { + value.subscribe(expand => { + if (expand) { + this.tree.treeModel.expandAll(); + } else { + this.tree.treeModel.collapseAll(); + } + }); + } + + /** + * The event emitter for the sort event. The data is the representation for the + * sorted part of the tree. + */ + @Output() + public readonly sort = new EventEmitter(); + + /** + * Options for the tree. As a default drag and drop is allowed. + */ + public treeOptions: ITreeOptions = { + allowDrag: true, + allowDrop: true + }; + + /** + * The tree. THis reference is used to expand and collapse the tree + */ + @ViewChild('tree') + public tree: any; + + /** + * This is our actual tree represented by our own nodes. + */ + public nodes: OSTreeNode[] = []; + + /** + * Constructor. Adds the eventhandler for the drop event to the tree. + */ + public constructor() { + this.treeOptions.actionMapping = { + mouse: { + drop: this.drop.bind(this) + } + }; + } + + /** + * Required by components using the selector as directive + */ + public ngOnInit(): void {} + + /** + * Closes all subscriptions/event emitters. + */ + public ngOnDestroy(): void { + if (this.modelSubscription) { + this.modelSubscription.unsubscribe(); + } + this.sort.complete(); + } + + /** + * Handles the main drop event. Emits the sort event afterwards. + * + * @param tree The tree + * @param node The affected node + * @param $event The DOM event + * @param param3 The previous and new position os the node + */ + private drop(tree: TreeModel, node: TreeNode, $event: any, { from, to }: { from: any; to: any }): void { + // check if dropped itself + if (from.id === to.parent.id) { + return; + } + + let parentId; + const fromArray = from.parent.data.children; + if (!to.parent.data.virtual) { + parentId = to.parent.data.id; + } + if (!to.parent.data.children) { + to.parent.data.children = []; + } + transferArrayItem(fromArray, to.parent.data.children, from.index, to.index); + this.sort.emit({ nodes: to.parent.data.children, parent_id: parentId }); + } + + /** + * Returns the weight casted to a number from a given model. + * + * @param model The model to get the weight from. + * @returns the weight of the model + */ + private getWeight(model: T): number { + return (model[this.weightKey]) as number; + } + + /** + * Returns the parent id casted to a number from a given model. + * + * @param model The model to get the parent id from. + * @returns the parent id of the model + */ + private getParentId(model: T): number { + return (model[this.parentIdKey]) as number; + } + + /** + * Build our representation of a tree node given the model and optional children + * to append to this node. + * + * @param model The model to create a node of. + * @param children Optional children to append to this node. + * @returns The created node. + */ + private buildTreeNode(model: T, children?: OSTreeNode[]): OSTreeNode { + return { + name: model.getTitle(), + id: model.id, + children: children + }; + } + + /** + * Creates a tree from the given models with their parent and weight properties. + * + * @param models All models to build the tree of + * @returns The first layer of the tree given as an array of nodes, because this tree may not have a single root. + */ + private makeTree(models: T[]): OSTreeNode[] { + // copy references to avoid side effects: + models = models.map(x => x); + + // Sort items after there weight + models.sort((a, b) => this.getWeight(a) - this.getWeight(b)); + + // Build a dict with all children (dict-value) to a specific + // item id (dict-key). + const children: { [parendId: number]: T[] } = {}; + + models.forEach(model => { + if (model[this.parentIdKey]) { + const parentId = this.getParentId(model); + if (children[parentId]) { + children[parentId].push(model); + } else { + children[parentId] = [model]; + } + } + }); + + // Recursive function that generates a nested list with all + // items with there children + const getChildren: (_models?: T[]) => OSTreeNode[] = _models => { + if (!_models) { + return; + } + const nodes: OSTreeNode[] = []; + _models.forEach(_model => { + nodes.push(this.buildTreeNode(_model, getChildren(children[_model.id]))); + }); + return nodes; + }; + + // Generates the list of root items (with no parents) + const parentItems = models.filter(model => !this.getParentId(model)); + return getChildren(parentItems); + } +} diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 529ad2168..68d03aafb 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -51,6 +51,9 @@ import { PermsDirective } from './directives/perms.directive'; import { DomChangeDirective } from './directives/dom-change.directive'; import { AutofocusDirective } from './directives/autofocus.directive'; +// tree sorting +import { TreeModule } from 'angular-tree-component'; + // components import { HeadBarComponent } from './components/head-bar/head-bar.component'; import { FooterComponent } from './components/footer/footer.component'; @@ -61,6 +64,7 @@ import { OpenSlidesDateAdapter } from './date-adapter'; import { PromptDialogComponent } from './components/prompt-dialog/prompt-dialog.component'; import { SortingListComponent } from './components/sorting-list/sorting-list.component'; import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/speaker-list.component'; +import { SortingTreeComponent } from './components/sorting-tree/sorting-tree.component'; /** * Share Module for all "dumb" components and pipes. @@ -111,7 +115,8 @@ import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/sp RouterModule, NgxMatSelectSearchModule, FileDropModule, - EditorModule + EditorModule, + TreeModule.forRoot() ], exports: [ FormsModule, @@ -156,7 +161,9 @@ import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/sp PrivacyPolicyContentComponent, PromptDialogComponent, SortingListComponent, - EditorModule + EditorModule, + SortingTreeComponent, + TreeModule ], declarations: [ PermsDirective, @@ -169,12 +176,14 @@ import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/sp SearchValueSelectorComponent, PromptDialogComponent, SortingListComponent, - SpeakerListComponent + SpeakerListComponent, + SortingTreeComponent ], providers: [ { provide: DateAdapter, useClass: OpenSlidesDateAdapter }, SearchValueSelectorComponent, - SortingListComponent + SortingListComponent, + SortingTreeComponent ] }) export class SharedModule {} diff --git a/client/src/app/site/motions/components/call-list/call-list.component.html b/client/src/app/site/motions/components/call-list/call-list.component.html index 78c2fbf9d..c6ea10e9b 100644 --- a/client/src/app/site/motions/components/call-list/call-list.component.html +++ b/client/src/app/site/motions/components/call-list/call-list.component.html @@ -3,15 +3,12 @@

Call list

- - - - + + + + diff --git a/client/src/app/site/motions/components/call-list/call-list.component.ts b/client/src/app/site/motions/components/call-list/call-list.component.ts index 1c79fdd7a..500a55262 100644 --- a/client/src/app/site/motions/components/call-list/call-list.component.ts +++ b/client/src/app/site/motions/components/call-list/call-list.component.ts @@ -1,14 +1,15 @@ -import { Component, ViewChild } from '@angular/core'; +import { Component, ViewChild, EventEmitter } 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 '../../../base/base-view'; -import { MatSnackBar } from '@angular/material'; import { MotionRepositoryService } from '../../services/motion-repository.service'; import { ViewMotion } from '../../models/view-motion'; import { SortingListComponent } from '../../../../shared/components/sorting-list/sorting-list.component'; -import { Router, ActivatedRoute } from '@angular/router'; +import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component'; /** * Sort view for the call list. @@ -21,7 +22,12 @@ export class CallListComponent extends BaseViewComponent { /** * All motions sorted first by weight, then by id. */ - public motions: ViewMotion[]; + public motionsObservable: Observable; + + /** + * Emits true for expand and false for collaps. Informs the sorter component about this actions. + */ + public readonly expandCollapse: EventEmitter = new EventEmitter(); /** * The sort component @@ -40,29 +46,27 @@ export class CallListComponent extends BaseViewComponent { title: Title, translate: TranslateService, matSnackBar: MatSnackBar, - private motionRepo: MotionRepositoryService, - private router: Router, - private route: ActivatedRoute + private motionRepo: MotionRepositoryService ) { super(title, translate, matSnackBar); - this.motionRepo.getViewModelListObservable().subscribe(motions => { - this.motions = motions.sort((a, b) => { - if (a.weight !== b.weight) { - return a.weight - b.weight; - } else { - return a.id - b.id; - } - }); - }); + this.motionsObservable = this.motionRepo.getViewModelListObservable(); } /** - * Saves the new motion order to the server. + * Handler for the sort event. The data to change is given to + * the repo, sending it to the server. */ - public save(): void { - this.motionRepo.sortMotions(this.sorter.array.map(s => ({ id: s.id }))).then(() => { - this.router.navigate(['../'], { relativeTo: this.route }); - }, this.raiseError); + public sort(data: OSTreeSortEvent): void { + this.motionRepo.sortMotions(data).then(null, this.raiseError); + } + + /** + * Fires the expandCollapse event emitter. + * + * @param expand True, if the tree should be expanded. Otherwise collapsed + */ + public expandCollapseAll(expand: boolean): void { + this.expandCollapse.emit(expand); } } diff --git a/client/src/app/site/motions/services/motion-repository.service.ts b/client/src/app/site/motions/services/motion-repository.service.ts index 7e93c314b..e0d920482 100644 --- a/client/src/app/site/motions/services/motion-repository.service.ts +++ b/client/src/app/site/motions/services/motion-repository.service.ts @@ -14,13 +14,14 @@ import { DiffService, LineRange, ModificationType } from './diff.service'; import { ViewChangeReco } from '../models/view-change-reco'; import { MotionChangeReco } from '../../../shared/models/motions/motion-change-reco'; import { ViewUnifiedChange } from '../models/view-unified-change'; -import { ViewStatuteParagraph } from '../models/view-statute-paragraph'; +import { ViewStatuteParagraph } from '../models/view-statute-paragraph'; import { Identifiable } from '../../../shared/models/base/identifiable'; import { CollectionStringModelMapperService } from '../../../core/services/collectionStringModelMapper.service'; import { HttpService } from 'app/core/services/http.service'; import { ConfigService } from 'app/core/services/config.service'; import { Observable } from 'rxjs'; import { Item } from 'app/shared/models/agenda/item'; +import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component'; /** * Repository Services for motions (and potentially categories) @@ -153,13 +154,13 @@ export class MotionRepositoryService extends BaseRepository } /** - * Sorts motions for the call list by the given list of ids (as identifiables with - * the format `{id: }`). - * @param motionIds all motion ids in the new order. + * Sends the changed nodes to the server. + * + * @param data The reordered data from the sorting */ - public async sortMotions(motionIds: Identifiable[]): Promise { + public async sortMotions(data: OSTreeSortEvent): Promise { const url = '/rest/motions/motion/sort/'; - await this.httpService.post(url, { nodes: motionIds }); + await this.httpService.post(url, data); } /** @@ -226,7 +227,11 @@ export class MotionRepositoryService extends BaseRepository } } - public formatStatuteAmendment(paragraphs: ViewStatuteParagraph[], amendment: ViewMotion, lineLength: number): string { + public formatStatuteAmendment( + paragraphs: ViewStatuteParagraph[], + amendment: ViewMotion, + lineLength: number + ): string { const origParagraph = paragraphs.find(paragraph => paragraph.id === amendment.statute_paragraph_id); let diffHtml = this.diff.diff(origParagraph.text, amendment.text); diffHtml = this.lineNumbering.insertLineBreaksWithoutNumbers(diffHtml, lineLength, true); diff --git a/client/src/styles.scss b/client/src/styles.scss index 692b1038e..43d478db3 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -14,6 +14,8 @@ @include angular-material-theme($openslides-theme); @include openslides-components-theme($openslides-theme); +@import '~angular-tree-component/dist/angular-tree-component.css'; + * { font-family: Roboto, Arial, Helvetica, sans-serif; } @@ -157,3 +159,16 @@ mat-panel-title mat-icon { height: 100px; border: 2px dotted #0782d0; } + +.os-tree { + .node-content-wrapper { + background-color: aliceblue; + border: 1px black; + width: 100%; + padding: 10px 20px; + } + + tree-loading-component { + display: none; + } +} diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 8173b92b8..e47d139ac 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -290,18 +290,19 @@ class MotionViewSet(ModelViewSet): abou the data to be send. """ nodes = request.data.get('nodes', []) - sort_parent_id = request.data.get('sort_parent_id') + sort_parent_id = request.data.get('parent_id') motions = [] with transaction.atomic(): for index, node in enumerate(nodes): - motion = Motion.objects.get(pk=node['id']) + id = node['id'] + motion = Motion.objects.get(pk=id) motion.sort_parent_id = sort_parent_id motion.weight = index motion.save(skip_autoupdate=True) motions.append(motion) # Now check consistency. TODO: Try to use less DB queries. - motion = Motion.objects.get(pk=node['id']) + motion = Motion.objects.get(pk=id) ancestor = motion.sort_parent while ancestor is not None: if ancestor == motion: