Merge pull request #4112 from MaximilianKrambach/sortSearchFixes

sortSearch improvements (fixes #4098)
This commit is contained in:
Sean 2019-01-18 13:05:47 +01:00 committed by GitHub
commit c3ed0d0dad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 441 additions and 175 deletions

View File

@ -22,11 +22,15 @@ export interface OsFilter {
} }
/** /**
* Describes a list of available options for a drop down menu of a filter * Describes a list of available options for a drop down menu of a filter.
* A filter condition of null will be interpreted as a negative filter
* ('None of the other filter options').
* Filter condition numbers/number arrays will be checked against numerical
* values and as id(s) for objects.
*/ */
export interface OsFilterOption { export interface OsFilterOption {
label: string; label: string;
condition: string | boolean | number; condition: string | boolean | number | number[];
isActive?: boolean; isActive?: boolean;
} }
@ -55,6 +59,47 @@ export abstract class FilterListService<M extends BaseModel, V extends BaseViewM
protected name: string; protected name: string;
/**
* @returns the total count of items before the filter
*/
public get totalCount(): number {
return this.currentRawData ? this.currentRawData.length : 0;
}
/**
* @returns the amount of items that pass the current filters
*/
public get filteredCount(): number {
return this.filteredData ? this.filteredData.length : 0;
}
/**
* Get the amount of filters currently in use by this filter Service
*
* @returns a number of filters
*/
public get activeFilterCount(): number {
if (!this.filterDefinitions || !this.filterDefinitions.length) {
return 0;
}
let filters = 0;
for (const filter of this.filterDefinitions) {
if (filter.count) {
filters += 1;
}
}
return filters;
}
/**
* Boolean indicationg if there are any filters described in this service
*
* @returns true if there are defined filters (regardless of current state)
*/
public get hasFilterOptions(): boolean {
return this.filterDefinitions && this.filterDefinitions.length ? true : false;
}
/** /**
* Constructor. * Constructor.
*/ */
@ -102,6 +147,12 @@ export abstract class FilterListService<M extends BaseModel, V extends BaseViewM
} }
} }
/**
* Remove a filter option.
*
* @param filterName: The property name of this filter
* @param option: The option to disable
*/
public removeFilterOption(filterName: string, option: OsFilterOption): void { public removeFilterOption(filterName: string, option: OsFilterOption): void {
const filter = this.filterDefinitions.find(f => f.property === filterName); const filter = this.filterDefinitions.find(f => f.property === filterName);
if (filter) { if (filter) {
@ -213,23 +264,84 @@ export abstract class FilterListService<M extends BaseModel, V extends BaseViewM
} }
/** /**
* Helper to see if a model instance passes a filter * Checks if a given ViewBaseModel passes the filter.
*
* @param item * @param item
* @param filter * @param filter
* @returns true if the item is to be dispalyed according to the filter
*/ */
private checkIncluded(item: V, filter: OsFilter): boolean { private checkIncluded(item: V, filter: OsFilter): boolean {
const nullFilter = filter.options.find(
option => typeof option !== 'string' && option.isActive && option.condition === null
);
let passesNullFilter = true;
for (const option of filter.options) { for (const option of filter.options) {
// ignored options
if (typeof option === 'string') { if (typeof option === 'string') {
continue; continue;
} else if (nullFilter && option === nullFilter) {
continue;
// active option. The item is included if it passes this test
} else if (option.isActive) {
if (this.checkFilterIncluded(item, filter, option)) {
return true;
} }
if (option.isActive) { // if a null filter is set, the item needs to not pass all inactive filters
if (option.condition === null) { } else if (
return this.checkIncludedNegative(item, filter); nullFilter &&
(item[filter.property] !== null || item[filter.property] !== undefined) &&
this.checkFilterIncluded(item, filter, option)
) {
passesNullFilter = false;
}
}
if (nullFilter && passesNullFilter) {
return true;
} }
if (item[filter.property] === undefined) {
return false; return false;
} }
if (item[filter.property] instanceof BaseModel) {
/**
* Checks an item against a single filter option.
*
* @param item A BaseModel to be checked
* @param filter The parent filter
* @param option The option to be checked
* @returns true if the filter condition matches the item
*/
private checkFilterIncluded(item: V, filter: OsFilter, option: OsFilterOption): boolean {
if (item[filter.property] === undefined || item[filter.property] === null) {
return false;
} else if (Array.isArray(item[filter.property])) {
const compareValueCondition = (value, condition): boolean => {
if (value === condition) {
return true;
} else if (value.hasOwnProperty('id') && value.id === condition) {
return true;
}
return false;
};
for (const value of item[filter.property]) {
if (Array.isArray(option.condition)) {
for (const condition of option.condition) {
if (compareValueCondition(value, condition)) {
return true;
}
}
} else {
if (compareValueCondition(value, option.condition)) {
return true;
}
}
}
} else if (Array.isArray(option.condition)) {
if (
option.condition.indexOf(item[filter.property]) > -1 ||
option.condition.indexOf(item[filter.property].id) > -1
) {
return true;
}
} else if (typeof item[filter.property] === 'object' && item[filter.property].hasOwnProperty('id')) {
if (item[filter.property].id === option.condition) { if (item[filter.property].id === option.condition) {
return true; return true;
} }
@ -238,34 +350,15 @@ export abstract class FilterListService<M extends BaseModel, V extends BaseViewM
} else if (item[filter.property].toString() === option.condition) { } else if (item[filter.property].toString() === option.condition) {
return true; return true;
} }
}
}
return false; return false;
} }
/** /**
* Returns true if none of the defined non-null filters apply, * Retrieves a translatable label or filter property used for displaying the filter
* aka 'items that match no filter' *
* @param item: A viewModel
* @param filter * @param filter
* @returns a name, capitalized first character
*/ */
private checkIncludedNegative(item: V, filter: OsFilter): boolean {
if (item[filter.property] === undefined) {
return true;
}
for (const option of filter.options) {
if (typeof option === 'string' || option.condition === null) {
continue;
}
if (item[filter.property] === option.condition) {
return false;
} else if (item[filter.property].toString() === option.condition) {
return false;
}
}
return true;
}
public getFilterName(filter: OsFilter): string { public getFilterName(filter: OsFilter): string {
if (filter.label) { if (filter.label) {
return filter.label; return filter.label;
@ -275,20 +368,24 @@ export abstract class FilterListService<M extends BaseModel, V extends BaseViewM
} }
} }
public get hasActiveFilters(): number { /**
if (!this.filterDefinitions || !this.filterDefinitions.length) { * Removes all active options of a given filter, clearing it
return 0; * @param filter
*/
public clearFilter(filter: OsFilter): void {
filter.options.forEach(option => {
if (typeof option === 'object' && option.isActive) {
this.removeFilterOption(filter.property, option);
} }
let filters = 0; });
for (const filter of this.filterDefinitions) {
if (filter.count) {
filters += 1;
}
}
return filters;
} }
public hasFilterOptions(): boolean { /**
return this.filterDefinitions && this.filterDefinitions.length ? true : false; * Removes all filters currently in use from this filterService
*/
public clearAllFilters(): void {
this.filterDefinitions.forEach(filter => {
this.clearFilter(filter);
});
} }
} }

