LP#1831788: add result filtering and other improvements to the Angular eg-grid
authorGalen Charlton <gmc@equinoxinitiative.org>
Wed, 5 Jun 2019 15:19:36 +0000 (11:19 -0400)
committerGalen Charlton <gmc@equinoxinitiative.org>
Thu, 1 Aug 2019 13:49:39 +0000 (09:49 -0400)
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 <eg-grid>:

  [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 <eg-grid-column> element like this:

  <eg-grid-column [sortable]="true" [filterable]="false"  path="barcode"></eg-grid-column>

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 <input>; 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
<eg-grid> 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 <gmc@equinoxinitiative.org>
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid-filter-control.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid-filter-control.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html
Open-ILS/src/eg2/src/app/share/grid/grid-header.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
Open-ILS/src/eg2/src/app/share/grid/grid.component.css
Open-ILS/src/eg2/src/app/share/grid/grid.component.html
Open-ILS/src/eg2/src/app/share/grid/grid.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid.module.ts
Open-ILS/src/eg2/src/app/share/grid/grid.ts

index fc18fc7..95af28d 100644 (file)
@@ -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 (file)
index 0000000..6367fb6
--- /dev/null
@@ -0,0 +1,281 @@
+<div *ngIf="col.isFilterable" class="eg-grid-filter-control">
+  <div [ngSwitch]="col.datatype">
+    <div *ngSwitchCase="'link'">
+      <div class="input-group">
+        <div ngbDropdown class="d-inline-block" autoClose="outside" placement="bottom-left" [ngClass]="{'eg-grid-col-is-filtered' : col.isFiltered}">
+          <button ngbDropdownToggle class="form-control btn btn-sm btn-outline-dark text-button"><span class="material-icons mat-icon-in-button">filter_list</span></button>
+          <div ngbDropdownMenu class="eg-grid-filter-menu">
+            <div class="dropdown-item">
+              <div style="padding-top: 2px;">
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); applyFilter(col)" i18n>Apply filter</button>
+                <span style="padding-left: 2px;"></span>
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); clearFilter(col)" i18n>Clear filter</button>
+              </div>
+            </div>
+          </div>
+        </div>
+        <eg-combobox [idlClass]="col.idlFieldDef.class" (onChange)="applyLinkFilter($event, col)" placeholder="Enter value to filter by"></eg-combobox>
+      </div>
+    </div>
+    <div *ngSwitchCase="'bool'">
+      <div class="input-group">
+        <div ngbDropdown class="d-inline-block" autoClose="outside" placement="bottom-left" [ngClass]="{'eg-grid-col-is-filtered' : col.isFiltered}">
+          <button ngbDropdownToggle class="form-control btn btn-sm btn-outline-dark text-button"><span class="material-icons mat-icon-in-button">filter_list</span></button>
+          <div ngbDropdownMenu class="eg-grid-filter-menu">
+            <div class="dropdown-item">
+              <div style="padding-top: 2px;">
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); applyBooleanFilter(col)" i18n>Apply filter</button>
+                <span style="padding-left: 2px;"></span>
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); clearFilter(col)" i18n>Clear filter</button>
+              </div>
+            </div>
+          </div>
+        </div>
+        <select class="custom-select" [(ngModel)]="col.filterValue" (change)="applyBooleanFilter(col)">
+          <option value="" i18n>Any</option>
+          <option value="t" i18n>True</option>
+          <option value="f" i18n>False</option>
+        </select>
+      </div>
+    </div>
+    <div *ngSwitchCase="'text'">
+      <div class="input-group">
+        <div ngbDropdown class="d-inline-block" autoClose="outside" placement="bottom-left" [ngClass]="{'eg-grid-col-is-filtered' : col.isFiltered}">
+          <button ngbDropdownToggle class="form-control btn btn-sm btn-outline-dark text-button"><span class="material-icons mat-icon-in-button">filter_list</span></button>
+          <div ngbDropdownMenu class="eg-grid-filter-menu">
+            <div class="dropdown-item">
+              <label for="eg-filter-op-select-{{col.name}}" i18n>Operator</label>
+              <select id="eg-filter-op-select-{{col.name}}" class="form-control" [(ngModel)]="col.filterOperator" (change)="operatorChanged(col)">
+                <option value="=" i18n>Is exactly</option>
+                <option value="!=" i18n>Is not</option>
+                <option value="like" i18n>Contains</option>
+                <option value="not like" i18n>Does not contain</option>
+                <option value="startswith" i18n>Starts with</option>
+                <option value="endswith" i18n>Ends with</option>
+                <option value="not null" i18n>Exists</option>
+                <option value="null" i18n>Does not exist</option>
+                <option value="<" i18n>Is less than</option>
+                <option value=">" i18n>Is greater than</option>
+                <option value="<=" i18n>Is less than or equal to</option>
+                <option value=">=" i18n>Is greater than or equal to</option>
+              </select>
+              <div style="padding-top: 2px;">
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); applyFilter(col)" i18n>Apply filter</button>
+                <span style="padding-left: 2px;"></span>
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); clearFilter(col)" i18n>Clear filter</button>
+              </div>
+            </div>
+          </div>
+        </div>
+        <input type="text" class="form-control" [(ngModel)]="col.filterValue" (keyup.enter)="applyFilter(col)" [disabled]="col.filterInputDisabled" placeholder="Enter value to filter by">
+      </div>
+    </div>
+    <div *ngSwitchCase="'int'">
+      <div class="input-group">
+        <div ngbDropdown class="d-inline-block" autoClose="outside" placement="bottom-left" [ngClass]="{'eg-grid-col-is-filtered' : col.isFiltered}">
+          <button ngbDropdownToggle class="form-control btn btn-sm btn-outline-dark text-button"><span class="material-icons mat-icon-in-button">filter_list</span></button>
+          <div ngbDropdownMenu class="eg-grid-filter-menu">
+            <div class="dropdown-item">
+              <label for="eg-filter-op-select-{{col.name}}" i18n>Operator</label>
+              <select id="eg-filter-op-select-{{col.name}}" class="form-control" [(ngModel)]="col.filterOperator" (change)="operatorChanged(col)">
+                <option value="=" i18n>Is exactly</option>
+                <option value="!=" i18n>Is not</option>
+                <option value="not null" i18n>Exists</option>
+                <option value="null" i18n>Does not exist</option>
+                <option value="<" i18n>Is less than</option>
+                <option value=">" i18n>Is greater than</option>
+                <option value="<=" i18n>Is less than or equal to</option>
+                <option value=">=" i18n>Is greater than or equal to</option>
+              </select>
+              <div style="padding-top: 2px;">
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); applyFilter(col)" i18n>Apply filter</button>
+                <span style="padding-left: 2px;"></span>
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); clearFilter(col)" i18n>Clear filter</button>
+              </div>
+            </div>
+          </div>
+        </div>
+        <input type="number" min="0" step="1" class="form-control" [(ngModel)]="col.filterValue" (keyup.enter)="applyFilter(col)" [disabled]="col.filterInputDisabled">
+      </div>
+    </div>
+    <div *ngSwitchCase="'id'">
+      <div class="input-group">
+        <div ngbDropdown class="d-inline-block" autoClose="outside" placement="bottom-left" [ngClass]="{'eg-grid-col-is-filtered' : col.isFiltered}">
+          <button ngbDropdownToggle class="form-control btn btn-sm btn-outline-dark text-button"><span class="material-icons mat-icon-in-button">filter_list</span></button>
+          <div ngbDropdownMenu class="eg-grid-filter-menu">
+            <div class="dropdown-item">
+              <label for="eg-filter-op-select-{{col.name}}" i18n>Operator</label>
+              <select id="eg-filter-op-select-{{col.name}}" class="form-control" [(ngModel)]="col.filterOperator" (change)="operatorChanged(col)">
+                <option value="=" i18n>Is exactly</option>
+                <option value="!=" i18n>Is not</option>
+                <option value="not null" i18n>Exists</option>
+                <option value="null" i18n>Does not exist</option>
+                <option value="<" i18n>Is less than</option>
+                <option value=">" i18n>Is greater than</option>
+                <option value="<=" i18n>Is less than or equal to</option>
+                <option value=">=" i18n>Is greater than or equal to</option>
+              </select>
+              <div style="padding-top: 2px;">
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); applyFilter(col)" i18n>Apply filter</button>
+                <span style="padding-left: 2px;"></span>
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); clearFilter(col)" i18n>Clear filter</button>
+              </div>
+            </div>
+          </div>
+        </div>
+        <input type="number" min="0" step="1" class="form-control" [(ngModel)]="col.filterValue" (keyup.enter)="applyFilter(col)" [disabled]="col.filterInputDisabled">
+      </div>
+    </div>
+    <div *ngSwitchCase="'float'">
+      <div class="input-group">
+        <div ngbDropdown class="d-inline-block" autoClose="outside" placement="bottom-left" [ngClass]="{'eg-grid-col-is-filtered' : col.isFiltered}">
+          <button ngbDropdownToggle class="form-control btn btn-sm btn-outline-dark text-button"><span class="material-icons mat-icon-in-button">filter_list</span></button>
+          <div ngbDropdownMenu class="eg-grid-filter-menu">
+            <div class="dropdown-item">
+              <label for="eg-filter-op-select-{{col.name}}" i18n>Operator</label>
+              <select id="eg-filter-op-select-{{col.name}}"  class="form-control" [(ngModel)]="col.filterOperator" (change)="operatorChanged(col)">
+                <option value="=" i18n>Is exactly</option>
+                <option value="!=" i18n>Is not</option>
+                <option value="not null" i18n>Exists</option>
+                <option value="null" i18n>Does not exist</option>
+                <option value="<" i18n>Is less than</option>
+                <option value=">" i18n>Is greater than</option>
+                <option value="<=" i18n>Is less than or equal to</option>
+                <option value=">=" i18n>Is greater than or equal to</option>
+              </select>
+              <div style="padding-top: 2px;">
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); applyFilter(col)" i18n>Apply filter</button>
+                <span style="padding-left: 2px;"></span>
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); clearFilter(col)" i18n>Clear filter</button>
+              </div>
+            </div>
+          </div>
+        </div>
+        <input type="number" class="form-control" [(ngModel)]="col.filterValue" (keyup.enter)="applyFilter(col)" [disabled]="col.filterInputDisabled">
+      </div>
+    </div>
+    <div *ngSwitchCase="'money'">
+      <div class="input-group">
+        <div ngbDropdown class="d-inline-block" autoClose="outside" placement="bottom-left" [ngClass]="{'eg-grid-col-is-filtered' : col.isFiltered}">
+          <button ngbDropdownToggle class="form-control btn btn-sm btn-outline-dark text-button"><span class="material-icons mat-icon-in-button">filter_list</span></button>
+          <div ngbDropdownMenu class="eg-grid-filter-menu">
+            <div class="dropdown-item">
+              <label for="eg-filter-op-select-{{col.name}}" i18n>Operator</label>
+              <select id="eg-filter-op-select-{{col.name}}" class="form-control" [(ngModel)]="col.filterOperator" (change)="operatorChanged(col)">
+                <option value="=" i18n>Is exactly</option>
+                <option value="!=" i18n>Is not</option>
+                <option value="not null" i18n>Exists</option>
+                <option value="null" i18n>Does not exist</option>
+                <option value="<" i18n>Is less than</option>
+                <option value=">" i18n>Is greater than</option>
+                <option value="<=" i18n>Is less than or equal to</option>
+                <option value=">=" i18n>Is greater than or equal to</option>
+              </select>
+              <div style="padding-top: 2px;">
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); applyFilter(col)" i18n>Apply filter</button>
+                <span style="padding-left: 2px;"></span>
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); clearFilter(col)" i18n>Clear filter</button>
+              </div>
+            </div>
+          </div>
+        </div>
+        <input type="number" step="0.01" class="form-control" [(ngModel)]="col.filterValue" (keyup.enter)="applyFilter(col)" [disabled]="col.filterInputDisabled">
+      </div>
+    </div>
+    <div *ngSwitchCase="'timestamp'">
+      <div class="input-group">
+        <div ngbDropdown class="d-inline-block" autoClose="outside" placement="bottom-left" [ngClass]="{'eg-grid-col-is-filtered' : col.isFiltered}">
+           <button ngbDropdownToggle class="form-control btn btn-sm btn-outline-dark text-button"><span class="material-icons mat-icon-in-button">filter_list</span></button>
+          <div ngbDropdownMenu class="eg-grid-filter-menu">
+            <div class="dropdown-item">
+              <label for="eg-filter-op-select-{{col.name}}" i18n>Operator</label>
+              <select id="eg-filter-op-select-{{col.name}}" class="form-control" [(ngModel)]="col.filterOperator" (change)="operatorChanged(col)">
+                <option value="=" i18n>Is exactly</option>
+                <option value="!=" i18n>Is not</option>
+                <option value="not null" i18n>Exists</option>
+                <option value="null" i18n>Does not exist</option>
+                <option value="<" i18n>Is less than</option>
+                <option value=">" i18n>Is greater than</option>
+                <option value="<=" i18n>Is less than or equal to</option>
+                <option value=">=" i18n>Is greater than or equal to</option>
+                <option value="between" i18n>Between</option>
+              </select>
+              <div style="padding-top: 2px;">
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); applyDateFilter(datesel.currentAsYmd(), col, dateendsel.currentAsYmd())" i18n>Apply filter</button>
+                <span style="padding-left: 2px;"></span>
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); clearFilter(col)" i18n>Clear filter</button>
+              </div>
+            </div>
+          </div>
+        </div>
+        <eg-date-select [initialYmd]="col.filterValue" (onChangeAsYmd)="applyDateFilter($event, col, dateendsel.currentAsYmd())" (onCleared)="clearDateFilter(col)"
+                        [disabled]="col.filterInputDisabled" #datesel></eg-date-select>
+        <div [hidden]="col.filterOperator !== 'between'" class="form-inline form-group">
+          <label for="eg-filter-end-date-select-{{col.name}}" style="width: 3em;" i18n>and</label>
+          <eg-date-select [hidden]="col.filterOperator !== 'between'" (onChangeAsYmd)="applyDateFilter(datesel.currentAsYmd(), col, $event)"
+                          [required]="col.filterOperator == 'between'" #dateendsel></eg-date-select>
+        </div>
+      </div>
+    </div>
+    <div *ngSwitchCase="'org_unit'">
+      <div class="input-group">
+        <div ngbDropdown class="d-inline-block" autoClose="outside" placement="bottom-left" [ngClass]="{'eg-grid-col-is-filtered' : col.isFiltered}">
+          <button ngbDropdownToggle class="form-control btn btn-sm btn-outline-dark text-button"><span class="material-icons mat-icon-in-button">filter_list</span></button>
+          <div ngbDropdownMenu class="eg-grid-filter-menu">
+            <div class="dropdown-item">
+              <label for="eg-filter-op-select-{{col.name}}" i18n>Operator</label>
+              <select id="eg-filter-op-select-{{col.name}}" class="form-control" [(ngModel)]="col.filterOperator" (change)="operatorChanged(col)">
+                <option value="=" i18n>Is (or includes)</option>
+                <option value="!=" i18n>Is not (or excludes)</option>
+              </select>
+            </div>
+            <div class="dropdown-item">
+              <div class="form-check">
+                <input type="checkbox"
+                  [(ngModel)]="col.filterIncludeOrgAncestors"
+                  class="form-check-input" id="include-ancestors">
+                <label class="form-check-label" for="include-ancestors" i18n>+ Ancestors</label>
+              </div>
+              <div class="form-check">
+                <input type="checkbox"
+                  [(ngModel)]="col.filterIncludeOrgDescendants"
+                  class="form-check-input" id="include-descendants">
+                <label class="form-check-label" for="include-descendants" i18n>+ Descendants</label>
+              </div>
+              <div style="padding-top: 2px;">
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); applyOrgFilter(ousel.selectedOrg(), col)" i18n>Apply filter</button>
+                <span style="padding-left: 2px;"></span>
+                <button class="btn btn-sm btn-outline-dark" (click)="closeDropdown(); clearFilter(col)" i18n>Clear filter</button>
+              </div>
+            </div>
+          </div>
+        </div>
+        <eg-org-select [applyOrgId]="col.filterValue" (onChange)="applyOrgFilter($event, col)" placeholder="Enter library to filter by" #ousel></eg-org-select>
+      </div>
+    </div>
+    <div *ngSwitchDefault>I don't know how to filter {{col.name}} - {{col.datatype}}</div>
+  </div>
+  <span *ngIf="col.datatype !== 'org_unit'" i18n class="eg-grid-filter-operator">Operator:
+    <span [ngSwitch]="col.filterOperator">
+      <span *ngSwitchCase="'='" i18n>Is exactly</span>
+      <span *ngSwitchCase="'!='" i18n>Is not</span>
+      <span *ngSwitchCase="'>'" i18n>Is greater than</span>
+      <span *ngSwitchCase="'>='" i18n>Is greater than or equal to</span>
+      <span *ngSwitchCase="'<'" i18n>Is less than</span>
+      <span *ngSwitchCase="'<='" i18n>Is less than or equal to</span>
+      <span *ngSwitchCase="'like'" i18n>Contains</span>
+      <span *ngSwitchCase="'not like'" i18n>Does not contain</span>
+      <span *ngSwitchCase="'startswith'" i18n>Starts with</span>
+      <span *ngSwitchCase="'endswith'" i18n>Ends with</span>
+      <span *ngSwitchCase="'null'" i18n>Does not exist</span>
+      <span *ngSwitchCase="'not null'" i18n>Exists</span>
+      <span *ngSwitchCase="'between'" i18n>Between</span>
+    </span>
+  </span>
+  <span *ngIf="col.datatype == 'org_unit'" i18n class="eg-grid-filter-operator">Operator:
+    <span [ngSwitch]="col.filterOperator">
+      <span *ngSwitchCase="'='" i18n>Is (or includes)</span>
+      <span *ngSwitchCase="'!='" i18n>Is not (or excludes)</span>
+    </span>
+  </span>
+</div>
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 (file)
index 0000000..fbc5918
--- /dev/null
@@ -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<ComboboxComponent>;
+    @ViewChildren(DateSelectComponent) dateSelects: QueryList<DateSelectComponent>;
+    @ViewChildren(OrgSelectComponent)  orgSelects: QueryList<OrgSelectComponent>;
+    @ViewChildren(NgbDropdown)         dropdowns: QueryList<NgbDropdown>;
+
+    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(); });
+    }
+}
+
index 96811a3..571d074 100644 (file)
     <span *ngIf="!col.isSortable">{{col.label}}</span>
   </div>
 </div>
