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 { Identifiable } from 'app/shared/models/base/identifiable';
import { Displayable } from 'app/shared/models/base/displayable'; import { Displayable } from 'app/shared/models/base/displayable';
import { OSTreeNode, TreeService } from 'app/core/services/tree.service';
/**
* 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. * The data representation for the sort event.
*/ */
export interface OSTreeSortEvent { export interface OSTreeSortEvent<T> {
/** /**
* Gives all nodes to be inserted below the parent_id. * 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 * 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) { if (this.modelSubscription) {
this.modelSubscription.unsubscribe(); this.modelSubscription.unsubscribe();
} }
this.modelSubscription = models.pipe(auditTime(100)).subscribe(m => { this.modelSubscription = models.pipe(auditTime(10)).subscribe(items => {
this.nodes = this.makeTree(m); this.nodes = this.treeService.makeTree(items, this.weightKey, this.parentIdKey);
setTimeout(() => this.tree.treeModel.expandAll()); setTimeout(() => this.tree.treeModel.expandAll());
}); });
} }
@ -93,7 +85,7 @@ export class SortingTreeComponent<T extends Identifiable & Displayable> implemen
* sorted part of the tree. * sorted part of the tree.
*/ */
@Output() @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. * 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. * 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. * Constructor. Adds the eventhandler for the drop event to the tree.
*/ */
public constructor() { public constructor(private treeService: TreeService) {
this.treeOptions.actionMapping = { this.treeOptions.actionMapping = {
mouse: { mouse: {
drop: this.drop.bind(this) 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 * @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 { private drop(tree: TreeModel, node: TreeNode, $event: any, { from, to }: { from: any; to: any }): void {
// check if dropped itself // check if dropped itself by going the tree upwards and check, if one of them is the "from"-node.
if (from.id === to.parent.id) { let parent = to.parent;
return; while (parent !== null) {
if (from.id === parent.id) {
return;
}
parent = parent.parent;
} }
let parentId; let parentId;
@ -165,86 +161,4 @@ export class SortingTreeComponent<T extends Identifiable & Displayable> implemen
transferArrayItem(fromArray, to.parent.data.children, from.index, to.index); transferArrayItem(fromArray, to.parent.data.children, from.index, to.index);
this.sort.emit({ nodes: to.parent.data.children, parent_id: parentId }); 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 { CollectionStringModelMapperService } from '../../core/services/collectionStringModelMapper.service';
import { DataStoreService } from '../../core/services/data-store.service'; import { DataStoreService } from '../../core/services/data-store.service';
import { Identifiable } from '../../shared/models/base/identifiable'; import { Identifiable } from '../../shared/models/base/identifiable';
import { auditTime } from 'rxjs/operators';
export abstract class BaseRepository<V extends BaseViewModel, M extends BaseModel> extends OpenSlidesComponent { 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 * 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. // Populate the local viewModelStore with ViewModel Objects.
this.DS.getAll(this.baseModelCtor).forEach((model: M) => { this.DS.getAll(this.baseModelCtor).forEach((model: M) => {
this.viewModelStore[model.id] = this.createViewModel(model); 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.updateViewModelObservable(model.id);
}); });
this.updateViewModelListObservable();
// Could be raise in error if the root injector is not known // Could be raise in error if the root injector is not known
this.DS.changeObservable.subscribe(model => { 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[]> { 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 * Handler for the sort event. The data to change is given to
* the repo, sending it to the server. * 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); 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 => { this.repo.getViewModelListObservable().subscribe(newMotions => {
// TODO: This is for testing purposes. Can be removed with #3963 // TODO: This is for testing purposes. Can be removed with #3963
this.dataSource.data = newMotions.sort((a, b) => { this.dataSource.data = newMotions.sort((a, b) => {
if (a.weight !== b.weight) { if (a.callListWeight !== b.callListWeight) {
return a.weight - b.weight; return a.callListWeight - b.callListWeight;
} else { } else {
return a.id - b.id; return a.id - b.id;
} }

View File

@ -62,6 +62,12 @@ export class ViewMotion extends BaseViewModel {
*/ */
public highlightedLine: number; 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 { public get motion(): Motion {
return this._motion; return this._motion;
} }
@ -179,7 +185,7 @@ export class ViewMotion extends BaseViewModel {
} }
public get agendaSpeakerAmount(): number { public get agendaSpeakerAmount(): number {
return this.item ? this.item.speakerAmount : null return this.item ? this.item.speakerAmount : null;
} }
public constructor( public constructor(
@ -189,7 +195,7 @@ export class ViewMotion extends BaseViewModel {
supporters?: User[], supporters?: User[],
workflow?: Workflow, workflow?: Workflow,
state?: WorkflowState, state?: WorkflowState,
item?: Item, item?: Item
) { ) {
super(); super();

View File

@ -1,5 +1,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { DataSendService } from '../../../core/services/data-send.service'; import { DataSendService } from '../../../core/services/data-send.service';
import { Motion } from '../../../shared/models/motions/motion'; import { Motion } from '../../../shared/models/motions/motion';
import { User } from '../../../shared/models/users/user'; 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 { HttpService } from 'app/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 { 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) * Repository Services for motions (and potentially categories)
@ -51,7 +55,8 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
private dataSend: DataSendService, private dataSend: DataSendService,
private httpService: HttpService, private httpService: HttpService,
private readonly lineNumbering: LinenumberingService, private readonly lineNumbering: LinenumberingService,
private readonly diff: DiffService private readonly diff: DiffService,
private treeService: TreeService
) { ) {
super(DS, mapperService, Motion, [Category, User, Workflow, Item]); 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); 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 motion
* Creates a (real) motion with patched data and delegate it * 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 * @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/'; const url = '/rest/motions/motion/sort/';
await this.httpService.post(url, data); await this.httpService.post(url, data);
} }