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: