Merge pull request #4018 from FinnStutzenstein/nested_dnd

angular2 tree
This commit is contained in:
Finn Stutzenstein 2018-11-22 17:21:45 +01:00 committed by GitHub
commit 8a7d0e8be9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 388 additions and 43 deletions

View File

@ -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",

View File

@ -0,0 +1,3 @@
<div class="os-tree">
<tree-root #tree [options]="treeOptions" [focused]="true" [nodes]="nodes"></tree-root>
</div>

View File

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

View File

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

View File

@ -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 {}

View File

@ -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>

View File

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

View File

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

View File

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

View File

@ -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: