OpenSlides ♥ Trees

This commit is contained in:
FinnStutzenstein 2018-11-22 15:14:01 +01:00
parent ec7f63b52d
commit 80782ccbec
8 changed files with 220 additions and 112 deletions

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { TreeService } from './tree.service';
import { E2EImportsModule } from '../../../e2e-imports.module';
describe('TreeService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [TreeService]
});
});
it('should be created', inject([TreeService], (service: TreeService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,142 @@
import { Injectable } from '@angular/core';
import { OpenSlidesComponent } from 'app/openslides.component';
import { Displayable } from 'app/shared/models/base/displayable';
import { Identifiable } from 'app/shared/models/base/identifiable';
/**
* A representation of nodes in our tree. Saves the displayed name, the id, the element and children to build a full tree.
*/
export interface OSTreeNode<T> {
name: string;
id: number;
item: T;
children?: OSTreeNode<T>[];
}
/**
* 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.
*/
@Injectable({
providedIn: 'root'
})
export class TreeService extends OpenSlidesComponent {
/**
* Yes, a constructor.
*/
public constructor() {
super();
}
/**
* Returns the weight casted to a number from a given model.
*
* @param item The model to get the weight from.
* @param key
* @returns the weight of the model
*/
private getAttributeAsNumber<T extends Identifiable & Displayable>(item: T, key: keyof T): number {
return (<any>item[key]) as number;
}
/**
* Build our representation of a tree node given the model and optional children
* to append to this node.
*
* @param item The model to create a node of.
* @param children Optional children to append to this node.
* @returns The created node.
*/
private buildTreeNode<T extends Identifiable & Displayable>(item: T, children?: OSTreeNode<T>[]): OSTreeNode<T> {
return {
name: item.getTitle(),
id: item.id,
item: item,
children: children
};
}
/**
* Builds a tree from the given items on the relations between items with weight and parentId
*
* @param items All items to traverse
* @param weightKey The key giving access to the weight property
* @param parentIdKey The key giving access to the parentId property
* @returns An iterator for all items in the right order.
*/
public makeTree<T extends Identifiable & Displayable>(
items: T[],
weightKey: keyof T,
parentIdKey: keyof T
): OSTreeNode<T>[] {
// Sort items after their weight
items.sort((a, b) => this.getAttributeAsNumber(a, weightKey) - this.getAttributeAsNumber(b, weightKey));
// Build a dict with all children (dict-value) to a specific
// item id (dict-key).
const children: { [parendId: number]: T[] } = {};
items.forEach(model => {
if (model[parentIdKey]) {
const parentId = this.getAttributeAsNumber(model, parentIdKey);
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<T>[] = _models => {
if (!_models) {
return;
}
const nodes: OSTreeNode<T>[] = [];
_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 = items.filter(model => !this.getAttributeAsNumber(model, parentIdKey));
return getChildren(parentItems);
}
/**
* Traverses the given tree in pre order.
*
* @param tree The tree to traverse
* @returns An iterator for all items in the right order.
*/
public *traverseTree<T>(tree: OSTreeNode<T>[]): Iterator<T> {
const nodesToVisit = tree.reverse();
while (nodesToVisit.length > 0) {
const node = nodesToVisit.pop();
if (node.children) {
node.children.reverse().forEach(n => {
nodesToVisit.push(n);
});
}
yield node.item;
}
}
/**
* Traverses items in pre-order givem (implicit) by the weight and parentId.
*
* Just builds the tree with `makeTree` and get the iterator from `traverseTree`.
*
* @param items All items to traverse
* @param weightKey The key giving access to the weight property
* @param parentIdKey The key giving access to the parentId property
* @returns An iterator for all items in the right order.
*/
public traverseItems<T extends Identifiable & Displayable>(
items: T[],
weightKey: keyof T,
parentIdKey: keyof T
): Iterator<T> {
const tree = this.makeTree(items, weightKey, parentIdKey);
return this.traverseTree(tree);
}
}

View File

@ -7,24 +7,16 @@ 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[];
}
import { OSTreeNode, TreeService } from 'app/core/services/tree.service';
/**
* The data representation for the sort event.
*/
export interface OSTreeSortEvent {
export interface OSTreeSortEvent<T> {
/**
* Gives all nodes to be inserted below the parent_id.
*/
nodes: OSTreeNode[];
nodes: OSTreeNode<T>[];
/**
* Provides the parent id for the nodes array. Do not provide it, if it's the
@ -62,8 +54,8 @@ export class SortingTreeComponent<T extends Identifiable & Displayable> implemen
if (this.modelSubscription) {
this.modelSubscription.unsubscribe();
}
this.modelSubscription = models.pipe(auditTime(100)).subscribe(m => {
this.nodes = this.makeTree(m);
this.modelSubscription = models.pipe(auditTime(10)).subscribe(items => {
this.nodes = this.treeService.makeTree(items, this.weightKey, this.parentIdKey);
setTimeout(() => this.tree.treeModel.expandAll());
});
}
@ -93,7 +85,7 @@ export class SortingTreeComponent<T extends Identifiable & Displayable> implemen
* sorted part of the tree.
*/
@Output()
public readonly sort = new EventEmitter<OSTreeSortEvent>();
public readonly sort = new EventEmitter<OSTreeSortEvent<T>>();
/**
* Options for the tree. As a default drag and drop is allowed.
@ -112,12 +104,12 @@ export class SortingTreeComponent<T extends Identifiable & Displayable> implemen
/**
* This is our actual tree represented by our own nodes.
*/
public nodes: OSTreeNode[] = [];
public nodes: OSTreeNode<T>[] = [];
/**
* Constructor. Adds the eventhandler for the drop event to the tree.
*/
public constructor() {
public constructor(private treeService: TreeService) {
this.treeOptions.actionMapping = {
mouse: {
drop: this.drop.bind(this)
@ -149,9 +141,13 @@ export class SortingTreeComponent<T extends Identifiable & Displayable> implemen
* @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;
// check if dropped itself by going the tree upwards and check, if one of them is the "from"-node.
let parent = to.parent;
while (parent !== null) {
if (from.id === parent.id) {
return;
}
parent = parent.parent;
}
let parentId;
@ -165,86 +161,4 @@ export class SortingTreeComponent<T extends Identifiable & Displayable> implemen
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

@ -5,6 +5,7 @@ import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model
import { CollectionStringModelMapperService } from '../../core/services/collectionStringModelMapper.service';
import { DataStoreService } from '../../core/services/data-store.service';
import { Identifiable } from '../../shared/models/base/identifiable';
import { auditTime } from 'rxjs/operators';
export abstract class BaseRepository<V extends BaseViewModel, M extends BaseModel> extends OpenSlidesComponent {
/**
@ -20,7 +21,7 @@ export abstract class BaseRepository<V extends BaseViewModel, M extends BaseMode
/**
* Observable subject for the whole list
*/
protected viewModelListSubject: BehaviorSubject<V[]> = new BehaviorSubject<V[]>(null);
protected readonly viewModelListSubject: BehaviorSubject<V[]> = new BehaviorSubject<V[]>([]);
/**
*
@ -42,9 +43,12 @@ export abstract class BaseRepository<V extends BaseViewModel, M extends BaseMode
// Populate the local viewModelStore with ViewModel Objects.
this.DS.getAll(this.baseModelCtor).forEach((model: M) => {
this.viewModelStore[model.id] = this.createViewModel(model);
});
// Update the list and then all models on their own
this.updateViewModelListObservable();
this.DS.getAll(this.baseModelCtor).forEach((model: M) => {
this.updateViewModelObservable(model.id);
});
this.updateViewModelListObservable();
// Could be raise in error if the root injector is not known
this.DS.changeObservable.subscribe(model => {
@ -131,10 +135,14 @@ export abstract class BaseRepository<V extends BaseViewModel, M extends BaseMode
}
/**
* return the Observable of the whole store
* Return the Observable of the whole store.
*
* All data is piped through an auditTime of 1ms. This is to prevent massive
* updates, if e.g. an autoupdate with a lot motions come in. The result is just one
* update of the new list instead of many unnecessary updates.
*/
public getViewModelListObservable(): Observable<V[]> {
return this.viewModelListSubject.asObservable();
return this.viewModelListSubject.asObservable().pipe(auditTime(1));
}
/**

View File

@ -56,8 +56,11 @@ export class CallListComponent extends BaseViewComponent {
/**
* Handler for the sort event. The data to change is given to
* the repo, sending it to the server.
*
* @param data The event data. The representation fits the servers requirements, so it can directly
* be send to the server via the repository.
*/
public sort(data: OSTreeSortEvent): void {
public sort(data: OSTreeSortEvent<ViewMotion>): void {
this.motionRepo.sortMotions(data).then(null, this.raiseError);
}

View File

@ -75,8 +75,8 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
this.repo.getViewModelListObservable().subscribe(newMotions => {
// TODO: This is for testing purposes. Can be removed with #3963
this.dataSource.data = newMotions.sort((a, b) => {
if (a.weight !== b.weight) {
return a.weight - b.weight;
if (a.callListWeight !== b.callListWeight) {
return a.callListWeight - b.callListWeight;
} else {
return a.id - b.id;
}

View File

@ -62,6 +62,12 @@ export class ViewMotion extends BaseViewModel {
*/
public highlightedLine: number;
/**
* Is set by the repository; this is the order of the flat call list given by
* the properties weight and sort_parent_id
*/
public callListWeight: number;
public get motion(): Motion {
return this._motion;
}
@ -179,7 +185,7 @@ export class ViewMotion extends BaseViewModel {
}
public get agendaSpeakerAmount(): number {
return this.item ? this.item.speakerAmount : null
return this.item ? this.item.speakerAmount : null;
}
public constructor(
@ -189,7 +195,7 @@ export class ViewMotion extends BaseViewModel {
supporters?: User[],
workflow?: Workflow,
state?: WorkflowState,
item?: Item,
item?: Item
) {
super();

View File

@ -1,5 +1,8 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { DataSendService } from '../../../core/services/data-send.service';
import { Motion } from '../../../shared/models/motions/motion';
import { User } from '../../../shared/models/users/user';
@ -20,6 +23,7 @@ import { CollectionStringModelMapperService } from '../../../core/services/colle
import { HttpService } from 'app/core/services/http.service';
import { Item } from 'app/shared/models/agenda/item';
import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component';
import { TreeService } from 'app/core/services/tree.service';
/**
* Repository Services for motions (and potentially categories)
@ -51,7 +55,8 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
private dataSend: DataSendService,
private httpService: HttpService,
private readonly lineNumbering: LinenumberingService,
private readonly diff: DiffService
private readonly diff: DiffService,
private treeService: TreeService
) {
super(DS, mapperService, Motion, [Category, User, Workflow, Item]);
}
@ -77,6 +82,19 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
return new ViewMotion(motion, category, submitters, supporters, workflow, state, item);
}
public getViewModelListObservable(): Observable<ViewMotion[]> {
return super.getViewModelListObservable().pipe(
tap(motions => {
const iterator = this.treeService.traverseItems(motions, 'weight', 'sort_parent_id');
let m: IteratorResult<ViewMotion>;
let virtualWeightCounter = 0;
while (!(m = iterator.next()).done) {
m.value.callListWeight = virtualWeightCounter++;
}
})
);
}
/**
* Creates a motion
* Creates a (real) motion with patched data and delegate it
@ -146,7 +164,7 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
*
* @param data The reordered data from the sorting
*/
public async sortMotions(data: OSTreeSortEvent): Promise<void> {
public async sortMotions(data: OSTreeSortEvent<ViewMotion>): Promise<void> {
const url = '/rest/motions/motion/sort/';
await this.httpService.post(url, data);
}