+<div *ngIf="context.isFilterable"
+  class="eg-grid-row eg-grid-filter-controls-row">
+  <ng-container *ngIf="!context.disableSelect">
+    <div class="eg-grid-cell eg-grid-header-cell eg-grid-cell-skinny"></div>
+  </ng-container>
+  <div class="eg-grid-cell eg-grid-header-cell eg-grid-cell-skinny"></div>
+  <div *ngIf="context.rowFlairIsEnabled" 
+    class="eg-grid-cell eg-grid-header-cell"></div>
 
+  <div *ngFor="let col of context.columnSet.displayColumns()" 
+    class="eg-grid-cell eg-grid-filter-control-cell" [ngStyle]="{flex:col.flex}">
+    <eg-grid-filter-control [context]="context" [col]="col"></eg-grid-filter-control>
+  </div>
+</div>
index 591fc66..cc53b26 100644 (file)
@@ -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<GridFilterControlComponent>;
+
     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;
index d75ef88..35781a5 100644 (file)
@@ -4,7 +4,13 @@
   <div class="btn-toolbar">
 
     <!-- buttons -->
-    <div class="btn-grp" *ngIf="gridContext.toolbarButtons.length">
+    <div class="btn-grp" *ngIf="gridContext.toolbarButtons.length || gridContext.isFilterable">
+      <!-- special case for remove filters button -->
+      <button *ngIf="gridContext.isFilterable"
+        class="btn btn-outline-dark mr-1" (click)="gridContext.removeFilters()"
+        [disabled]="!gridContext.filtersSet()" i18n>
+        Remove Filters
+      </button>
       <button *ngFor="let btn of gridContext.toolbarButtons"
         [disabled]="btn.disabled"
         class="btn btn-outline-dark mr-1" (click)="performButtonAction(btn)">
