Implements the possibility to sort trees by a given property

- Separates the base sorting function to a lower service, called `base-sort.service`.
- Adds the `tree-sort.service`.
This commit is contained in:
GabrielMeyer 2019-09-04 17:42:03 +02:00
parent 2c58d0f0fe
commit 1ec5dfbb88
16 changed files with 299 additions and 100 deletions

View File

@ -1,32 +1,15 @@
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { BaseSortService, OsSortingDefinition, OsSortingOption } from './base-sort.service';
import { BaseViewModel } from '../../site/base/base-view-model'; import { BaseViewModel } from '../../site/base/base-view-model';
import { OpenSlidesStatusService } from '../core-services/openslides-status.service'; import { OpenSlidesStatusService } from '../core-services/openslides-status.service';
import { StorageService } from '../core-services/storage.service'; import { StorageService } from '../core-services/storage.service';
/**
* Describes the sorting columns of an associated ListView, and their state.
*/
export interface OsSortingDefinition<V> {
sortProperty: keyof V;
sortAscending: boolean;
}
/**
* A sorting property (data may be a string, a number, a function, or an object
* with a toString method) to sort after. Sorting will be done in {@link filterData}
*/
export interface OsSortingOption<V> {
property: keyof V;
label?: string;
sortFn?: (itemA: V, itemB: V, ascending: boolean, intl?: Intl.Collator) => number;
}
/** /**
* Base class for generic sorting purposes * Base class for generic sorting purposes
*/ */
export abstract class BaseSortListService<V extends BaseViewModel> { export abstract class BaseSortListService<V extends BaseViewModel> extends BaseSortService<V> {
/** /**
* The data to be sorted. See also the setter for {@link data} * The data to be sorted. See also the setter for {@link data}
*/ */
@ -60,11 +43,6 @@ export abstract class BaseSortListService<V extends BaseViewModel> {
*/ */
protected abstract readonly storageKey: string; protected abstract readonly storageKey: string;
/**
* The sorting function according to current settings.
*/
public sortFn?: (a: V, b: V, ascending: boolean, intl?: Intl.Collator) => number;
/** /**
* Set the current sorting order * Set the current sorting order
* *
@ -131,7 +109,9 @@ export abstract class BaseSortListService<V extends BaseViewModel> {
protected translate: TranslateService, protected translate: TranslateService,
private store: StorageService, private store: StorageService,
private OSStatus: OpenSlidesStatusService private OSStatus: OpenSlidesStatusService
) {} ) {
super(translate);
}
/** /**
* Enforce children to implement a function that returns their sorting options * Enforce children to implement a function that returns their sorting options
@ -229,79 +209,14 @@ export abstract class BaseSortListService<V extends BaseViewModel> {
} }
} }
/**
* Helper function to determine false-like values (if they are not boolean)
* @param property
*/
private isFalsy(property: any): boolean {
return property === null || property === undefined || property === 0 || property === '';
}
/** /**
* Recreates the sorting function. Is supposed to be called on init and * Recreates the sorting function. Is supposed to be called on init and
* every time the sorting (property, ascending/descending) or the language changes * every time the sorting (property, ascending/descending) or the language changes
*/ */
protected updateSortedData(): void { protected updateSortedData(): void {
if (this.inputData && this.sortDefinition) { if (this.inputData) {
const property = this.sortProperty as string;
const intl = new Intl.Collator(this.translate.currentLang, {
numeric: true,
ignorePunctuation: true,
sensitivity: 'base'
});
this.inputData.sort((itemA, itemB) => { this.inputData.sort((itemA, itemB) => {
// always sort falsy values to the bottom return this.sortItems(itemA, itemB, this.sortProperty, this.ascending);
if (this.isFalsy(itemA[property]) && this.isFalsy(itemB[property])) {
return 0;
} else if (this.isFalsy(itemA[property])) {
return 1;
} else if (this.isFalsy(itemB[property])) {
return -1;
}
const firstProperty = this.ascending ? itemA[property] : itemB[property];
const secondProperty = this.ascending ? itemB[property] : itemA[property];
if (this.sortFn) {
return this.sortFn(itemA, itemB, this.ascending, intl);
} else {
switch (typeof firstProperty) {
case 'boolean':
if (!firstProperty && secondProperty) {
return -1;
} else {
return 1;
}
case 'number':
return firstProperty > secondProperty ? 1 : -1;
case 'string':
if (!!firstProperty && !secondProperty) {
return -1;
} else if (!firstProperty && !!secondProperty) {
return 1;
} else if ((!secondProperty && !secondProperty) || firstProperty === secondProperty) {
return 0;
} else {
return intl.compare(firstProperty, secondProperty);
}
case 'function':
const a = firstProperty();
const b = secondProperty();
return intl.compare(a, b);
case 'object':
if (firstProperty instanceof Date) {
return firstProperty > secondProperty ? 1 : -1;
} else {
return intl.compare(firstProperty.toString(), secondProperty.toString());
}
case 'undefined':
return 1;
default:
return -1;
}
}
}); });
this.outputSubject.next(this.inputData); this.outputSubject.next(this.inputData);
} }

View File

@ -0,0 +1,19 @@
import { TestBed } from '@angular/core/testing';
import { BaseSortService } from './base-sort.service';
import { E2EImportsModule } from '../../../e2e-imports.module';
describe('BaseSortService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [BaseSortService]
});
});
// TODO testing (does not work without injecting a BaseViewComponent)
// it('should be created', () => {
// const service: BaseSortService = TestBed.get(BaseSortService);
// expect(service).toBeTruthy();
// });
});

