diff --git a/client/package.json b/client/package.json index bf6f1d717..ce025796e 100644 --- a/client/package.json +++ b/client/package.json @@ -41,7 +41,6 @@ "@ngx-translate/core": "^11.0.1", "@ngx-translate/http-loader": "^4.0.0", "@tinymce/tinymce-angular": "^3.0.0", - "angular-tree-component": "^8.2.0", "core-js": "^2.6.5", "css-element-queries": "^1.1.1", "file-saver": "^2.0.1", diff --git a/client/src/app/core/repositories/agenda/item-repository.service.ts b/client/src/app/core/repositories/agenda/item-repository.service.ts index a9b5ec004..1cc620d86 100644 --- a/client/src/app/core/repositories/agenda/item-repository.service.ts +++ b/client/src/app/core/repositories/agenda/item-repository.service.ts @@ -1,23 +1,22 @@ import { Injectable } from '@angular/core'; + import { tap, map } from 'rxjs/operators'; import { Observable } from 'rxjs'; - import { TranslateService } from '@ngx-translate/core'; -import { BaseAgendaContentObjectRepository } from '../base-agenda-content-object-repository'; import { BaseRepository } from '../base-repository'; -import { BaseAgendaViewModel } from 'app/site/base/base-agenda-view-model'; -import { BaseViewModel } from 'app/site/base/base-view-model'; import { CollectionStringMapperService } from '../../core-services/collectionStringMapper.service'; import { ConfigService } from 'app/core/ui-services/config.service'; import { DataSendService } from 'app/core/core-services/data-send.service'; import { DataStoreService } from '../../core-services/data-store.service'; import { HttpService } from 'app/core/core-services/http.service'; import { Item } from 'app/shared/models/agenda/item'; -import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component'; -import { TreeService } from 'app/core/ui-services/tree.service'; import { ViewItem } from 'app/site/agenda/models/view-item'; +import { TreeService, TreeIdNode } from 'app/core/ui-services/tree.service'; +import { BaseAgendaViewModel } from 'app/site/base/base-agenda-view-model'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; +import { BaseViewModel } from 'app/site/base/base-view-model'; +import { BaseAgendaContentObjectRepository } from '../base-agenda-content-object-repository'; /** * Repository service for users @@ -152,9 +151,8 @@ export class ItemRepositoryService extends BaseRepository { * * @param data The reordered data from the sorting */ - public async sortItems(data: OSTreeSortEvent): Promise { - const url = '/rest/agenda/item/sort/'; - await this.httpService.post(url, data); + public async sortItems(data: TreeIdNode[]): Promise { + await this.httpService.post('/rest/agenda/item/sort/', data); } /** diff --git a/client/src/app/core/repositories/motions/motion-repository.service.ts b/client/src/app/core/repositories/motions/motion-repository.service.ts index ca295d960..37419e0e9 100644 --- a/client/src/app/core/repositories/motions/motion-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-repository.service.ts @@ -20,12 +20,11 @@ import { Motion } from 'app/shared/models/motions/motion'; import { MotionBlock } from 'app/shared/models/motions/motion-block'; import { MotionChangeRecommendation } from 'app/shared/models/motions/motion-change-reco'; import { MotionPoll } from 'app/shared/models/motions/motion-poll'; -import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component'; -import { TreeService } from 'app/core/ui-services/tree.service'; +import { TreeService, TreeIdNode } from 'app/core/ui-services/tree.service'; import { User } from 'app/shared/models/users/user'; import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-change-recommendation'; import { ViewMotionAmendedParagraph } from 'app/site/motions/models/view-motion-amended-paragraph'; -import { ViewUnifiedChange } from '../../../shared/models/motions/view-unified-change'; +import { ViewUnifiedChange } from 'app/shared/models/motions/view-unified-change'; import { ViewStatuteParagraph } from 'app/site/motions/models/view-statute-paragraph'; import { Workflow } from 'app/shared/models/motions/workflow'; import { WorkflowState } from 'app/shared/models/motions/workflow-state'; @@ -377,9 +376,8 @@ export class MotionRepositoryService extends BaseAgendaContentObjectRepository { - const url = '/rest/motions/motion/sort/'; - await this.httpService.post(url, data); + public async sortMotions(data: TreeIdNode[]): Promise { + await this.httpService.post('/rest/motions/motion/sort/', data); } /** diff --git a/client/src/app/core/ui-services/tree.service.ts b/client/src/app/core/ui-services/tree.service.ts index a554c2642..25e766573 100644 --- a/client/src/app/core/ui-services/tree.service.ts +++ b/client/src/app/core/ui-services/tree.service.ts @@ -6,20 +6,48 @@ import { Identifiable } from 'app/shared/models/base/identifiable'; /** * A basic representation of a tree node. This node does not stores any data. */ -export interface OSTreeNodeWithoutItem { - name: string; +export interface TreeIdNode { id: number; - children?: OSTreeNodeWithoutItem[]; + children?: TreeIdNode[]; +} + +/** + * Extends the TreeIdNode with a name to display. + */ +export interface TreeNodeWithoutItem extends TreeIdNode { + name: string; + children?: TreeNodeWithoutItem[]; } /** * A representation of nodes with the item atached. */ -export interface OSTreeNode extends OSTreeNodeWithoutItem { +export interface OSTreeNode extends TreeNodeWithoutItem { item: T; children?: OSTreeNode[]; } +/** + * Interface which defines the nodes for the sorting trees. + * + * Contains information like + * name: The name of the node. + * level: The level of the node. The higher, the deeper the level. + * position: The position in the array of the node. + * isExpanded: Boolean if the node is expanded. + * expandable: Boolean if the node is expandable. + * id: The id of the node. + */ +export interface FlatNode { + name: string; + level: number; + position?: number; + isExpanded?: boolean; + isSeen: boolean; + expandable: boolean; + id: number; +} + /** * This services handles all operations belonging to trees. It can build trees of plain lists (giving the weight * and parentId property) and traverse the trees in pre-order. @@ -56,6 +84,54 @@ export class TreeService { }; } + /** + * Function to build flat nodes from `OSTreeNode`s. + * Iterates recursively through the list of nodes. + * + * @param items + * @param weightKey + * @param parentKey + * + * @returns An array containing flat nodes. + */ + public makeFlatTree( + items: T[], + weightKey: keyof T, + parentKey: keyof T + ): FlatNode[] { + const tree = this.makeTree(items, weightKey, parentKey); + const flatNodes: FlatNode[] = []; + for (const node of tree) { + flatNodes.push(...this.makePartialFlatTree(node, 0, [])); + } + for (let i = 0; i < flatNodes.length; ++i) { + flatNodes[i].position = i; + } + return flatNodes; + } + + /** + * Function to convert a flat tree to a nested tree built from `OSTreeNodeWithOutItem`. + * + * @param nodes The array of flat nodes, which should be converted. + * + * @returns The tree with nested information. + */ + public makeTreeFromFlatTree(nodes: FlatNode[]): TreeIdNode[] { + const basicTree: TreeIdNode[] = []; + + for (let i = 0; i < nodes.length; ) { + // build the next node inclusive its children + const nextNode = this.buildBranchFromFlatTree(nodes[i], nodes, 0); + // append this node to the tree + basicTree.push(nextNode.node); + // step to the next related item in the array + i += nextNode.length; + } + + return basicTree; + } + /** * Builds a tree from the given items on the relations between items with weight and parentId * @@ -126,9 +202,9 @@ export class TreeService { * @param tree The tree with items * @returns The tree without items */ - public stripTree(tree: OSTreeNode[]): OSTreeNodeWithoutItem[] { + public stripTree(tree: OSTreeNode[]): TreeNodeWithoutItem[] { return tree.map(node => { - const nodeWithoutItem: OSTreeNodeWithoutItem = { + const nodeWithoutItem: TreeNodeWithoutItem = { name: node.name, id: node.id }; @@ -214,4 +290,75 @@ export class TreeService { }); return result; } + + /** + * Helper function to go recursively through the children of given node. + * + * @param item + * @param level + * + * @returns An array containing the parent node with all its children. + */ + private makePartialFlatTree( + item: OSTreeNode, + level: number, + parents: FlatNode[] + ): FlatNode[] { + const children = item.children; + const node: FlatNode = { + id: item.id, + name: item.name, + expandable: !!children, + isExpanded: !!children, + level: level, + isSeen: true + }; + const flatNodes: FlatNode[] = [node]; + if (children) { + parents.push(node); + for (const child of children) { + flatNodes.push(...this.makePartialFlatTree(child, level + 1, parents)); + } + } + return flatNodes; + } + + /** + * Function, that returns a node containing information like id, name and children. + * Children only, if available. + * + * @param node The node which is converted. + * @param nodes The array with all nodes to convert. + * @param length The number of converted nodes related to the parent node. + * + * @returns `OSTreeNodeWithOutItem` + */ + private buildBranchFromFlatTree( + node: FlatNode, + nodes: FlatNode[], + length: number + ): { node: TreeIdNode; length: number } { + const children = []; + // Begins at the position of the node in the array. + // Ends if the next node has the same or higher level than the given node. + for (let i = node.position + 1; !!nodes[i] && nodes[i].level >= node.level + 1; ++i) { + const nextNode = nodes[i]; + // The next node is a child if the level is one higher than the given node. + if (nextNode.level === node.level + 1) { + // Makes the child nodes recursively. + const child = this.buildBranchFromFlatTree(nextNode, nodes, 0); + length += child.length; + children.push(child.node); + } + } + + // Makes the node with child nodes. + const osNode: TreeIdNode = { + id: node.id, + children: children.length > 0 ? children : undefined + }; + + // Returns the built node and increase the length by one. + return { node: osNode, length: ++length }; + } } 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 index e3d6ee7c0..2dddcf986 100644 --- a/client/src/app/shared/components/sorting-tree/sorting-tree.component.html +++ b/client/src/app/shared/components/sorting-tree/sorting-tree.component.html @@ -1,3 +1,45 @@ -
- -
+ + + +
+ + + {{ node.name }} +
+
+
+
diff --git a/client/src/app/shared/components/sorting-tree/sorting-tree.component.scss b/client/src/app/shared/components/sorting-tree/sorting-tree.component.scss new file mode 100644 index 000000000..bb254c0b9 --- /dev/null +++ b/client/src/app/shared/components/sorting-tree/sorting-tree.component.scss @@ -0,0 +1,43 @@ +@import '../../../../assets/styles/drag.scss'; + +cdk-tree-node { + margin-bottom: 5px; + display: flex; + align-items: center; + cursor: pointer; + + div { + width: 100%; + + mat-icon { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); + } + } +} + +// Overwrite the preview +.cdk-drag-preview { + box-shadow: none; + background-color: unset; + + div { + box-sizing: border-box; + background-color: white; + border-radius: 4px; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); + } +} + +// Overwrite the placeholder +.cdk-drag-placeholder { + opacity: 1; + background: #ccc; + border: dotted 3px #999; + min-height: 42px; + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +.cdk-drop-list.cdk-drop-list-dragging .cdk-tree-node:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} 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 index 2f9aff9d9..dbdb89050 100644 --- 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 @@ -1,5 +1,5 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { E2EImportsModule } from '../../../../e2e-imports.module'; +import { E2EImportsModule } from 'e2e-imports.module'; import { SortingTreeComponent } from './sorting-tree.component'; import { Component, ViewChild } from '@angular/core'; import { Displayable } from 'app/site/base/displayable'; @@ -53,7 +53,7 @@ describe('SortingTreeComponent', () => { models.push(new TestModel(i, `TOP${i}`, i, null)); } const modelSubject = new BehaviorSubject(models); - hostComponent.sortingTreeCompononent.modelsObservable = modelSubject.asObservable(); + hostComponent.sortingTreeCompononent.model = 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 index 0e6368815..db795eb34 100644 --- a/client/src/app/shared/components/sorting-tree/sorting-tree.component.ts +++ b/client/src/app/shared/components/sorting-tree/sorting-tree.component.ts @@ -1,165 +1,736 @@ -import { Component, OnInit, ViewChild, Input, EventEmitter, Output, OnDestroy } from '@angular/core'; -import { transferArrayItem } from '@angular/cdk/drag-drop'; +import { Component, OnInit, Input, OnDestroy, Output, EventEmitter } from '@angular/core'; -import { ITreeOptions, TreeModel, TreeNode } from 'angular-tree-component'; +import { FlatTreeControl } from '@angular/cdk/tree'; +import { ArrayDataSource } from '@angular/cdk/collections'; +import { CdkDragMove, CdkDragStart, CdkDragSortEvent } from '@angular/cdk/drag-drop'; +import { Observable, Subscription } from 'rxjs'; import { auditTime } from 'rxjs/operators'; -import { Subscription, Observable } from 'rxjs'; import { Identifiable } from 'app/shared/models/base/identifiable'; import { Displayable } from 'app/site/base/displayable'; -import { OSTreeNode, TreeService, OSTreeNodeWithoutItem } from 'app/core/ui-services/tree.service'; +import { TreeService, FlatNode, TreeIdNode } from 'app/core/ui-services/tree.service'; /** - * The data representation for the sort event. + * Enumaration to separate between the directions. */ -export interface OSTreeSortEvent { - /** - * Gives all nodes to be inserted below the parent_id. - */ - nodes: OSTreeNodeWithoutItem[]; +enum Direction { + UPWARDS = 'upwards', + DOWNWARDS = 'downwards', + RIGHT = 'right', + LEFT = 'left', + NOWAY = 'noway' +} - /** - * 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; +/** + * Interface which extends the `OSFlatNode`. + * Containing further information like start- and next-position. + */ +interface ExFlatNode extends FlatNode { + startPosition: number; + nextPosition: number; +} + +/** + * Interface to hold the start position and the current position. + */ +interface DragEvent { + position: { x: number; y: number }; + currentPosition: { x: number; y: number }; +} + +/** + * Class to hold the moved steps and the direction in horizontal and vertical way. + */ +class Movement { + public verticalMove: Direction.DOWNWARDS | Direction.UPWARDS | Direction.NOWAY; + public horizontalMove: Direction.LEFT | Direction.NOWAY | Direction.RIGHT; + public steps: number; } @Component({ selector: 'os-sorting-tree', - templateUrl: './sorting-tree.component.html' + templateUrl: './sorting-tree.component.html', + styleUrls: ['./sorting-tree.component.scss'] }) export class SortingTreeComponent implements OnInit, OnDestroy { /** - * The property key to get the parent id. + * The data to build the tree */ - @Input() - public parentIdKey: keyof T; + public osTreeData: FlatNode[] = []; /** - * The property key used for the weight attribute. + * The tree control + */ + public treeControl = new FlatTreeControl(node => node.level, node => node.expandable); + + /** + * Source for the tree + */ + public dataSource = new ArrayDataSource(this.osTreeData); + + /** + * Number to calculate the next position the node is moved + */ + private nextPosition = 0; + + /** + * Number which defines the level of the placeholder. + * Necessary to show the placeholder for the moved node correctly. + */ + public placeholderLevel = 0; + + /** + * Node with calculated next information. + * Containing information like the position, when the drag starts and where it is in the moment. + */ + public nextNode: ExFlatNode = null; + + /** + * Pointer for the move event + */ + private pointer: DragEvent = null; + + /** + * Subscription for the data store + */ + private modelSubscription: Subscription = null; + + /** + * Reference to the model that is passed to this component + */ + private _model: Observable = null; + + /** + * Input that defines the key for the parent id + */ + @Input() + public parentKey: keyof T; + + /** + * Input that defines the key for the weight of the items. + * The weight defines the order of the items. */ @Input() public weightKey: keyof T; /** - * An observable to recieve the models to display. + * Setter to get all models from data store. + * It will create or replace the existing subscription. */ @Input() - public set modelsObservable(models: Observable) { - if (!models) { + public set model(model: Observable) { + if (!model) { return; } - if (this.modelSubscription) { - this.modelSubscription.unsubscribe(); - } - this.modelSubscription = models.pipe(auditTime(10)).subscribe(items => { - this.nodes = this.treeService.makeTree(items, this.weightKey, this.parentIdKey); - setTimeout(() => this.tree.treeModel.expandAll()); - }); + this._model = model; + this.setSubscription(); } /** - * 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. + * EventEmitter to send info if changes has been made. */ @Output() - public readonly sort = new EventEmitter(); + public hasChanged: EventEmitter = new EventEmitter(); /** - * Options for the tree. As a default drag and drop is allowed. + * Constructor + * + * @param treeService Service to get data from store and build the tree nodes. */ - public treeOptions: ITreeOptions = { - allowDrag: true, - allowDrop: true - }; + public constructor(private treeService: TreeService) {} /** - * The tree. THis reference is used to expand and collapse the tree + * On init method */ - @ViewChild('tree') - public tree: any; + public ngOnInit(): void {} /** - * This is our actual tree represented by our own nodes. + * On destroy - unsubscribe the subscription */ - public nodes: OSTreeNode[] = []; + public ngOnDestroy(): void { + this.removeSubscription(); + } /** - * Constructor. Adds the eventhandler for the drop event to the tree. + * Function to check if the node has a parent. + * + * @param node which is viewed. + * + * @returns The parent node if available otherwise it returns null. */ - public constructor(private treeService: TreeService) { - this.treeOptions.actionMapping = { - mouse: { - drop: this.drop.bind(this) + public getParentNode(node: FlatNode): FlatNode { + const nodeIndex = this.osTreeData.indexOf(node); + + for (let i = nodeIndex - 1; i >= 0; --i) { + if (this.osTreeData[i].level === node.level - 1) { + return this.osTreeData[i]; + } + } + + return null; + } + + /** + * This function check if the parent of one node is expanded or not. + * Necessary to check if the user swaps over a child or the parent of the next node. + * + * @param node is the node which is the next node the user could step over. + * + * @returns The node which is either the parent if not expanded or the next node. + */ + private getExpandedParentNode(node: FlatNode): FlatNode { + const allParents = this.getAllParents(node); + for (let i = allParents.length - 1; i >= 0; --i) { + if (!allParents[i].isExpanded) { + return allParents[i]; + } + } + return node; + } + + /** + * Function to search for all parents over the given node. + * + * @param node is the affected node. + * + * @returns An array containing its parent and the parents of its parent. + */ + private getAllParents(node: FlatNode): FlatNode[] { + return this._getAllParents(node, []); + } + + /** + * Function to search recursively for all parents, that are in relation to the given node. + * + * @param node is the affected node. + * @param array is the array which contains the parents. + * + * @returns An array containing all parents that are in relation to the given node. + */ + private _getAllParents(node: FlatNode, array: FlatNode[]): FlatNode[] { + const parent = this.getParentNode(node); + if (parent) { + array.push(parent); + return this._getAllParents(parent, array); + } else { + return array; + } + } + + /** + * Function to get all nodes under the given node with level + 1. + * + * @param node The parent of the searched children. + * + * @returns An array that contains all the nearest children. + */ + public getChildNodes(node: FlatNode): FlatNode[] { + const nodeIndex = this.osTreeData.indexOf(node); + const childNodes: FlatNode[] = []; + + if (nodeIndex < this.osTreeData.length - 1) { + for (let i = nodeIndex + 1; i < this.osTreeData.length && this.osTreeData[i].level >= node.level + 1; ++i) { + if (this.osTreeData[i].level === node.level + 1) { + childNodes.push(this.osTreeData[i]); + } + } + } + + return childNodes; + } + + /** + * Function to look for all nodes that are under the given node. + * This includes not only the nearest children, but also the children of the children. + * + * @param node The parent of the nodes. + * + * @returns An array containing all the subnodes, inclusive the children of the children. + */ + private getAllSubNodes(node: FlatNode): FlatNode[] { + return this._getAllSubNodes(node, []); + } + + /** + * Function iterates through the array of children. + * `Warning: Side Effects`: The passed array will be filled. + * + * @param node The parent of the nodes. + * @param array The existing array containing all subnodes + * + * @returns An array containing all subnodes, inclusive the children of the children. + */ + private _getAllSubNodes(node: FlatNode, array: FlatNode[]): FlatNode[] { + array.push(node); + for (const child of this.getChildNodes(node)) { + this._getAllSubNodes(child, array); + } + return array; + } + + /** + * Function to check the position in the list that is shown. + * This is necessary to identify the calculated position from CdkDragDrop. + * + * @param node The node whose position at the shown list should be checked. + * + * @returns The calculated position as number. + */ + private getPositionOnScreen(node: FlatNode): number { + let currentPosition = this.osTreeData.length; + for (let i = this.osTreeData.length - 1; i >= 0; --i) { + --currentPosition; + const parent = this.getExpandedParentNode(this.osTreeData[i]); + if (parent === node) { + break; + } else { + i = parent.position; + } + } + return currentPosition; + } + + /** + * Function to check if the node should render. + * + * @param node which is viewed. + * + * @returns boolean if the node should render. Related to the state of the parent, if expanded or not. + */ + public shouldRender(node: FlatNode): boolean { + return node.isSeen; + } + + /** + * Function, that handles the click on a node. + * + * @param node which is clicked. + */ + public handleClick(node: FlatNode): void { + node.isExpanded = !node.isExpanded; + if (node.isExpanded) { + for (const child of this.getChildNodes(node)) { + this.showChildren(child); + } + } else { + const allChildren = this.getAllSubNodes(node); + for (let i = 1; i < allChildren.length; ++i) { + allChildren[i].isSeen = false; + } + } + } + + /** + * Function to show children if the parent has expanded. + * + * @param node is the node which should be shown again. + */ + private showChildren(node: FlatNode): void { + node.isSeen = true; + if (node.expandable && node.isExpanded) { + for (const child of this.getChildNodes(node)) { + this.showChildren(child); + } + } + } + + /** + * Function to check the visibility of moved nodes after moving them. + * `Warning: Side Effects`: This function works with side effects. The changed nodes won't be returned! + * + * @param nodes All affected nodes, that are either shown or not. + */ + private checkVisibility(nodes: FlatNode[]): void { + if (this.getAllParents(nodes[0]).find(item => item.expandable && !item.isExpanded)) { + for (const child of nodes) { + child.isSeen = false; + } + } + } + + /** + * Function to calculate the next position of the moved node. + * So, the user could see where he moves the node. + * + * @param event The CdkDragSortEvent which emits the event + */ + public sortItems(event: CdkDragSortEvent): void { + this.nextNode.nextPosition = event.currentIndex; + this.calcNextPosition(); + } + + /** + * Function to set the cursor position immediately if the user starts dragging a node. + * + * @param event The mouse event which emits the event. + */ + public mouseDown(event: MouseEvent): void { + this.pointer = { + position: { + x: event.x, + y: event.y + }, + currentPosition: { + x: event.x, + y: event.y } }; } /** - * Required by components using the selector as directive + * If the user stops moving a node and he does not drag it, then the pointer would be set to null. */ - public ngOnInit(): void {} + public mouseUp(): void { + this.pointer = null; + } /** - * Closes all subscriptions/event emitters. + * Function to initiate the dragging. + * + * @param event CdkDragStart which emits the event */ - public ngOnDestroy(): void { + public startsDrag(event: CdkDragStart): void { + this.removeSubscription(); + const draggedNode = event.source.data; + this.placeholderLevel = draggedNode.level; + this.nextNode = { + ...draggedNode, + startPosition: this.getPositionOnScreen(draggedNode), + nextPosition: this.getPositionOnScreen(draggedNode) + }; + } + + /** + * Function to handle the dropping of a node. + * + * @param node Is the dropped node. + */ + public onDrop(node: FlatNode): void { + const moving = this.getDirection(); + this.pointer = null; + + this.madeChanges(true); + this.moveItemToTree(node, node.position, this.nextPosition, this.placeholderLevel, moving.verticalMove); + } + + /** + * Function to handle the moving of an item. + * Fires, when an item is moved. + * + * @param event the drag event for 'drag move'. + */ + public moveItem(event: CdkDragMove): void { + this.pointer.currentPosition = event.pointerPosition; + this.calcNextPosition(); + } + + /** + * Function to calculate the direction of the moving of a node. + * + * @returns `Movement` object which contains horizontal and vertical movement. + */ + private getDirection(): Movement { + const movement = new Movement(); + movement.verticalMove = + this.nextNode.startPosition < this.nextNode.nextPosition + ? Direction.DOWNWARDS + : this.nextNode.startPosition > this.nextNode.nextPosition + ? Direction.UPWARDS + : Direction.NOWAY; + const deltaX = this.pointer.currentPosition.x - this.pointer.position.x; + movement.steps = Math.trunc(deltaX / 40); + movement.horizontalMove = + movement.steps > 0 ? Direction.RIGHT : movement.steps < 0 ? Direction.LEFT : Direction.NOWAY; + return movement; + } + + /** + * Function to calculate the position, where the node is being moved. + * First it separates between the different vertical movements. + */ + private calcNextPosition(): void { + const steps = this.osTreeData.length - this.nextNode.nextPosition - 1; + const direction = this.getDirection(); + const nextPosition = this.findNextIndex(steps, this.nextNode, direction.verticalMove); + this.nextPosition = nextPosition; + + const corrector = direction.verticalMove === Direction.DOWNWARDS ? 0 : 1; + const possibleParent = this.osTreeData[nextPosition - corrector]; + switch (direction.horizontalMove) { + case Direction.LEFT: + if (this.nextNode.level > 0 || this.placeholderLevel > 0) { + const nextLevel = this.nextNode.level + direction.steps; + if (nextLevel >= 0) { + this.placeholderLevel = nextLevel; + } else { + this.placeholderLevel = 0; + } + } + break; + + case Direction.RIGHT: + if (!!possibleParent) { + const nextLevel = this.nextNode.level + direction.steps; + if (nextLevel <= possibleParent.level) { + this.placeholderLevel = nextLevel; + } else { + this.placeholderLevel = possibleParent.level + 1; + } + } else { + this.placeholderLevel = 0; + } + break; + + case Direction.NOWAY: + if (!!possibleParent) { + if (this.nextNode.level <= possibleParent.level + 1) { + this.placeholderLevel = this.nextNode.level; + } else { + this.placeholderLevel = possibleParent.level + 1; + } + } else { + this.placeholderLevel = 0; + } + break; + } + } + + /** + * Function to calculate the next index for the dragged node. + * + * @param steps Are the caluclated steps the item will be dropped. + * @param node Is the corresponding node which is dragged. + * @param verticalMove Is the direction in the vertical way. + * + * @returns The next position of the node in the array. + */ + private findNextIndex( + steps: number, + node: ExFlatNode, + verticalMove: Direction.DOWNWARDS | Direction.UPWARDS | Direction.NOWAY + ): number { + let currentPosition = this.osTreeData.length; + switch (verticalMove) { + case Direction.UPWARDS: + for (let i = 0; i < steps; ++i) { + const parent = this.getExpandedParentNode(this.osTreeData[currentPosition - 1]); + if (!!parent) { + currentPosition = parent.position; + } else { + break; + } + if (node.name === parent.name) { + --i; + } + } + break; + + case Direction.DOWNWARDS: + currentPosition -= 1; + for (let i = 0; i < steps; ++i) { + const parent = this.getExpandedParentNode(this.osTreeData[currentPosition]); + if (!!parent) { + currentPosition = parent.position - 1; + } else { + break; + } + } + break; + + case Direction.NOWAY: + currentPosition = node.position; + break; + } + return currentPosition; + } + + /** + * Function to re-sort the tree, when a node was dropped. + * + * @param node The corresponding node. + * @param previousIndex The previous index of the node in the array. + * @param nextIndex The next calculated index of the node in the array. + * @param nextLevel The next level the node should have. + * @param verticalMove The direction of the movement in the vertical way. + */ + private moveItemToTree( + node: FlatNode, + previousIndex: number, + nextIndex: number, + nextLevel: number, + verticalMove: Direction.UPWARDS | Direction.DOWNWARDS | Direction.NOWAY + ): void { + // Get all affected nodes. + const movedNodes = this.getAllSubNodes(node); + const corrector = verticalMove === Direction.DOWNWARDS ? 0 : 1; + const lastChildIndex = movedNodes[movedNodes.length - 1].position; + + // Get the neighbor above and below of the new index. + const nextNeighborAbove = this.osTreeData[nextIndex - corrector]; + const nextNeighborBelow = + verticalMove !== Direction.NOWAY + ? this.osTreeData[nextIndex - corrector + 1] + : this.osTreeData[lastChildIndex + 1]; + + // Check if there is a change at all. + if ((nextIndex !== previousIndex || node.level !== nextLevel) && !movedNodes.includes(nextNeighborAbove)) { + // move something only if there is a change + const levelDifference = nextLevel - node.level; + + // Check if the node was a subnode. + if (node.level > 0) { + const previousNode = this.osTreeData[previousIndex - 1]; + const isMovedLowerLevel = + previousIndex === nextIndex && + nextLevel <= previousNode.level && + this.getChildNodes(previousNode).length === 1; + const isMovedAway = previousIndex !== nextIndex && this.getChildNodes(previousNode).length === 1; + + // Check if the previous parent will have no children anymore. + if (isMovedAway || isMovedLowerLevel) { + previousNode.expandable = false; + previousNode.isExpanded = false; + } + } + + // Check if the node becomes a subnode. + if (nextLevel > 0) { + // Check if the new parent has not have any children before. + if (nextNeighborAbove.level + 1 === nextLevel && this.getChildNodes(nextNeighborAbove).length === 0) { + nextNeighborAbove.expandable = true; + nextNeighborAbove.isExpanded = + (!!this.getParentNode(nextNeighborAbove) && this.getParentNode(nextNeighborAbove).isExpanded) || + this.getChildNodes(nextNeighborAbove).length === 0 + ? true + : false; + } + } + + // Check if the neighbor below has a higher level than the moved node. + if (nextNeighborBelow && nextNeighborBelow.level === nextLevel + 1) { + // Check if the new neighbor above has the same level like the moved node. + if (nextNeighborAbove.level === nextLevel) { + nextNeighborAbove.expandable = false; + nextNeighborAbove.isExpanded = false; + } + + // Set the moved node to the new parent for the subnodes. + node.expandable = true; + node.isExpanded = true; + } + + // Check if the neighbor below has a level equals to two or more higher than the moved node. + if (nextNeighborBelow && nextNeighborBelow.level >= nextLevel + 2) { + let found = false; + for (let i = nextIndex + 1; i < this.osTreeData.length; ++i) { + if (this.osTreeData[i].level <= nextLevel && node !== this.osTreeData[i]) { + found = true; + nextIndex = verticalMove === Direction.UPWARDS ? i : i - 1; + break; + } else if (node === this.osTreeData[i] && this.osTreeData[i + 1].level <= nextLevel + 1) { + // Remain at the same position and change only the level if changed. + nextIndex = previousIndex; + found = true; + break; + } + } + if (!found) { + nextIndex = this.osTreeData.length - 1; + } + if (verticalMove === Direction.NOWAY || previousIndex < nextIndex) { + verticalMove = Direction.DOWNWARDS; + } + } + + // Handles the moving upwards + if (verticalMove === Direction.UPWARDS) { + const difference = Math.abs(nextIndex - previousIndex); + // Moves the other nodes starting from the next index to the previous index. + for (let i = 1; i <= difference; ++i) { + const currentIndex = previousIndex + movedNodes.length - i; + this.osTreeData[currentIndex] = this.osTreeData[currentIndex - movedNodes.length]; + this.osTreeData[currentIndex].position = currentIndex; + } + + // Moves the affected nodes back to the array starting at the `nextIndex`. + for (let i = 0; i < movedNodes.length; ++i) { + this.osTreeData[nextIndex + i] = movedNodes[i]; + this.osTreeData[nextIndex + i].position = nextIndex + i; + this.osTreeData[nextIndex + i].level += levelDifference; + } + + // Handles the moving downwards + } else if (verticalMove === Direction.DOWNWARDS) { + const difference = Math.abs(nextIndex - previousIndex) - (movedNodes.length - 1); + for (let i = 0; i < difference; ++i) { + const currentIndex = previousIndex + movedNodes.length + i; + this.osTreeData[previousIndex + i] = this.osTreeData[currentIndex]; + this.osTreeData[previousIndex + i].position = previousIndex + i; + } + for (let i = 0; i < movedNodes.length; ++i) { + this.osTreeData[nextIndex - i] = movedNodes[movedNodes.length - i - 1]; + this.osTreeData[nextIndex - i].position = nextIndex - i; + this.osTreeData[nextIndex - i].level += levelDifference; + } + + // Handles the moving in the same direction + } else { + for (let i = 0; i < movedNodes.length; ++i) { + this.osTreeData[nextIndex + i].level += levelDifference; + } + } + + // Check the visibility to prevent seeing nodes that are actually unseen. + this.checkVisibility(movedNodes); + + // Set a new data source. + this.dataSource = null; + this.dataSource = new ArrayDataSource(this.osTreeData); + } + } + + /** + * Function to get the data from tree. + * + * @returns An array that contains all necessary information to see the connections between the nodes and their subnodes. + */ + public getTreeData(): TreeIdNode[] { + return this.treeService.makeTreeFromFlatTree(this.osTreeData); + } + + /** + * Function to remove the current subscription to prevent overwriting the changes made from user. + */ + private removeSubscription(): void { if (this.modelSubscription) { this.modelSubscription.unsubscribe(); + this.modelSubscription = null; } - 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 + * Function to (re-) set the subscription to recognize changes. */ - private drop(tree: TreeModel, node: TreeNode, $event: any, { from, to }: { from: any; to: any }): void { - // check if dropped itself by going the tree upwards and check, if one of them is the "from"-node. - let parent = to.parent; - while (parent !== null) { - if (from.id === parent.id) { - return; - } - parent = parent.parent; - } - - 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); - const strippedNodes = this.treeService.stripTree(to.parent.data.children); - this.sort.emit({ nodes: strippedNodes, parent_id: parentId }); + public setSubscription(): void { + this.removeSubscription(); + this.madeChanges(false); + this.modelSubscription = this._model.pipe(auditTime(10)).subscribe(values => { + this.osTreeData = this.treeService.makeFlatTree(values, this.weightKey, this.parentKey); + this.dataSource = new ArrayDataSource(this.osTreeData); + }); } + + /** + * Function to emit the boolean if changes has been made or not. + * + * @param hasChanged Boolean that will be emitted. + */ + private madeChanges(hasChanged: boolean): void { + this.hasChanged.emit(hasChanged); + } + + /** + * Function to check if a node has children. + */ + public hasChild = (_: number, node: FlatNode) => node.expandable; } diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 3d185da14..afff74077 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -41,6 +41,7 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { DragDropModule } from '@angular/cdk/drag-drop'; +import { CdkTreeModule } from '@angular/cdk/tree'; // ngx-translate import { TranslateModule } from '@ngx-translate/core'; @@ -56,9 +57,6 @@ 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 { LegalNoticeContentComponent } from './components/legal-notice-content/legal-notice-content.component'; @@ -140,7 +138,7 @@ import { MediaUploadContentComponent } from './components/media-upload-content/m NgxMatSelectSearchModule, FileDropModule, EditorModule, - TreeModule.forRoot() + CdkTreeModule ], exports: [ FormsModule, @@ -190,7 +188,6 @@ import { MediaUploadContentComponent } from './components/media-upload-content/m SortingListComponent, EditorModule, SortingTreeComponent, - TreeModule, OsSortFilterBarComponent, LogoComponent, CopyrightSignComponent, diff --git a/client/src/app/shared/utils/watch-sorting-tree.guard.spec.ts b/client/src/app/shared/utils/watch-sorting-tree.guard.spec.ts new file mode 100644 index 000000000..f1999dd86 --- /dev/null +++ b/client/src/app/shared/utils/watch-sorting-tree.guard.spec.ts @@ -0,0 +1,15 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { WatchSortingTreeGuard } from './watch-sorting-tree.guard'; + +describe('WatchSortingTreeGuard', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [WatchSortingTreeGuard] + }); + }); + + it('should ...', inject([WatchSortingTreeGuard], (guard: WatchSortingTreeGuard) => { + expect(guard).toBeTruthy(); + })); +}); diff --git a/client/src/app/shared/utils/watch-sorting-tree.guard.ts b/client/src/app/shared/utils/watch-sorting-tree.guard.ts new file mode 100644 index 000000000..aa709fd46 --- /dev/null +++ b/client/src/app/shared/utils/watch-sorting-tree.guard.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { CanDeactivate } from '@angular/router'; + +/** + * Interface to describe the function that is necessary. + */ +export interface CanComponentDeactivate { + canDeactivate: () => Promise; +} + +@Injectable({ + providedIn: 'root' +}) +export class WatchSortingTreeGuard implements CanDeactivate { + /** + * Function to determine whether the route will change or not. + * + * @param component Is the component that implements the interface described above. + * + * @returns A boolean: True if the route should change, false if the route shouldn't change. + */ + public async canDeactivate(component: CanComponentDeactivate): Promise { + return component.canDeactivate ? await component.canDeactivate() : true; + } +} diff --git a/client/src/app/site/agenda/agenda-routing.module.ts b/client/src/app/site/agenda/agenda-routing.module.ts index fa0d424b0..adf4d1b6c 100644 --- a/client/src/app/site/agenda/agenda-routing.module.ts +++ b/client/src/app/site/agenda/agenda-routing.module.ts @@ -6,12 +6,13 @@ import { AgendaListComponent } from './components/agenda-list/agenda-list.compon import { AgendaSortComponent } from './components/agenda-sort/agenda-sort.component'; import { ListOfSpeakersComponent } from './components/list-of-speakers/list-of-speakers.component'; import { TopicDetailComponent } from './components/topic-detail/topic-detail.component'; +import { WatchSortingTreeGuard } from 'app/shared/utils/watch-sorting-tree.guard'; const routes: Routes = [ { path: '', component: AgendaListComponent, pathMatch: 'full' }, { path: 'import', component: AgendaImportListComponent }, { path: 'topics/new', component: TopicDetailComponent }, - { path: 'sort-agenda', component: AgendaSortComponent }, + { path: 'sort-agenda', component: AgendaSortComponent, canDeactivate: [WatchSortingTreeGuard] }, { path: 'speakers', component: ListOfSpeakersComponent }, { path: 'topics/:id', component: TopicDetailComponent }, { path: ':id/speakers', component: ListOfSpeakersComponent } diff --git a/client/src/app/site/agenda/components/agenda-sort/agenda-sort.component.html b/client/src/app/site/agenda/components/agenda-sort/agenda-sort.component.html index a4f6e48a3..d3ff99ac2 100644 --- a/client/src/app/site/agenda/components/agenda-sort/agenda-sort.component.html +++ b/client/src/app/site/agenda/components/agenda-sort/agenda-sort.component.html @@ -1,23 +1,19 @@ - + +