index 9748c0c..de4894d 100644 (file)
   box-shadow: none;
 }
 
+.eg-grid-filter-control-cell {
+    overflow: visible !important;
+}
+.eg-grid-col-is-filtered {
+    background: lightblue;
+}
+.eg-grid-filter-menu {
+  min-width: 17rem;
+}
+
+.eg-grid-sticky-header {
+  position: sticky;
+  top: 50px;
+  background: white;
+  z-index: 1;
+}
+
+.eg-grid-filter-operator {
+  font-style: italic;
+}
+
+/* override the dropdown menu effects for the filter menus */
+.eg-grid-filter-menu .dropdown-item:active {
+  color: #212529;
+  background-color: transparent;
+}
+.eg-grid-filter-menu .dropdown-item:hover {
+  background-color: transparent;
+}
index 20015ca..e29eb67 100644 (file)
@@ -8,7 +8,9 @@
     [disableSaveSettings]="!persistKey || ('disabled' === persistKey)">
   </eg-grid-toolbar>
 
-  <eg-grid-header [context]="context"></eg-grid-header>
+  <div #egGridStickyHeader [ngClass]="{'eg-grid-sticky-header' : context.stickyGridHeader}">
+    <eg-grid-header [context]="context"></eg-grid-header>
+  </div>
 
   <eg-grid-column-width #colWidthConfig [gridContext]="context">
   </eg-grid-column-width>
