Replaces the old angular2tree with a custom drag&drop tree

Calculates the direction of the moving.

Finishes the moving of nodes in same level

Adds some style

Sets the padding dynamically

Adds placeholder depends on the horizontal movement

Set the placeholder at the correct place, so the user can see, where he will drop the moved node

Finishes moving of nodes

- Old parents change their option to expand.
- New parents change their option to expand.
- If the user moves a node between nodes with a higher level, the node will be moved to the next index with same or lower level.

Fixes the visibility of moved node

- If the new parent is not visible, the moved node will not be seen.

If the user moves an expanded node, the new parent should expanded, too, if it's not already.

Sending successfully data to the server

- Sorting the items

Handles moving nodes between parent and children

- If the user moves a node between a parent and its children, the children will be relinked to the moved node as their new parent.

Replaces the old `sorting-tree` to a new one

- The new `sorted-tree` replaces the old `sorting-tree`.
- The old package `angular-tree-component` was removed.
- The user will only see the buttons to save or cancel his changes, if he made changes.
- The buttons, that do not work currently, were removed.

Adds a guard to check if the user made changes.

- If the user made changes but he has not saved them, then there is a dialog that will prompt to ask for confirmation.

Before cancelling the changes the user has to confirm this.
This commit is contained in:
GabrielMeyer 2019-02-22 12:04:36 +01:00
parent 5d045a894a
commit 76d760bd41
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"
[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>
<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.
*/ */
parent_id?: number; 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({ @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 tree: any;
/**
* This is our actual tree represented by our own nodes.
*/
public nodes: OSTreeNode<T>[] = [];
/**
* Constructor. Adds the eventhandler for the drop event to the tree.
*/
public constructor(private treeService: TreeService) {
this.treeOptions.actionMapping = {
mouse: {
drop: this.drop.bind(this)
}
};
}
/**
* Required by components using the selector as directive
*/ */
public ngOnInit(): void {} public ngOnInit(): void {}
/** /**
* Closes all subscriptions/event emitters. * On destroy - unsubscribe the subscription
*/ */
public ngOnDestroy(): void { public ngOnDestroy(): void {
if (this.modelSubscription) { this.removeSubscription();
this.modelSubscription.unsubscribe();
}
this.sort.complete();
} }
/** /**
* Handles the main drop event. Emits the sort event afterwards. * Function to check if the node has a parent.
* *
* @param tree The tree * @param node which is viewed.
* @param node The affected node *
* @param $event The DOM event * @returns The parent node if available otherwise it returns null.
* @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 getParentNode(node: FlatNode): FlatNode {
// check if dropped itself by going the tree upwards and check, if one of them is the "from"-node. const nodeIndex = this.osTreeData.indexOf(node);
let parent = to.parent;
while (parent !== null) { for (let i = nodeIndex - 1; i >= 0; --i) {
if (from.id === parent.id) { if (this.osTreeData[i].level === node.level - 1) {
return; return this.osTreeData[i];
} }
parent = parent.parent;
} }
let parentId; return null;
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 = []; /**
} * This function check if the parent of one node is expanded or not.
transferArrayItem(fromArray, to.parent.data.children, from.index, to.index); * Necessary to check if the user swaps over a child or the parent of the next node.
const strippedNodes = this.treeService.stripTree(to.parent.data.children); *
this.sort.emit({ nodes: strippedNodes, parent_id: parentId }); * @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
}
};
}
/**
* If the user stops moving a node and he does not drag it, then the pointer would be set to null.
*/
public mouseUp(): void {
this.pointer = null;
}
/**
* Function to initiate the dragging.
*
* @param event CdkDragStart which emits the event
*/
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) {
this.modelSubscription.unsubscribe();
this.modelSubscription = null;
}
}
/**
* Function to (re-) set the subscription to recognize changes.
*/
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;
}

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:
"""
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)
# 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." id: <the id>
children: [
<children, optional>
]
} }
) Every id has to be given.
ancestor = ancestor.parent """
return self.sort_tree(request, Item, "weight", "parent_id")
inform_changed_data(items) @list_route(methods=["post"])
return Response({"detail": "The agenda has been sorted."}) 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: <the id>
children: [
<children, optional>
]
}
Every id has to be given.
"""
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()