View File

@ -0,0 +1,124 @@
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Identifiable } from 'app/shared/models/base/identifiable';
import { Displayable } from 'app/site/base/displayable';
export type SortDefinition<T> = keyof T | OsSortingDefinition<T>;
/**
* Describes the sorting columns of an associated ListView, and their state.
*/
export interface OsSortingDefinition<T> {
sortProperty: keyof T;
sortAscending: boolean;
}
/**
* A sorting property (data may be a string, a number, a function, or an object
* with a toString method) to sort after. Sorting will be done in {@link filterData}
*/
export interface OsSortingOption<T> {
property: keyof T;
label?: string;
sortFn?: (itemA: T, itemB: T, ascending: boolean, intl?: Intl.Collator) => number;
}
/**
* Base sorting service with main functionality for sorting.
*
* Extends sorting services to sort with a consistent function.
*/
@Injectable({
providedIn: 'root'
})
export abstract class BaseSortService<T extends Identifiable & Displayable> {
/**
* The sorting function according to current settings.
*/
public sortFn?: (a: T, b: T, ascending: boolean, intl?: Intl.Collator) => number;
/**
* The international localisation.
*/
protected intl: Intl.Collator;
/**
* Constructor.
* Pass the `TranslatorService`.
*/
public constructor(protected translate: TranslateService) {
this.intl = new Intl.Collator(translate.currentLang, {
numeric: true,
ignorePunctuation: true,
sensitivity: 'base'
});
}
/**
* Helper function to determine false-like values (if they are not boolean)
* @param property
*/
private isFalsy(property: any): boolean {
return property === null || property === undefined || property === 0 || property === '';
}
/**
* Recreates the sorting function. Is supposed to be called on init and
* every time the sorting (property, ascending/descending) or the language changes
*/
protected sortItems(itemA: T, itemB: T, sortProperty: keyof T, ascending: boolean = true): number {
// always sort falsy values to the bottom
const property = sortProperty as string;
if (this.isFalsy(itemA[property]) && this.isFalsy(itemB[property])) {
return 0;
} else if (this.isFalsy(itemA[property])) {
return 1;
} else if (this.isFalsy(itemB[property])) {
return -1;
}
const firstProperty = ascending ? itemA[property] : itemB[property];
const secondProperty = ascending ? itemB[property] : itemA[property];
if (this.sortFn) {
return this.sortFn(itemA, itemB, ascending, this.intl);
} else {
switch (typeof firstProperty) {
case 'boolean':
if (!firstProperty && secondProperty) {
return -1;
} else {
return 1;
}
case 'number':
return firstProperty > secondProperty ? 1 : -1;
case 'string':
if (!!firstProperty && !secondProperty) {
return -1;
} else if (!firstProperty && !!secondProperty) {
return 1;
} else if ((!secondProperty && !secondProperty) || firstProperty === secondProperty) {
return 0;
} else {
return this.intl.compare(firstProperty, secondProperty);
}
case 'function':
const a = firstProperty();
const b = secondProperty();
return this.intl.compare(a, b);
case 'object':
if (firstProperty instanceof Date) {
return firstProperty > secondProperty ? 1 : -1;
} else {
return this.intl.compare(firstProperty.toString(), secondProperty.toString());
}
case 'undefined':
return 1;
default:
return -1;
}
}
}
}

