Merge pull request #4489 from GabrielInTheWorld/tree-sort-agenda

Replaces the DragDrop-Tree
This commit is contained in:
Emanuel Schütze 2019-03-19 19:46:03 +01:00 committed by GitHub
commit d62f1538ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1244 additions and 261 deletions

View File

@ -41,7 +41,6 @@
"@ngx-translate/core": "^11.0.1", "@ngx-translate/core": "^11.0.1",
"@ngx-translate/http-loader": "^4.0.0", "@ngx-translate/http-loader": "^4.0.0",
"@tinymce/tinymce-angular": "^3.0.0", "@tinymce/tinymce-angular": "^3.0.0",
"angular-tree-component": "^8.2.0",
"core-js": "^2.6.5", "core-js": "^2.6.5",
"css-element-queries": "^1.1.1", "css-element-queries": "^1.1.1",
"file-saver": "^2.0.1", "file-saver": "^2.0.1",

View File

@ -1,23 +1,22 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { tap, map } from 'rxjs/operators'; import { tap, map } from 'rxjs/operators';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { BaseAgendaContentObjectRepository } from '../base-agenda-content-object-repository';
import { BaseRepository } from '../base-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 { CollectionStringMapperService } from '../../core-services/collectionStringMapper.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { DataSendService } from 'app/core/core-services/data-send.service'; import { DataSendService } from 'app/core/core-services/data-send.service';
import { DataStoreService } from '../../core-services/data-store.service'; import { DataStoreService } from '../../core-services/data-store.service';
import { HttpService } from 'app/core/core-services/http.service'; import { HttpService } from 'app/core/core-services/http.service';
import { Item } from 'app/shared/models/agenda/item'; 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 { 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 { 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 * Repository service for users
@ -152,9 +151,8 @@ export class ItemRepositoryService extends BaseRepository<ViewItem, Item> {
* *
* @param data The reordered data from the sorting * @param data The reordered data from the sorting
*/ */
public async sortItems(data: OSTreeSortEvent): Promise<void> { public async sortItems(data: TreeIdNode[]): Promise<void> {
const url = '/rest/agenda/item/sort/'; await this.httpService.post('/rest/agenda/item/sort/', data);
await this.httpService.post(url, data);
} }
/** /**

View File

@ -20,12 +20,11 @@ import { Motion } from 'app/shared/models/motions/motion';
import { MotionBlock } from 'app/shared/models/motions/motion-block'; import { MotionBlock } from 'app/shared/models/motions/motion-block';
import { MotionChangeRecommendation } from 'app/shared/models/motions/motion-change-reco'; import { MotionChangeRecommendation } from 'app/shared/models/motions/motion-change-reco';
import { MotionPoll } from 'app/shared/models/motions/motion-poll'; import { MotionPoll } from 'app/shared/models/motions/motion-poll';
import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component'; import { TreeService, TreeIdNode } from 'app/core/ui-services/tree.service';
import { TreeService } from 'app/core/ui-services/tree.service';
import { User } from 'app/shared/models/users/user'; import { User } from 'app/shared/models/users/user';
import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-change-recommendation'; import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-change-recommendation';
import { ViewMotionAmendedParagraph } from 'app/site/motions/models/view-motion-amended-paragraph'; 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 { ViewStatuteParagraph } from 'app/site/motions/models/view-statute-paragraph';
import { Workflow } from 'app/shared/models/motions/workflow'; import { Workflow } from 'app/shared/models/motions/workflow';
import { WorkflowState } from 'app/shared/models/motions/workflow-state'; import { WorkflowState } from 'app/shared/models/motions/workflow-state';
@ -377,9 +376,8 @@ export class MotionRepositoryService extends BaseAgendaContentObjectRepository<V
* *
* @param data The reordered data from the sorting * @param data The reordered data from the sorting
*/ */
public async sortMotions(data: OSTreeSortEvent): Promise<void> { public async sortMotions(data: TreeIdNode[]): Promise<void> {
const url = '/rest/motions/motion/sort/'; await this.httpService.post('/rest/motions/motion/sort/', data);
await this.httpService.post(url, data);
} }
/** /**

View File

@ -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. * A basic representation of a tree node. This node does not stores any data.
*/ */
export interface OSTreeNodeWithoutItem { export interface TreeIdNode {
name: string;
id: number; 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. * A representation of nodes with the item atached.
*/ */
export interface OSTreeNode<T> extends OSTreeNodeWithoutItem { export interface OSTreeNode<T> extends TreeNodeWithoutItem {
item: T; item: T;
children?: OSTreeNode<T>[]; children?: OSTreeNode<T>[];
} }
/**
* 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 * 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. * 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<T extends Identifiable & Displayable>(
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 * 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 * @param tree The tree with items
* @returns The tree without items * @returns The tree without items
*/ */
public stripTree<T>(tree: OSTreeNode<T>[]): OSTreeNodeWithoutItem[] { public stripTree<T>(tree: OSTreeNode<T>[]): TreeNodeWithoutItem[] {
return tree.map(node => { return tree.map(node => {
const nodeWithoutItem: OSTreeNodeWithoutItem = { const nodeWithoutItem: TreeNodeWithoutItem = {
name: node.name, name: node.name,
id: node.id id: node.id
}; };
@ -214,4 +290,75 @@ export class TreeService {
}); });
return result; 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<T extends Identifiable & Displayable>(
item: OSTreeNode<T>,
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 };
}
} }

View File

@ -1,3 +1,45 @@
<div class="os-tree"> <cdk-tree
<tree-root #tree [options]="treeOptions" [focused]="true" [nodes]="nodes"></tree-root> #osTree="cdkDropList"
</div> [dataSource]="dataSource"
[treeControl]="treeControl"
cdkDropList
[cdkDropListData]="osTreeData"
(cdkDropListSorted)="sortItems($event)"
>
<cdk-tree-node
cdkDrag
[cdkDragData]="node"
(cdkDragDropped)="onDrop(node)"
(mousedown)="mouseDown($event)"
(mouseup)="mouseUp($event)"
(cdkDragStarted)="startsDrag($event)"
(cdkDragMoved)="moveItem($event)"
*cdkTreeNodeDef="let node"
[style.display]="shouldRender(node) ? 'flex' : 'none'"
[style.padding-left]="node.level * 40 + 'px'">
<div class="backgroundColorLight">
<button
*ngIf="!hasChild"
mat-icon-button
disabled></button>
<button
*ngIf="hasChild"
mat-icon-button
cdkTreeNodeToggle
[attr.aria-label]="'toggle ' + node.filename"
(click)="handleClick(node, true)"
[style.visibility]="node.expandable ? 'visible' : 'hidden'">
<mat-icon
[style.transform]="node.isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'"
class="mat-icon-rtl-mirror">
chevron_right
</mat-icon>
</button>
{{ node.name }}
</div>
<div
[style.margin-left]="placeholderLevel * 40 + 'px'"
*cdkDragPlaceholder></div>
</cdk-tree-node>
</cdk-tree>

View File

@ -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);
}

View File

@ -1,5 +1,5 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 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 { SortingTreeComponent } from './sorting-tree.component';
import { Component, ViewChild } from '@angular/core'; import { Component, ViewChild } from '@angular/core';
import { Displayable } from 'app/site/base/displayable'; import { Displayable } from 'app/site/base/displayable';
@ -53,7 +53,7 @@ describe('SortingTreeComponent', () => {
models.push(new TestModel(i, `TOP${i}`, i, null)); models.push(new TestModel(i, `TOP${i}`, i, null));
} }
const modelSubject = new BehaviorSubject<TestModel[]>(models); const modelSubject = new BehaviorSubject<TestModel[]>(models);
hostComponent.sortingTreeCompononent.modelsObservable = modelSubject.asObservable(); hostComponent.sortingTreeCompononent.model = modelSubject.asObservable();
hostFixture.detectChanges(); hostFixture.detectChanges();
expect(hostComponent.sortingTreeCompononent).toBeTruthy(); expect(hostComponent.sortingTreeCompononent).toBeTruthy();

View File

@ -1,165 +1,736 @@
import { Component, OnInit, ViewChild, Input, EventEmitter, Output, OnDestroy } from '@angular/core'; import { Component, OnInit, Input, OnDestroy, Output, EventEmitter } from '@angular/core';
import { transferArrayItem } from '@angular/cdk/drag-drop';
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 { auditTime } from 'rxjs/operators';
import { Subscription, Observable } from 'rxjs';
import { Identifiable } from 'app/shared/models/base/identifiable'; import { Identifiable } from 'app/shared/models/base/identifiable';
import { Displayable } from 'app/site/base/displayable'; 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 { enum Direction {
/** UPWARDS = 'upwards',
* Gives all nodes to be inserted below the parent_id. DOWNWARDS = 'downwards',
*/ RIGHT = 'right',
nodes: OSTreeNodeWithoutItem[]; LEFT = 'left',
NOWAY = 'noway'
}
/** /**
* Provides the parent id for the nodes array. Do not provide it, if it's the * Interface which extends the `OSFlatNode`.
* full tree, e.g. when inserting a node into the first layer of the tree. The * Containing further information like start- and next-position.
* name is not camelCase, because this format can be send to the server as is. */
*/ interface ExFlatNode extends FlatNode {
parent_id?: number; 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({ @Component({
selector: 'os-sorting-tree', selector: 'os-sorting-tree',
templateUrl: './sorting-tree.component.html' templateUrl: './sorting-tree.component.html',
styleUrls: ['./sorting-tree.component.scss']
}) })
export class SortingTreeComponent<T extends Identifiable & Displayable> implements OnInit, OnDestroy { export class SortingTreeComponent<T extends Identifiable & Displayable> implements OnInit, OnDestroy {
/** /**
* The property key to get the parent id. * The data to build the tree
*/ */
@Input() public osTreeData: FlatNode[] = [];
public parentIdKey: keyof T;
/** /**
* The property key used for the weight attribute. * The tree control
*/
public treeControl = new FlatTreeControl<FlatNode>(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<T[]> = 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() @Input()
public weightKey: keyof T; 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() @Input()
public set modelsObservable(models: Observable<T[]>) { public set model(model: Observable<T[]>) {
if (!models) { if (!model) {
return; return;
} }
if (this.modelSubscription) { this._model = model;
this.modelSubscription.unsubscribe(); this.setSubscription();
}
this.modelSubscription = models.pipe(auditTime(10)).subscribe(items => {
this.nodes = this.treeService.makeTree(items, this.weightKey, this.parentIdKey);
setTimeout(() => this.tree.treeModel.expandAll());
});
} }
/** /**
* Saves the current subscription to the model oberservable. * EventEmitter to send info if changes has been made.
*/
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<boolean>) {
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() @Output()
public readonly sort = new EventEmitter<OSTreeSortEvent>(); public hasChanged: EventEmitter<boolean> = new EventEmitter<boolean>();
/** /**
* 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 = { public constructor(private treeService: TreeService) {}
allowDrag: true,
allowDrop: true
};
/** /**
* The tree. THis reference is used to expand and collapse the tree * On init method
*/ */
@ViewChild('tree') public ngOnInit(): void {}
public tree: any;
/** /**
* This is our actual tree represented by our own nodes. * On destroy - unsubscribe the subscription
*/ */
public nodes: OSTreeNode<T>[] = []; 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) { public getParentNode(node: FlatNode): FlatNode {
this.treeOptions.actionMapping = { const nodeIndex = this.osTreeData.indexOf(node);
mouse: {
drop: this.drop.bind(this) 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 = <FlatNode>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) { if (this.modelSubscription) {
this.modelSubscription.unsubscribe(); this.modelSubscription.unsubscribe();
this.modelSubscription = null;
} }
this.sort.complete();
} }
/** /**
* Handles the main drop event. Emits the sort event afterwards. * Function to (re-) set the subscription to recognize changes.
*
* @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 { public setSubscription(): void {
// check if dropped itself by going the tree upwards and check, if one of them is the "from"-node. this.removeSubscription();
let parent = to.parent; this.madeChanges(false);
while (parent !== null) { this.modelSubscription = this._model.pipe(auditTime(10)).subscribe(values => {
if (from.id === parent.id) { this.osTreeData = this.treeService.makeFlatTree(values, this.weightKey, this.parentKey);
return; this.dataSource = new ArrayDataSource(this.osTreeData);
} });
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 });
} }
/**
* 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;
} }

View File

@ -41,6 +41,7 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { DragDropModule } from '@angular/cdk/drag-drop'; import { DragDropModule } from '@angular/cdk/drag-drop';
import { CdkTreeModule } from '@angular/cdk/tree';
// ngx-translate // ngx-translate
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
@ -56,9 +57,6 @@ import { PermsDirective } from './directives/perms.directive';
import { DomChangeDirective } from './directives/dom-change.directive'; import { DomChangeDirective } from './directives/dom-change.directive';
import { AutofocusDirective } from './directives/autofocus.directive'; import { AutofocusDirective } from './directives/autofocus.directive';
// tree sorting
import { TreeModule } from 'angular-tree-component';
// components // components
import { HeadBarComponent } from './components/head-bar/head-bar.component'; import { HeadBarComponent } from './components/head-bar/head-bar.component';
import { LegalNoticeContentComponent } from './components/legal-notice-content/legal-notice-content.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, NgxMatSelectSearchModule,
FileDropModule, FileDropModule,
EditorModule, EditorModule,
TreeModule.forRoot() CdkTreeModule
], ],
exports: [ exports: [
FormsModule, FormsModule,
@ -190,7 +188,6 @@ import { MediaUploadContentComponent } from './components/media-upload-content/m
SortingListComponent, SortingListComponent,
EditorModule, EditorModule,
SortingTreeComponent, SortingTreeComponent,
TreeModule,
OsSortFilterBarComponent, OsSortFilterBarComponent,
LogoComponent, LogoComponent,
CopyrightSignComponent, CopyrightSignComponent,

View File

@ -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();
}));
});

View File

@ -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<boolean>;
}
@Injectable({
providedIn: 'root'
})
export class WatchSortingTreeGuard implements CanDeactivate<CanComponentDeactivate> {
/**
* 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<boolean> {
return component.canDeactivate ? await component.canDeactivate() : true;
}
}

View File

@ -6,12 +6,13 @@ import { AgendaListComponent } from './components/agenda-list/agenda-list.compon
import { AgendaSortComponent } from './components/agenda-sort/agenda-sort.component'; import { AgendaSortComponent } from './components/agenda-sort/agenda-sort.component';
import { ListOfSpeakersComponent } from './components/list-of-speakers/list-of-speakers.component'; import { ListOfSpeakersComponent } from './components/list-of-speakers/list-of-speakers.component';
import { TopicDetailComponent } from './components/topic-detail/topic-detail.component'; import { TopicDetailComponent } from './components/topic-detail/topic-detail.component';
import { WatchSortingTreeGuard } from 'app/shared/utils/watch-sorting-tree.guard';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: AgendaListComponent, pathMatch: 'full' }, { path: '', component: AgendaListComponent, pathMatch: 'full' },
{ path: 'import', component: AgendaImportListComponent }, { path: 'import', component: AgendaImportListComponent },
{ path: 'topics/new', component: TopicDetailComponent }, { path: 'topics/new', component: TopicDetailComponent },
{ path: 'sort-agenda', component: AgendaSortComponent }, { path: 'sort-agenda', component: AgendaSortComponent, canDeactivate: [WatchSortingTreeGuard] },
{ path: 'speakers', component: ListOfSpeakersComponent }, { path: 'speakers', component: ListOfSpeakersComponent },
{ path: 'topics/:id', component: TopicDetailComponent }, { path: 'topics/:id', component: TopicDetailComponent },
{ path: ':id/speakers', component: ListOfSpeakersComponent } { path: ':id/speakers', component: ListOfSpeakersComponent }

View File

@ -1,23 +1,19 @@
<os-head-bar [nav]="false"> <os-head-bar
[nav]="false"
[editMode]="hasChanged"
(mainEvent)="onCancel()"
(saveEvent)="onSave()">
<!-- Title --> <!-- Title -->
<div class="title-slot"><h2 translate>Sort agenda</h2></div> <div class="title-slot"><h2 translate>Sort agenda</h2></div>
</os-head-bar> </os-head-bar>
<mat-card> <mat-card>
<div>
<span translate>
Drag and drop items to change the order of the agenda. Your modification will be saved immediately.
</span>
</div>
<button mat-button (click)="expandCollapseAll(true)">{{ 'Expand all' | translate }}</button>
<button mat-button (click)="expandCollapseAll(false)">{{ 'Collapse all' | translate }}</button>
<os-sorting-tree <os-sorting-tree
#sorter #osSortedTree
(sort)="sort($event)" (hasChanged)="receiveChanges($event)"
parentIdKey="parent_id" [model]="itemsObservable"
parentKey="parent_id"
weightKey="weight" weightKey="weight"
[modelsObservable]="itemsObservable" ></os-sorting-tree>
[expandCollapseAll]="expandCollapse"
>
</os-sorting-tree>
</mat-card> </mat-card>

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter } from '@angular/core'; import { Component, ViewChild } from '@angular/core';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material'; import { MatSnackBar } from '@angular/material';
@ -7,8 +7,10 @@ import { Observable } from 'rxjs';
import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service'; import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service';
import { BaseViewComponent } from '../../../base/base-view'; 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 { 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. * Sort view for the agenda.
@ -17,50 +19,81 @@ import { ViewItem } from '../../models/view-item';
selector: 'os-agenda-sort', selector: 'os-agenda-sort',
templateUrl: './agenda-sort.component.html' 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<ViewItem>;
/**
* Boolean to check if changes has been made.
*/
public hasChanged = false;
/** /**
* All agendaItems sorted by their virtual weight {@link ViewItem.agendaListWeight} * All agendaItems sorted by their virtual weight {@link ViewItem.agendaListWeight}
*/ */
public itemsObservable: Observable<ViewItem[]>; public itemsObservable: Observable<ViewItem[]>;
/**
* Emits true for expand and false for collapse. Informs the sorter component about this actions.
*/
public readonly expandCollapse: EventEmitter<boolean> = new EventEmitter<boolean>();
/** /**
* Updates the incoming/changing agenda items. * Updates the incoming/changing agenda items.
* @param title * @param title
* @param translate * @param translate
* @param matSnackBar * @param matSnackBar
* @param agendaRepo * @param agendaRepo
* @param promptService
*/ */
public constructor( public constructor(
title: Title, title: Title,
translate: TranslateService, translate: TranslateService,
matSnackBar: MatSnackBar, matSnackBar: MatSnackBar,
private agendaRepo: ItemRepositoryService private agendaRepo: ItemRepositoryService,
private promptService: PromptService
) { ) {
super(title, translate, matSnackBar); super(title, translate, matSnackBar);
this.itemsObservable = this.agendaRepo.getViewModelListObservable(); this.itemsObservable = this.agendaRepo.getViewModelListObservable();
} }
/** /**
* Handler for the sort event. The data to change is given to the repo, sending it to the server. * Function to save the tree by click.
*
* @param data The event data. The representation fits the servers requirements, so it can directly
* be send to the server via the repository.
*/ */
public sort(data: OSTreeSortEvent): void { public async onSave(): Promise<void> {
this.agendaRepo.sortItems(data).then(null, this.raiseError); await this.agendaRepo
.sortItems(this.osSortTree.getTreeData())
.then(() => this.osSortTree.setSubscription(), this.raiseError);
} }
/** /**
* Fires the expandCollapse event emitter. * Function to restore the old state.
*
* @param expand True, if the tree should be expanded. Otherwise collapsed
*/ */
public expandCollapseAll(expand: boolean): void { public async onCancel(): Promise<void> {
this.expandCollapse.emit(expand); 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<boolean> {
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;
} }
} }

View File

@ -2,8 +2,11 @@ import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router';
import { CallListComponent } from './call-list.component'; 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({ @NgModule({
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],

View File

@ -1,4 +1,10 @@
<os-head-bar prevUrl="../.." [nav]="false"> <os-head-bar
prevUrl="../.."
[nav]="false"
[editMode]="hasChanged"
(mainEvent)="onCancel()"
(saveEvent)="onSave()">
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2 translate>Call list</h2> <h2 translate>Call list</h2>
@ -13,17 +19,12 @@
</os-head-bar> </os-head-bar>
<mat-card> <mat-card>
<button mat-button (click)="expandCollapseAll(true)">{{ 'Expand all' | translate }}</button>
<button mat-button (click)="expandCollapseAll(false)">{{ 'Collapse all' | translate }}</button>
<os-sorting-tree <os-sorting-tree
#sorter #osSortedTree
(sort)="sort($event)" parentKey="sort_parent_id"
parentIdKey="sort_parent_id"
weightKey="weight" weightKey="weight"
[modelsObservable]="motionsObservable" (hasChanged)="receiveChanges($event)"
[expandCollapseAll]="expandCollapse" [model]="motionsObservable"></os-sorting-tree>
>
</os-sorting-tree>
</mat-card> </mat-card>
<mat-menu #downloadMenu="matMenu"> <mat-menu #downloadMenu="matMenu">

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter } from '@angular/core'; import { Component, ViewChild } from '@angular/core';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material'; import { MatSnackBar } from '@angular/material';
@ -6,12 +6,13 @@ import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service'; import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
import { MotionCsvExportService } from 'app/site/motions/services/motion-csv-export.service'; import { MotionCsvExportService } from 'app/site/motions/services/motion-csv-export.service';
import { MotionPdfExportService } from 'app/site/motions/services/motion-pdf-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 { 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. * Sort view for the call list.
@ -20,7 +21,13 @@ import { ViewMotion } from 'app/site/motions/models/view-motion';
selector: 'os-call-list', selector: 'os-call-list',
templateUrl: './call-list.component.html' 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<ViewMotion>;
/** /**
* All motions sorted first by weight, then by id. * All motions sorted first by weight, then by id.
*/ */
@ -32,9 +39,9 @@ export class CallListComponent extends BaseViewComponent {
private motions: ViewMotion[] = []; 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<boolean> = new EventEmitter<boolean>(); public hasChanged = false;
/** /**
* Updates the motions member, and sorts it. * Updates the motions member, and sorts it.
@ -42,6 +49,7 @@ export class CallListComponent extends BaseViewComponent {
* @param translate * @param translate
* @param matSnackBar * @param matSnackBar
* @param motionRepo * @param motionRepo
* @param promptService
*/ */
public constructor( public constructor(
title: Title, title: Title,
@ -49,7 +57,8 @@ export class CallListComponent extends BaseViewComponent {
matSnackBar: MatSnackBar, matSnackBar: MatSnackBar,
private motionRepo: MotionRepositoryService, private motionRepo: MotionRepositoryService,
private motionCsvExport: MotionCsvExportService, private motionCsvExport: MotionCsvExportService,
private motionPdfExport: MotionPdfExportService private motionPdfExport: MotionPdfExportService,
private promptService: PromptService
) { ) {
super(title, translate, matSnackBar); 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 * Function to save changes on click.
* 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.
*/ */
public sort(data: OSTreeSortEvent): void { public async onSave(): Promise<void> {
this.motionRepo.sortMotions(data).then(null, this.raiseError); await this.motionRepo
.sortMotions(this.osSortTree.getTreeData())
.then(() => this.osSortTree.setSubscription(), this.raiseError);
} }
/** /**
* Fires the expandCollapse event emitter. * Function to restore the old state.
*
* @param expand True, if the tree should be expanded. Otherwise collapsed
*/ */
public expandCollapseAll(expand: boolean): void { public async onCancel(): Promise<void> {
this.expandCollapse.emit(expand); 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 { public pdfExportCallList(): void {
this.motionPdfExport.exportPdfCallList(this.motions); 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<boolean> {
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;
}
} }

View File

@ -24,8 +24,6 @@
/** More components are added here */ /** More components are added here */
} }
@import '~angular-tree-component/dist/angular-tree-component.css';
/** date-time-picker */ /** date-time-picker */
@import '~ng-pick-datetime/assets/style/picker.min.css'; @import '~ng-pick-datetime/assets/style/picker.min.css';

View File

@ -15,6 +15,7 @@ from openslides.utils.rest_api import (
detail_route, detail_route,
list_route, list_route,
) )
from openslides.utils.views import TreeSortMixin
from ..utils.auth import has_perm from ..utils.auth import has_perm
from .access_permissions import ItemAccessPermissions from .access_permissions import ItemAccessPermissions
@ -24,7 +25,9 @@ from .models import Item, Speaker
# Viewsets for the REST API # Viewsets for the REST API
class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericViewSet): class ItemViewSet(
ListModelMixin, RetrieveModelMixin, UpdateModelMixin, TreeSortMixin, GenericViewSet
):
""" """
API endpoint for agenda items. API endpoint for agenda items.
@ -44,7 +47,13 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
result = has_perm(self.request.user, "agenda.can_see") result = has_perm(self.request.user, "agenda.can_see")
# For manage_speaker and tree requests the rest of the check is # For manage_speaker and tree requests the rest of the check is
# done in the specific method. See below. # 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 = ( result = (
has_perm(self.request.user, "agenda.can_see") has_perm(self.request.user, "agenda.can_see")
and has_perm(self.request.user, "agenda.can_see_internal_items") 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"]) @list_route(methods=["post"])
def sort(self, request): def sort(self, request):
""" """
Sort agenda items. Also checks parent field to prevent hierarchical Sorts the whole agenda represented in a tree of ids. The request data should be a list (the root)
loops. of all main agenda items. Each node is a dict with an id and optional children:
{
id: <the id>
children: [
<children, optional>
]
}
Every id has to be given.
""" """
nodes = request.data.get("nodes", []) return self.sort_tree(request, Item, "weight", "parent_id")
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)
# Now check consistency. TODO: Try to use less DB queries. @list_route(methods=["post"])
item = Item.objects.get(pk=node["id"]) def sort_whole(self, request):
ancestor = item.parent """
while ancestor is not None: Sorts the whole agenda represented in a tree of ids. The request data should be a list (the root)
if ancestor == item: of all main agenda items. Each node is a dict with an id and optional all children:
raise ValidationError( {
{ id: <the id>
"detail": "There must not be a hierarchical loop. Please reload the page." children: [
} <children, optional>
) ]
ancestor = ancestor.parent }
Every id has to be given.
inform_changed_data(items) """
return Response({"detail": "The agenda has been sorted."}) return self.sort_tree(request, Item, "weight", "parent_id")
@list_route(methods=["post"]) @list_route(methods=["post"])
@transaction.atomic @transaction.atomic

View File

@ -26,6 +26,7 @@ from ..utils.rest_api import (
detail_route, detail_route,
list_route, list_route,
) )
from ..utils.views import TreeSortMixin
from .access_permissions import ( from .access_permissions import (
CategoryAccessPermissions, CategoryAccessPermissions,
MotionAccessPermissions, MotionAccessPermissions,
@ -55,7 +56,7 @@ from .serializers import MotionPollSerializer, StateSerializer
# Viewsets for the REST API # Viewsets for the REST API
class MotionViewSet(ModelViewSet): class MotionViewSet(TreeSortMixin, ModelViewSet):
""" """
API endpoint for motions. API endpoint for motions.
@ -325,35 +326,17 @@ class MotionViewSet(ModelViewSet):
@list_route(methods=["post"]) @list_route(methods=["post"])
def sort(self, request): def sort(self, request):
""" """
Sort motions. Also checks sort_parent field to prevent hierarchical loops. 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:
Note: This view is not tested! Maybe needs to be refactored. Add documentation {
abou the data to be send. id: <the id>
children: [
<children, optional>
]
}
Every id has to be given.
""" """
nodes = request.data.get("nodes", []) return self.sort_tree(request, Motion, "weight", "sort_parent_id")
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."})
@detail_route(methods=["POST", "DELETE"]) @detail_route(methods=["POST", "DELETE"])
def manage_comments(self, request, pk=None): def manage_comments(self, request, pk=None):

View File

@ -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 rest_framework.views import APIView as _APIView
from .autoupdate import inform_changed_data
from .rest_api import Response, ValidationError
class APIView(_APIView): class APIView(_APIView):
""" """
@ -32,3 +35,95 @@ class APIView(_APIView):
# Add the http-methods and delete the method "method_call" # Add the http-methods and delete the method "method_call"
get = post = put = patch = delete = head = options = trace = method_call get = post = put = patch = delete = head = options = trace = method_call
del 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: <the id>
children: [
<children, optional>
]
}
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()