index e4938cc..b1b2989 100644 (file)
@@ -1,11 +1,12 @@
 import {Component, Input, Output, OnInit, AfterViewInit, EventEmitter,
-    OnDestroy, HostListener, ViewEncapsulation} from '@angular/core';
+    OnDestroy, HostListener, ViewEncapsulation, QueryList, ViewChildren} from '@angular/core';
 import {Subscription} from 'rxjs';
 import {IdlService} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
 import {ServerStoreService} from '@eg/core/server-store.service';
 import {FormatService} from '@eg/core/format.service';
 import {GridContext, GridColumn, GridDataSource, GridRowFlairEntry} from './grid';
+import {GridFilterControlComponent} from './grid-filter-control.component';
 
 /**
  * Main grid entry point.
@@ -105,6 +106,20 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy {
 
     @Input() disablePaging: boolean;
 
+    // result filtering
+    //
+    // filterable: true if the result filtering controls
+    // should be displayed
+    @Input() filterable: boolean;
+
+    // sticky grid header
+    //
+    // stickyHeader: true of the grid header should be
+    // "sticky", i.e., remain visible if if the table is long
+    // and the user has scrolled far enough that the header
+    // would go out of view
+    @Input() stickyHeader: boolean;
+
     context: GridContext;
 
     // These events are emitted from our grid-body component.
@@ -134,6 +149,8 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy {
         this.context.dataSource = this.dataSource;
         this.context.persistKey = this.persistKey;
         this.context.isSortable = this.sortable === true;
+        this.context.isFilterable = this.filterable === true;
+        this.context.stickyGridHeader = this.stickyHeader === true;
         this.context.isMultiSortable = this.multiSortable === true;
         this.context.useLocalSort = this.useLocalSort === true;
         this.context.disableSelect = this.disableSelect === true;
@@ -178,6 +195,11 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy {
     reload() {
         this.context.reload();
     }
+    reloadSansPagerReset() {
+        this.context.reloadSansPagerReset();
+    }
+
+
 }
 
 
index a6eb093..b738ac6 100644 (file)
@@ -14,6 +14,7 @@ import {GridToolbarActionsMenuComponent} from './grid-toolbar-actions-menu.compo
 import {GridColumnConfigComponent} from './grid-column-config.component';
 import {GridColumnWidthComponent} from './grid-column-width.component';
 import {GridPrintComponent} from './grid-print.component';
+import {GridFilterControlComponent} from './grid-filter-control.component';
 
 
 @NgModule({
@@ -31,7 +32,8 @@ import {GridPrintComponent} from './grid-print.component';
         GridToolbarActionsMenuComponent,
         GridColumnConfigComponent,
         GridColumnWidthComponent,
-        GridPrintComponent
+        GridPrintComponent,
+        GridFilterControlComponent
     ],
     imports: [
         EgCommonModule,
index e7c7f71..7740bdd 100644 (file)
@@ -1,13 +1,14 @@
 /**
  * Collection of grid related classses and interfaces.
  */