View File

@ -0,0 +1,19 @@
import { TestBed } from '@angular/core/testing';
import { E2EImportsModule } from '../../../e2e-imports.module';
import { TreeSortService } from './tree-sort.service';
describe('TreeSortService', () => {
beforeEach(() =>
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [TreeSortService]
})
);
// TODO testing (does not work without injecting a BaseViewComponent)
// it('should be created', () => {
// const service: TreeSortService = TestBed.get(TreeSortService);
// expect(service).toBeTruthy();
// });
});

View File

@ -0,0 +1,53 @@
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Identifiable } from 'app/shared/models/base/identifiable';
import { Displayable } from 'app/site/base/displayable';
import { BaseSortService } from './base-sort.service';
import { FlatNode } from './tree.service';
/**
* Sorting service for trees.
*
* Contains base functions to sort a tree by different properties.
*/
@Injectable({
providedIn: 'root'
})
export class TreeSortService<T extends Identifiable & Displayable> extends BaseSortService<T> {
/**
* Constructor.
* Calls the `super()`-method.
*
* @param translate The reference to the `TranslateService`
*/
public constructor(protected translate: TranslateService) {
super(translate);
}
/**
* Function to sort the passed source of a tree
* and resets some properties like `level`, `expandable`, `position`.
*
* @param sourceData The source array of `FlatNode`s.
* @param property The property, the array will be sorted by.
* @param ascending Boolean, if the array should be sorted in ascending order.
*
* @returns {FlatNode<T>[]} The sorted array.
*/
public sortTree(sourceData: FlatNode<T>[], property: keyof T, ascending: boolean = true): FlatNode<T>[] {
return sourceData
.sort((nodeA, nodeB) => {
const itemA = nodeA.item;
const itemB = nodeB.item;
return this.sortItems(itemA, itemB, property, ascending);
})
.map((node, index) => {
node.level = 0;
node.position = index;
node.expandable = false;
return node;
});
}
}

View File

@ -4,8 +4,8 @@ import { MatBottomSheet } from '@angular/material/bottom-sheet';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { BaseFilterListService, OsFilterIndicator } from 'app/core/ui-services/base-filter-list.service'; import { BaseFilterListService, OsFilterIndicator } from 'app/core/ui-services/base-filter-list.service';
import { OsSortingOption } from 'app/core/ui-services/base-sort-list.service';
import { BaseSortListService } from 'app/core/ui-services/base-sort-list.service'; import { BaseSortListService } from 'app/core/ui-services/base-sort-list.service';
import { OsSortingOption } from 'app/core/ui-services/base-sort.service';
import { ViewportService } from 'app/core/ui-services/viewport.service'; import { ViewportService } from 'app/core/ui-services/viewport.service';
import { BaseViewModel } from 'app/site/base/base-view-model'; import { BaseViewModel } from 'app/site/base/base-view-model';
import { FilterMenuComponent } from './filter-menu/filter-menu.component'; import { FilterMenuComponent } from './filter-menu/filter-menu.component';

View File

