Merge pull request #3963 from MaximilianKrambach/os3/sortSearch

Sorting/Filters for list views
This commit is contained in:
Sean 2019-01-08 12:23:53 +01:00 committed by GitHub
commit 405ddaec60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1720 additions and 72 deletions

View File

@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { FilterListService } from './filter-list.service';
import { E2EImportsModule } from '../../../e2e-imports.module';
describe('FilterListService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [FilterListService]
});
});
// TODO testing needs a BaseViewComponent
// it('should be created', inject([FilterListService], (service: FilterListService) => {
// expect(service).toBeTruthy();
// }));
});

View File

@ -0,0 +1,290 @@
import { auditTime } from 'rxjs/operators';
import { BehaviorSubject, Observable } from 'rxjs';
import { BaseModel } from '../../shared/models/base/base-model';
import { BaseViewModel } from '../../site/base/base-view-model';
import { StorageService } from './storage.service';
/**
* Describes the available filters for a listView.
* @param isActive: the current state of the filter
* @param property: the ViewModel's property or method to filter by
* @param label: An optional, different label (if not present, the property will be used)
* @param condition: The conditions to be met for a successful display of data. These will
* be updated by the {@link filterMenu}
* @param options a list of available options for a filter
*/
export interface OsFilter {
property: string;
label?: string;
options: (OsFilterOption | string )[];
count?: number;
}
/**
* Describes a list of available options for a drop down menu of a filter
*/
export interface OsFilterOption {
label: string;
condition: string | boolean | number;
isActive?: boolean;
}
/**
* Filter for the list view. List views can subscribe to its' dataService (providing filter definitions)
* and will receive their filtered data as observable
*/
export abstract class FilterListService<M extends BaseModel, V extends BaseViewModel> {
/**
* stores the currently used raw data to be used for the filter
*/
private currentRawData: V[];
/**
* The currently used filters.
*/
public filterDefinitions: OsFilter[];
/**
* The observable output for the filtered data
*/
public filterDataOutput = new BehaviorSubject<V[]>([]);
protected filteredData: V[];
protected name: string;
/**
* Constructor.
*/
public constructor(private store: StorageService, private repo: any) {
// repo( extends BaseRepository<V, M> ) { // TODO
}
/**
* Initializes the filterService. Returns the filtered data as Observable
*/
public filter(): Observable<V[]> {
this.repo.getViewModelListObservable().pipe(auditTime(100)).subscribe( data => {
this.currentRawData = data;
this.filteredData = this.filterData(data);
this.filterDataOutput.next(this.filteredData);
});
this.loadStorageDefinition(this.filterDefinitions);
return this.filterDataOutput;
}
/**
* Apply a newly created filter
* @param filter
*/
public addFilterOption(filterName: string, option: OsFilterOption): void {
const filter = this.filterDefinitions.find(f => f.property === filterName );
if (filter) {
const filterOption = filter.options.find(o =>
(typeof o !== 'string') && o.condition === option.condition) as OsFilterOption;
if (filterOption && !filterOption.isActive){
filterOption.isActive = true;
filter.count += 1;
}
if (filter.count === 1) {
this.filteredData = this.filterData(this.filteredData);
} else {
this.filteredData = this.filterData(this.currentRawData);
}
this.filterDataOutput.next(this.filteredData);
this.setStorageDefinition();
}
}
public removeFilterOption(filterName: string, option: OsFilterOption): void {
const filter = this.filterDefinitions.find(f => f.property === filterName );
if (filter) {
const filterOption = filter.options.find(o =>
(typeof o !== 'string') && o.condition === option.condition) as OsFilterOption;
if (filterOption && filterOption.isActive){
filterOption.isActive = false;
filter.count -= 1;
this.filteredData = this.filterData(this.currentRawData);
this.filterDataOutput.next(this.filteredData);
this.setStorageDefinition();
}
}
}
/**
* Toggles a filter option, to be called after a checkbox state has changed.
* @param filterName
* @param option
*/
public toggleFilterOption(filterName: string, option: OsFilterOption): void {
option.isActive ? this.removeFilterOption(filterName, option) : this.addFilterOption(filterName, option);
}
public updateFilterDefinitions(filters: OsFilter[]) : void {
this.loadStorageDefinition(filters);
}
/**
* Retrieve the currently saved filter definition from the StorageService,
* check their match with current definitions and set the current filter
* @param definitions: Currently defined Filter definitions
*/
private loadStorageDefinition(definitions: OsFilter[]): void {
if (!definitions || !definitions.length) {
return;
}
const me = this;
this.store.get('filter_' + this.name).then(function(storedData: { name: string, data: OsFilter[] }): void {
const storedFilters = (storedData && storedData.data) ? storedData.data : [];
definitions.forEach(definedFilter => {
const matchingStoreFilter = storedFilters.find(f => f.property === definedFilter.property);
let count = 0;
definedFilter.options.forEach(option => {
if (typeof option === 'string'){
return;
};
if (matchingStoreFilter && matchingStoreFilter.options){
const storedOption = matchingStoreFilter.options.find(o =>
typeof o !== 'string' && o.condition === option.condition) as OsFilterOption;
if (storedOption) {
option.isActive = storedOption.isActive;
}
}
if (option.isActive) {
count += 1;
}
});
definedFilter.count = count;
});
me.filterDefinitions = definitions;
me.filteredData = me.filterData(me.currentRawData);
me.filterDataOutput.next(me.filteredData);
}, function(error: any) : void {
me.filteredData = me.filterData(me.currentRawData);
me.filterDataOutput.next(me.filteredData);
});
}
/**
* Save the current filter definitions via StorageService
*/
private setStorageDefinition(): void {
this.store.set('filter_' + this.name, {
name: 'filter_' + this.name, data: this.filterDefinitions});
}
/**
* Takes an array of data and applies current filters
*/
private filterData(data: V[]): V[] {
const filteredData = [];
if (!data) {
return filteredData;
}
if (!this.filterDefinitions || !this.filterDefinitions.length){
return data;
}
data.forEach(newItem => {
let excluded = false;
for (const filter of this.filterDefinitions) {
if (filter.count && !this.checkIncluded(newItem, filter)) {
excluded = true;
break;
}
}
if (!excluded){
filteredData.push(newItem);
}
});
return filteredData;
}
/**
* Helper to see if a model instance passes a filter
* @param item
* @param filter
*/
private checkIncluded(item: V, filter: OsFilter): boolean {
for (const option of filter.options) {
if (typeof option === 'string' ){
continue;
}
if (option.isActive) {
if (option.condition === null ) {
return this.checkIncludedNegative(item, filter);
}
if (item[filter.property] === undefined) {
return false;
}
if (item[filter.property] instanceof BaseModel ) {
if (item[filter.property].id === option.condition){
return true;
}
} else if (item[filter.property] === option.condition){
return true;
} 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
* @param filter
*/
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;
} else {
const itemProperty = filter.property as string;
return itemProperty.charAt(0).toUpperCase() + itemProperty.slice(1);
}
}
public get hasActiveFilters(): 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;
}
public hasFilterOptions(): boolean {
return (this.filterDefinitions && this.filterDefinitions.length) ? true : false;
}
}

View File

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

View File