View File

@ -1,7 +1,35 @@
<div class="custom-table-header on-transition-fade"> <div class="custom-table-header flex-spaced on-transition-fade">
<div class="filter-count" *ngIf="filterService">
<span>{{ filterService.filteredCount }}&nbsp;</span><span translate>of</span>
<span>&nbsp;{{ filterService.totalCount }}</span>
</div>
<div class="current-filters" *ngIf="filterService && filterService.activeFilterCount">
<div>
<span translate>Active filters</span>:&nbsp;
</div>
<div>
<button mat-button (click)="filterService.clearAllFilters()">
<mat-icon inline>cancel</mat-icon>
<span translate>Clear all</span>
</button>
</div>
<div *ngFor="let filter of filterService.filterDefinitions">
<button mat-button *ngIf="filter.count" (click)="filterService.clearFilter(filter)">
<mat-icon inline>close</mat-icon>
<span translate>{{ filterService.getFilterName(filter) }}</span>
</button>
</div>
</div>
<div>
<button mat-button *ngIf="hasFilters" (click)="filterMenu.opened ? filterMenu.close() : filterMenu.open()"> <button mat-button *ngIf="hasFilters" (click)="filterMenu.opened ? filterMenu.close() : filterMenu.open()">
<span *ngIf="filterService.hasActiveFilters > 0">{{ filterService.hasActiveFilters }}&nbsp;</span> <span *ngIf="!filterService.activeFilterCount" class="upper" translate>
<span class="upper" translate>Filter</span> Filter
</span>
<span *ngIf="filterService.activeFilterCount">
{{ filterService.activeFilterCount }}&nbsp;
<span *ngIf="filterService.activeFilterCount === 1" class="upper" translate>Filter</span>
<span *ngIf="filterService.activeFilterCount > 1" class="upper" translate>Filters</span>
</span>
</button> </button>
<button mat-button *ngIf="vp.isMobile && hasSorting" (click)="openSortDropDown()"> <button mat-button *ngIf="vp.isMobile && hasSorting" (click)="openSortDropDown()">
<span class="upper" translate>Sort</span> <span class="upper" translate>Sort</span>
@ -21,6 +49,7 @@
<mat-icon>{{ isSearchBar ? 'keyboard_arrow_right' : 'search' }}</mat-icon> <mat-icon>{{ isSearchBar ? 'keyboard_arrow_right' : 'search' }}</mat-icon>
</button> </button>
</div> </div>
</div>
<!-- Header for the filter side bar --> <!-- Header for the filter side bar -->
<mat-drawer #filterMenu mode="push" position="end"> <mat-drawer #filterMenu mode="push" position="end">