@ -6,6 +6,8 @@ import { Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output
import { Observable, Subscription } from 'rxjs'; import { Observable, Subscription } from 'rxjs';
import { auditTime } from 'rxjs/operators'; import { auditTime } from 'rxjs/operators';
import { SortDefinition } from 'app/core/ui-services/base-sort.service';
import { TreeSortService } from 'app/core/ui-services/tree-sort.service';
import { FlatNode, TreeIdNode, TreeService } from 'app/core/ui-services/tree.service'; import { FlatNode, TreeIdNode, TreeService } from 'app/core/ui-services/tree.service';
import { Identifiable } from 'app/shared/models/base/identifiable'; import { Identifiable } from 'app/shared/models/base/identifiable';
import { Displayable } from 'app/site/base/displayable'; import { Displayable } from 'app/site/base/displayable';
@ -175,6 +177,19 @@ export class SortingTreeComponent<T extends Identifiable & Displayable> implemen
}); });
} }
/**
* Setter for sorting functions.
*
* @param predicate An `EventEmitter` to push to sort the tree by the passed property.
* It should pass a `SortDefinition`.
*/
@Input()
public set sortingDefinition(predicate: EventEmitter<SortDefinition<T>>) {
predicate.subscribe((event: SortDefinition<T>) => {
this.resolveSortingPredicate(event);
});
}
/** /**
* EventEmitter to send info if changes has been made. * EventEmitter to send info if changes has been made.
*/ */
@ -197,8 +212,9 @@ export class SortingTreeComponent<T extends Identifiable & Displayable> implemen
* Constructor * Constructor
* *
* @param treeService Service to get data from store and build the tree nodes. * @param treeService Service to get data from store and build the tree nodes.
* @param sortService Service to sort tree nodes by their given items.
*/ */
public constructor(private treeService: TreeService) {} public constructor(private treeService: TreeService, private sortService: TreeSortService<T>) {}
/** /**
* On init method * On init method
@ -947,6 +963,27 @@ export class SortingTreeComponent<T extends Identifiable & Displayable> implemen
return willFiltered; return willFiltered;
} }
/**
* Function to sort the given source-array by the passed property of the underlying items `<T>`.
*
* @param event `SortDefinition<T>` - can be only a `keyof T` or
* an object defining the property and whether ascending order or not:
* `{ property: keyof T, ascending: boolean }`.
*/
private resolveSortingPredicate(event: SortDefinition<T>): void {
this.removeSubscription();
const sortProperty = typeof event === 'object' ? event.sortProperty : event;
const sortAscending = typeof event === 'object' ? event.sortAscending : true;
this.osTreeData = this.sortService.sortTree(this.osTreeData, sortProperty, sortAscending);
this.checkActiveFilters();
this.dataSource = null;
this.dataSource = new ArrayDataSource(this.osTreeData);
this.madeChanges(true);
}
/** /**
* Function to check if a node has children. * Function to check if a node has children.
*/ */

View File

@ -4,7 +4,8 @@ import { TranslateService } from '@ngx-translate/core';
import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service'; import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service';
import { StorageService } from 'app/core/core-services/storage.service'; import { StorageService } from 'app/core/core-services/storage.service';
import { BaseSortListService, OsSortingDefinition, OsSortingOption } from 'app/core/ui-services/base-sort-list.service'; import { BaseSortListService } from 'app/core/ui-services/base-sort-list.service';
import { OsSortingDefinition, OsSortingOption } from 'app/core/ui-services/base-sort.service';
import { ViewAssignment } from '../models/view-assignment'; import { ViewAssignment } from '../models/view-assignment';
/** /**

View File

@ -4,6 +4,7 @@ import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { SortDefinition } from 'app/core/ui-services/base-sort.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component'; import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component';
import { Identifiable } from 'app/shared/models/base/identifiable'; import { Identifiable } from 'app/shared/models/base/identifiable';
@ -40,6 +41,11 @@ export abstract class SortTreeViewComponent<V extends BaseViewModel> extends Bas
*/ */
public readonly changeFilter: EventEmitter<(item: V) => boolean> = new EventEmitter<(item: V) => boolean>(); public readonly changeFilter: EventEmitter<(item: V) => boolean> = new EventEmitter<(item: V) => boolean>();
/**
* Emitter to notice the `tree-sorting.service` for sorting the data-source.
*/
public readonly forceSort = new EventEmitter<SortDefinition<V>>();
/** /**
* Boolean to check if changes has been made. * Boolean to check if changes has been made.
*/ */

View File

