Merge pull request #4018 from FinnStutzenstein/nested_dnd
angular2 tree
This commit is contained in:
commit
8a7d0e8be9
@ -31,6 +31,7 @@
|
||||
"@ngx-translate/core": "^11.0.1",
|
||||
"@ngx-translate/http-loader": "^4.0.0",
|
||||
"@tinymce/tinymce-angular": "^2.3.1",
|
||||
"angular-tree-component": "^8.0.0",
|
||||
"core-js": "^2.5.4",
|
||||
"file-saver": "^2.0.0-rc.3",
|
||||
"material-design-icons": "^3.0.1",
|
||||
|
@ -0,0 +1,3 @@
|
||||
<div class="os-tree">
|
||||
<tree-root #tree [options]="treeOptions" [focused]="true" [nodes]="nodes"></tree-root>
|
||||
</div>
|
@ -0,0 +1,60 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { E2EImportsModule } from '../../../../e2e-imports.module';
|
||||
import { SortingTreeComponent } from './sorting-tree.component';
|
||||
import { Component, ViewChild } from '@angular/core';
|
||||
import { Displayable } from 'app/shared/models/base/displayable';
|
||||
import { Identifiable } from 'app/shared/models/base/identifiable';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
/**
|
||||
* A test model for the sorting
|
||||
*/
|
||||
class TestModel implements Identifiable, Displayable {
|
||||
public constructor(public id: number, public name: string, public weight: number, public parent_id: number | null){}
|
||||
|
||||
public getTitle(): string {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public getListTitle(): string {
|
||||
return this.getTitle();
|
||||
}
|
||||
}
|
||||
|
||||
describe('SortingTreeComponent', () => {
|
||||
@Component({
|
||||
selector: 'os-host-component',
|
||||
template: '<os-sorting-tree><os-sorting-tree>'
|
||||
})
|
||||
class TestHostComponent {
|
||||
@ViewChild(SortingTreeComponent)
|
||||
public sortingTreeCompononent: SortingTreeComponent<TestModel>;
|
||||
}
|
||||
|
||||
let hostComponent: TestHostComponent;
|
||||
let hostFixture: ComponentFixture<TestHostComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [E2EImportsModule],
|
||||
declarations: [TestHostComponent]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
hostFixture = TestBed.createComponent(TestHostComponent);
|
||||
hostComponent = hostFixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
const models: TestModel[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
models.push(new TestModel(i, `TOP${i}`, i, null));
|
||||
}
|
||||
const modelSubject = new BehaviorSubject<TestModel[]>(models);
|
||||
hostComponent.sortingTreeCompononent.modelsObservable = modelSubject.asObservable();
|
||||
|
||||
hostFixture.detectChanges();
|
||||
expect(hostComponent.sortingTreeCompononent).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,250 @@
|
||||
import { Component, OnInit, ViewChild, Input, EventEmitter, Output, OnDestroy } from '@angular/core';
|
||||
import { transferArrayItem } from '@angular/cdk/drag-drop';
|
||||
|
||||
import { ITreeOptions, TreeModel, TreeNode } from 'angular-tree-component';
|
||||
import { auditTime } from 'rxjs/operators';
|
||||
import { Subscription, Observable } from 'rxjs';
|
||||
|
||||
import { Identifiable } from 'app/shared/models/base/identifiable';
|
||||
import { Displayable } from 'app/shared/models/base/displayable';
|
||||
|
||||
/**
|
||||
* An representation of our nodes. Saves the displayed name, the id and children to build a full tree.
|
||||
*/
|
||||
interface OSTreeNode {
|
||||
name: string;
|
||||
id: number;
|
||||
children?: OSTreeNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The data representation for the sort event.
|
||||
*/
|
||||
export interface OSTreeSortEvent {
|
||||
/**
|
||||
* Gives all nodes to be inserted below the parent_id.
|
||||
*/
|
||||
nodes: OSTreeNode[];
|
||||
|
||||
/**
|
||||
* Provides the parent id for the nodes array. Do not provide it, if it's the
|
||||
* full tree, e.g. when inserting a node into the first layer of the tree. The
|
||||
* name is not camelCase, because this format can be send to the server as is.
|
||||
*/
|
||||
parent_id?: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'os-sorting-tree',
|
||||
templateUrl: './sorting-tree.component.html'
|
||||
})
|
||||
export class SortingTreeComponent<T extends Identifiable & Displayable> implements OnInit, OnDestroy {
|
||||
/**
|
||||
* The property key to get the parent id.
|
||||
*/
|
||||
@Input()
|
||||
public parentIdKey: keyof T;
|
||||
|
||||
/**
|
||||
* The property key used for the weight attribute.
|
||||
*/
|
||||
@Input()
|
||||
public weightKey: keyof T;
|
||||
|
||||
/**
|
||||
* An observable to recieve the models to display.
|
||||
*/
|
||||
@Input()
|
||||
public set modelsObservable(models: Observable<T[]>) {
|
||||
if (!models) {
|
||||
return;
|
||||
}
|
||||
if (this.modelSubscription) {
|
||||
this.modelSubscription.unsubscribe();
|
||||
}
|
||||
this.modelSubscription = models.pipe(auditTime(100)).subscribe(m => {
|
||||
this.nodes = this.makeTree(m);
|
||||
setTimeout(() => this.tree.treeModel.expandAll());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Output()
|
||||
public readonly sort = new EventEmitter<OSTreeSortEvent>();
|
||||
|
||||
/**
|
||||
* Options for the tree. As a default drag and drop is allowed.
|
||||
*/
|
||||
public treeOptions: ITreeOptions = {
|
||||
allowDrag: true,
|
||||
allowDrop: true
|
||||
};
|
||||
|
||||
/**
|
||||
* 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[] = [];
|
||||
|
||||
/**
|
||||
* Constructor. Adds the eventhandler for the drop event to the tree.
|
||||
*/
|
||||
public constructor() {
|
||||
this.treeOptions.actionMapping = {
|
||||
mouse: {
|
||||
drop: this.drop.bind(this)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Required by components using the selector as directive
|
||||
*/
|
||||
public ngOnInit(): void {}
|
||||
|
||||
/**
|
||||
* Closes all subscriptions/event emitters.
|
||||
*/
|
||||
public ngOnDestroy(): void {
|
||||
if (this.modelSubscription) {
|
||||
this.modelSubscription.unsubscribe();
|
||||
}
|
||||
this.sort.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the main drop event. Emits the sort event afterwards.
|
||||
*
|
||||
* @param tree The tree
|
||||
* @param node The affected node
|
||||
* @param $event The DOM event
|
||||
* @param param3 The previous and new position os the node
|
||||
*/
|
||||
private drop(tree: TreeModel, node: TreeNode, $event: any, { from, to }: { from: any; to: any }): void {
|
||||
// check if dropped itself
|
||||
if (from.id === to.parent.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parentId;
|
||||
const fromArray = from.parent.data.children;
|
||||
if (!to.parent.data.virtual) {
|
||||
parentId = to.parent.data.id;
|
||||
}
|
||||
if (!to.parent.data.children) {
|
||||
to.parent.data.children = [];
|
||||
}
|
||||
transferArrayItem(fromArray, to.parent.data.children, from.index, to.index);
|
||||
this.sort.emit({ nodes: to.parent.data.children, parent_id: parentId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the weight casted to a number from a given model.
|
||||
*
|
||||
* @param model The model to get the weight from.
|
||||
* @returns the weight of the model
|
||||
*/
|
||||
private getWeight(model: T): number {
|
||||
return (<any>model[this.weightKey]) as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent id casted to a number from a given model.
|
||||
*
|
||||
* @param model The model to get the parent id from.
|
||||
* @returns the parent id of the model
|
||||
*/
|
||||
private getParentId(model: T): number {
|
||||
return (<any>model[this.parentIdKey]) as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build our representation of a tree node given the model and optional children
|
||||
* to append to this node.
|
||||
*
|
||||
* @param model The model to create a node of.
|
||||
* @param children Optional children to append to this node.
|
||||
* @returns The created node.
|
||||
*/
|
||||
private buildTreeNode(model: T, children?: OSTreeNode[]): OSTreeNode {
|
||||
return {
|
||||
name: model.getTitle(),
|
||||
id: model.id,
|
||||
children: children
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a tree from the given models with their parent and weight properties.
|
||||
*
|
||||
* @param models All models to build the tree of
|
||||
* @returns The first layer of the tree given as an array of nodes, because this tree may not have a single root.
|
||||
*/
|
||||
private makeTree(models: T[]): OSTreeNode[] {
|
||||
// copy references to avoid side effects:
|
||||
models = models.map(x => x);
|
||||
|
||||
// Sort items after there weight
|
||||
models.sort((a, b) => this.getWeight(a) - this.getWeight(b));
|
||||
|
||||
// Build a dict with all children (dict-value) to a specific
|
||||
// item id (dict-key).
|
||||
const children: { [parendId: number]: T[] } = {};
|
||||
|
||||
models.forEach(model => {
|
||||
if (model[this.parentIdKey]) {
|
||||
const parentId = this.getParentId(model);
|
||||
if (children[parentId]) {
|
||||
children[parentId].push(model);
|
||||
} else {
|
||||
children[parentId] = [model];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Recursive function that generates a nested list with all
|
||||
// items with there children
|
||||
const getChildren: (_models?: T[]) => OSTreeNode[] = _models => {
|
||||
if (!_models) {
|
||||
return;
|
||||
}
|
||||
const nodes: OSTreeNode[] = [];
|
||||
_models.forEach(_model => {
|
||||
nodes.push(this.buildTreeNode(_model, getChildren(children[_model.id])));
|
||||
});
|
||||
return nodes;
|
||||
};
|
||||
|
||||
// Generates the list of root items (with no parents)
|
||||
const parentItems = models.filter(model => !this.getParentId(model));
|
||||
return getChildren(parentItems);
|
||||
}
|
||||
}
|
@ -51,6 +51,9 @@ 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 { FooterComponent } from './components/footer/footer.component';
|
||||
@ -61,6 +64,7 @@ import { OpenSlidesDateAdapter } from './date-adapter';
|
||||
import { PromptDialogComponent } from './components/prompt-dialog/prompt-dialog.component';
|
||||
import { SortingListComponent } from './components/sorting-list/sorting-list.component';
|
||||
import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/speaker-list.component';
|
||||
import { SortingTreeComponent } from './components/sorting-tree/sorting-tree.component';
|
||||
|
||||
/**
|
||||
* Share Module for all "dumb" components and pipes.
|
||||
@ -111,7 +115,8 @@ import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/sp
|
||||
RouterModule,
|
||||
NgxMatSelectSearchModule,
|
||||
FileDropModule,
|
||||
EditorModule
|
||||
EditorModule,
|
||||
TreeModule.forRoot()
|
||||
],
|
||||
exports: [
|
||||
FormsModule,
|
||||
@ -156,7 +161,9 @@ import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/sp
|
||||
PrivacyPolicyContentComponent,
|
||||
PromptDialogComponent,
|
||||
SortingListComponent,
|
||||
EditorModule
|
||||
EditorModule,
|
||||
SortingTreeComponent,
|
||||
TreeModule
|
||||
],
|
||||
declarations: [
|
||||
PermsDirective,
|
||||
@ -169,12 +176,14 @@ import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/sp
|
||||
SearchValueSelectorComponent,
|
||||
PromptDialogComponent,
|
||||
SortingListComponent,
|
||||
SpeakerListComponent
|
||||
SpeakerListComponent,
|
||||
SortingTreeComponent
|
||||
],
|
||||
providers: [
|
||||
{ provide: DateAdapter, useClass: OpenSlidesDateAdapter },
|
||||
SearchValueSelectorComponent,
|
||||
SortingListComponent
|
||||
SortingListComponent,
|
||||
SortingTreeComponent
|
||||
]
|
||||
})
|
||||
export class SharedModule {}
|
||||
|
@ -3,15 +3,12 @@
|
||||
<div class="title-slot">
|
||||
<h2 translate>Call list</h2>
|
||||
</div>
|
||||
|
||||
<div class="menu-slot">
|
||||
<button mat-button (click)="save()">
|
||||
<strong translate class="upper">Save</strong>
|
||||
</button>
|
||||
</div>
|
||||
</os-head-bar>
|
||||
|
||||
<mat-card>
|
||||
<os-sorting-list #sorter [input]="motions">
|
||||
</os-sorting-list>
|
||||
<button mat-button (click)="expandCollapseAll(true)" translate>Expand</button>
|
||||
<button mat-button (click)="expandCollapseAll(false)" translate>Collapse</button>
|
||||
<os-sorting-tree #sorter (sort)="sort($event)" parentIdKey="sort_parent_id"
|
||||
weightKey="weight" [modelsObservable]="motionsObservable" [expandCollapseAll]="expandCollapse">
|
||||
</os-sorting-tree>
|
||||
</mat-card>
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { Component, ViewChild } from '@angular/core';
|
||||
import { Component, ViewChild, EventEmitter } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { MatSnackBar } from '@angular/material';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { BaseViewComponent } from '../../../base/base-view';
|
||||
import { MatSnackBar } from '@angular/material';
|
||||
import { MotionRepositoryService } from '../../services/motion-repository.service';
|
||||
import { ViewMotion } from '../../models/view-motion';
|
||||
import { SortingListComponent } from '../../../../shared/components/sorting-list/sorting-list.component';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component';
|
||||
|
||||
/**
|
||||
* Sort view for the call list.
|
||||
@ -21,7 +22,12 @@ export class CallListComponent extends BaseViewComponent {
|
||||
/**
|
||||
* All motions sorted first by weight, then by id.
|
||||
*/
|
||||
public motions: ViewMotion[];
|
||||
public motionsObservable: Observable<ViewMotion[]>;
|
||||
|
||||
/**
|
||||
* Emits true for expand and false for collaps. Informs the sorter component about this actions.
|
||||
*/
|
||||
public readonly expandCollapse: EventEmitter<boolean> = new EventEmitter<boolean>();
|
||||
|
||||
/**
|
||||
* The sort component
|
||||
@ -40,29 +46,27 @@ export class CallListComponent extends BaseViewComponent {
|
||||
title: Title,
|
||||
translate: TranslateService,
|
||||
matSnackBar: MatSnackBar,
|
||||
private motionRepo: MotionRepositoryService,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute
|
||||
private motionRepo: MotionRepositoryService
|
||||
) {
|
||||
super(title, translate, matSnackBar);
|
||||
|
||||
this.motionRepo.getViewModelListObservable().subscribe(motions => {
|
||||
this.motions = motions.sort((a, b) => {
|
||||
if (a.weight !== b.weight) {
|
||||
return a.weight - b.weight;
|
||||
} else {
|
||||
return a.id - b.id;
|
||||
}
|
||||
});
|
||||
});
|
||||
this.motionsObservable = this.motionRepo.getViewModelListObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the new motion order to the server.
|
||||
* Handler for the sort event. The data to change is given to
|
||||
* the repo, sending it to the server.
|
||||
*/
|
||||
public save(): void {
|
||||
this.motionRepo.sortMotions(this.sorter.array.map(s => ({ id: s.id }))).then(() => {
|
||||
this.router.navigate(['../'], { relativeTo: this.route });
|
||||
}, this.raiseError);
|
||||
public sort(data: OSTreeSortEvent): void {
|
||||
this.motionRepo.sortMotions(data).then(null, this.raiseError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires the expandCollapse event emitter.
|
||||
*
|
||||
* @param expand True, if the tree should be expanded. Otherwise collapsed
|
||||
*/
|
||||
public expandCollapseAll(expand: boolean): void {
|
||||
this.expandCollapse.emit(expand);
|
||||
}
|
||||
}
|
||||
|
@ -14,13 +14,14 @@ import { DiffService, LineRange, ModificationType } from './diff.service';
|
||||
import { ViewChangeReco } from '../models/view-change-reco';
|
||||
import { MotionChangeReco } from '../../../shared/models/motions/motion-change-reco';
|
||||
import { ViewUnifiedChange } from '../models/view-unified-change';
|
||||
import { ViewStatuteParagraph } from '../models/view-statute-paragraph';
|
||||
import { ViewStatuteParagraph } from '../models/view-statute-paragraph';
|
||||
import { Identifiable } from '../../../shared/models/base/identifiable';
|
||||
import { CollectionStringModelMapperService } from '../../../core/services/collectionStringModelMapper.service';
|
||||
import { HttpService } from 'app/core/services/http.service';
|
||||
import { ConfigService } from 'app/core/services/config.service';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Item } from 'app/shared/models/agenda/item';
|
||||
import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component';
|
||||
|
||||
/**
|
||||
* Repository Services for motions (and potentially categories)
|
||||
@ -153,13 +154,13 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts motions for the call list by the given list of ids (as identifiables with
|
||||
* the format `{id: <id>}`).
|
||||
* @param motionIds all motion ids in the new order.
|
||||
* Sends the changed nodes to the server.
|
||||
*
|
||||
* @param data The reordered data from the sorting
|
||||
*/
|
||||
public async sortMotions(motionIds: Identifiable[]): Promise<void> {
|
||||
public async sortMotions(data: OSTreeSortEvent): Promise<void> {
|
||||
const url = '/rest/motions/motion/sort/';
|
||||
await this.httpService.post(url, { nodes: motionIds });
|
||||
await this.httpService.post(url, data);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -226,7 +227,11 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
|
||||
}
|
||||
}
|
||||
|
||||
public formatStatuteAmendment(paragraphs: ViewStatuteParagraph[], amendment: ViewMotion, lineLength: number): string {
|
||||
public formatStatuteAmendment(
|
||||
paragraphs: ViewStatuteParagraph[],
|
||||
amendment: ViewMotion,
|
||||
lineLength: number
|
||||
): string {
|
||||
const origParagraph = paragraphs.find(paragraph => paragraph.id === amendment.statute_paragraph_id);
|
||||
let diffHtml = this.diff.diff(origParagraph.text, amendment.text);
|
||||
diffHtml = this.lineNumbering.insertLineBreaksWithoutNumbers(diffHtml, lineLength, true);
|
||||
|
@ -14,6 +14,8 @@
|
||||
@include angular-material-theme($openslides-theme);
|
||||
@include openslides-components-theme($openslides-theme);
|
||||
|
||||
@import '~angular-tree-component/dist/angular-tree-component.css';
|
||||
|
||||
* {
|
||||
font-family: Roboto, Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@ -157,3 +159,16 @@ mat-panel-title mat-icon {
|
||||
height: 100px;
|
||||
border: 2px dotted #0782d0;
|
||||
}
|
||||
|
||||
.os-tree {
|
||||
.node-content-wrapper {
|
||||
background-color: aliceblue;
|
||||
border: 1px black;
|
||||
width: 100%;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
tree-loading-component {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@ -290,18 +290,19 @@ class MotionViewSet(ModelViewSet):
|
||||
abou the data to be send.
|
||||
"""
|
||||
nodes = request.data.get('nodes', [])
|
||||
sort_parent_id = request.data.get('sort_parent_id')
|
||||
sort_parent_id = request.data.get('parent_id')
|
||||
motions = []
|
||||
with transaction.atomic():
|
||||
for index, node in enumerate(nodes):
|
||||
motion = Motion.objects.get(pk=node['id'])
|
||||
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=node['id'])
|
||||
motion = Motion.objects.get(pk=id)
|
||||
ancestor = motion.sort_parent
|
||||
while ancestor is not None:
|
||||
if ancestor == motion:
|
||||
|
Loading…
Reference in New Issue
Block a user