@ -0,0 +1,247 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { BaseViewModel } from '../../site/base/base-view-model';
import { TranslateService } from '@ngx-translate/core';
import { StorageService } from './storage.service';
/**
* Describes the sorting columns of an associated ListView, and their state.
*/
export interface OsSortingDefinition<V> {
sortProperty: keyof V;
sortAscending?: boolean;
options: OsSortingItem<V>[];
}
/**
* 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 OsSortingItem<V> {
property: keyof V;
label?: string;
}
@Injectable({
providedIn: 'root'
})
export abstract class SortListService<V extends BaseViewModel> {
/**
* Observable output that submits the newly sorted data each time a sorting has been done
*/
public sortedData = new BehaviorSubject<V[]>([]);
/**
* The data to be sorted. See also the setter for {@link data}
*/
private unsortedData: V[];
/**
* The current sorting definitions
*/
public sortOptions: OsSortingDefinition<V>;
/**
* used for the key in the StorageService to save/load the correct sorting definitions.
*/
protected name: string;
/**
* The sorting function according to current settings. Set via {@link updateSortFn}.
*/
private sortFn: (a: V, b: V) => number;
/**
* Constructor. Does nothing. TranslateService is used for localeCompeare.
*/
public constructor(private translate: TranslateService, private store: StorageService) {}
/**
* Put an array of data that you want sorted.
*/
public set data(data: V[]) {
this.unsortedData = data;
this.doAsyncSorting();
}
/**
* Defines the sorting properties, and returns an observable with sorted data
* @param name arbitrary name, used to save/load correct saved settings from StorageService
* @param definitions The definitions of the possible options
*/
public sort(): BehaviorSubject<V[]> {
this.loadStorageDefinition();
this.updateSortFn();
return this.sortedData;
}
/**
* Set the current sorting order
*/
public set ascending(ascending: boolean) {
this.sortOptions.sortAscending = ascending;
this.updateSortFn();
this.saveStorageDefinition();
this.doAsyncSorting();
}
/**
* get the current sorting order
*/
public get ascending(): boolean {
return this.sortOptions.sortAscending;
}
/**
* set the property of the viewModel the sorting will be based on.
* If the property stays the same, only the sort direction will be toggled,
* new sortProperty will result in an ascending order.
*/
public set sortProperty(property: string) {
if (this.sortOptions.sortProperty === (property as keyof V)) {
this.ascending = !this.ascending;
this.updateSortFn();
} else {
this.sortOptions.sortProperty = property as keyof V;
this.sortOptions.sortAscending = true;
this.updateSortFn();
this.doAsyncSorting();
}
this.saveStorageDefinition();
}
/**
* get the property of the viewModel the sorting is based on.
*/
public get sortProperty(): string {
return this.sortOptions.sortProperty as string;
}
public get isActive(): boolean {
return this.sortOptions && this.sortOptions.options.length > 0;
}
/**
* Change the property and the sorting direction at the same time
* @param property
* @param ascending
*/
public setSorting(property: string, ascending: boolean): void {
this.sortOptions.sortProperty = property as keyof V;
this.sortOptions.sortAscending = ascending;
this.saveStorageDefinition();
this.updateSortFn();
this.doAsyncSorting();
}
/**
* Retrieves the currently active icon for an option.
* @param option
*/
public getSortIcon(option: OsSortingItem<V>): string {
if (this.sortProperty !== (option.property as string)) {
return '';
}
return this.ascending ? 'arrow_downward' : 'arrow_upward';
}
public getSortLabel(option: OsSortingItem<V>): string {
if (option.label) {
return option.label;
}
const itemProperty = option.property as string;
return itemProperty.charAt(0).toUpperCase() + itemProperty.slice(1);
}
/**
* Retrieve the currently saved sorting definition from the borwser's
* store
*/
private loadStorageDefinition(): void {
const me = this;
this.store.get('sorting_' + this.name).then(function(sorting: OsSortingDefinition<V> | null): void {
if (sorting) {
if (sorting.sortProperty) {
me.sortOptions.sortProperty = sorting.sortProperty;
if (sorting.sortAscending !== undefined) {
me.sortOptions.sortAscending = sorting.sortAscending;
}
}
}
me.updateSortFn();
me.doAsyncSorting();
});
}
/**
* SSaves the current sorting definitions to the local store
*/
private saveStorageDefinition(): void {
this.store.set('sorting_' + this.name, {
sortProperty: this.sortProperty,
ascending: this.ascending
});
}
/**
* starts sorting, and
*/
private doAsyncSorting(): Promise<void> {
const me = this;
return new Promise(function(): void {
const data = me.unsortedData.sort(me.sortFn);
me.sortedData.next(data);
});
}
/**
* Recreates the sorting function. Is supposed to be called on init and
* every time the sorting (property, ascending/descending) or the language changes
*/
private updateSortFn(): void {
const property = this.sortProperty as string;
const ascending = this.ascending;
const lang = this.translate.currentLang; // TODO: observe and update sorting on change
this.sortFn = function(itemA: V, itemB: V): number {
const firstProperty = ascending ? itemA[property] : itemB[property];
const secondProperty = ascending ? itemB[property] : itemA[property];
if (typeof firstProperty !== typeof secondProperty) {
// undefined/null items should always land at the end
if (!firstProperty) {
return ascending ? 1 : -1;
} else if (!secondProperty) {
return ascending ? -1 : 1;
} else {
throw new TypeError('sorting of items failed because of mismatched types');
}
} else {
switch (typeof firstProperty) {
case 'boolean':
if (firstProperty === false && secondProperty === true) {
return -1;
} else {
return 1;
}
case 'number':
return firstProperty > secondProperty ? 1 : -1;
case 'string':
if (!firstProperty) {
return 1;
}
return firstProperty.localeCompare(secondProperty, lang);
case 'function':
const a = firstProperty();
const b = secondProperty();
return a.localeCompare(b, lang);
case 'object':
return firstProperty.toString().localeCompare(secondProperty.toString(), lang);
case 'undefined':
return 1;
default:
return -1;
}
}
};
}
}

View File

@ -0,0 +1,27 @@
<mat-accordion (keyup)=checkKeyEvent($event)>
<mat-expansion-panel *ngFor="let filter of service.filterDefinitions">
<mat-expansion-panel-header *ngIf="filter.options && filter.options.length">
<mat-panel-title>
<mat-icon>
{{ filter.count ? 'checked' : ''}}
</mat-icon>
<span translate>{{ service.getFilterName(filter) }}</span>
</mat-panel-title>
</mat-expansion-panel-header>
<div class="indent" *ngIf="filter.options && filter.options.length">
<mat-action-list class="filtermenu-expanded">
<div *ngFor="let option of filter.options">
<div *ngIf="isFilter(option)">
<mat-checkbox [checked]="option.isActive" (change)="service.toggleFilterOption(filter.property, option)">
{{ option.label |translate }}
</mat-checkbox>
</div>
<div class="filter-subtitle" *ngIf="!isFilter(option)">
<mat-divider *ngIf="option === '-'"></mat-divider>
<span *ngIf="option !== '-'" translate> {{option}}</span>
</div>
</div>
</mat-action-list>
</div>
</mat-expansion-panel>
</mat-accordion>

View File

@ -0,0 +1,13 @@
div.indent {
margin-left: 24px;
}
mat-divider {
margin-top: 5px;
margin-bottom: 5px;
}
div.filter-subtitle {
margin-top: 5px;
margin-bottom: 5px;
opacity: 0.9;
font-style: italic;
}

View File

@ -0,0 +1,24 @@
// import { async, ComponentFixture, TestBed } from '@angular/core/testing';
// import { FilterMenuComponent } from './filter-menu.component';
describe('FilterMenuComponent', () => {
// TODO test won't work without a BaseViewModel
// let component: FilterMenuComponent<V>;
// let fixture: ComponentFixture<FilterMenuComponent<V>>;
// beforeEach(async(() => {
// TestBed.configureTestingModule({
// declarations: [FilterMenuComponent]
// }).compileComponents();
// }));
// beforeEach(() => {
// fixture = TestBed.createComponent(FilterMenuComponent);
// component = fixture.componentInstance;
// fixture.detectChanges();
// });
// it('should create', () => {
// expect(component).toBeTruthy();
// });
});