Sort agenda

-
- - Drag and drop items to change the order of the agenda. Your modification will be saved immediately. - -
- - - + >
diff --git a/client/src/app/site/agenda/components/agenda-sort/agenda-sort.component.ts b/client/src/app/site/agenda/components/agenda-sort/agenda-sort.component.ts index 2545b1bf1..10e99f833 100644 --- a/client/src/app/site/agenda/components/agenda-sort/agenda-sort.component.ts +++ b/client/src/app/site/agenda/components/agenda-sort/agenda-sort.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter } from '@angular/core'; +import { Component, ViewChild } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material'; @@ -7,8 +7,10 @@ import { Observable } from 'rxjs'; import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service'; import { BaseViewComponent } from '../../../base/base-view'; -import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component'; +import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component'; import { ViewItem } from '../../models/view-item'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { CanComponentDeactivate } from 'app/shared/utils/watch-sorting-tree.guard'; /** * Sort view for the agenda. @@ -17,50 +19,81 @@ import { ViewItem } from '../../models/view-item'; selector: 'os-agenda-sort', templateUrl: './agenda-sort.component.html' }) -export class AgendaSortComponent extends BaseViewComponent { +export class AgendaSortComponent extends BaseViewComponent implements CanComponentDeactivate { + /** + * Reference to the view child + */ + @ViewChild('osSortedTree') + public osSortTree: SortingTreeComponent; + + /** + * Boolean to check if changes has been made. + */ + public hasChanged = false; + /** * All agendaItems sorted by their virtual weight {@link ViewItem.agendaListWeight} */ public itemsObservable: Observable; - /** - * Emits true for expand and false for collapse. Informs the sorter component about this actions. - */ - public readonly expandCollapse: EventEmitter = new EventEmitter(); - /** * Updates the incoming/changing agenda items. * @param title * @param translate * @param matSnackBar * @param agendaRepo + * @param promptService */ public constructor( title: Title, translate: TranslateService, matSnackBar: MatSnackBar, - private agendaRepo: ItemRepositoryService + private agendaRepo: ItemRepositoryService, + private promptService: PromptService ) { super(title, translate, matSnackBar); this.itemsObservable = this.agendaRepo.getViewModelListObservable(); } /** - * Handler for the sort event. The data to change is given to the repo, sending it to the server. - * - * @param data The event data. The representation fits the servers requirements, so it can directly - * be send to the server via the repository. + * Function to save the tree by click. */ - public sort(data: OSTreeSortEvent): void { - this.agendaRepo.sortItems(data).then(null, this.raiseError); + public async onSave(): Promise { + await this.agendaRepo + .sortItems(this.osSortTree.getTreeData()) + .then(() => this.osSortTree.setSubscription(), this.raiseError); } /** - * Fires the expandCollapse event emitter. - * - * @param expand True, if the tree should be expanded. Otherwise collapsed + * Function to restore the old state. */ - public expandCollapseAll(expand: boolean): void { - this.expandCollapse.emit(expand); + 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('You made changes.'); + const content = this.translate.instant('Do you really want to exit?'); + return await this.promptService.open(title, content); + } + return true; } } diff --git a/client/src/app/site/motions/modules/call-list/call-list-routing.module.ts b/client/src/app/site/motions/modules/call-list/call-list-routing.module.ts index 78c587fec..af486f335 100644 --- a/client/src/app/site/motions/modules/call-list/call-list-routing.module.ts +++ b/client/src/app/site/motions/modules/call-list/call-list-routing.module.ts @@ -2,8 +2,11 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { CallListComponent } from './call-list.component'; +import { WatchSortingTreeGuard } from 'app/shared/utils/watch-sorting-tree.guard'; -const routes: Routes = [{ path: '', component: CallListComponent, pathMatch: 'full' }]; +const routes: Routes = [ + { path: '', component: CallListComponent, pathMatch: 'full', canDeactivate: [WatchSortingTreeGuard] } +]; @NgModule({ imports: [RouterModule.forChild(routes)], diff --git a/client/src/app/site/motions/modules/call-list/call-list.component.html b/client/src/app/site/motions/modules/call-list/call-list.component.html index 1c0059979..62fcc26df 100644 --- a/client/src/app/site/motions/modules/call-list/call-list.component.html +++ b/client/src/app/site/motions/modules/call-list/call-list.component.html @@ -1,4 +1,10 @@ - + +

Call list

@@ -13,17 +19,12 @@ - - - + (hasChanged)="receiveChanges($event)" + [model]="motionsObservable"> diff --git a/client/src/app/site/motions/modules/call-list/call-list.component.ts b/client/src/app/site/motions/modules/call-list/call-list.component.ts index e8a03b9db..0e1560b7d 100644 --- a/client/src/app/site/motions/modules/call-list/call-list.component.ts +++ b/client/src/app/site/motions/modules/call-list/call-list.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter } from '@angular/core'; +import { Component, ViewChild } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material'; @@ -6,12 +6,13 @@ import { TranslateService } from '@ngx-translate/core'; import { Observable } from 'rxjs'; import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service'; - import { BaseViewComponent } from 'app/site/base/base-view'; import { MotionCsvExportService } from 'app/site/motions/services/motion-csv-export.service'; import { MotionPdfExportService } from 'app/site/motions/services/motion-pdf-export.service'; -import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component'; +import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component'; import { ViewMotion } from 'app/site/motions/models/view-motion'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { CanComponentDeactivate } from 'app/shared/utils/watch-sorting-tree.guard'; /** * Sort view for the call list. @@ -20,7 +21,13 @@ import { ViewMotion } from 'app/site/motions/models/view-motion'; selector: 'os-call-list', templateUrl: './call-list.component.html' }) -export class CallListComponent extends BaseViewComponent { +export class CallListComponent extends BaseViewComponent implements CanComponentDeactivate { + /** + * Reference to the sorting tree. + */ + @ViewChild('osSortedTree') + private osSortTree: SortingTreeComponent; + /** * All motions sorted first by weight, then by id. */ @@ -32,9 +39,9 @@ export class CallListComponent extends BaseViewComponent { private motions: ViewMotion[] = []; /** - * Emits true for expand and false for collaps. Informs the sorter component about this actions. + * Boolean to check if the tree has changed. */ - public readonly expandCollapse: EventEmitter = new EventEmitter(); + public hasChanged = false; /** * Updates the motions member, and sorts it. @@ -42,6 +49,7 @@ export class CallListComponent extends BaseViewComponent { * @param translate * @param matSnackBar * @param motionRepo + * @param promptService */ public constructor( title: Title, @@ -49,7 +57,8 @@ export class CallListComponent extends BaseViewComponent { matSnackBar: MatSnackBar, private motionRepo: MotionRepositoryService, private motionCsvExport: MotionCsvExportService, - private motionPdfExport: MotionPdfExportService + private motionPdfExport: MotionPdfExportService, + private promptService: PromptService ) { super(title, translate, matSnackBar); @@ -61,23 +70,30 @@ export class CallListComponent extends BaseViewComponent { } /** - * Handler for the sort event. The data to change is given to - * the repo, sending it to the server. - * - * @param data The event data. The representation fits the servers requirements, so it can directly - * be send to the server via the repository. + * Function to save changes on click. */ - public sort(data: OSTreeSortEvent): void { - this.motionRepo.sortMotions(data).then(null, this.raiseError); + public async onSave(): Promise { + await this.motionRepo + .sortMotions(this.osSortTree.getTreeData()) + .then(() => this.osSortTree.setSubscription(), this.raiseError); } /** - * Fires the expandCollapse event emitter. - * - * @param expand True, if the tree should be expanded. Otherwise collapsed + * Function to restore the old state. */ - public expandCollapseAll(expand: boolean): void { - this.expandCollapse.emit(expand); + 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; } /** @@ -93,4 +109,19 @@ export class CallListComponent extends BaseViewComponent { public pdfExportCallList(): void { this.motionPdfExport.exportPdfCallList(this.motions); } + + /** + * 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('You made changes.'); + const content = this.translate.instant('Do you really want to exit?'); + return await this.promptService.open(title, content); + } + return true; + } } diff --git a/client/src/styles.scss b/client/src/styles.scss index e93afef84..5f19094cf 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -24,8 +24,6 @@ /** More components are added here */ } -@import '~angular-tree-component/dist/angular-tree-component.css'; - /** date-time-picker */ @import '~ng-pick-datetime/assets/style/picker.min.css'; diff --git a/openslides/agenda/views.py b/openslides/agenda/views.py index fe4504ac6..12e1f6566 100644 --- a/openslides/agenda/views.py +++ b/openslides/agenda/views.py @@ -15,6 +15,7 @@ from openslides.utils.rest_api import ( detail_route, list_route, ) +from openslides.utils.views import TreeSortMixin from ..utils.auth import has_perm from .access_permissions import ItemAccessPermissions @@ -24,7 +25,9 @@ from .models import Item, Speaker # Viewsets for the REST API -class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericViewSet): +class ItemViewSet( + ListModelMixin, RetrieveModelMixin, UpdateModelMixin, TreeSortMixin, GenericViewSet +): """ API endpoint for agenda items. @@ -44,7 +47,13 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV result = has_perm(self.request.user, "agenda.can_see") # For manage_speaker and tree requests the rest of the check is # done in the specific method. See below. - elif self.action in ("partial_update", "update", "sort", "assign"): + elif self.action in ( + "partial_update", + "update", + "sort", + "sort_whole", + "assign", + ): result = ( has_perm(self.request.user, "agenda.can_see") and has_perm(self.request.user, "agenda.can_see_internal_items") @@ -322,34 +331,32 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV @list_route(methods=["post"]) def sort(self, request): """ - Sort agenda items. Also checks parent field to prevent hierarchical - loops. + Sorts the whole agenda represented in a tree of ids. The request data should be a list (the root) + of all main agenda items. Each node is a dict with an id and optional children: + { + id: + children: [ + + ] + } + Every id has to be given. """ - nodes = request.data.get("nodes", []) - parent_id = request.data.get("parent_id") - items = [] - with transaction.atomic(): - for index, node in enumerate(nodes): - item = Item.objects.get(pk=node["id"]) - item.parent_id = parent_id - item.weight = index - item.save(skip_autoupdate=True) - items.append(item) + return self.sort_tree(request, Item, "weight", "parent_id") - # Now check consistency. TODO: Try to use less DB queries. - item = Item.objects.get(pk=node["id"]) - ancestor = item.parent - while ancestor is not None: - if ancestor == item: - raise ValidationError( - { - "detail": "There must not be a hierarchical loop. Please reload the page." - } - ) - ancestor = ancestor.parent - - inform_changed_data(items) - return Response({"detail": "The agenda has been sorted."}) + @list_route(methods=["post"]) + def sort_whole(self, request): + """ + Sorts the whole agenda represented in a tree of ids. The request data should be a list (the root) + of all main agenda items. Each node is a dict with an id and optional all children: + { + id: + children: [ + + ] + } + Every id has to be given. + """ + return self.sort_tree(request, Item, "weight", "parent_id") @list_route(methods=["post"]) @transaction.atomic diff --git a/openslides/motions/views.py b/openslides/motions/views.py index feca5a5f8..b62823779 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -26,6 +26,7 @@ from ..utils.rest_api import ( detail_route, list_route, ) +from ..utils.views import TreeSortMixin from .access_permissions import ( CategoryAccessPermissions, MotionAccessPermissions, @@ -55,7 +56,7 @@ from .serializers import MotionPollSerializer, StateSerializer # Viewsets for the REST API -class MotionViewSet(ModelViewSet): +class MotionViewSet(TreeSortMixin, ModelViewSet): """ API endpoint for motions. @@ -325,35 +326,17 @@ class MotionViewSet(ModelViewSet): @list_route(methods=["post"]) def sort(self, request): """ - Sort motions. Also checks sort_parent field to prevent hierarchical loops. - - Note: This view is not tested! Maybe needs to be refactored. Add documentation - abou the data to be send. + Sorts all motions represented in a tree of ids. The request data should be a list (the root) + of all main agenda items. Each node is a dict with an id and optional children: + { + id: + children: [ + + ] + } + Every id has to be given. """ - nodes = request.data.get("nodes", []) - sort_parent_id = request.data.get("parent_id") - motions = [] - with transaction.atomic(): - for index, node in enumerate(nodes): - 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=id) - ancestor = motion.sort_parent - while ancestor is not None: - if ancestor == motion: - raise ValidationError( - {"detail": "There must not be a hierarchical loop."} - ) - ancestor = ancestor.sort_parent - - inform_changed_data(motions) - return Response({"detail": "The motions has been sorted."}) + return self.sort_tree(request, Motion, "weight", "sort_parent_id") @detail_route(methods=["POST", "DELETE"]) def manage_comments(self, request, pk=None): diff --git a/openslides/utils/views.py b/openslides/utils/views.py index dbc6178d6..c18002f44 100644 --- a/openslides/utils/views.py +++ b/openslides/utils/views.py @@ -1,8 +1,11 @@ -from typing import Any, Dict, List +from typing import Any, Dict, List, Set -from rest_framework.response import Response +from django.db import models, transaction from rest_framework.views import APIView as _APIView +from .autoupdate import inform_changed_data +from .rest_api import Response, ValidationError + class APIView(_APIView): """ @@ -32,3 +35,95 @@ class APIView(_APIView): # Add the http-methods and delete the method "method_call" get = post = put = patch = delete = head = options = trace = method_call del method_call + + +class TreeSortMixin: + """ + Provides a handler for sorting a model tree. + """ + + def sort_tree( + self, request: Any, model: models.Model, weight_key: str, parent_id_key: str + ) -> None: + """ + Sorts the all model objects represented in a tree of ids. The request data should be a list (the root) + of all main agenda items. Each node is a dict with an id and optional children: + { + id: + children: [ + + ] + } + Every id has to be given. + """ + if not isinstance(request.data, list): + raise ValidationError("The data must be a list.") + + # get all item ids to verify, that the user send all ids. + all_item_ids = set(model.objects.all().values_list("pk", flat=True)) + + # The stack where all nodes to check are saved. Invariant: Each node + # must be a dict with an id, a parent id (may be None for the root + # layer) and a weight. + nodes_to_check = [] + ids_found: Set[int] = set() # Set to save all found ids. + # Insert all root nodes. + for index, node in enumerate(request.data): + if not isinstance(node, dict) or not isinstance(node.get("id"), int): + raise ValidationError("node must be a dict with an id as integer") + node[parent_id_key] = None + node[weight_key] = index + nodes_to_check.append(node) + + # Traverse and check, if every id is given, valid and there are no duplicate ids. + while len(nodes_to_check) > 0: + node = nodes_to_check.pop() + id = node["id"] + + if id in ids_found: + raise ValidationError(f"Duplicate id: {id}") + if id not in all_item_ids: + raise ValidationError(f"Id does not exist: {id}") + + ids_found.add(id) + # Add children, if exist. + if isinstance(node.get("children"), list): + for index, child in enumerate(node["children"]): + # ensure invariant for nodes_to_check + if not isinstance(node, dict) or not isinstance( + node.get("id"), int + ): + raise ValidationError( + "node must be a dict with an id as integer" + ) + child[parent_id_key] = id + child[weight_key] = index + nodes_to_check.append(child) + + if len(all_item_ids) != len(ids_found): + raise ValidationError( + f"Did not recieved {len(all_item_ids)} ids, got {len(ids_found)}." + ) + + nodes_to_update = [] + nodes_to_update.extend( + request.data + ) # this will prevent mutating the request data. + with transaction.atomic(): + while len(nodes_to_update) > 0: + node = nodes_to_update.pop() + id = node["id"] + weight = node[weight_key] + parent_id = node[parent_id_key] + + db_node = model.objects.get(pk=id) + setattr(db_node, parent_id_key, parent_id) + setattr(db_node, weight_key, weight) + db_node.save(skip_autoupdate=True) + # Add children, if exist. + children = node.get("children") + if isinstance(children, list): + nodes_to_update.extend(children) + + inform_changed_data(model.objects.all()) + return Response()