From fb3925bd56dc98d35d62822c918b5bc7add9b258 Mon Sep 17 00:00:00 2001 From: Galen Charlton Date: Wed, 5 Jun 2019 11:19:36 -0400 Subject: [PATCH] LP#1831788: add result filtering and other improvements to the Angular eg-grid This patch enables users to filter results in Angular eg-grids that use PCRUD-based data sources. Filtering can be enabled in an eg-grid defintion by adding the following attribute to : [filterable]="true" If, for some reason, a particular column should not be filterable by the user, filtering can be disabled by passing false to the [filterable] attribute of an element like this: When filtering is enabled, a new section of the grid header is displayed that includes, for each filterable column: * A drop-down menu letting the user specify an operator such as "is exactly", "exists" (i.e., is not null), "is greater than", and so forth. The drop-down also allows the user to clear a filter for a specific column or re-apply it after changing the operator. * An input widget for setting the value to filter on. The type of input displayed depend on the IDL type of the column. For example, a text field will use a normal text ; an OU field will use an eg-org-select, a link to another IDL class will use a combobox, a timestamp field will use an eg-date-select, and so forth. * A separate display of the current operator. When filtering is enabled, the grid will also display a "Remove Filters" button in the action bar. Under the hood, the widgets for entering filtering parameters expect the data source to have a "filters" key that in turn contains a dictionary of PCRUD-style filtering conditions indexed by column name. Consequently, a grid data source that wants to use filtering should look something like this: this.acpSource.getRows = (pager: Pager, sort: any[]) => { const orderBy: any = {acp: 'id'}; if (sort.length) { orderBy.acp = sort[0].name + ' ' + sort[0].dir; } // base query to grab everything let base: Object = {}; base[this.idl.classes['acp'].pkey] = {'!=' : null}; var query: any = new Array(); query.push(base); // and add any filters Object.keys(this.acpSource.filters).forEach(key => { Object.keys(this.acpSource.filters[key]).forEach(key2 => { query.push(this.acpSource.filters[key][key2]); }); }); return this.pcrud.search('acp', query, { flesh: 1, flesh_fields: {acp: ['location']}, offset: pager.offset, limit: pager.limit, order_by: orderBy }); }; This patch also adds two related grid options, sticky headers and the ability to reload the data source without losing one's current place in page. Sticky headers are enabled by adding the following attribute to the element: [stickyHeader]="true" When this is enabled, as the user scrolls the grid from top to bottom, the header row, including the filter controls, will continue to remain visible at the top of the viewport until the user scrolls past the end of the grid entirely. Reloading grids without losing the current paging settings can now be done by a caller (such as code that opens an edit modal) invoking a new reloadSansPagerReset() method. Implementation Notes -------------------- [1] This patch adds special-case logic for handling the "dob" column, which is the sole date column in the Evergreen schema. Longer-term, it would be better to define a new "date" IDL field type that's distinct from "timestamp". [2] stickyHeader currently makes only the grid header sticky, not both the header and the action bar. This outcome is a result of z-index messiness with the ng-bootstrap dropdown menu which I couldn't get past. However, the forthcoming grid context menus hopefully will be a reasonable amelioration. [3] During testing it became evident that it would be handy to add support for open-ils.fielder as a grid data source at some point in the near future. To test ------- General testing can be done using the new second grid in the Angular sandbox page added by the following test. Things to check include: - grid filter operators are displayed - hitting enter in text inputs activates the filter - the grid-level Remove Filters button works - per-column filter clearing works - operators have the expected results - The header of both grids on the sandbox page is sticky. This can be tested by increasing the row count in the second grid and scrolling. Sponsored-by: MassLNC Sponsored-by: Georgia Public Library Service Sponsored-by: Indiana State Library Sponsored-by: CW MARS Sponsored-by: King County Library System Signed-off-by: Galen Charlton Signed-off-by: Bill Erickson Signed-off-by: Jane Sandberg --- .../app/share/grid/grid-column.component.ts | 4 + .../grid/grid-filter-control.component.html | 281 ++++++++++++++++++ .../grid/grid-filter-control.component.ts | 280 +++++++++++++++++ .../app/share/grid/grid-header.component.html | 13 + .../app/share/grid/grid-header.component.ts | 11 +- .../share/grid/grid-toolbar.component.html | 8 +- .../eg2/src/app/share/grid/grid.component.css | 29 ++ .../src/app/share/grid/grid.component.html | 4 +- .../eg2/src/app/share/grid/grid.component.ts | 24 +- .../src/eg2/src/app/share/grid/grid.module.ts | 4 +- Open-ILS/src/eg2/src/app/share/grid/grid.ts | 57 +++- 11 files changed, 708 insertions(+), 7 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid-filter-control.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid-filter-control.component.ts diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts index fc18fc7258..95af28d8bf 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts @@ -27,6 +27,9 @@ export class GridColumnComponent implements OnInit { // If true, boolean fields support 3 values: true, false, null (unset) @Input() ternaryBool: boolean; + // result filtering + @Input() filterable: boolean; + // Display date and time when datatype = timestamp @Input() datePlusTime: boolean; @@ -57,6 +60,7 @@ export class GridColumnComponent implements OnInit { col.cellContext = this.cellContext; col.disableTooltip = this.disableTooltip; col.isSortable = this.sortable; + col.isFilterable = this.filterable; col.isMultiSortable = this.multiSortable; col.datatype = this.datatype; col.datePlusTime = this.datePlusTime; diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-filter-control.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-filter-control.component.html new file mode 100644 index 0000000000..6367fb6e83 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-filter-control.component.html @@ -0,0 +1,281 @@ +
+
+
+
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+ +
+
+ +
+ + +
+
+
+
+
+
+ +
+ + +
+
+ +
+
+
I don't know how to filter {{col.name}} - {{col.datatype}}
+
+ Operator: + + Is exactly + Is not + Is greater than + Is greater than or equal to + Is less than + Is less than or equal to + Contains + Does not contain + Starts with + Ends with + Does not exist + Exists + Between + + + Operator: + + Is (or includes) + Is not (or excludes) + + +
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-filter-control.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-filter-control.component.ts new file mode 100644 index 0000000000..fbc59186c2 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-filter-control.component.ts @@ -0,0 +1,280 @@ +import {Component, Input, OnInit, QueryList, ViewChildren} from '@angular/core'; +import {GridContext, GridColumn, GridRowSelector, + GridColumnSet, GridDataSource} from './grid'; +import {IdlObject} from '@eg/core/idl.service'; +import {ComboboxComponent, + ComboboxEntry} from '@eg/share/combobox/combobox.component'; +import {DateSelectComponent} from '@eg/share/date-select/date-select.component'; +import {OrgSelectComponent} from '@eg/share/org-select/org-select.component'; +import {OrgService} from '@eg/core/org.service'; +import {NgbDropdown} from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'eg-grid-filter-control', + templateUrl: './grid-filter-control.component.html' +}) + +export class GridFilterControlComponent implements OnInit { + + @Input() context: GridContext; + @Input() col: GridColumn; + + + @ViewChildren(ComboboxComponent) filterComboboxes: QueryList; + @ViewChildren(DateSelectComponent) dateSelects: QueryList; + @ViewChildren(OrgSelectComponent) orgSelects: QueryList; + @ViewChildren(NgbDropdown) dropdowns: QueryList; + + constructor( + private org: OrgService + ) {} + + ngOnInit() { } + + operatorChanged(col: GridColumn) { + if (col.filterOperator === 'null' || col.filterOperator === 'not null') { + col.filterInputDisabled = true; + col.filterValue = undefined; + } else { + col.filterInputDisabled = false; + } + } + + applyOrgFilter(org: IdlObject, col: GridColumn) { + if (org == null) { + this.clearFilter(col); + return; + } + const ous: any[] = new Array(); + if (col.filterIncludeOrgDescendants || col.filterIncludeOrgAncestors) { + if (col.filterIncludeOrgAncestors) { + ous.push(...this.org.ancestors(org, true)); + } + if (col.filterIncludeOrgDescendants) { + ous.push(...this.org.descendants(org, true)); + } + } else { + ous.push(org.id()); + } + const filt: any = {}; + filt[col.name] = {}; + const op: string = (col.filterOperator === '=' ? 'in' : 'not in'); + filt[col.name][op] = ous; + this.context.dataSource.filters[col.name] = [ filt ]; + col.isFiltered = true; + this.context.reload(); + } + applyLinkFilter($event, col: GridColumn) { + col.filterValue = $event.id; + this.applyFilter(col); + } + + // TODO: this was copied from date-select and + // really belongs in a date service + localDateFromYmd(ymd: string): Date { + const parts = ymd.split('-'); + return new Date( + Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); + } + applyDateFilter(dateStr: string, col: GridColumn, endDateStr: string) { + if (col.filterOperator === 'null' || col.filterOperator === 'not null') { + this.applyFilter(col); + } else { + if (dateStr == null) { + this.clearFilter(col); + return; + } + const date: Date = this.localDateFromYmd(dateStr); + let date1 = new Date(); + let date2 = new Date(); + const op: string = col.filterOperator; + const filt: Object = {}; + const filt2: Object = {}; + const filters = new Array(); + if (col.filterOperator === '>') { + date1 = date; + date1.setHours(23); + date1.setMinutes(59); + date1.setSeconds(59); + filt[op] = date1.toISOString(); + if (col.name === 'dob') { filt[op] = dateStr; } // special case + filt2[col.name] = filt; + filters.push(filt2); + } else if (col.filterOperator === '>=') { + date1 = date; + filt[op] = date1.toISOString(); + if (col.name === 'dob') { filt[op] = dateStr; } // special case + filt2[col.name] = filt; + filters.push(filt2); + } else if (col.filterOperator === '<') { + date1 = date; + filt[op] = date1.toISOString(); + if (col.name === 'dob') { filt[op] = dateStr; } // special case + filt2[col.name] = filt; + filters.push(filt2); + } else if (col.filterOperator === '<=') { + date1 = date; + date1.setHours(23); + date1.setMinutes(59); + date1.setSeconds(59); + filt[op] = date1.toISOString(); + if (col.name === 'dob') { filt[op] = dateStr; } // special case + filt2[col.name] = filt; + filters.push(filt2); + } else if (col.filterOperator === '=') { + date1 = new Date(date.valueOf()); + filt['>='] = date1.toISOString(); + if (col.name === 'dob') { filt['>='] = dateStr; } // special case + filt2[col.name] = filt; + filters.push(filt2); + + date2 = new Date(date.valueOf()); + date2.setHours(23); + date2.setMinutes(59); + date2.setSeconds(59); + const filt_a: Object = {}; + const filt2_a: Object = {}; + filt_a['<='] = date2.toISOString(); + if (col.name === 'dob') { filt_a['<='] = dateStr; } // special case + filt2_a[col.name] = filt_a; + filters.push(filt2_a); + } else if (col.filterOperator === '!=') { + date1 = new Date(date.valueOf()); + filt['<'] = date1.toISOString(); + if (col.name === 'dob') { filt['<'] = dateStr; } // special case + filt2[col.name] = filt; + + date2 = new Date(date.valueOf()); + date2.setHours(23); + date2.setMinutes(59); + date2.setSeconds(59); + const filt_a: Object = {}; + const filt2_a: Object = {}; + filt_a['>'] = date2.toISOString(); + if (col.name === 'dob') { filt_a['>'] = dateStr; } // special case + filt2_a[col.name] = filt_a; + + const date_filt: any = { '-or': [] }; + date_filt['-or'].push(filt2); + date_filt['-or'].push(filt2_a); + filters.push(date_filt); + } else if (col.filterOperator === 'between') { + date1 = date; + date2 = this.localDateFromYmd(endDateStr); + + let date1op = '>='; + let date2op = '<='; + if (date1 > date2) { + // don't make user care about the order + // they enter the dates in + date1op = '<='; + date2op = '>='; + } + filt[date1op] = date1.toISOString(); + if (col.name === 'dob') { filt['>='] = dateStr; } // special case + filt2[col.name] = filt; + filters.push(filt2); + + date2.setHours(23); + date2.setMinutes(59); + date2.setSeconds(59); + const filt_a: Object = {}; + const filt2_a: Object = {}; + filt_a[date2op] = date2.toISOString(); + if (col.name === 'dob') { filt_a['<='] = endDateStr; } // special case + filt2_a[col.name] = filt_a; + filters.push(filt2_a); + } + this.context.dataSource.filters[col.name] = filters; + col.isFiltered = true; + this.context.reload(); + } + } + clearDateFilter(col: GridColumn) { + delete this.context.dataSource.filters[col.name]; + col.isFiltered = false; + this.context.reload(); + } + applyBooleanFilter(col: GridColumn) { + if (!col.filterValue || col.filterValue === '') { + delete this.context.dataSource.filters[col.name]; + col.isFiltered = false; + this.context.reload(); + } else { + const val: string = col.filterValue; + const op = '='; + const filt: Object = {}; + filt[op] = val; + const filt2: Object = {}; + filt2[col.name] = filt; + this.context.dataSource.filters[col.name] = [ filt2 ]; + col.isFiltered = true; + this.context.reload(); + } + } + applyFilter(col: GridColumn) { + // fallback if the operator somehow was not set yet + if (col.filterOperator === undefined) { col.filterOperator = '='; } + + if ( (col.filterOperator !== 'null') && (col.filterOperator !== 'not null') && + (!col.filterValue || col.filterValue === '') ) { + // if value is empty and we're _not_ checking for null/not null, clear + // the filter + delete this.context.dataSource.filters[col.name]; + col.isFiltered = false; + } else { + let op: string = col.filterOperator; + let val: string = col.filterValue; + const name: string = col.name; + if (col.filterOperator === 'null') { + op = '='; + val = null; + } else if (col.filterOperator === 'not null') { + op = '!='; + val = null; + } else if (col.filterOperator === 'like' || col.filterOperator === 'not like') { + val = '%' + val + '%'; + } else if (col.filterOperator === 'startswith') { + op = 'like'; + val = val + '%'; + } else if (col.filterOperator === 'endswith') { + op = 'like'; + val = '%' + val; + } + const filt: any = {}; + if (col.filterOperator === 'not like') { + filt['-not'] = {}; + filt['-not'][col.name] = {}; + filt['-not'][col.name]['like'] = val; + this.context.dataSource.filters[col.name] = [ filt ]; + col.isFiltered = true; + } else { + filt[col.name] = {}; + filt[col.name][op] = val; + this.context.dataSource.filters[col.name] = [ filt ]; + col.isFiltered = true; + } + } + this.context.reload(); + } + clearFilter(col: GridColumn) { + // clear filter values... + col.removeFilter(); + // ... and inform the data source + delete this.context.dataSource.filters[col.name]; + col.isFiltered = false; + this.reset(); + this.context.reload(); + } + + closeDropdown() { + this.dropdowns.forEach(drp => { drp.close(); }); + } + + reset() { + this.filterComboboxes.forEach(ctl => { ctl.applyEntryId(null); }); + this.dateSelects.forEach(ctl => { ctl.reset(); }); + this.orgSelects.forEach(ctl => { ctl.reset(); }); + } +} + diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html index 96811a32aa..571d0740ea 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html @@ -36,4 +36,17 @@ {{col.label}} +
+ +
+
+
+
+
+ +
+
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.ts index 591fc66c2b..cc53b26130 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.ts @@ -1,13 +1,14 @@ -import {Component, Input, OnInit} from '@angular/core'; +import {Component, Input, OnInit, AfterViewInit, QueryList, ViewChildren} from '@angular/core'; import {GridContext, GridColumn, GridRowSelector, GridColumnSet, GridDataSource} from './grid'; +import {GridFilterControlComponent} from './grid-filter-control.component'; @Component({ selector: 'eg-grid-header', templateUrl: './grid-header.component.html' }) -export class GridHeaderComponent implements OnInit { +export class GridHeaderComponent implements OnInit, AfterViewInit { @Input() context: GridContext; @@ -15,6 +16,8 @@ export class GridHeaderComponent implements OnInit { batchRowCheckbox: boolean; + @ViewChildren(GridFilterControlComponent) filterControls: QueryList; + constructor() {} ngOnInit() { @@ -23,6 +26,10 @@ export class GridHeaderComponent implements OnInit { ); } + ngAfterViewInit() { + this.context.filterControls = this.filterControls; + } + onColumnDragEnter($event: any, col: any) { if (this.dragColumn && this.dragColumn.name !== col.name) { col.isDragTarget = true; diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html index d75ef88b60..35781a5014 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html @@ -4,7 +4,13 @@
-
+
+ +