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