View File

@ -0,0 +1,64 @@
import { Output, Component, OnInit, EventEmitter, Input } from '@angular/core';
import { FilterListService, OsFilterOption } from '../../../../core/services/filter-list.service';
/**
* Component for selecting the filters in a filter menu.
* It expects to be opened inside a sidenav container,
*
* ## Examples:
*
* ### Usage of the selector:
* ```html
* <os-filter-menu (dismissed)="this.filterMenu.close()">
* ```
*/
@Component({
selector: 'os-filter-menu',
templateUrl: './filter-menu.component.html',
styleUrls: ['./filter-menu.component.scss']
})
export class FilterMenuComponent implements OnInit {
/**
* An event emitter to submit a desire to close this component
* TODO: Might be an easier way to do this
*/
@Output()
public dismissed = new EventEmitter<boolean>();
/**
* A filterListService for the listView. There are several Services extending
* the FilterListService; unsure about how to get them in any other way.
*/
@Input()
public service: FilterListService<any, any>; // TODO (M, V)
/**
* Constructor. Does nothing.
* @param service
*/
public constructor() {
}
/**
* Directly closes again if no sorting is available
*/
public ngOnInit(): void {
if (!this.service.filterDefinitions) {
this.dismissed.next(true);
}
}
/**
* Tests for escape key (to colose the sidebar)
* @param event
*/
public checkKeyEvent(event: KeyboardEvent) : void {
if (event.key === 'Escape'){
this.dismissed.next(true)
}
}
public isFilter(option: OsFilterOption) : boolean{
return (typeof option === 'string') ? false : true;
}
}

View File

@ -0,0 +1,8 @@
<mat-nav-list class='main-nav'>
<mat-list-item *ngFor="let option of this.data.sortOptions.options" (click)="clickedOption(option.property)">
<button mat-menu-item>
<mat-icon>{{ data.getSortIcon(option) }}</mat-icon>
<span translate>{{ data.getSortLabel(option) }}</span>
</button>
</mat-list-item>
</mat-nav-list>

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { OsSortBottomSheetComponent } from './os-sort-bottom-sheet.component';
describe('OsSortBottomSheetComponent', () => {
// let component: OsSortBottomSheetComponent<any>;
let fixture: ComponentFixture<OsSortBottomSheetComponent<any>>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(OsSortBottomSheetComponent);
// component = fixture.componentInstance;
fixture.detectChanges();
});
// it('should create', () => {
// expect(component).toBeTruthy();
// });
});

View File

@ -0,0 +1,48 @@
import { Inject, Component, OnInit } from '@angular/core';
import { MatBottomSheetRef, MAT_BOTTOM_SHEET_DATA } from '@angular/material';
import { BaseViewModel } from '../../../../site/base/base-view-model';
import { SortListService } from '../../../../core/services/sort-list.service';
/**
* A bottom sheet used for setting a list's sorting, used by {@link SortFilterBarComponent}
* usage:
* ```
* @ViewChild('sortBottomSheet')
* public sortBottomSheet: OsSortBottomSheetComponent<V>;
* ...
* this.bottomSheet.open(OsSortBottomSheetComponent, { data: SortService });
* ```
*/
@Component({
selector: 'os-sort-bottom-sheet',
templateUrl: './os-sort-bottom-sheet.component.html',
styleUrls: ['./os-sort-bottom-sheet.component.scss']
})
export class OsSortBottomSheetComponent<V extends BaseViewModel> implements OnInit {
/**
* Constructor. Gets a reference to itself (for closing after interaction)
* @param data
* @param sheetRef
*/
public constructor(
@Inject(MAT_BOTTOM_SHEET_DATA) public data: SortListService<V>, private sheetRef: MatBottomSheetRef ) {
}
/**
* init fucntion. Closes inmediately if no sorting is available.
*/
public ngOnInit(): void {
if (!this.data || !this.data.sortOptions || !this.data.sortOptions.options.length){
throw new Error('No sorting available for a sorting list');
}
}
/**
* Logic for a toggle of options. Either reverses sorting, or
* sorts after a new property.
*/
public clickedOption(item: string): void {
this.sheetRef.dismiss(item);
}
}

View File

@ -0,0 +1,48 @@
<div class="custom-table-header on-transition-fade">
<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>
</button>
<button mat-button *ngIf="vp.isMobile && hasSorting" (click)="openSortDropDown()">
<span class="upper" translate>Sort</span>
</button>
<button mat-button *ngIf="!vp.isMobile && hasSorting" [matMenuTriggerFor]="menu">
<span class="upper" translate>Sort</span>
</button>
<input
matInput
*ngIf="isSearchBar"
(keyup)="applySearch($event, $event.target.value)"
osAutofocus
placeholder="{{ translate.instant('Search') }}"
[ngClass]="vp.isMobile ? 'vp' : ''"
/>
<button mat-button (click)="toggleSearchBar()">
<mat-icon>{{ isSearchBar ? 'keyboard_arrow_right' : 'search' }}</mat-icon>
</button>
</div>
<!-- Header for the filter side bar -->
<mat-drawer #filterMenu mode="push" position="end">
<div class="custom-table-header filter-menu" (click)="this.filterMenu.toggle()">
<span><mat-icon>keyboard_arrow_right</mat-icon></span>
<span class="right-with-margin" translate>Filter options:</span>
</div>
<os-filter-menu *ngIf="filterService" (dismissed)="this.filterMenu.close()" [service]="filterService">
</os-filter-menu>
</mat-drawer>
<!-- non-mobile sorting menu -->
<mat-menu #menu>
<div *ngIf="hasSorting">
<mat-list-item
*ngFor="let option of sortService.sortOptions.options"
(click)="sortService.sortProperty = option.property"
>
<button mat-menu-item>
<mat-icon>{{ sortService.getSortIcon(option) }}</mat-icon>
<span translate> {{ sortService.getSortLabel(option) }}</span>
</button>
</mat-list-item>
</div>
</mat-menu>

View File