@ -4,7 +4,8 @@ import { TranslateService } from '@ngx-translate/core';
import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service'; import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service';
import { StorageService } from 'app/core/core-services/storage.service'; import { StorageService } from 'app/core/core-services/storage.service';
import { BaseSortListService, OsSortingDefinition, OsSortingOption } from 'app/core/ui-services/base-sort-list.service'; import { BaseSortListService } from 'app/core/ui-services/base-sort-list.service';
import { OsSortingDefinition, OsSortingOption } from 'app/core/ui-services/base-sort.service';
import { ViewMediafile } from '../models/view-mediafile'; import { ViewMediafile } from '../models/view-mediafile';
/** /**

View File

@ -82,6 +82,7 @@
(hasChanged)="receiveChanges($event)" (hasChanged)="receiveChanges($event)"
[model]="motionsObservable" [model]="motionsObservable"
[filterChange]="changeFilter" [filterChange]="changeFilter"
[sortingDefinition]="forceSort"
> >
<ng-template #innerNode let-item="item"> <ng-template #innerNode let-item="item">
<div class="line"> <div class="line">
@ -100,6 +101,13 @@
</mat-card> </mat-card>
<mat-menu #mainMenu="matMenu"> <mat-menu #mainMenu="matMenu">
<button mat-menu-item (click)="sortMotionsByIdentifier()">
<mat-icon>sort</mat-icon>
<span translate>Sort by identifier</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="pdfExportCallList()"> <button mat-menu-item (click)="pdfExportCallList()">
<mat-icon>picture_as_pdf</mat-icon> <mat-icon>picture_as_pdf</mat-icon>
<span translate>Export as PDF</span> <span translate>Export as PDF</span>

View File

@ -256,6 +256,19 @@ export class CallListComponent extends SortTreeViewComponent<ViewMotion> impleme
this.activeCatFilters.next([]); this.activeCatFilters.next([]);
} }
/**
* This method requires a confirmation from the user
* and starts the sorting by the property `identifier` of the motions
* in case of `true`.
*/
public async sortMotionsByIdentifier(): Promise<void> {
const title = this.translate.instant('Do you really want to go ahead?');
const text = this.translate.instant('This will reset all made changes and sort the tree...');
if (await this.promptService.open(title, text)) {
this.forceSort.emit('identifier');
}
}
/** /**
* Helper to trigger an update of the filter itself and the information about * Helper to trigger an update of the filter itself and the information about
* the state of filters * the state of filters

View File

@ -4,7 +4,7 @@ import { TranslateService } from '@ngx-translate/core';
import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service'; import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service';
import { StorageService } from 'app/core/core-services/storage.service'; import { StorageService } from 'app/core/core-services/storage.service';
import { OsSortingDefinition, OsSortingOption } from 'app/core/ui-services/base-sort-list.service'; import { OsSortingDefinition, OsSortingOption } from 'app/core/ui-services/base-sort.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { MotionSortListService } from './motion-sort-list.service'; import { MotionSortListService } from './motion-sort-list.service';
import { ViewMotion } from '../models/view-motion'; import { ViewMotion } from '../models/view-motion';

View File

@ -4,7 +4,8 @@ import { TranslateService } from '@ngx-translate/core';
import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service'; import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service';
import { StorageService } from 'app/core/core-services/storage.service'; import { StorageService } from 'app/core/core-services/storage.service';
import { BaseSortListService, OsSortingDefinition, OsSortingOption } from 'app/core/ui-services/base-sort-list.service'; import { BaseSortListService } from 'app/core/ui-services/base-sort-list.service';
import { OsSortingDefinition, OsSortingOption } from 'app/core/ui-services/base-sort.service';
import { ViewMotionBlock } from '../models/view-motion-block'; import { ViewMotionBlock } from '../models/view-motion-block';
@Injectable({ @Injectable({

View File

@ -6,7 +6,8 @@ import { OpenSlidesStatusService } from 'app/core/core-services/openslides-statu
import { StorageService } from 'app/core/core-services/storage.service'; import { StorageService } from 'app/core/core-services/storage.service';
import { Deferred } from 'app/core/promises/deferred'; import { Deferred } from 'app/core/promises/deferred';
import { _ } from 'app/core/translate/translation-marker'; import { _ } from 'app/core/translate/translation-marker';
import { BaseSortListService, OsSortingDefinition, OsSortingOption } from 'app/core/ui-services/base-sort-list.service'; import { BaseSortListService } from 'app/core/ui-services/base-sort-list.service';
import { OsSortingDefinition, OsSortingOption } from 'app/core/ui-services/base-sort.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { ViewMotion } from '../models/view-motion'; import { ViewMotion } from '../models/view-motion';

View File

@ -4,7 +4,8 @@ import { TranslateService } from '@ngx-translate/core';
import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service'; import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service';
import { StorageService } from 'app/core/core-services/storage.service'; import { StorageService } from 'app/core/core-services/storage.service';
import { BaseSortListService, OsSortingDefinition, OsSortingOption } from 'app/core/ui-services/base-sort-list.service'; import { BaseSortListService } from 'app/core/ui-services/base-sort-list.service';
import { OsSortingDefinition, OsSortingOption } from 'app/core/ui-services/base-sort.service';
import { ViewUser } from '../models/view-user'; import { ViewUser } from '../models/view-user';
/** /**