OpenSlides ♥ Trees
This commit is contained in:
parent
ec7f63b52d
commit
80782ccbec
17
client/src/app/core/services/tree.service.spec.ts
Normal file
17
client/src/app/core/services/tree.service.spec.ts
Normal 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();
|
||||
}));
|
||||
});
|
142
client/src/app/core/services/tree.service.ts
Normal file
142
client/src/app/core/services/tree.service.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user