@ -0,0 +1,10 @@
.filter-menu {
margin-left: 5px;
justify-content: space-between;
:hover {
cursor: pointer;
}
}
span.right-with-margin {
margin-right: 25px;
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { OsSortFilterBarComponent } from './os-sort-filter-bar.component';
describe('OsSortFilterBarComponent', () => {
let component: OsSortFilterBarComponent<any>;
let fixture: ComponentFixture<any>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(OsSortFilterBarComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,156 @@
import { Input, Output, Component, ViewChild, EventEmitter } from '@angular/core';
import { MatBottomSheet } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
import { BaseViewModel } from '../../../site/base/base-view-model';
import { OsSortBottomSheetComponent } from './os-sort-bottom-sheet/os-sort-bottom-sheet.component';
import { FilterMenuComponent} from './filter-menu/filter-menu.component';
import { OsSortingItem } from '../../../core/services/sort-list.service'
import { SortListService } from '../../../core/services/sort-list.service';
import { ViewportService } from '../../../core/services/viewport.service';
/**
* Reusable bar for list views, offering sorting and filter options.
* It will modify the DataSource of the listView to include custom sorting and
* filters.
*
* ## Examples:
* ### Usage of the selector:
*
* ```html
* <os-sort-filter-bar [sortService]="sortService" [filterService]="filterService"
* (searchFieldChange)="searchFilter($event)">
* </os-sort-filter-bar>
* ```
*/
@Component({
selector: 'os-sort-filter-bar',
templateUrl: './os-sort-filter-bar.component.html',
styleUrls: ['./os-sort-filter-bar.component.scss']
})
export class OsSortFilterBarComponent<V extends BaseViewModel> {
/**
* The currently active sorting service for the list view
*/
@Input()
public sortService: SortListService<V>;
/**
* The currently active filter service for the list view. It is supposed to
* be a FilterListService extendingFilterListService.
*/
@Input()
public filterService: any; // TODO a FilterListService extendingFilterListService
@Output()
public searchFieldChange = new EventEmitter<string>();
/**
* The filter side drawer
*/
@ViewChild('filterMenu')
public filterMenu: FilterMenuComponent;
/**
* The bottom sheet used to alter sorting in mobile view
*/
@ViewChild('sortBottomSheet')
public sortBottomSheet: OsSortBottomSheetComponent<V>;
/**
* The 'opened/active' state of the fulltext filter input field
*/
public isSearchBar = false;
/**
* Constructor. Also creates a filtermenu component and a bottomSheet
* @param translate
* @param vp
* @param bottomSheet
*/
public constructor(public translate: TranslateService, public vp: ViewportService, private bottomSheet: MatBottomSheet) {
this.filterMenu = new FilterMenuComponent();
}
/**
* Handles the sorting menu/bottom sheet (depending on state of mobile/desktop)
*/
public openSortDropDown(): void {
if (this.vp.isMobile) {
const bottomSheetRef = this.bottomSheet.open(OsSortBottomSheetComponent,
{ data: this.sortService }
);
bottomSheetRef.afterDismissed().subscribe(result => {
if (result) {
this.sortService.sortProperty = result;
}
});
}
}
/**
* Listen to keypresses on the quick-search input
*/
public applySearch(event: KeyboardEvent, value?: string): void {
if (event.key === 'Escape' ) {
this.searchFieldChange.emit('');
this.isSearchBar = false;
} else {
this.searchFieldChange.emit(value);
}
}
/**
* Checks if there is an active SortService present
*/
public get hasSorting(): boolean {
return (this.sortService && this.sortService.isActive);
}
/**
* Checks if there is an active FilterService present
*/
public get hasFilters(): boolean {
if (this.filterService && this.filterService.hasFilterOptions()){
return true;
};
return false;
}
/**
* Retrieves the currently active icon for an option.
* @param option
*/
public getSortIcon(option: OsSortingItem<V>): string {
if (this.sortService.sortProperty !== option.property){
return '';
}
return this.sortService.ascending ? 'arrow_downward' : 'arrow_upward';
}
/**
* Gets the label for anoption. If no label is defined, a capitalized version of
* the property is used.
* @param option
*/
public getSortLabel(option: OsSortingItem<V>) : string {
if (option.label) {
return option.label;
}
const itemProperty = option.property as string;
return itemProperty.charAt(0).toUpperCase() + itemProperty.slice(1);
}
/**
* Open/closes the 'quick search input'. When closing, also removes the filter
* that input applied
*/
public toggleSearchBar(): void {
if (!this.isSearchBar){
this.isSearchBar = true;
} else {
this.searchFieldChange.emit('');
this.isSearchBar = false;
}
}
}

View File

@ -3,6 +3,13 @@ import { Poll } from './poll';
import { AgendaBaseModel } from '../base/agenda-base-model';
import { SearchRepresentation } from '../../../core/services/search.service';
export const assignmentPhase = [
{key: 0, name: 'Searching for candidates'},
{key: 1, name: 'Voting'},
{key: 2, name: 'Finished'}
];
/**
* Representation of an assignment.
* @ignore

View File

@ -57,4 +57,17 @@ export class WorkflowState extends Deserializer {
public toString = (): string => {
return this.name;
};
/**
* Checks if a workflowstate has no 'next state' left, and is final
*/
public get isFinalState(): boolean {
if (!this.next_states_id || !this.next_states_id.length ){
return true;
}
if (this.next_states_id.length === 1 && this.next_states_id[0] === 0) {
return true;
}
return false;
}
}

View File

@ -24,7 +24,8 @@ import {
MatIconModule,
MatButtonToggleModule,
MatBadgeModule,
MatStepperModule
MatStepperModule,
MatBottomSheetModule
} from '@angular/material';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material';
@ -67,6 +68,9 @@ import { SortingListComponent } from './components/sorting-list/sorting-list.com
import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/speaker-list.component';
import { SortingTreeComponent } from './components/sorting-tree/sorting-tree.component';
import { ChoiceDialogComponent } from './components/choice-dialog/choice-dialog.component';
import { OsSortFilterBarComponent } from './components/os-sort-filter-bar/os-sort-filter-bar.component';
import { OsSortBottomSheetComponent } from './components/os-sort-filter-bar/os-sort-bottom-sheet/os-sort-bottom-sheet.component';
import { FilterMenuComponent } from './components/os-sort-filter-bar/filter-menu/filter-menu.component';
/**
* Share Module for all "dumb" components and pipes.
@ -104,6 +108,7 @@ import { ChoiceDialogComponent } from './components/choice-dialog/choice-dialog.
MatDialogModule,
MatSnackBarModule,
MatChipsModule,
MatBottomSheetModule,
MatTooltipModule,
MatBadgeModule,
// TODO: there is an error with missing icons
@ -167,7 +172,8 @@ import { ChoiceDialogComponent } from './components/choice-dialog/choice-dialog.
SortingListComponent,
EditorModule,
SortingTreeComponent,
TreeModule
TreeModule,
OsSortFilterBarComponent
],
declarations: [
PermsDirective,
@ -182,13 +188,19 @@ import { ChoiceDialogComponent } from './components/choice-dialog/choice-dialog.
SortingListComponent,
SpeakerListComponent,
SortingTreeComponent,
ChoiceDialogComponent
ChoiceDialogComponent,
OsSortFilterBarComponent,
OsSortBottomSheetComponent,
FilterMenuComponent
],
providers: [
{ provide: DateAdapter, useClass: OpenSlidesDateAdapter },
SearchValueSelectorComponent,
SortingListComponent,
SortingTreeComponent
]
SortingTreeComponent,
OsSortFilterBarComponent,
OsSortBottomSheetComponent
],
entryComponents: [OsSortBottomSheetComponent]
})
export class SharedModule {}

View File

@ -13,6 +13,7 @@
</div>
</os-head-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>
<!-- selector column -->

View File

@ -4,10 +4,12 @@ import { Title } from '@angular/platform-browser';
import { MatSnackBar, MatDialog } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
import { ViewItem } from '../../models/view-item';
import { ListViewBaseComponent } from 'app/site/base/list-view-base';
import { AgendaFilterListService } from '../../services/agenda-filter-list.service';
import { AgendaRepositoryService } from '../../services/agenda-repository.service';
import { ListViewBaseComponent } from 'app/site/base/list-view-base';
import { PromptService } from '../../../../core/services/prompt.service';
import { ViewItem } from '../../models/view-item';
import { AgendaCsvExportService } from '../../services/agenda-csv-export.service';
import { ItemInfoDialogComponent } from '../item-info-dialog/item-info-dialog.component';
@ -50,6 +52,9 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
* @param vp determine the viewport
* @param durationService Converts numbers to readable duration strings
* @param csvExport Handles the exporting into csv
* @param repo the agenda repository
* @param promptService
* @param filterService: service for filtering data
*/
public constructor(
titleService: Title,
@ -63,7 +68,8 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
private config: ConfigService,
public vp: ViewportService,
public durationService: DurationService,
private csvExport: AgendaCsvExportService
private csvExport: AgendaCsvExportService,
public filterService: AgendaFilterListService
) {
super(titleService, translate, matSnackBar);
@ -73,16 +79,15 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
/**
* Init function.
* Sets the title, initializes the table and calls the repository.
* Sets the title, initializes the table and filter options, subscribes to filter service.
*/
public ngOnInit(): void {
super.setTitle('Agenda');
this.initTable();
this.repo.getViewModelListObservable().subscribe(newAgendaItem => {
this.filterService.filter().subscribe(newAgendaItem => {
this.dataSource.data = newAgendaItem;
this.checkSelection();
});
this.config
.get('agenda_enable_numbering')
.subscribe(autoNumbering => (this.isNumberingAllowed = autoNumbering));

View File

@ -0,0 +1,51 @@
import { Injectable } from "@angular/core";
import { FilterListService, OsFilter, OsFilterOption } from "../../../core/services/filter-list.service";
import { Item, itemVisibilityChoices } from "../../../shared/models/agenda/item";
import { ViewItem } from "../models/view-item";
import { StorageService } from "app/core/services/storage.service";
import { AgendaRepositoryService } from "./agenda-repository.service";
@Injectable({
providedIn: 'root'
})
export class AgendaFilterListService extends FilterListService<Item, ViewItem> {
protected name = 'Agenda';
public filterOptions: OsFilter[] = [];
/**
* Constructor. Also creates the dynamic filter options
* @param store
* @param repo
*/
public constructor(store: StorageService, repo: AgendaRepositoryService) {
super(store, repo);
this.filterOptions = [{
label: 'Visibility',
property: 'type',
options: this.createVisibilityFilterOptions()
}, {
label: 'Hidden Status',
property: 'done',
options: [
{label: 'Open', condition: false},
{label: 'Closed', condition: true}
]
}];
}
private createVisibilityFilterOptions(): OsFilterOption[] {
const options = [];
itemVisibilityChoices.forEach(choice => {
options.push({
condition: choice.key as number,
label: choice.name
});
});
return options;
}
}

View File

@ -15,6 +15,10 @@
</div>
</os-head-bar>
<mat-drawer-container class="on-transition-fade">
<os-sort-filter-bar [filterService]="filterService" [sortService]="sortService"
(searchFieldChange)="searchFilter($event)">
</os-sort-filter-bar>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<!-- slector column -->
<ng-container matColumnDef="selector">
@ -90,3 +94,4 @@
</button>
</div>
</mat-menu>
</mat-drawer-container>

View File

@ -1,11 +1,16 @@
import { Component, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
import { Title } from '@angular/platform-browser';
import { ViewAssignment } from '../models/view-assignment';
import { ListViewBaseComponent } from '../../base/list-view-base';
import { AssignmentFilterListService } from '../services/assignment-filter.service';
import { AssignmentRepositoryService } from '../services/assignment-repository.service';
import { MatSnackBar } from '@angular/material';
import { ListViewBaseComponent } from '../../base/list-view-base';
import { PromptService } from '../../../core/services/prompt.service';
import { ViewAssignment } from '../models/view-assignment';
import { AssignmentSortListService } from '../services/assignment-sort-list.service';
/**
* Listview for the assignments
@ -17,21 +22,25 @@ import { PromptService } from '../../../core/services/prompt.service';
styleUrls: ['./assignment-list.component.scss']
})
export class AssignmentListComponent extends ListViewBaseComponent<ViewAssignment> implements OnInit {
/**
* Constructor.
*
* @param titleService
* @param translate
* @param matSnackBar
* @param repo the repository
* @param promptService
* @param filterService: A service to supply the filtered datasource
* @param sortService: Service to sort the filtered dataSource
*/
public constructor(
titleService: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private repo: AssignmentRepositoryService,
private promptService: PromptService
public repo: AssignmentRepositoryService,
private promptService: PromptService,
public filterService: AssignmentFilterListService,
public sortService: AssignmentSortListService
) {
super(titleService, translate, matSnackBar);
// activate multiSelect mode for this listview
@ -40,13 +49,18 @@ export class AssignmentListComponent extends ListViewBaseComponent<ViewAssignmen
/**
* Init function.
* Sets the title, inits the table and calls the repo.
* Sets the title, inits the table, sets sorting and filter definitions, subscribes to filtered
* data and sorting service
*/
public ngOnInit(): void {
super.setTitle('Assignments');
this.initTable();
this.repo.getViewModelListObservable().subscribe(newAssignments => {
this.dataSource.data = newAssignments;
this.filterService.filter().subscribe(filteredData => {
this.sortService.data = filteredData;
});
this.sortService.sort().subscribe(sortedData => {
this.dataSource.data = sortedData;
this.checkSelection();
});
}

View File

@ -0,0 +1,43 @@
import { Injectable } from "@angular/core";
import { AssignmentRepositoryService } from "./assignment-repository.service";
import { Assignment, assignmentPhase } from "../../../shared/models/assignments/assignment";
import { FilterListService, OsFilter, OsFilterOption } from "../../../core/services/filter-list.service";
import { StorageService } from "app/core/services/storage.service";
import { ViewAssignment } from "../models/view-assignment";
@Injectable({
providedIn: 'root'
})
export class AssignmentFilterListService extends FilterListService<Assignment, ViewAssignment> {
protected name = 'Assignment';
public filterOptions: OsFilter[];
public constructor(store: StorageService, assignmentRepo: AssignmentRepositoryService) {
super(store, assignmentRepo);
this.filterOptions = [{
property: 'phase',
options: this.createPhaseOptions()
}];
}
private createPhaseOptions(): OsFilterOption[] {
const options = [];
assignmentPhase.forEach(phase => {
options.push({
label: phase.name,
condition: phase.key,
isActive: false
});
});
options.push('-');
options.push({
label: 'Other',
condition: null
});
return options;
}
}

View File

@ -0,0 +1,22 @@
import { Injectable } from '@angular/core';
import { SortListService, OsSortingDefinition } from '../../../core/services/sort-list.service';
import { ViewAssignment } from '../models/view-assignment';
@Injectable({
providedIn: 'root'
})
export class AssignmentSortListService extends SortListService<ViewAssignment> {
public sortOptions: OsSortingDefinition<ViewAssignment> = {
sortProperty: 'assignment',
sortAscending: true,
options: [
{ property: 'agendaItem', label: 'agenda Item' },
{ property: 'assignment' },
{ property: 'phase' },
{ property: 'candidateAmount', label: 'Number of candidates' }
]
};
protected name = 'Assignment';
}

View File

@ -1,9 +1,10 @@
import { ViewChild } from '@angular/core';
import { MatTableDataSource, MatTable, MatSort, MatPaginator, MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { MatTableDataSource, MatTable, MatSort, MatPaginator, MatSnackBar } from '@angular/material';
import { BaseViewModel } from './base-view-model';
import { ViewChild } from '@angular/core';
import { BaseViewComponent } from './base-view';
import { BaseViewModel } from './base-view-model';
export abstract class ListViewBaseComponent<V extends BaseViewModel> extends BaseViewComponent {
/**
@ -64,7 +65,28 @@ export abstract class ListViewBaseComponent<V extends BaseViewModel> extends Bas
public initTable(): void {
this.dataSource = new MatTableDataSource();
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
public onSortButton(itemProperty: string): void {
let newOrder: 'asc' | 'desc' = 'asc';
if (itemProperty === this.sort.active) {
newOrder = this.sort.direction === 'asc' ? 'desc' : 'asc';
}
const newSort = {
disableClear: true,
id: itemProperty,
start: newOrder
};
this.sort.sort(newSort);
}
public onFilterData(filteredDataSource: MatTableDataSource<V>) : void {
this.dataSource = filteredDataSource;
this.dataSource.paginator = this.paginator;
}
public searchFilter(event: string): void {
this.dataSource.filter = event;
}
/**

View File

@ -48,6 +48,10 @@
</os-head-bar>
<mat-drawer-container class="on-transition-fade">
<os-sort-filter-bar [sortService]="sortService" [filterService]="filterService"
(searchFieldChange)="searchFilter($event)">
</os-sort-filter-bar>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<!-- Selector Column -->
<ng-container matColumnDef="selector">
@ -172,3 +176,4 @@
</button>
</div>
</mat-menu>
</mat-drawer-container>

View File

@ -12,8 +12,13 @@ import { MediafileRepositoryService } from '../../services/mediafile-repository.
import { MediaManageService } from '../../services/media-manage.service';
import { PromptService } from 'app/core/services/prompt.service';
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
import { MediafileFilterListService } from '../../services/mediafile-filter.service';
import { MediafilesSortListService } from '../../services/mediafiles-sort-list.service';
import { ViewportService } from 'app/core/services/viewport.service';
/**
* Lists all the uploaded files.
*/
@ -81,11 +86,13 @@ export class MediafileListComponent extends ListViewBaseComponent<ViewMediafile>
private repo: MediafileRepositoryService,
private mediaManage: MediaManageService,
private promptService: PromptService,
public vp: ViewportService
public vp: ViewportService,
public filterService: MediafileFilterListService,
public sortService: MediafilesSortListService
) {
super(titleService, translate, matSnackBar);
// emables multiSelection for this listView
// embles multiSelection for this listView
this.canMultiSelect = true;
}
@ -102,8 +109,12 @@ export class MediafileListComponent extends ListViewBaseComponent<ViewMediafile>
hidden: new FormControl(),
});
this.repo.getViewModelListObservable().subscribe(newFiles => {
this.dataSource.data = newFiles;
this.filterService.filter().subscribe(filteredData => {
this.sortService.data = filteredData;
});
this.sortService.sort().subscribe(sortedData => {
this.dataSource.data = sortedData;
});
// Observe the logo actions

View File

@ -118,4 +118,9 @@ export class ViewMediafile extends BaseViewModel {
this._mediafile = update;
}
}
public is_hidden(): boolean {
return this._mediafile.hidden;
}
}

View File

@ -0,0 +1,35 @@
import { Injectable } from "@angular/core";
import { FilterListService } from "../../../core/services/filter-list.service";
import { Mediafile } from "../../../shared/models/mediafiles/mediafile";
import { ViewMediafile } from "../models/view-mediafile";
import { StorageService } from "app/core/services/storage.service";
import { MediafileRepositoryService } from "./mediafile-repository.service";
@Injectable({
providedIn: 'root'
})
export class MediafileFilterListService extends FilterListService<Mediafile, ViewMediafile> {
protected name = 'Mediafile';
public filterOptions = [{
property: 'is_hidden', label: 'Hidden',
options: [
{ condition: true, label: 'is hidden' },
{ condition: false, label: 'is not hidden', isActive: true }
]
}
// , { TODO: is_pdf is not yet implemented on mediafile side
// property: 'is_pdf', isActive: false, label: 'PDF',
// options: [
// {condition: true, label: 'is a PDF'},
// {condition: false, label: 'is not a PDF'}
// ]
// }
];
public constructor(store: StorageService, repo: MediafileRepositoryService){
super(store, repo);
}
}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
import { SortListService, OsSortingDefinition } from '../../../core/services/sort-list.service';
import { ViewMediafile } from '../models/view-mediafile';
@Injectable({
providedIn: 'root'
})
export class MediafilesSortListService extends SortListService<ViewMediafile> {
public sortOptions: OsSortingDefinition<ViewMediafile> = {
sortProperty: 'title',
sortAscending: true,
options: [
{ property: 'title' },
{ property: 'type' },
{ property: 'size' },
// { property: 'upload_date' }
{ property: 'uploader' }
]
};
protected name = 'Mediafile';
}

View File

@ -1,13 +1,3 @@
.custom-table-header {
// display: none;
width: 100%;
height: 60px;
line-height: 60px;
text-align: right;
background: white;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
}
.header-container {
display: grid;
grid-template-rows: auto;

View File

@ -16,9 +16,10 @@
</div>
</os-head-bar>
<div class="custom-table-header on-transition-fade">
<button mat-button><span translate>SORT</span></button> <button mat-button><span translate>FILTER</span></button>
</div>
<mat-drawer-container class="on-transition-fade">
<os-sort-filter-bar [filterService]="filterService" [sortService]="sortService"
(searchFieldChange)="searchFilter($event)">
</os-sort-filter-bar>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<!-- Selector column -->
@ -219,3 +220,4 @@
</button>
</div>
</mat-menu>
</mat-drawer-container>

View File

@ -7,19 +7,21 @@ import { ConfigService } from '../../../../core/services/config.service';
import { MotionCsvExportService } from '../../services/motion-csv-export.service';
import { ListViewBaseComponent } from '../../../base/list-view-base';
import { MatSnackBar } from '@angular/material';
import { MotionRepositoryService } from '../../services/motion-repository.service';
import { ViewMotion } from '../../models/view-motion';
import { WorkflowState } from '../../../../shared/models/motions/workflow-state';
import { MotionMultiselectService } from '../../services/motion-multiselect.service';
import { TagRepositoryService } from 'app/site/tags/services/tag-repository.service';
import { MotionBlockRepositoryService } from '../../services/motion-block-repository.service';
import { CategoryRepositoryService } from '../../services/category-repository.service';
import { MotionBlockRepositoryService } from '../../services/motion-block-repository.service';
import { MotionFilterListService } from '../../services/motion-filter-list.service';
import { MotionSortListService } from '../../services/motion-sort-list.service';
import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewWorkflow } from '../../models/view-workflow';
import { ViewCategory } from '../../models/view-category';
import { ViewMotionBlock } from '../../models/view-motion-block';
import { WorkflowRepositoryService } from '../../services/workflow-repository.service';
/**
* Component that displays all the motions in a Table using DataSource.
*/
@ -29,6 +31,7 @@ import { WorkflowRepositoryService } from '../../services/workflow-repository.se
styleUrls: ['./motion-list.component.scss']
})
export class MotionListComponent extends ListViewBaseComponent<ViewMotion> implements OnInit {
/**
* Use for minimal width. Please note the 'selector' row for multiSelect mode,
* to be able to display an indicator for the state of selection
@ -68,8 +71,13 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
* @param tagRepo Tag Repository
* @param motionBlockRepo
* @param categoryRepo
* @param categoryRepo: Repo for categories. Used to define filters
* @param workflowRepo: Repo for Workflows. Used to define filters
* @param motionCsvExport
* @param multiselectService Service for the multiSelect actions
* @param userRepo
* @param sortService
* @param filterService
*/
public constructor(
titleService: Title,
@ -78,13 +86,14 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
private router: Router,
private route: ActivatedRoute,
private configService: ConfigService,
private repo: MotionRepositoryService,
private tagRepo: TagRepositoryService,
private motionBlockRepo: MotionBlockRepositoryService,
private categoryRepo: CategoryRepositoryService,
private workflowRepo: WorkflowRepositoryService,
private motionCsvExport: MotionCsvExportService,
public multiselectService: MotionMultiselectService
public multiselectService: MotionMultiselectService,
public sortService: MotionSortListService,
public filterService: MotionFilterListService
) {
super(titleService, translate, matSnackBar);
@ -95,28 +104,23 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
/**
* Init function.
*
* Sets the title, inits the table and calls the repository
* Sets the title, inits the table, defines the filter/sorting options and
* subscribes to filter and sorting services
*/
public ngOnInit(): void {
super.setTitle('Motions');
this.initTable();
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.callListWeight !== b.callListWeight) {
return a.callListWeight - b.callListWeight;
} else {
return a.id - b.id;
}
});
this.checkSelection();
});
this.configService.get('motions_statutes_enabled').subscribe(enabled => (this.statutesEnabled = enabled));
this.configService.get('motions_recommendations_by').subscribe(id => (this.recomendationEnabled = !!id));
this.motionBlockRepo.getViewModelListObservable().subscribe(mBs => this.motionBlocks = mBs);
this.categoryRepo.getViewModelListObservable().subscribe(cats => this.categories = cats);
this.tagRepo.getViewModelListObservable().subscribe(tags => this.tags = tags);
this.workflowRepo.getViewModelListObservable().subscribe(wfs => this.workflows = wfs);
this.filterService.filter().subscribe(filteredData => this.sortService.data = filteredData);
this.sortService.sort().subscribe(sortedData => {
this.dataSource.data = sortedData;
this.checkSelection();
});
}
/**
@ -207,4 +211,6 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
this.raiseError(e);
}
}
}

View File

@ -50,6 +50,10 @@ export class ViewCategory extends BaseViewModel {
return this.name;
}
public get prefixedName(): string {
return this.category.getTitle();
}
/**
* Updates the local objects if required
* @param update

View File

@ -10,6 +10,7 @@ import { BaseViewModel } from '../../base/base-view-model';
* @ignore
*/
export class ViewWorkflow extends BaseViewModel {
private _workflow: Workflow;
public constructor(workflow?: Workflow) {

View File

@ -1,4 +1,6 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { MotionBlock } from 'app/shared/models/motions/motion-block';
import { ViewMotionBlock } from '../models/view-motion-block';
@ -9,9 +11,7 @@ import { DataSendService } from 'app/core/services/data-send.service';
import { Identifiable } from 'app/shared/models/base/identifiable';
import { Motion } from 'app/shared/models/motions/motion';
import { ViewMotion } from '../models/view-motion';
import { Observable } from 'rxjs';
import { MotionRepositoryService } from './motion-repository.service';
import { map } from 'rxjs/operators';
/**
* Repository service for motion blocks

View File

@ -0,0 +1,149 @@
import { Injectable } from "@angular/core";
import { FilterListService, OsFilter } 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";
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";
@Injectable({
providedIn: 'root'
})
export class MotionFilterListService extends FilterListService<Motion, ViewMotion> {
protected name = 'Motion';
/**
* getter for the filterOptions. Note that in this case, the options are
* generated dynamically, as the options change with the datastore
*/
public get filterOptions(): OsFilter[] {
return [
this.flowFilterOptions,
this.categoryFilterOptions,
this.motionBlockFilterOptions
].concat(
this.staticFilterOptions);
}
/**
* Filter definitions for the workflow filter. Options will be generated by
* getFilterOptions (as the workflows available may change)
*/
public flowFilterOptions = {
property: 'state',
label: 'State',
isActive: false,
options: []
};
/**
* Filter definitions for the category filter. Options will be generated by
* getFilterOptions (as the categories available may change)
*/
public categoryFilterOptions = {
property: 'category',
isActive: false,
options: []
};
public motionBlockFilterOptions = {
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 constructor(store: StorageService,
private workflowRepo: WorkflowRepositoryService,
private categoryRepo: CategoryRepositoryService,
private motionBlockRepo: MotionBlockRepositoryService,
// private commentRepo: MotionCommentRepositoryService
motionRepo: MotionRepositoryService,
){
super(store, motionRepo);
this.subscribeWorkflows();
this.subscribeCategories();
this.subscribeMotionBlocks();
this.subscribeComments();
}
private subscribeMotionBlocks(): void {
this.motionBlockRepo.getViewModelListObservable().subscribe(motionBlocks => {
const motionBlockOptions = [];
motionBlocks.forEach(mb => {
motionBlockOptions.push({
condition: mb.id,
label: mb.title,
isActive: false
});
});
motionBlockOptions.push('-');
motionBlockOptions.push({
condition: null,
label: 'No motion block set',
isActive: false
});
this.motionBlockFilterOptions.options = motionBlockOptions;
this.updateFilterDefinitions(this.filterOptions);
});
}
private subscribeCategories(): void {
this.categoryRepo.getViewModelListObservable().subscribe(categories => {
const categoryOptions = [];
categories.forEach(cat => {
categoryOptions.push({
condition: cat.id,
label: cat.prefixedName,
isActive: false
});
});
this.categoryFilterOptions.options = categoryOptions;
this.updateFilterDefinitions(this.filterOptions);
});
}
private subscribeWorkflows(): void {
this.workflowRepo.getViewModelListObservable().subscribe(workflows => {
const workflowOptions = [];
workflows.forEach(workflow => {
workflowOptions.push(workflow.name);
workflow.states.forEach(state => {
workflowOptions.push({
condition: state.name,
label: state.name,
isActive: false
});
});
});
workflowOptions.push('-');
workflowOptions.push({
condition: null,
label: 'no workflow set',
isActive: false
});
this.flowFilterOptions.options = workflowOptions;
this.updateFilterDefinitions(this.filterOptions);
});
}
private subscribeComments(): void {
// TODO
}
}

View File

@ -0,0 +1,27 @@
import { Injectable } from '@angular/core';
import { SortListService, OsSortingDefinition } from '../../../core/services/sort-list.service';
import { ViewMotion } from '../models/view-motion';
@Injectable({
providedIn: 'root'
})
export class MotionSortListService extends SortListService<ViewMotion> {
public sortOptions: OsSortingDefinition<ViewMotion> = {
sortProperty: 'callListWeight',
sortAscending: true,
options: [
{ property: 'callListWeight', label: 'Call List' },
{ property: 'supporters' },
{ property: 'identifier' },
{ property: 'title' },
{ property: 'submitters' },
{ property: 'category' },
{ property: 'motion_block_id', label: 'Motion block' },
{ property: 'state' }
// choices from 2.3:
// TODO creation date
// TODO last modified
]
};
protected name = 'Motion';
}

View File

@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { Workflow } from '../../../shared/models/motions/workflow';
import { ViewWorkflow } from '../models/view-workflow';
import { DataSendService } from '../../../core/services/data-send.service';
@ -30,7 +31,7 @@ export class WorkflowRepositoryService extends BaseRepository<ViewWorkflow, Work
* @param dataSend
*/
public constructor(
DS: DataStoreService,
protected DS: DataStoreService,
mapperService: CollectionStringModelMapperService,
private dataSend: DataSendService
) {
@ -84,5 +85,6 @@ export class WorkflowRepositoryService extends BaseRepository<ViewWorkflow, Work
states = states.concat(workflow.states);
});
return states;
}
}

View File

@ -14,9 +14,10 @@
</div>
</os-head-bar>
<div class="custom-table-header on-transition-fade">
<button mat-button><span translate>SORT</span></button> <button mat-button><span translate>FILTER</span></button>
</div>
<mat-drawer-container class="on-transition-fade">
<os-sort-filter-bar [sortService]="sortService" [filterService]="filterService"
(searchFieldChange)="searchFilter($event)">
</os-sort-filter-bar>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<!-- Selector column -->
@ -36,9 +37,9 @@
<!-- prefix column -->
<ng-container matColumnDef="group">
<mat-header-cell *matHeaderCellDef mat-sort-header>Group</mat-header-cell>
<mat-cell *matCellDef="let user" (click)="selectItem(user, $event)">
<div class="groupsCell">
<span *ngIf="user.groups.length > 0">
<mat-cell *matCellDef="let user">
<div class='groupsCell'>
<span *ngIf="user.groups && user.groups.length">
<mat-icon>people</mat-icon>
{{ user.groups }}
</span>
@ -143,3 +144,4 @@
</div>
</div>
</mat-menu>
</mat-drawer-container>

View File

@ -5,12 +5,14 @@ import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { CsvExportService } from '../../../../core/services/csv-export.service';
import { ChoiceService } from '../../../../core/services/choice.service';
import { ListViewBaseComponent } from '../../../base/list-view-base';
import { GroupRepositoryService } from '../../services/group-repository.service';
import { PromptService } from '../../../../core/services/prompt.service';
import { UserRepositoryService } from '../../services/user-repository.service';
import { ViewUser } from '../../models/view-user';
import { ChoiceService } from '../../../../core/services/choice.service';
import { UserFilterListService } from '../../services/user-filter-list.service';
import { UserSortListService } from '../../services/user-sort-list.service';
/**
* Component for the user list view.
@ -22,9 +24,10 @@ import { ChoiceService } from '../../../../core/services/choice.service';
styleUrls: ['./user-list.component.scss']
})
export class UserListComponent extends ListViewBaseComponent<ViewUser> implements OnInit {
/**
* /**
* The usual constructor for components
*
* @param titleService Serivce for setting the title
* @param translate Service for translation handling
* @param matSnackBar Helper to diplay errors
@ -34,6 +37,9 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
* @param route the local route
* @param csvExport CSV export Service,
* @param promptService
* @param groupRepo
* @param filterService
* @param sortService
*/
public constructor(
titleService: Title,
@ -45,7 +51,9 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
private router: Router,
private route: ActivatedRoute,
protected csvExport: CsvExportService,
private promptService: PromptService
private promptService: PromptService,
public filterService: UserFilterListService,
public sortService: UserSortListService
) {
super(titleService, translate, matSnackBar);
@ -56,13 +64,19 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
/**
* Init function
*
* sets the title, inits the table and calls the repo
* sets the title, inits the table, sets sorting and filter options, subscribes
* to filter/sort services
*/
public ngOnInit(): void {
super.setTitle('Users');
this.initTable();
this.repo.getViewModelListObservable().subscribe(newUsers => {
this.dataSource.data = newUsers;
this.filterService.filter().subscribe(filteredData => {
this.sortService.data = filteredData;
});
this.sortService.sort().subscribe(sortedData => {
this.dataSource.data = sortedData;
this.checkSelection();
});
}
@ -240,5 +254,7 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
public async setPresent(viewUser: ViewUser): Promise<void> {
viewUser.user.is_present = !viewUser.user.is_present;
await this.repo.update(viewUser.user, viewUser);
}
}

View File

@ -92,12 +92,20 @@ export class ViewUser extends BaseViewModel {
return this.user ? this.user.about_me : null;
}
public get is_last_email_send(): boolean {
if (this.user && this.user.last_email_send){
return true;
}
return false;
}
public constructor(user?: User, groups?: Group[]) {
super();
this._user = user;
this._groups = groups;
}
/**
* required by BaseViewModel. Don't confuse with the users title.
*/

View File

@ -0,0 +1,84 @@
import { Injectable } from "@angular/core";
import { FilterListService, OsFilter } from "../../../core/services/filter-list.service";
import { StorageService } from "../../../core/services/storage.service";
import { User } from "../../../shared/models/users/user";
import { ViewUser } from "../models/view-user";
import { GroupRepositoryService } from "./group-repository.service";
import { UserRepositoryService } from "./user-repository.service";
@Injectable({
providedIn: 'root'
})
export class UserFilterListService extends FilterListService<User, ViewUser> {
protected name = 'User';
private userGroupFilterOptions = {
isActive: false,
property: 'group',
label: 'User Group',
options: []
};
public staticFilterOptions = [
{
property: 'is_present',
label: 'Presence',
isActive: false,
options: [
{ condition: true, label: 'Is present'},
{ condition: false, label: 'Is not present'}]
}, {
property: 'is_active',
label: 'Active',
isActive: false,
options: [
{ condition: true, label: 'Is active' },
{ condition: false, label: 'Is not active' }]
}, {
property: 'is_committee',
label: 'Committee',
isActive: false,
options: [
{ condition: true, label: 'Is a committee' },
{ condition: false, label: 'Is not a committee'}]
}, {
property: 'is_last_email_send',
label: 'Last email send',
isActive: false,
options: [
{ condition: true, label: 'Got an email' },
{ condition: false, label: 'Didn\'t get an email' }]
}
];
/**
* getter for the filterOptions. Note that in this case, the options are
* generated dynamically, as the options change with the datastore
*/
public get filterOptions(): OsFilter[] {
return [this.userGroupFilterOptions].concat(this.staticFilterOptions);
}
public constructor(store: StorageService, private groupRepo: GroupRepositoryService,
repo: UserRepositoryService){
super(store, repo);
this.subscribeGroups();
}
public subscribeGroups(): void {
this.groupRepo.getViewModelListObservable().subscribe(groups => {
const groupOptions = [];
groupOptions.forEach(group => {
groupOptions.push({
condition: group.name,
label: group.name,
isActive: false
});
});
this.userGroupFilterOptions.options = groupOptions;
this.updateFilterDefinitions(this.filterOptions);
})
}
}

View File

@ -0,0 +1,26 @@
import { Injectable } from '@angular/core';
import { SortListService, OsSortingDefinition } from '../../../core/services/sort-list.service';
import { ViewUser } from '../models/view-user';
@Injectable({
providedIn: 'root'
})
export class UserSortListService extends SortListService<ViewUser> {
public sortOptions: OsSortingDefinition<ViewUser> = {
sortProperty: 'first_name',
sortAscending: true,
options: [
{ property: 'first_name', label: 'Given name' },
{ property: 'last_name', label: 'Surname' },
{ property: 'is_present', label: 'Presence' },
{ property: 'is_active', label: 'Is active' },
{ property: 'is_committee', label: 'Is Committee' },
{ property: 'participant_number', label: 'Number' },
{ property: 'structure_level', label: 'Structure level' },
{ property: 'comment' }
// TODO email send?
]
};
protected name = 'User';
}

View File

@ -110,6 +110,11 @@ body {
margin-right: auto;
}
@keyframes fadeIn {
0% {width:0%; margin-left:0;}
100% {width:100%;margin-left:-100%;}
}
//custom table header for search button, filtering and more. Used in ListViews
.custom-table-header {
width: 100%;
@ -118,6 +123,32 @@ body {
text-align: right;
background: white;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
display: flex;
justify-content: flex-end;
button {
border-radius: 0%;
}
input {
position: relative;
max-width: 400px;
z-index: 2;
background-color: #EEE;
padding-right: 5px;
margin-right: 0px;
}
input.vp {
margin-left: -100%;
max-width: 100%;
animation-name: fadeIn;
animation-duration: 0.3s;
}
mat-icon {
vertical-align: text-bottom;
}
}
.os-listview-table {
@ -272,3 +303,7 @@ button.mat-menu-item.selected {
background-color: #e0e0e0 !important;
color: rgba(0, 0, 0, 0.87) !important;
}
.os-listview-table {
min-height: 800px;
}