View File

@ -8,3 +8,26 @@
span.right-with-margin { span.right-with-margin {
margin-right: 25px; margin-right: 25px;
} }
.flex-spaced {
display: flex;
justify-content: space-between;
}
.filter-count {
text-align: right;
margin-right: 10px;
margin-left: 10px;
min-width: 50px;
}
.current-filters {
display: flex;
justify-content: flex-start;
margin: 5px;
button {
padding: 3px;
font-size: 80%;
margin: 5px;
}
}

View File

@ -112,7 +112,7 @@ export class OsSortFilterBarComponent<V extends BaseViewModel> {
* Checks if there is an active FilterService present * Checks if there is an active FilterService present
*/ */
public get hasFilters(): boolean { public get hasFilters(): boolean {
if (this.filterService && this.filterService.hasFilterOptions()) { if (this.filterService && this.filterService.hasFilterOptions) {
return true; return true;
} }
return false; return false;

View File

@ -13,6 +13,7 @@
</div> </div>
</os-head-bar> </os-head-bar>
<mat-drawer-container class="on-transition-fade">
<os-sort-filter-bar [filterService] = "filterService" (searchFieldChange)="searchFilter($event)"></os-sort-filter-bar> <os-sort-filter-bar [filterService] = "filterService" (searchFieldChange)="searchFilter($event)"></os-sort-filter-bar>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort> <mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
@ -85,6 +86,7 @@
></mat-row> ></mat-row>
</mat-table> </mat-table>
<mat-paginator class="on-transition-fade" [pageSizeOptions]="[25, 50, 75, 100, 125]"></mat-paginator> <mat-paginator class="on-transition-fade" [pageSizeOptions]="[25, 50, 75, 100, 125]"></mat-paginator>
</mat-drawer-container>
<mat-menu #agendaMenu="matMenu"> <mat-menu #agendaMenu="matMenu">
<div *ngIf="!isMultiSelect"> <div *ngIf="!isMultiSelect">

View File

@ -33,6 +33,7 @@ export class AgendaFilterListService extends FilterListService<Item, ViewItem> {
options: [{ label: 'Open', condition: false }, { label: 'Closed', condition: true }] options: [{ label: 'Open', condition: false }, { label: 'Closed', condition: true }]
} }
]; ];
this.updateFilterDefinitions(this.filterOptions);
} }
private createVisibilityFilterOptions(): OsFilterOption[] { private createVisibilityFilterOptions(): OsFilterOption[] {

View File

@ -10,8 +10,7 @@ export class AssignmentSortListService extends SortListService<ViewAssignment> {
sortProperty: 'assignment', sortProperty: 'assignment',
sortAscending: true, sortAscending: true,
options: [ options: [
{ property: 'agendaItem', label: 'agenda Item' }, { property: 'assignment', label: 'Name' },
{ property: 'assignment' },
{ property: 'phase' }, { property: 'phase' },
{ property: 'candidateAmount', label: 'Number of candidates' } { property: 'candidateAmount', label: 'Number of candidates' }
] ]

View File

@ -134,6 +134,15 @@ export class ViewMotion extends BaseViewModel {
return this._state; return this._state;
} }
/**
* Checks if the current state of thw workflow is final
*
* @returns true if it is final
*/
public get isFinalState(): boolean {
return this._state.isFinalState;
}
public get state_id(): number { public get state_id(): number {
return this.motion && this.motion.state_id ? this.motion.state_id : null; return this.motion && this.motion.state_id ? this.motion.state_id : null;
} }
@ -209,6 +218,18 @@ export class ViewMotion extends BaseViewModel {
return this._attachments ? this._attachments : null; return this._attachments ? this._attachments : null;
} }
/**
* Gets the comments' section ids of a motion. Used in filter by motionComment
*
* @returns an array of ids, or an empty array
*/
public get commentSectionIds(): number[] {
if (!this.motion) {
return [];
}
return this.motion.comments.map(comment => comment.section_id);
}
public constructor( public constructor(
motion?: Motion, motion?: Motion,
category?: Category, category?: Category,

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { FilterListService, OsFilter } from '../../../core/services/filter-list.service'; import { FilterListService, OsFilter, OsFilterOption } from '../../../core/services/filter-list.service';
import { Motion } from '../../../shared/models/motions/motion'; import { Motion } from '../../../shared/models/motions/motion';
import { ViewMotion } from '../models/view-motion'; import { ViewMotion } from '../models/view-motion';
import { CategoryRepositoryService } from './category-repository.service'; import { CategoryRepositoryService } from './category-repository.service';
@ -8,6 +8,7 @@ import { WorkflowRepositoryService } from './workflow-repository.service';
import { StorageService } from '../../../core/services/storage.service'; import { StorageService } from '../../../core/services/storage.service';
import { MotionRepositoryService } from './motion-repository.service'; import { MotionRepositoryService } from './motion-repository.service';
import { MotionBlockRepositoryService } from './motion-block-repository.service'; import { MotionBlockRepositoryService } from './motion-block-repository.service';
import { MotionCommentSectionRepositoryService } from './motion-comment-section-repository.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -19,19 +20,22 @@ export class MotionFilterListService extends FilterListService<Motion, ViewMotio
* generated dynamically, as the options change with the datastore * generated dynamically, as the options change with the datastore
*/ */
public get filterOptions(): OsFilter[] { public get filterOptions(): OsFilter[] {
return [this.flowFilterOptions, this.categoryFilterOptions, this.motionBlockFilterOptions].concat( return [
this.staticFilterOptions this.flowFilterOptions,
); this.categoryFilterOptions,
this.motionBlockFilterOptions,
this.recommendationFilterOptions,
this.motionCommentFilterOptions
];
} }
/** /**
* Filter definitions for the workflow filter. Options will be generated by * Filter definitions for the workflow filter. Options will be generated by
* getFilterOptions (as the workflows available may change) * getFilterOptions (as the workflows available may change)
*/ */
public flowFilterOptions = { public flowFilterOptions: OsFilter = {
property: 'state', property: 'state',
label: 'State', label: 'State',
isActive: false,
options: [] options: []
}; };
@ -39,35 +43,46 @@ export class MotionFilterListService extends FilterListService<Motion, ViewMotio
* Filter definitions for the category filter. Options will be generated by * Filter definitions for the category filter. Options will be generated by
* getFilterOptions (as the categories available may change) * getFilterOptions (as the categories available may change)
*/ */
public categoryFilterOptions = { public categoryFilterOptions: OsFilter = {
property: 'category', property: 'category',
isActive: false,
options: [] options: []
}; };
public motionBlockFilterOptions = { public motionBlockFilterOptions: OsFilter = {
property: 'motion_block_id', property: 'motion_block_id',
label: 'Motion block', label: 'Motion block',
isActive: false,
options: []
};
public commentFilterOptions = {
property: 'comment',
isActive: false,
options: [] options: []
}; };
public staticFilterOptions = [ public motionCommentFilterOptions: OsFilter = {
// TODO favorite (attached to user:whoamI!) property: 'commentSectionIds',
// TODO personalNote (attached to user:whoamI!) label: 'Comment',
]; options: []
};
public recommendationFilterOptions: OsFilter = {
property: 'recommendation',
label: 'Recommendation',
options: []
};
/**
* Constructor. Subscribes to a variety of Repository to dynamically update
* the available filters
*
* @param store The browser's storage; required for fetching filters from any previous sessions
* @param workflowRepo Subscribing to filters by states/Recommendation
* @param categoryRepo Subscribing to filters by Categories
* @param motionBlockRepo Subscribing to filters by MotionBlock
* @param commentRepo subycribing filter by presense of comment
* @param motionRepo the motion's own repository, required by the parent
*/
public constructor( public constructor(
store: StorageService, store: StorageService,
private workflowRepo: WorkflowRepositoryService, private workflowRepo: WorkflowRepositoryService,
private categoryRepo: CategoryRepositoryService, private categoryRepo: CategoryRepositoryService,
private motionBlockRepo: MotionBlockRepositoryService, private motionBlockRepo: MotionBlockRepositoryService,
// private commentRepo: MotionCommentRepositoryService private commentRepo: MotionCommentSectionRepositoryService,
motionRepo: MotionRepositoryService motionRepo: MotionRepositoryService
) { ) {
super(store, motionRepo); super(store, motionRepo);
@ -77,6 +92,20 @@ export class MotionFilterListService extends FilterListService<Motion, ViewMotio
this.subscribeComments(); this.subscribeComments();
} }
// TODO: Notes/Favorite
// does not work, some cloning error. I want to:
// 'check all items in filterService against this function, in the
// scope of motion-filter.service'
// public getNoteFilterFn(): Function {
// const notesRepo = this.notesRepo;
// return (m: ViewMotion) => {
// return notesRepo.hasPersonalNote('Motion', m.id)
// }
// };
/**
* Subscibes to changing MotionBlocks, and updates the filter accordingly
*/
private subscribeMotionBlocks(): void { private subscribeMotionBlocks(): void {
this.motionBlockRepo.getViewModelListObservable().subscribe(motionBlocks => { this.motionBlockRepo.getViewModelListObservable().subscribe(motionBlocks => {
const motionBlockOptions = []; const motionBlockOptions = [];
@ -87,20 +116,25 @@ export class MotionFilterListService extends FilterListService<Motion, ViewMotio
isActive: false isActive: false
}); });
}); });
if (motionBlocks.length) {
motionBlockOptions.push('-'); motionBlockOptions.push('-');
motionBlockOptions.push({ motionBlockOptions.push({
condition: null, condition: null,
label: 'No motion block set', label: 'No motion block set',
isActive: false isActive: false
}); });
}
this.motionBlockFilterOptions.options = motionBlockOptions; this.motionBlockFilterOptions.options = motionBlockOptions;
this.updateFilterDefinitions(this.filterOptions); this.updateFilterDefinitions(this.filterOptions);
}); });
} }
/**
* Subscibes to changing Categories, and updates the filter accordingly
*/
private subscribeCategories(): void { private subscribeCategories(): void {
this.categoryRepo.getViewModelListObservable().subscribe(categories => { this.categoryRepo.getViewModelListObservable().subscribe(categories => {
const categoryOptions = []; const categoryOptions: (OsFilterOption | string)[] = [];
categories.forEach(cat => { categories.forEach(cat => {
categoryOptions.push({ categoryOptions.push({
condition: cat.id, condition: cat.id,
@ -108,36 +142,96 @@ export class MotionFilterListService extends FilterListService<Motion, ViewMotio
isActive: false isActive: false
}); });
}); });
if (categories.length) {
categoryOptions.push('-');
categoryOptions.push({
label: 'No category set',
condition: null
});
}
this.categoryFilterOptions.options = categoryOptions; this.categoryFilterOptions.options = categoryOptions;
this.updateFilterDefinitions(this.filterOptions); this.updateFilterDefinitions(this.filterOptions);
}); });
} }
/**
* Subscibes to changing Workflows, and updates the state and recommendation filters accordingly
*/
private subscribeWorkflows(): void { private subscribeWorkflows(): void {
this.workflowRepo.getViewModelListObservable().subscribe(workflows => { this.workflowRepo.getViewModelListObservable().subscribe(workflows => {
const workflowOptions = []; const workflowOptions: (OsFilterOption | string)[] = [];
const finalStates: number[] = [];
const nonFinalStates: number[] = [];
const recommendationOptions: (OsFilterOption | string)[] = [];
workflows.forEach(workflow => { workflows.forEach(workflow => {
workflowOptions.push(workflow.name); workflowOptions.push(workflow.name);
recommendationOptions.push(workflow.name);
workflow.states.forEach(state => { workflow.states.forEach(state => {
if (state.isFinalState) {
finalStates.push(state.id);
} else {
nonFinalStates.push(state.id);
}
workflowOptions.push({ workflowOptions.push({
condition: state.name, condition: state.id,
label: state.name, label: state.name,
isActive: false isActive: false
}); });
}); if (state.recommendation_label) {
}); recommendationOptions.push({
workflowOptions.push('-'); condition: state.id,
workflowOptions.push({ label: state.recommendation_label,
condition: null,
label: 'no workflow set',
isActive: false isActive: false
}); });
}
});
});
if (workflowOptions.length) {
workflowOptions.push('-');
workflowOptions.push({
label: 'Done',
condition: finalStates
});
workflowOptions.push({
label: 'Undone',
condition: nonFinalStates
});
}
if (recommendationOptions.length) {
recommendationOptions.push('-');
recommendationOptions.push({
label: 'No recommendation',
condition: null
});
}
this.flowFilterOptions.options = workflowOptions; this.flowFilterOptions.options = workflowOptions;
this.recommendationFilterOptions.options = recommendationOptions;
this.updateFilterDefinitions(this.filterOptions); this.updateFilterDefinitions(this.filterOptions);
}); });
} }
/**
* Subscibes to changing Comments, and updates the filter accordingly
*/
private subscribeComments(): void { private subscribeComments(): void {
// TODO this.commentRepo.getViewModelListObservable().subscribe(comments => {
const commentOptions: (OsFilterOption | string)[] = [];
comments.forEach(comm => {
commentOptions.push({
condition: comm.id,
label: comm.name,
isActive: false
});
});
if (comments.length) {
commentOptions.push('-');
commentOptions.push({
label: 'No comment',
condition: null
});
}
this.motionCommentFilterOptions.options = commentOptions;
this.updateFilterDefinitions(this.filterOptions);
});
} }
} }

View File

@ -63,7 +63,7 @@ export class UserFilterListService extends FilterListService<User, ViewUser> {
public subscribeGroups(): void { public subscribeGroups(): void {
this.groupRepo.getViewModelListObservable().subscribe(groups => { this.groupRepo.getViewModelListObservable().subscribe(groups => {
const groupOptions = []; const groupOptions = [];
groupOptions.forEach(group => { groups.forEach(group => {
groupOptions.push({ groupOptions.push({
condition: group.name, condition: group.name,
label: group.name, label: group.name,