-import {TemplateRef, EventEmitter} from '@angular/core';
+import {TemplateRef, EventEmitter, QueryList} from '@angular/core';
 import {Observable, Subscription} from 'rxjs';
 import {IdlService, IdlObject} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
 import {ServerStoreService} from '@eg/core/server-store.service';
 import {FormatService} from '@eg/core/format.service';
 import {Pager} from '@eg/share/util/pager';
+import {GridFilterControlComponent} from './grid-filter-control.component';
 
 const MAX_ALL_ROW_COUNT = 10000;
 
@@ -32,6 +33,8 @@ export class GridColumn {
     isIndex: boolean;
     isDragTarget: boolean;
     isSortable: boolean;
+    isFilterable: boolean;
+    isFiltered: boolean;
     isMultiSortable: boolean;
     disableTooltip: boolean;
     comparator: (valueA: any, valueB: any) => number;
@@ -39,6 +42,13 @@ export class GridColumn {
     // True if the column was automatically generated.
     isAuto: boolean;
 
+    // for filters
+    filterValue: string;
+    filterOperator: string;
+    filterInputDisabled: boolean;
+    filterIncludeOrgAncestors: boolean;
+    filterIncludeOrgDescendants: boolean;
+
     flesher: (obj: any, col: GridColumn, item: any) => any;
 
     getCellContext(row: any) {
@@ -48,6 +58,19 @@ export class GridColumn {
           userContext: this.cellContext
         };
     }
+
+    constructor() {
+        this.removeFilter();
+    }
+
+    removeFilter() {
+        this.isFiltered = false;
+        this.filterValue = undefined;
+        this.filterOperator = '=';
+        this.filterInputDisabled = false;
+        this.filterIncludeOrgAncestors = false;
+        this.filterIncludeOrgDescendants = false;
+    }
 }
 
 export class GridColumnSet {
@@ -55,6 +78,7 @@ export class GridColumnSet {
     idlClass: string;
     indexColumn: GridColumn;
     isSortable: boolean;
+    isFilterable: boolean;
     isMultiSortable: boolean;
     stockVisible: string[];
     idl: IdlService;
@@ -85,6 +109,7 @@ export class GridColumnSet {
         }
 
         this.applyColumnSortability(col);
+        this.applyColumnFilterability(col);
     }
 
     // Returns true if the new column was inserted, false otherwise.
@@ -224,6 +249,12 @@ export class GridColumnSet {
             col.isSortable = true;
         }
     }
+    applyColumnFilterability(col: GridColumn) {
+        // column filterability defaults to the afilterability of the column set.
+        if (col.isFilterable === undefined && this.isFilterable) {
+            col.isFilterable = true;
+        }
+    }
 
     displayColumns(): GridColumn[] {
         return this.columns.filter(c => c.visible);
@@ -425,6 +456,8 @@ export class GridContext {
     pager: Pager;
     idlClass: string;
     isSortable: boolean;
+    isFilterable: boolean;
+    stickyGridHeader: boolean;
     isMultiSortable: boolean;
     useLocalSort: boolean;
     persistKey: string;
@@ -453,6 +486,8 @@ export class GridContext {
     // action has occurred.
     selectRowsInPageEmitter: EventEmitter<void>;
 
+    filterControls: QueryList<GridFilterControlComponent>;
+
     // Services injected by our grid component
     idl: IdlService;
     org: OrgService;
@@ -480,6 +515,7 @@ export class GridContext {
         this.selectRowsInPageEmitter = new EventEmitter<void>();
         this.columnSet = new GridColumnSet(this.idl, this.idlClass);
         this.columnSet.isSortable = this.isSortable === true;
+        this.columnSet.isFilterable = this.isFilterable === true;
         this.columnSet.isMultiSortable = this.isMultiSortable === true;
         this.columnSet.defaultHiddenFields = this.defaultHiddenFields;
         this.columnSet.defaultVisibleFields = this.defaultVisibleFields;
@@ -528,6 +564,13 @@ export class GridContext {
         });
     }
 
+    reloadSansPagerReset() {
+        setTimeout(() => {
+            this.dataSource.reset();
+            this.dataSource.requestPage(this.pager);
+        });
+    }
+
     // Sort the existing data source instead of requesting sorted
     // data from the client.  Reset pager to page 1.  As with reload(),
     // give the client a chance to setting before redisplaying.
@@ -942,6 +985,16 @@ export class GridContext {
         });
     }
 
+    removeFilters(): void {
+        this.dataSource.filters = {};
+        this.columnSet.displayColumns().forEach(col => { col.removeFilter(); });
+        this.filterControls.forEach(ctl => ctl.reset());
+        this.reload();
+    }
+    filtersSet(): boolean {
+        return Object.keys(this.dataSource.filters).length > 0;
+    }
+
     gridToCsv(): Promise<string> {
 
         let csvStr = '';
@@ -1069,12 +1122,14 @@ export class GridDataSource {
 
     data: any[];
     sort: any[];
+    filters: Object;
     allRowsRetrieved: boolean;
     requestingData: boolean;
     getRows: (pager: Pager, sort: any[]) => Observable<any>;
 
     constructor() {
         this.sort = [];
+        this.filters = {};
         this.reset();
     }