sortSearch improvements (#4098)

- "no workflows set" removed
-  motion block filter hides if there is no motion block
- inserted the group filter
- fixed display of agenda filter menu
- show plural if multiple filters are selected, show filtered amount
- recommendation, done/not done status filter
- active filter count and clearing option
- number of current items to the left
This commit is contained in:
Maximilian Krambach 2019-01-10 14:41:20 +01:00
parent 57202e74ca
commit 82a1ad8709
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 {
label: string;
condition: string | boolean | number;
condition: string | boolean | number | number[];
isActive?: boolean;
}
@ -55,6 +59,47 @@ export abstract class FilterListService<M extends BaseModel, V extends BaseViewM
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.
*/
@ -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 {
const filter = this.filterDefinitions.find(f => f.property === filterName);
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 filter
* @returns true if the item is to be dispalyed according to the filter
*/
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) {
// ignored options
if (typeof option === 'string') {
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 (option.condition === null) {
return this.checkIncludedNegative(item, filter);
// if a null filter is set, the item needs to not pass all inactive filters
} else if (
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;
}
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) {
return true;
}
@ -238,34 +350,15 @@ export abstract class FilterListService<M extends BaseModel, V extends BaseViewM
} else if (item[filter.property].toString() === option.condition) {
return true;
}
}
}
return false;
}
/**
* Returns true if none of the defined non-null filters apply,
* aka 'items that match no filter'
* @param item: A viewModel
* Retrieves a translatable label or filter property used for displaying the 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 {
if (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) {
return 0;
/**
* Removes all active options of a given filter, clearing it
* @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()">
<span *ngIf="filterService.hasActiveFilters > 0">{{ filterService.hasActiveFilters }}&nbsp;</span>
<span class="upper" translate>Filter</span>
<span *ngIf="!filterService.activeFilterCount" class="upper" translate>
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 mat-button *ngIf="vp.isMobile && hasSorting" (click)="openSortDropDown()">
<span class="upper" translate>Sort</span>
@ -20,6 +48,7 @@
<button mat-button (click)="toggleSearchBar()">
<mat-icon>{{ isSearchBar ? 'keyboard_arrow_right' : 'search' }}</mat-icon>
</button>
</div>
</div>
<!-- Header for the filter side bar -->

View File

@ -8,3 +8,26 @@
span.right-with-margin {
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

@ -40,7 +40,7 @@ export class OsSortFilterBarComponent<V extends BaseViewModel> {
* be a FilterListService extendingFilterListService.
*/
@Input()
public filterService: any; // TODO a FilterListService extendingFilterListService
public filterService: any; // TODO a FilterListService extending FilterListService
@Output()
public searchFieldChange = new EventEmitter<string>();
@ -112,7 +112,7 @@ export class OsSortFilterBarComponent<V extends BaseViewModel> {
* Checks if there is an active FilterService present
*/
public get hasFilters(): boolean {
if (this.filterService && this.filterService.hasFilterOptions()) {
if (this.filterService && this.filterService.hasFilterOptions) {
return true;
}
return false;

View File

@ -13,9 +13,10 @@
</div>
</os-head-bar>
<os-sort-filter-bar [filterService] = "filterService" (searchFieldChange)="searchFilter($event)"></os-sort-filter-bar>
<mat-drawer-container class="on-transition-fade">
<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>
<!-- selector column -->
<ng-container matColumnDef="selector">
<mat-header-cell *matHeaderCellDef mat-sort-header class="checkbox-cell"></mat-header-cell>
@ -83,8 +84,9 @@
[ngClass]="selectedRows.indexOf(row) >= 0 ? 'selected' : ''"
*matRowDef="let row; columns: getColumnDefinition()"
></mat-row>
</mat-table>
<mat-paginator class="on-transition-fade" [pageSizeOptions]="[25, 50, 75, 100, 125]"></mat-paginator>
</mat-table>
<mat-paginator class="on-transition-fade" [pageSizeOptions]="[25, 50, 75, 100, 125]"></mat-paginator>
</mat-drawer-container>
<mat-menu #agendaMenu="matMenu">
<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 }]
}
];
this.updateFilterDefinitions(this.filterOptions);
}
private createVisibilityFilterOptions(): OsFilterOption[] {

View File

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

View File

@ -134,6 +134,15 @@ export class ViewMotion extends BaseViewModel {
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 {
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;
}
/**
* 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(
motion?: Motion,
category?: Category,

View File

@ -1,6 +1,6 @@
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 { ViewMotion } from '../models/view-motion';
import { CategoryRepositoryService } from './category-repository.service';
@ -8,6 +8,7 @@ import { WorkflowRepositoryService } from './workflow-repository.service';
import { StorageService } from '../../../core/services/storage.service';
import { MotionRepositoryService } from './motion-repository.service';
import { MotionBlockRepositoryService } from './motion-block-repository.service';
import { MotionCommentSectionRepositoryService } from './motion-comment-section-repository.service';
@Injectable({
providedIn: 'root'
@ -19,19 +20,22 @@ export class MotionFilterListService extends FilterListService<Motion, ViewMotio
* generated dynamically, as the options change with the datastore
*/
public get filterOptions(): OsFilter[] {
return [this.flowFilterOptions, this.categoryFilterOptions, this.motionBlockFilterOptions].concat(
this.staticFilterOptions
);
return [
this.flowFilterOptions,
this.categoryFilterOptions,
this.motionBlockFilterOptions,
this.recommendationFilterOptions,
this.motionCommentFilterOptions
];
}
/**
* Filter definitions for the workflow filter. Options will be generated by
* getFilterOptions (as the workflows available may change)
*/
public flowFilterOptions = {
public flowFilterOptions: OsFilter = {
property: 'state',
label: 'State',
isActive: false,
options: []
};
@ -39,35 +43,46 @@ export class MotionFilterListService extends FilterListService<Motion, ViewMotio
* Filter definitions for the category filter. Options will be generated by
* getFilterOptions (as the categories available may change)
*/
public categoryFilterOptions = {
public categoryFilterOptions: OsFilter = {
property: 'category',
isActive: false,
options: []
};
public motionBlockFilterOptions = {
public motionBlockFilterOptions: OsFilter = {
property: 'motion_block_id',
label: 'Motion block',
isActive: false,
options: []
};
public commentFilterOptions = {
property: 'comment',
isActive: false,
options: []
};
public staticFilterOptions = [
// TODO favorite (attached to user:whoamI!)
// TODO personalNote (attached to user:whoamI!)
];
public motionCommentFilterOptions: OsFilter = {
property: 'commentSectionIds',
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(
store: StorageService,
private workflowRepo: WorkflowRepositoryService,
private categoryRepo: CategoryRepositoryService,
private motionBlockRepo: MotionBlockRepositoryService,
// private commentRepo: MotionCommentRepositoryService
private commentRepo: MotionCommentSectionRepositoryService,
motionRepo: MotionRepositoryService
) {
super(store, motionRepo);
@ -77,6 +92,20 @@ export class MotionFilterListService extends FilterListService<Motion, ViewMotio
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 {
this.motionBlockRepo.getViewModelListObservable().subscribe(motionBlocks => {
const motionBlockOptions = [];
@ -87,20 +116,25 @@ export class MotionFilterListService extends FilterListService<Motion, ViewMotio
isActive: false
});
});
if (motionBlocks.length) {
motionBlockOptions.push('-');
motionBlockOptions.push({
condition: null,
label: 'No motion block set',
isActive: false
});
}
this.motionBlockFilterOptions.options = motionBlockOptions;
this.updateFilterDefinitions(this.filterOptions);
});
}
/**
* Subscibes to changing Categories, and updates the filter accordingly
*/
private subscribeCategories(): void {
this.categoryRepo.getViewModelListObservable().subscribe(categories => {
const categoryOptions = [];
const categoryOptions: (OsFilterOption | string)[] = [];
categories.forEach(cat => {
categoryOptions.push({
condition: cat.id,
@ -108,36 +142,96 @@ export class MotionFilterListService extends FilterListService<Motion, ViewMotio
isActive: false
});
});
if (categories.length) {
categoryOptions.push('-');
categoryOptions.push({
label: 'No category set',
condition: null
});
}
this.categoryFilterOptions.options = categoryOptions;
this.updateFilterDefinitions(this.filterOptions);
});
}
/**
* Subscibes to changing Workflows, and updates the state and recommendation filters accordingly
*/
private subscribeWorkflows(): void {
this.workflowRepo.getViewModelListObservable().subscribe(workflows => {
const workflowOptions = [];
const workflowOptions: (OsFilterOption | string)[] = [];
const finalStates: number[] = [];
const nonFinalStates: number[] = [];
const recommendationOptions: (OsFilterOption | string)[] = [];
workflows.forEach(workflow => {
workflowOptions.push(workflow.name);
recommendationOptions.push(workflow.name);
workflow.states.forEach(state => {
if (state.isFinalState) {
finalStates.push(state.id);
} else {
nonFinalStates.push(state.id);
}
workflowOptions.push({
condition: state.name,
condition: state.id,
label: state.name,
isActive: false
});
});
});
workflowOptions.push('-');
workflowOptions.push({
condition: null,
label: 'no workflow set',
if (state.recommendation_label) {
recommendationOptions.push({
condition: state.id,
label: state.recommendation_label,
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.recommendationFilterOptions.options = recommendationOptions;
this.updateFilterDefinitions(this.filterOptions);
});
}
/**
* Subscibes to changing Comments, and updates the filter accordingly
*/
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 {
this.groupRepo.getViewModelListObservable().subscribe(groups => {
const groupOptions = [];
groupOptions.forEach(group => {
groups.forEach(group => {
groupOptions.push({
condition: group.name,
label: group.name,