Merge pull request #4489 from GabrielInTheWorld/tree-sort-agenda
Replaces the DragDrop-Tree
This commit is contained in:
commit
d62f1538ab
@ -41,7 +41,6 @@
|
||||
"@ngx-translate/core": "^11.0.1",
|
||||
"@ngx-translate/http-loader": "^4.0.0",
|
||||
"@tinymce/tinymce-angular": "^3.0.0",
|
||||
"angular-tree-component": "^8.2.0",
|
||||
"core-js": "^2.6.5",
|
||||
"css-element-queries": "^1.1.1",
|
||||
"file-saver": "^2.0.1",
|
||||
|
@ -1,23 +1,22 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { tap, map } from 'rxjs/operators';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { BaseAgendaContentObjectRepository } from '../base-agenda-content-object-repository';
|
||||
import { BaseRepository } from '../base-repository';
|
||||
import { BaseAgendaViewModel } from 'app/site/base/base-agenda-view-model';
|
||||
import { BaseViewModel } from 'app/site/base/base-view-model';
|
||||
import { CollectionStringMapperService } from '../../core-services/collectionStringMapper.service';
|
||||
import { ConfigService } from 'app/core/ui-services/config.service';
|
||||
import { DataSendService } from 'app/core/core-services/data-send.service';
|
||||
import { DataStoreService } from '../../core-services/data-store.service';
|
||||
import { HttpService } from 'app/core/core-services/http.service';
|
||||
import { Item } from 'app/shared/models/agenda/item';
|
||||
import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component';
|
||||
import { TreeService } from 'app/core/ui-services/tree.service';
|
||||
import { ViewItem } from 'app/site/agenda/models/view-item';
|
||||
import { TreeService, TreeIdNode } from 'app/core/ui-services/tree.service';
|
||||
import { BaseAgendaViewModel } from 'app/site/base/base-agenda-view-model';
|
||||
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
|
||||
import { BaseViewModel } from 'app/site/base/base-view-model';
|
||||
import { BaseAgendaContentObjectRepository } from '../base-agenda-content-object-repository';
|
||||
|
||||
/**
|
||||
* Repository service for users
|
||||
@ -152,9 +151,8 @@ export class ItemRepositoryService extends BaseRepository<ViewItem, Item> {
|
||||
*
|
||||
* @param data The reordered data from the sorting
|
||||
*/
|
||||
public async sortItems(data: OSTreeSortEvent): Promise<void> {
|
||||
const url = '/rest/agenda/item/sort/';
|
||||
await this.httpService.post(url, data);
|
||||
public async sortItems(data: TreeIdNode[]): Promise<void> {
|
||||
await this.httpService.post('/rest/agenda/item/sort/', data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -20,12 +20,11 @@ import { Motion } from 'app/shared/models/motions/motion';
|
||||
import { MotionBlock } from 'app/shared/models/motions/motion-block';
|
||||
import { MotionChangeRecommendation } from 'app/shared/models/motions/motion-change-reco';
|
||||
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
|
||||
import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component';
|
||||
import { TreeService } from 'app/core/ui-services/tree.service';
|
||||
import { TreeService, TreeIdNode } from 'app/core/ui-services/tree.service';
|
||||
import { User } from 'app/shared/models/users/user';
|
||||
import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-change-recommendation';
|
||||
import { ViewMotionAmendedParagraph } from 'app/site/motions/models/view-motion-amended-paragraph';
|
||||
import { ViewUnifiedChange } from '../../../shared/models/motions/view-unified-change';
|
||||
import { ViewUnifiedChange } from 'app/shared/models/motions/view-unified-change';
|
||||
import { ViewStatuteParagraph } from 'app/site/motions/models/view-statute-paragraph';
|
||||
import { Workflow } from 'app/shared/models/motions/workflow';
|
||||
import { WorkflowState } from 'app/shared/models/motions/workflow-state';
|
||||
@ -377,9 +376,8 @@ export class MotionRepositoryService extends BaseAgendaContentObjectRepository<V
|
||||
*
|
||||
* @param data The reordered data from the sorting
|
||||
*/
|
||||
public async sortMotions(data: OSTreeSortEvent): Promise<void> {
|
||||
const url = '/rest/motions/motion/sort/';
|
||||
await this.httpService.post(url, data);
|
||||
public async sortMotions(data: TreeIdNode[]): Promise<void> {
|
||||
await this.httpService.post('/rest/motions/motion/sort/', data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -6,20 +6,48 @@ import { Identifiable } from 'app/shared/models/base/identifiable';
|
||||
/**
|
||||
* A basic representation of a tree node. This node does not stores any data.
|
||||
*/
|
||||
export interface OSTreeNodeWithoutItem {
|
||||
name: string;
|
||||
export interface TreeIdNode {
|
||||
id: number;
|
||||
children?: OSTreeNodeWithoutItem[];
|
||||
children?: TreeIdNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the TreeIdNode with a name to display.
|
||||
*/
|
||||
export interface TreeNodeWithoutItem extends TreeIdNode {
|
||||
name: string;
|
||||
children?: TreeNodeWithoutItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A representation of nodes with the item atached.
|
||||
*/
|
||||
export interface OSTreeNode<T> extends OSTreeNodeWithoutItem {
|
||||
export interface OSTreeNode<T> extends TreeNodeWithoutItem {
|
||||
item: 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
|
||||
* 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
|
||||
*
|
||||
@ -126,9 +202,9 @@ export class TreeService {
|
||||
* @param tree The tree with items
|
||||
* @returns The tree without items
|
||||
*/
|
||||
public stripTree<T>(tree: OSTreeNode<T>[]): OSTreeNodeWithoutItem[] {
|
||||
public stripTree<T>(tree: OSTreeNode<T>[]): TreeNodeWithoutItem[] {
|
||||
return tree.map(node => {
|
||||
const nodeWithoutItem: OSTreeNodeWithoutItem = {
|
||||
const nodeWithoutItem: TreeNodeWithoutItem = {
|
||||
name: node.name,
|
||||
id: node.id
|
||||
};
|
||||
@ -214,4 +290,75 @@ export class TreeService {
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to go recursively through the children of given node.
|
||||
*
|
||||
* @param item
|
||||
* @param level
|
||||
*
|
||||
* @returns An array containing the parent node with all its children.
|
||||
*/
|
||||
private makePartialFlatTree<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 };
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,45 @@
|
||||
<div class="os-tree">
|
||||
<tree-root #tree [options]="treeOptions" [focused]="true" [nodes]="nodes"></tree-root>
|
||||
</div>
|
||||
<cdk-tree
|
||||
#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
|
||||
[style.margin-left]="placeholderLevel * 40 + 'px'"
|
||||
*cdkDragPlaceholder></div>
|
||||
</cdk-tree-node>
|
||||
</cdk-tree>
|
||||
|
@ -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);
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { E2EImportsModule } from '../../../../e2e-imports.module';
|
||||
import { E2EImportsModule } from 'e2e-imports.module';
|
||||
import { SortingTreeComponent } from './sorting-tree.component';
|
||||
import { Component, ViewChild } from '@angular/core';
|
||||
import { Displayable } from 'app/site/base/displayable';
|
||||
@ -53,7 +53,7 @@ describe('SortingTreeComponent', () => {
|
||||
models.push(new TestModel(i, `TOP${i}`, i, null));
|
||||
}
|
||||
const modelSubject = new BehaviorSubject<TestModel[]>(models);
|
||||
hostComponent.sortingTreeCompononent.modelsObservable = modelSubject.asObservable();
|
||||
hostComponent.sortingTreeCompononent.model = modelSubject.asObservable();
|
||||
|
||||
hostFixture.detectChanges();
|
||||
expect(hostComponent.sortingTreeCompononent).toBeTruthy();
|
||||
|
@ -1,165 +1,736 @@
|
||||
import { Component, OnInit, ViewChild, Input, EventEmitter, Output, OnDestroy } from '@angular/core';
|
||||
import { transferArrayItem } from '@angular/cdk/drag-drop';
|
||||
import { Component, OnInit, Input, OnDestroy, Output, EventEmitter } from '@angular/core';
|
||||
|
||||
import { ITreeOptions, TreeModel, TreeNode } from 'angular-tree-component';
|
||||
import { FlatTreeControl } from '@angular/cdk/tree';
|
||||
import { ArrayDataSource } from '@angular/cdk/collections';
|
||||
import { CdkDragMove, CdkDragStart, CdkDragSortEvent } from '@angular/cdk/drag-drop';
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
import { auditTime } from 'rxjs/operators';
|
||||
import { Subscription, Observable } from 'rxjs';
|
||||
|
||||
import { Identifiable } from 'app/shared/models/base/identifiable';
|
||||
import { Displayable } from 'app/site/base/displayable';
|
||||
import { OSTreeNode, TreeService, OSTreeNodeWithoutItem } from 'app/core/ui-services/tree.service';
|
||||
import { TreeService, FlatNode, TreeIdNode } from 'app/core/ui-services/tree.service';
|
||||
|
||||
/**
|
||||
* The data representation for the sort event.
|
||||
* Enumaration to separate between the directions.
|
||||
*/
|
||||
export interface OSTreeSortEvent {
|
||||
/**
|
||||
* Gives all nodes to be inserted below the parent_id.
|
||||
*/
|
||||
nodes: OSTreeNodeWithoutItem[];
|
||||
enum Direction {
|
||||
UPWARDS = 'upwards',
|
||||
DOWNWARDS = 'downwards',
|
||||
RIGHT = 'right',
|
||||
LEFT = 'left',
|
||||
NOWAY = 'noway'
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the parent id for the nodes array. Do not provide it, if it's the
|
||||
* full tree, e.g. when inserting a node into the first layer of the tree. The
|
||||
* name is not camelCase, because this format can be send to the server as is.
|
||||
/**
|
||||
* Interface which extends the `OSFlatNode`.
|
||||
* Containing further information like start- and next-position.
|
||||
*/
|
||||
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({
|
||||
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 {
|
||||
/**
|
||||
* The property key to get the parent id.
|
||||
* The data to build the tree
|
||||
*/
|
||||
@Input()
|
||||
public parentIdKey: keyof T;
|
||||
public osTreeData: FlatNode[] = [];
|
||||
|
||||
/**
|
||||
* The property key used for the weight attribute.
|
||||
* The tree control
|
||||
*/
|
||||
public treeControl = new FlatTreeControl<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()
|
||||
public weightKey: keyof T;
|
||||
|
||||
/**
|
||||
* An observable to recieve the models to display.
|
||||
* Setter to get all models from data store.
|
||||
* It will create or replace the existing subscription.
|
||||
*/
|
||||
@Input()
|
||||
public set modelsObservable(models: Observable<T[]>) {
|
||||
if (!models) {
|
||||
public set model(model: Observable<T[]>) {
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
if (this.modelSubscription) {
|
||||
this.modelSubscription.unsubscribe();
|
||||
}
|
||||
this.modelSubscription = models.pipe(auditTime(10)).subscribe(items => {
|
||||
this.nodes = this.treeService.makeTree(items, this.weightKey, this.parentIdKey);
|
||||
setTimeout(() => this.tree.treeModel.expandAll());
|
||||
});
|
||||
this._model = model;
|
||||
this.setSubscription();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current subscription to the model oberservable.
|
||||
*/
|
||||
private modelSubscription: Subscription = null;
|
||||
|
||||
/**
|
||||
* An event emitter for expanding an collapsing the whole tree. The parent component
|
||||
* can emit true or false to expand or collapse the tree.
|
||||
*/
|
||||
@Input()
|
||||
public set expandCollapseAll(value: EventEmitter<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.
|
||||
* EventEmitter to send info if changes has been made.
|
||||
*/
|
||||
@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 = {
|
||||
allowDrag: true,
|
||||
allowDrop: true
|
||||
};
|
||||
public constructor(private treeService: TreeService) {}
|
||||
|
||||
/**
|
||||
* The tree. THis reference is used to expand and collapse the tree
|
||||
*/
|
||||
@ViewChild('tree')
|
||||
public tree: any;
|
||||
|
||||
/**
|
||||
* This is our actual tree represented by our own nodes.
|
||||
*/
|
||||
public nodes: OSTreeNode<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
|
||||
* On init method
|
||||
*/
|
||||
public ngOnInit(): void {}
|
||||
|
||||
/**
|
||||
* Closes all subscriptions/event emitters.
|
||||
* On destroy - unsubscribe the subscription
|
||||
*/
|
||||
public ngOnDestroy(): void {
|
||||
if (this.modelSubscription) {
|
||||
this.modelSubscription.unsubscribe();
|
||||
}
|
||||
this.sort.complete();
|
||||
this.removeSubscription();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 The affected node
|
||||
* @param $event The DOM event
|
||||
* @param param3 The previous and new position os the node
|
||||
* @param node which is viewed.
|
||||
*
|
||||
* @returns The parent node if available otherwise it returns null.
|
||||
*/
|
||||
private drop(tree: TreeModel, node: TreeNode, $event: any, { from, to }: { from: any; to: any }): void {
|
||||
// check if dropped itself by going the tree upwards and check, if one of them is the "from"-node.
|
||||
let parent = to.parent;
|
||||
while (parent !== null) {
|
||||
if (from.id === parent.id) {
|
||||
return;
|
||||
public getParentNode(node: FlatNode): FlatNode {
|
||||
const nodeIndex = this.osTreeData.indexOf(node);
|
||||
|
||||
for (let i = nodeIndex - 1; i >= 0; --i) {
|
||||
if (this.osTreeData[i].level === node.level - 1) {
|
||||
return this.osTreeData[i];
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
|
||||
let parentId;
|
||||
const fromArray = from.parent.data.children;
|
||||
if (!to.parent.data.virtual) {
|
||||
parentId = to.parent.data.id;
|
||||
return null;
|
||||
}
|
||||
if (!to.parent.data.children) {
|
||||
to.parent.data.children = [];
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
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 });
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
@ -41,6 +41,7 @@ import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||
import { CdkTreeModule } from '@angular/cdk/tree';
|
||||
|
||||
// ngx-translate
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@ -56,9 +57,6 @@ import { PermsDirective } from './directives/perms.directive';
|
||||
import { DomChangeDirective } from './directives/dom-change.directive';
|
||||
import { AutofocusDirective } from './directives/autofocus.directive';
|
||||
|
||||
// tree sorting
|
||||
import { TreeModule } from 'angular-tree-component';
|
||||
|
||||
// components
|
||||
import { HeadBarComponent } from './components/head-bar/head-bar.component';
|
||||
import { LegalNoticeContentComponent } from './components/legal-notice-content/legal-notice-content.component';
|
||||
@ -140,7 +138,7 @@ import { MediaUploadContentComponent } from './components/media-upload-content/m
|
||||
NgxMatSelectSearchModule,
|
||||
FileDropModule,
|
||||
EditorModule,
|
||||
TreeModule.forRoot()
|
||||
CdkTreeModule
|
||||
],
|
||||
exports: [
|
||||
FormsModule,
|
||||
@ -190,7 +188,6 @@ import { MediaUploadContentComponent } from './components/media-upload-content/m
|
||||
SortingListComponent,
|
||||
EditorModule,
|
||||
SortingTreeComponent,
|
||||
TreeModule,
|
||||
OsSortFilterBarComponent,
|
||||
LogoComponent,
|
||||
CopyrightSignComponent,
|
||||
|
15
client/src/app/shared/utils/watch-sorting-tree.guard.spec.ts
Normal file
15
client/src/app/shared/utils/watch-sorting-tree.guard.spec.ts
Normal 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();
|
||||
}));
|
||||
});
|
25
client/src/app/shared/utils/watch-sorting-tree.guard.ts
Normal file
25
client/src/app/shared/utils/watch-sorting-tree.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -6,12 +6,13 @@ import { AgendaListComponent } from './components/agenda-list/agenda-list.compon
|
||||
import { AgendaSortComponent } from './components/agenda-sort/agenda-sort.component';
|
||||
import { ListOfSpeakersComponent } from './components/list-of-speakers/list-of-speakers.component';
|
||||
import { TopicDetailComponent } from './components/topic-detail/topic-detail.component';
|
||||
import { WatchSortingTreeGuard } from 'app/shared/utils/watch-sorting-tree.guard';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', component: AgendaListComponent, pathMatch: 'full' },
|
||||
{ path: 'import', component: AgendaImportListComponent },
|
||||
{ path: 'topics/new', component: TopicDetailComponent },
|
||||
{ path: 'sort-agenda', component: AgendaSortComponent },
|
||||
{ path: 'sort-agenda', component: AgendaSortComponent, canDeactivate: [WatchSortingTreeGuard] },
|
||||
{ path: 'speakers', component: ListOfSpeakersComponent },
|
||||
{ path: 'topics/:id', component: TopicDetailComponent },
|
||||
{ path: ':id/speakers', component: ListOfSpeakersComponent }
|
||||
|
@ -1,23 +1,19 @@
|
||||
<os-head-bar [nav]="false">
|
||||
<os-head-bar
|
||||
[nav]="false"
|
||||
[editMode]="hasChanged"
|
||||
(mainEvent)="onCancel()"
|
||||
(saveEvent)="onSave()">
|
||||
|
||||
<!-- Title -->
|
||||
<div class="title-slot"><h2 translate>Sort agenda</h2></div>
|
||||
</os-head-bar>
|
||||
|
||||
<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
|
||||
#sorter
|
||||
(sort)="sort($event)"
|
||||
parentIdKey="parent_id"
|
||||
#osSortedTree
|
||||
(hasChanged)="receiveChanges($event)"
|
||||
[model]="itemsObservable"
|
||||
parentKey="parent_id"
|
||||
weightKey="weight"
|
||||
[modelsObservable]="itemsObservable"
|
||||
[expandCollapseAll]="expandCollapse"
|
||||
>
|
||||
</os-sorting-tree>
|
||||
></os-sorting-tree>
|
||||
</mat-card>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter } from '@angular/core';
|
||||
import { Component, ViewChild } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { MatSnackBar } from '@angular/material';
|
||||
|
||||
@ -7,8 +7,10 @@ import { Observable } from 'rxjs';
|
||||
|
||||
import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service';
|
||||
import { BaseViewComponent } from '../../../base/base-view';
|
||||
import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component';
|
||||
import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component';
|
||||
import { ViewItem } from '../../models/view-item';
|
||||
import { PromptService } from 'app/core/ui-services/prompt.service';
|
||||
import { CanComponentDeactivate } from 'app/shared/utils/watch-sorting-tree.guard';
|
||||
|
||||
/**
|
||||
* Sort view for the agenda.
|
||||
@ -17,50 +19,81 @@ import { ViewItem } from '../../models/view-item';
|
||||
selector: 'os-agenda-sort',
|
||||
templateUrl: './agenda-sort.component.html'
|
||||
})
|
||||
export class AgendaSortComponent extends BaseViewComponent {
|
||||
export class AgendaSortComponent extends BaseViewComponent implements CanComponentDeactivate {
|
||||
/**
|
||||
* Reference to the view child
|
||||
*/
|
||||
@ViewChild('osSortedTree')
|
||||
public osSortTree: SortingTreeComponent<ViewItem>;
|
||||
|
||||
/**
|
||||
* Boolean to check if changes has been made.
|
||||
*/
|
||||
public hasChanged = false;
|
||||
|
||||
/**
|
||||
* All agendaItems sorted by their virtual weight {@link ViewItem.agendaListWeight}
|
||||
*/
|
||||
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.
|
||||
* @param title
|
||||
* @param translate
|
||||
* @param matSnackBar
|
||||
* @param agendaRepo
|
||||
* @param promptService
|
||||
*/
|
||||
public constructor(
|
||||
title: Title,
|
||||
translate: TranslateService,
|
||||
matSnackBar: MatSnackBar,
|
||||
private agendaRepo: ItemRepositoryService
|
||||
private agendaRepo: ItemRepositoryService,
|
||||
private promptService: PromptService
|
||||
) {
|
||||
super(title, translate, matSnackBar);
|
||||
this.itemsObservable = this.agendaRepo.getViewModelListObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the sort event. The data to change is given to the repo, sending it to the server.
|
||||
*
|
||||
* @param data The event data. The representation fits the servers requirements, so it can directly
|
||||
* be send to the server via the repository.
|
||||
* Function to save the tree by click.
|
||||
*/
|
||||
public sort(data: OSTreeSortEvent): void {
|
||||
this.agendaRepo.sortItems(data).then(null, this.raiseError);
|
||||
public async onSave(): Promise<void> {
|
||||
await this.agendaRepo
|
||||
.sortItems(this.osSortTree.getTreeData())
|
||||
.then(() => this.osSortTree.setSubscription(), this.raiseError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires the expandCollapse event emitter.
|
||||
*
|
||||
* @param expand True, if the tree should be expanded. Otherwise collapsed
|
||||
* Function to restore the old state.
|
||||
*/
|
||||
public expandCollapseAll(expand: boolean): void {
|
||||
this.expandCollapse.emit(expand);
|
||||
public async onCancel(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -2,8 +2,11 @@ import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { CallListComponent } from './call-list.component';
|
||||
import { WatchSortingTreeGuard } from 'app/shared/utils/watch-sorting-tree.guard';
|
||||
|
||||
const routes: Routes = [{ path: '', component: CallListComponent, pathMatch: 'full' }];
|
||||
const routes: Routes = [
|
||||
{ path: '', component: CallListComponent, pathMatch: 'full', canDeactivate: [WatchSortingTreeGuard] }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
|
@ -1,4 +1,10 @@
|
||||
<os-head-bar prevUrl="../.." [nav]="false">
|
||||
<os-head-bar
|
||||
prevUrl="../.."
|
||||
[nav]="false"
|
||||
[editMode]="hasChanged"
|
||||
(mainEvent)="onCancel()"
|
||||
(saveEvent)="onSave()">
|
||||
|
||||
<!-- Title -->
|
||||
<div class="title-slot">
|
||||
<h2 translate>Call list</h2>
|
||||
@ -13,17 +19,12 @@
|
||||
</os-head-bar>
|
||||
|
||||
<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
|
||||
#sorter
|
||||
(sort)="sort($event)"
|
||||
parentIdKey="sort_parent_id"
|
||||
#osSortedTree
|
||||
parentKey="sort_parent_id"
|
||||
weightKey="weight"
|
||||
[modelsObservable]="motionsObservable"
|
||||
[expandCollapseAll]="expandCollapse"
|
||||
>
|
||||
</os-sorting-tree>
|
||||
(hasChanged)="receiveChanges($event)"
|
||||
[model]="motionsObservable"></os-sorting-tree>
|
||||
</mat-card>
|
||||
|
||||
<mat-menu #downloadMenu="matMenu">
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter } from '@angular/core';
|
||||
import { Component, ViewChild } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { MatSnackBar } from '@angular/material';
|
||||
|
||||
@ -6,12 +6,13 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service';
|
||||
|
||||
import { BaseViewComponent } from 'app/site/base/base-view';
|
||||
import { MotionCsvExportService } from 'app/site/motions/services/motion-csv-export.service';
|
||||
import { MotionPdfExportService } from 'app/site/motions/services/motion-pdf-export.service';
|
||||
import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component';
|
||||
import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component';
|
||||
import { ViewMotion } from 'app/site/motions/models/view-motion';
|
||||
import { PromptService } from 'app/core/ui-services/prompt.service';
|
||||
import { CanComponentDeactivate } from 'app/shared/utils/watch-sorting-tree.guard';
|
||||
|
||||
/**
|
||||
* Sort view for the call list.
|
||||
@ -20,7 +21,13 @@ import { ViewMotion } from 'app/site/motions/models/view-motion';
|
||||
selector: 'os-call-list',
|
||||
templateUrl: './call-list.component.html'
|
||||
})
|
||||
export class CallListComponent extends BaseViewComponent {
|
||||
export class CallListComponent extends BaseViewComponent implements CanComponentDeactivate {
|
||||
/**
|
||||
* Reference to the sorting tree.
|
||||
*/
|
||||
@ViewChild('osSortedTree')
|
||||
private osSortTree: SortingTreeComponent<ViewMotion>;
|
||||
|
||||
/**
|
||||
* All motions sorted first by weight, then by id.
|
||||
*/
|
||||
@ -32,9 +39,9 @@ export class CallListComponent extends BaseViewComponent {
|
||||
private motions: ViewMotion[] = [];
|
||||
|
||||
/**
|
||||
* Emits true for expand and false for collaps. Informs the sorter component about this actions.
|
||||
* Boolean to check if the tree has changed.
|
||||
*/
|
||||
public readonly expandCollapse: EventEmitter<boolean> = new EventEmitter<boolean>();
|
||||
public hasChanged = false;
|
||||
|
||||
/**
|
||||
* Updates the motions member, and sorts it.
|
||||
@ -42,6 +49,7 @@ export class CallListComponent extends BaseViewComponent {
|
||||
* @param translate
|
||||
* @param matSnackBar
|
||||
* @param motionRepo
|
||||
* @param promptService
|
||||
*/
|
||||
public constructor(
|
||||
title: Title,
|
||||
@ -49,7 +57,8 @@ export class CallListComponent extends BaseViewComponent {
|
||||
matSnackBar: MatSnackBar,
|
||||
private motionRepo: MotionRepositoryService,
|
||||
private motionCsvExport: MotionCsvExportService,
|
||||
private motionPdfExport: MotionPdfExportService
|
||||
private motionPdfExport: MotionPdfExportService,
|
||||
private promptService: PromptService
|
||||
) {
|
||||
super(title, translate, matSnackBar);
|
||||
|
||||
@ -61,23 +70,30 @@ export class CallListComponent extends BaseViewComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the sort event. The data to change is given to
|
||||
* the repo, sending it to the server.
|
||||
*
|
||||
* @param data The event data. The representation fits the servers requirements, so it can directly
|
||||
* be send to the server via the repository.
|
||||
* Function to save changes on click.
|
||||
*/
|
||||
public sort(data: OSTreeSortEvent): void {
|
||||
this.motionRepo.sortMotions(data).then(null, this.raiseError);
|
||||
public async onSave(): Promise<void> {
|
||||
await this.motionRepo
|
||||
.sortMotions(this.osSortTree.getTreeData())
|
||||
.then(() => this.osSortTree.setSubscription(), this.raiseError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires the expandCollapse event emitter.
|
||||
*
|
||||
* @param expand True, if the tree should be expanded. Otherwise collapsed
|
||||
* Function to restore the old state.
|
||||
*/
|
||||
public expandCollapseAll(expand: boolean): void {
|
||||
this.expandCollapse.emit(expand);
|
||||
public async onCancel(): Promise<void> {
|
||||
if (await this.canDeactivate()) {
|
||||
this.osSortTree.setSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to get an info if changes has been made.
|
||||
*
|
||||
* @param hasChanged Boolean received from the tree to see that changes has been made.
|
||||
*/
|
||||
public receiveChanges(hasChanged: boolean): void {
|
||||
this.hasChanged = hasChanged;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -93,4 +109,19 @@ export class CallListComponent extends BaseViewComponent {
|
||||
public pdfExportCallList(): void {
|
||||
this.motionPdfExport.exportPdfCallList(this.motions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to open a prompt dialog,
|
||||
* so the user will be warned if he has made changes and not saved them.
|
||||
*
|
||||
* @returns The result from the prompt dialog.
|
||||
*/
|
||||
public async canDeactivate(): Promise<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;
|
||||
}
|
||||
}
|
||||
|
@ -24,8 +24,6 @@
|
||||
/** More components are added here */
|
||||
}
|
||||
|
||||
@import '~angular-tree-component/dist/angular-tree-component.css';
|
||||
|
||||
/** date-time-picker */
|
||||
@import '~ng-pick-datetime/assets/style/picker.min.css';
|
||||
|
||||
|
@ -15,6 +15,7 @@ from openslides.utils.rest_api import (
|
||||
detail_route,
|
||||
list_route,
|
||||
)
|
||||
from openslides.utils.views import TreeSortMixin
|
||||
|
||||
from ..utils.auth import has_perm
|
||||
from .access_permissions import ItemAccessPermissions
|
||||
@ -24,7 +25,9 @@ from .models import Item, Speaker
|
||||
# Viewsets for the REST API
|
||||
|
||||
|
||||
class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericViewSet):
|
||||
class ItemViewSet(
|
||||
ListModelMixin, RetrieveModelMixin, UpdateModelMixin, TreeSortMixin, GenericViewSet
|
||||
):
|
||||
"""
|
||||
API endpoint for agenda items.
|
||||
|
||||
@ -44,7 +47,13 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
|
||||
result = has_perm(self.request.user, "agenda.can_see")
|
||||
# For manage_speaker and tree requests the rest of the check is
|
||||
# done in the specific method. See below.
|
||||
elif self.action in ("partial_update", "update", "sort", "assign"):
|
||||
elif self.action in (
|
||||
"partial_update",
|
||||
"update",
|
||||
"sort",
|
||||
"sort_whole",
|
||||
"assign",
|
||||
):
|
||||
result = (
|
||||
has_perm(self.request.user, "agenda.can_see")
|
||||
and has_perm(self.request.user, "agenda.can_see_internal_items")
|
||||
@ -322,34 +331,32 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
|
||||
@list_route(methods=["post"])
|
||||
def sort(self, request):
|
||||
"""
|
||||
Sort agenda items. Also checks parent field to prevent hierarchical
|
||||
loops.
|
||||
"""
|
||||
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(
|
||||
Sorts the whole agenda represented in a tree of ids. The request data should be a list (the root)
|
||||
of all main agenda items. Each node is a dict with an id and optional children:
|
||||
{
|
||||
"detail": "There must not be a hierarchical loop. Please reload the page."
|
||||
id: <the id>
|
||||
children: [
|
||||
<children, optional>
|
||||
]
|
||||
}
|
||||
)
|
||||
ancestor = ancestor.parent
|
||||
Every id has to be given.
|
||||
"""
|
||||
return self.sort_tree(request, Item, "weight", "parent_id")
|
||||
|
||||
inform_changed_data(items)
|
||||
return Response({"detail": "The agenda has been sorted."})
|
||||
@list_route(methods=["post"])
|
||||
def sort_whole(self, request):
|
||||
"""
|
||||
Sorts the whole agenda represented in a tree of ids. The request data should be a list (the root)
|
||||
of all main agenda items. Each node is a dict with an id and optional all children:
|
||||
{
|
||||
id: <the id>
|
||||
children: [
|
||||
<children, optional>
|
||||
]
|
||||
}
|
||||
Every id has to be given.
|
||||
"""
|
||||
return self.sort_tree(request, Item, "weight", "parent_id")
|
||||
|
||||
@list_route(methods=["post"])
|
||||
@transaction.atomic
|
||||
|
@ -26,6 +26,7 @@ from ..utils.rest_api import (
|
||||
detail_route,
|
||||
list_route,
|
||||
)
|
||||
from ..utils.views import TreeSortMixin
|
||||
from .access_permissions import (
|
||||
CategoryAccessPermissions,
|
||||
MotionAccessPermissions,
|
||||
@ -55,7 +56,7 @@ from .serializers import MotionPollSerializer, StateSerializer
|
||||
# Viewsets for the REST API
|
||||
|
||||
|
||||
class MotionViewSet(ModelViewSet):
|
||||
class MotionViewSet(TreeSortMixin, ModelViewSet):
|
||||
"""
|
||||
API endpoint for motions.
|
||||
|
||||
@ -325,35 +326,17 @@ class MotionViewSet(ModelViewSet):
|
||||
@list_route(methods=["post"])
|
||||
def sort(self, request):
|
||||
"""
|
||||
Sort motions. Also checks sort_parent field to prevent hierarchical loops.
|
||||
|
||||
Note: This view is not tested! Maybe needs to be refactored. Add documentation
|
||||
abou the data to be send.
|
||||
Sorts all motions represented in a tree of ids. The request data should be a list (the root)
|
||||
of all main agenda items. Each node is a dict with an id and optional children:
|
||||
{
|
||||
id: <the id>
|
||||
children: [
|
||||
<children, optional>
|
||||
]
|
||||
}
|
||||
Every id has to be given.
|
||||
"""
|
||||
nodes = request.data.get("nodes", [])
|
||||
sort_parent_id = request.data.get("parent_id")
|
||||
motions = []
|
||||
with transaction.atomic():
|
||||
for index, node in enumerate(nodes):
|
||||
id = node["id"]
|
||||
motion = Motion.objects.get(pk=id)
|
||||
motion.sort_parent_id = sort_parent_id
|
||||
motion.weight = index
|
||||
motion.save(skip_autoupdate=True)
|
||||
motions.append(motion)
|
||||
|
||||
# Now check consistency. TODO: Try to use less DB queries.
|
||||
motion = Motion.objects.get(pk=id)
|
||||
ancestor = motion.sort_parent
|
||||
while ancestor is not None:
|
||||
if ancestor == motion:
|
||||
raise ValidationError(
|
||||
{"detail": "There must not be a hierarchical loop."}
|
||||
)
|
||||
ancestor = ancestor.sort_parent
|
||||
|
||||
inform_changed_data(motions)
|
||||
return Response({"detail": "The motions has been sorted."})
|
||||
return self.sort_tree(request, Motion, "weight", "sort_parent_id")
|
||||
|
||||
@detail_route(methods=["POST", "DELETE"])
|
||||
def manage_comments(self, request, pk=None):
|
||||
|
@ -1,8 +1,11 @@
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Set
|
||||
|
||||
from rest_framework.response import Response
|
||||
from django.db import models, transaction
|
||||
from rest_framework.views import APIView as _APIView
|
||||
|
||||
from .autoupdate import inform_changed_data
|
||||
from .rest_api import Response, ValidationError
|
||||
|
||||
|
||||
class APIView(_APIView):
|
||||
"""
|
||||
@ -32,3 +35,95 @@ class APIView(_APIView):
|
||||
# Add the http-methods and delete the method "method_call"
|
||||
get = post = put = patch = delete = head = options = trace = method_call
|
||||
del method_call
|
||||
|
||||
|
||||
class TreeSortMixin:
|
||||
"""
|
||||
Provides a handler for sorting a model tree.
|
||||
"""
|
||||
|
||||
def sort_tree(
|
||||
self, request: Any, model: models.Model, weight_key: str, parent_id_key: str
|
||||
) -> None:
|
||||
"""
|
||||
Sorts the all model objects represented in a tree of ids. The request data should be a list (the root)
|
||||
of all main agenda items. Each node is a dict with an id and optional children:
|
||||
{
|
||||
id: <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()
|
||||
|
Loading…
Reference in New Issue
Block a user