lp1993824: grids enhancement; support for saving filter sets
authorJason Etheridge <jason@EquinoxOLI.org>
Mon, 13 Mar 2023 18:51:20 +0000 (14:51 -0400)
committerJason Etheridge <phasefx@gmail.com>
Sun, 14 May 2023 13:02:33 +0000 (09:02 -0400)
turned off by default

Signed-off-by: Jason Etheridge <jason@EquinoxOLI.org>
Open-ILS/src/eg2/src/app/share/grid/grid-filter-control.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid-manage-filters-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid-manage-filters-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.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
Open-ILS/src/eg2/tsconfig.json

index 6989361..3714258 100644 (file)
@@ -49,12 +49,13 @@ export class GridFilterControlComponent implements OnInit {
     }
 
     applyOrgFilter(col: GridColumn) {
-        const org: IdlObject = (col.filterValue as unknown) as IdlObject;
+        let org: IdlObject = (col.filterValue as unknown) as IdlObject;
 
         if (org == null) {
             this.clearFilter(col);
             return;
         }
+        org = this.org.get(org); // if coming from a Named Filter Set, filterValue would be an an org id
         const ous: any[] = new Array();
         if (col.filterIncludeOrgDescendants || col.filterIncludeOrgAncestors) {
             if (col.filterIncludeOrgAncestors) {
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-manage-filters-dialog.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-manage-filters-dialog.component.html
new file mode 100644 (file)
index 0000000..10d46dc
--- /dev/null
@@ -0,0 +1,82 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title">
+      <span i18n>Manage Grid Filters</span>
+    </h4>
+    <button type="button" class="close"
+    i18n-aria-label aria-label="Close" (click)="close()">
+    <span aria-hidden="true">&times;</span>
+  </button>
+</div>
+<div class="modal-body">
+    <form #manageFiltersForm="ngForm" role="form" class="form-validated common-form striped-odd">
+        <div class="form-group row">
+            <div class="col-lg-3">
+                <label for="session_name" i18n>Save as</label>
+            </div>
+            <div class="col-lg-9">
+              <input
+                class="form-control"
+                id="session_name" name="session_name"
+                type="text" pattern="[\s\S]*\S[\s\S]*"
+                placeholder="Name..." i18n-placeholder
+                required="false"
+                (ngModelChange)="saveFilterNameModelChanged.next($event)"
+                [ngModel]="saveFilterName"/>
+                <div *ngIf="nameCollision" class="alert alert-warning" i18n>
+                    An existing Filter Set with the same name will be overwritten if you Save.
+                </div>
+            </div>
+        </div>
+        <div class="form-group row">
+            <div class="col-lg-3">
+            </div>
+            <div class="col-lg-9">
+                <button type="button" class="btn btn-success" [disabled]="saveFilterName === ''"
+                    (click)="gridContext.saveFilters(saveFilterName); refreshEntries(); this.nameCollision = true; close()" i18n>
+                    Save Active Filters</button>
+            </div>
+        </div>
+        <div class="form-group row">
+            <div class="col-lg-3">
+                <label for="filter_sets" i18n>Filter Sets</label>
+            </div>
+            <div class="col-lg-9">
+              <eg-combobox #namedFilterSetSelector
+                domId="filterSets" name="filter_sets" [entries]="filterSetEntries"
+                placeholder="Filter Sets..." i18n-placeholder>
+              </eg-combobox>
+            </div>
+        </div>
+        <div class="form-group row">
+            <div class="col-lg-3">
+            </div>
+            <div class="col-lg-9">
+                  <button type="button" class="btn btn-success" [disabled]="!namedFilterSetSelector.selectedId"
+                        (click)="gridContext.loadFilters(namedFilterSetSelector.selectedId); close();" i18n>
+                        Load Filter Set</button>
+                <div *ngIf="gridContext.filtersSet()" class="alert alert-warning" i18n>
+                    Your active filters will be replaced with this filter set if you Load.
+                </div>
+            </div>
+        </div>
+        <div class="form-group row">
+            <div class="col-lg-3">
+            </div>
+            <div class="col-lg-9">
+                  <button type="button" class="btn btn-success" [disabled]="!namedFilterSetSelector.selectedId"
+                        (click)="gridContext.deleteFilters(namedFilterSetSelector.selectedId); namedFilterSetSelector.selectedId = ''; refreshEntries();" i18n>
+                        Delete Filter Set</button>
+            </div>
+        </div>
+    </form>
+</div>
+<div class="modal-footer">
+  <button type="button" class="btn btn-success" [disabled]="manageFiltersForm.invalid"
+        (click)="gridContext.removeFilters(); close(false);" i18n>
+        Remove Filters</button>
+  <button type="button" class="btn btn-secondary"
+        (click)="close()" i18n>
+        Cancel</button>
+</div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-manage-filters-dialog.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-manage-filters-dialog.component.ts
new file mode 100644 (file)
index 0000000..a0a22a0
--- /dev/null
@@ -0,0 +1,238 @@
+import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
+import {AuthService} from '@eg/core/auth.service';
+import {ComboboxEntry, ComboboxComponent} from '@eg/share/combobox/combobox.component';
+import {Component, Input, OnInit, ViewChild, Renderer2} from '@angular/core';
+import {GridContext} from '@eg/share/grid/grid';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {EventService} from '@eg/core/event.service';
+import {FormControl} from '@angular/forms';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {NgForm} from '@angular/forms';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {Subject, Subscription, Observable, from, EMPTY, throwError} from 'rxjs';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {debounceTime, distinctUntilChanged, switchMap, takeLast, finalize} from 'rxjs/operators';
+
+@Component({
+  selector: 'eg-grid-manage-filters-dialog',
+  templateUrl: './grid-manage-filters-dialog.component.html'
+})
+
+export class GridManageFiltersDialogComponent extends DialogComponent implements OnInit {
+
+    @Input() gridContext: GridContext;
+
+    subscriptions: Subscription[] = [];
+
+    saveFilterName: string = '';
+    saveFilterNameModelChanged: Subject<string> = new Subject<string>();
+    nameCollision: boolean = false;
+
+    filterSetEntries: ComboboxEntry[] = [];
+
+    @ViewChild('manageFiltersForm', { static: false}) manageFiltersForm: NgForm;
+    @ViewChild('namedFilterSetSelector', { static: true}) namedFilterSetSelector: ComboboxComponent;
+
+    constructor(
+        private modal: NgbModal,
+        private auth: AuthService,
+        private evt: EventService,
+        private net: NetService,
+        private toast: ToastService,
+        private idl: IdlService,
+        private pcrud: PcrudService,
+        private renderer: Renderer2,
+        private store: ServerStoreService,
+    ) {
+        super(modal);
+    }
+
+    ngOnInit() {
+
+        this.subscriptions.push( this.onOpen$.subscribe(
+            _ => {
+                const el = this.renderer.selectRootElement('#session_name');
+                if (el) { el.focus(); el.select(); }
+            }
+        ));
+
+        this.subscriptions.push(
+            this.saveFilterNameModelChanged
+            .pipe(
+                debounceTime(300),
+                distinctUntilChanged()
+            )
+            .subscribe( newText => {
+                this.saveFilterName = newText;
+                this.nameCollision = false;
+                this.store.getItem('eg.grid.filters.' + this.gridContext.persistKey).then( setting => {
+                    if (setting) {
+                        if (setting[newText]) {
+                            this.nameCollision = true;
+                        }
+                    }
+                });
+            })
+        );
+
+        this.refreshEntries();
+
+        console.log('manage-filters-dialog this', this);
+    }
+
+    ngOnDestroy() {
+        this.subscriptions.forEach((subscription) => {
+            subscription.unsubscribe();
+        });
+    }
+
+    refreshEntries() {
+        this.filterSetEntries = [];
+        this.store.getItem('eg.grid.filters.' + this.gridContext.persistKey).then( setting => {
+            console.log('getItem, setting =',setting);
+            if (setting /* for testing only: && Object.keys( setting ).length > 0 */) {
+                Object.keys(setting).forEach( key => {
+                    this.filterSetEntries.push({ id: key, label: key });
+                });
+            } else {
+                if (this.gridContext.migrateLegacyFilterSets) {
+                    this.attemptLegacyFilterSetMigration();
+                }
+            }
+            if (this.namedFilterSetSelector && this.filterSetEntries.length > 0) {
+                this.namedFilterSetSelector.selected = this.filterSetEntries[0];
+            }
+        });
+    }
+
+    legacyFieldMap(legacy_field: string): string {
+        if (this.gridContext.idlClass === 'uvuv') {
+            if (legacy_field === 'url_id') { return 'url'; }
+            if (legacy_field === 'attempt_id') { return 'id'; }
+            if (legacy_field === 'res_time') { return 'res_time'; }
+            if (legacy_field === 'res_code') { return 'res_code'; }
+            if (legacy_field === 'res_text') { return 'res_text'; }
+            if (legacy_field === 'req_time') { return 'req_time'; }
+            return 'url.' + legacy_field;
+        } else {
+            if (legacy_field === 'url_id') { return 'id'; }
+        }
+
+        return legacy_field;
+    }
+
+    legacyOperatorValueMap(field_name: string, field_datatype: string, legacy_operator: string, legacy_value: any): any {
+        let operator = legacy_operator;
+        let value = legacy_value;
+        let filterOperator = legacy_operator;
+        let filterValue = legacy_value;
+        let filterInputDisabled = false;
+        let filterIncludeOrgAncestors = false;
+        let filterIncludeOrgDescendants = false;
+        let notSupported = false;
+        switch(legacy_operator) {
+            case '=': case '!=': case '>': case '<': case '>=': case '<=':
+                /* same */
+            break;
+            case 'in': case 'not in':
+            case 'between': case 'not between':
+                /* not supported, warn user */
+                operator = undefined;
+                value = undefined;
+                filterOperator = '=';
+                filterValue = undefined;
+                notSupported = true;
+            break;
+            case 'null':
+                operator = '=';
+                value = undefined;
+                filterOperator = '=';
+                filterValue = null;
+            break;
+            case 'not null':
+                operator = '!=';
+                value = undefined;
+                filterOperator = '!=';
+                filterValue = null;
+            break;
+            case 'like': case 'not like':
+                value = '%' + filterValue + '%';
+                /* not like needs special handling further below */
+            break;
+        }
+        if (notSupported) {
+            return undefined;
+        }
+
+        let filter = {}
+        let mappedFieldName = this.legacyFieldMap(field_name);
+        filter[mappedFieldName] = {};
+        if (operator === 'not like') {
+            filter[mappedFieldName]['-not'] = {};
+            filter[mappedFieldName]['-not'][mappedFieldName] = {};
+            filter[mappedFieldName]['-not'][mappedFieldName]['like'] = value;
+        } else {
+            filter[mappedFieldName][operator] = value;
+        }
+
+        let control = {
+            isFiltered: true,
+            filterValue: filterValue,
+            filterOperator: filterOperator,
+            filterInputDisabled: filterInputDisabled,
+            filterIncludeOrgAncestors: filterIncludeOrgAncestors,
+            filterIncludeOrgDescendants: filterIncludeOrgDescendants
+        }
+
+        return [ filter, control ];
+    }
+
+    attemptLegacyFilterSetMigration() {
+    // The legacy interface allows you to define multiple filters for the same column, which our current filters
+    // do not support (well, the dataSource.filters part can, but not the grid.context.filterControls).  The legacy
+    // filters also have an unintuitive additive behavior if you do that.  We should take the last filter and warn
+    // the user if this happens.  None of the filters for date columns is working correctly in the legacy UI, so no
+    // need to map those.  We also not able to support between, not between, in, and not in.
+        this.pcrud.search('cfdfs', {'interface':this.gridContext.migrateLegacyFilterSets},{},{'atomic':true}).subscribe(
+            (legacySets) => {
+                legacySets.forEach( s => {
+                    let obj = {
+                        'filters' : {},
+                        'controls' : {}
+                    };
+                    console.log('migrating legacy set ' + s.name(), s );
+                    JSON.parse( s.filters() ).forEach( f => {
+                        let mappedFieldName = this.legacyFieldMap(f.field);
+                        let c = this.gridContext.columnSet.getColByName( mappedFieldName );
+                        if (c) {
+                            let r = this.legacyOperatorValueMap(f.field, c.datatype, f.operator, f.value || f.values);
+                            console.log(f.field, r);
+                            obj['filters'][mappedFieldName] = [ r[0] ];
+                            obj['controls'][mappedFieldName] = r[1];
+                        } else {
+                            console.log('with legacy set ' + s.name()
+                                + ', column not found for ' + f.field + ' (' + this.legacyFieldMap( f.field) + ')');
+                        }
+                    });
+                    if (Object.keys(obj.filters).length > 0) {
+                        this.store.getItem('eg.grid.filters.' + this.gridContext.persistKey).then( setting => {
+                            console.log('saveFilters, setting = ', setting);
+                            setting ||= {};
+                            setting[s.name()] = obj;
+                            console.log('saving ' + s.name(), JSON.stringify(obj));
+                            this.store.setItem('eg.grid.filters.' + this.gridContext.persistKey, setting).then( res => {
+                                this.refreshEntries();
+                                console.log('save toast here',res);
+                            });
+                        });
+                    }
+                });
+            }
+        );
+    }
+}
index 8e998d1..a37b5aa 100644 (file)
@@ -1,8 +1,12 @@
 <eg-grid-toolbar-actions-editor #toolbarActionsEditor [gridContext]="gridContext">
 </eg-grid-toolbar-actions-editor>
 
+<eg-grid-manage-filters-dialog #gridManageFiltersDialog [gridContext]="gridContext">
+</eg-grid-manage-filters-dialog>
+
 <div class="eg-grid-toolbar mb-2">
 
+
   <div class="btn-toolbar">
     <span class="fw-bold me-2" *ngIf="gridContext.toolbarLabel">
       {{gridContext.toolbarLabel}}
     <!-- buttons -->
     <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 me-1" (click)="gridContext.removeFilters()"
-        [disabled]="!gridContext.filtersSet() || gridContext.dataSource.requestingData" i18n>
-        Remove Filters
-      </button>
+      <ng-container *ngIf="gridContext.isFilterable">
+        <button *ngIf="!gridContext.allowNamedFilterSets"
+          class="btn btn-outline-dark me-1" (click)="gridContext.removeFilters()"
+          [disabled]="!gridContext.filtersSet() || gridContext.dataSource.requestingData" i18n>
+          Remove Filters
+        </button>
+        <button *ngIf="gridContext.allowNamedFilterSets"
+          class="btn btn-outline-dark me-1"
+          (click)="gridManageFiltersDialog.open().subscribe()"
+          [disabled]="gridContext.dataSource.requestingData" i18n>
+          Manage Filters
+        </button>
+      </ng-container>
       <ng-container *ngFor="let btn of gridContext.toolbarButtons">
         <label
             *ngIf="btn.adjacentPreceedingLabel"
index eab8442..7e08f8f 100644 (file)
@@ -110,6 +110,15 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy {
     // should be displayed
     @Input() filterable: boolean;
 
+    // allowNamedFilterSets: true if the result filtering
+    // controls can be saved or loaded via a name
+    @Input() allowNamedFilterSets: boolean;
+
+    // migrateLegacyFilterSets: if set to a legacy filter interface type
+    // (i.e. url_verify), attempt to migrate any legacy filter sets for
+    // that interface.
+    @Input() migrateLegacyFilterSets: string;
+
     // sticky grid header
     //
     // stickyHeader: true of the grid header should be
@@ -164,6 +173,8 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy {
         this.context.autoGeneratedColumnOrder = this.autoGeneratedColumnOrder;
         this.context.isSortable = this.sortable === true;
         this.context.isFilterable = this.filterable === true;
+        this.context.allowNamedFilterSets = this.allowNamedFilterSets === true;
+        this.context.migrateLegacyFilterSets = this.migrateLegacyFilterSets;
         this.context.stickyGridHeader = this.stickyHeader === true;
         this.context.isMultiSortable = this.multiSortable === true;
         this.context.useLocalSort = this.useLocalSort === true;
index 579235e..5b60409 100644 (file)
@@ -17,6 +17,7 @@ import {GridPrintComponent} from './grid-print.component';
 import {GridFilterControlComponent} from './grid-filter-control.component';
 import {GridToolbarActionsEditorComponent} from './grid-toolbar-actions-editor.component';
 import {GridFlatDataService} from './grid-flat-data.service';
+import {GridManageFiltersDialogComponent} from './grid-manage-filters-dialog.component';
 
 
 @NgModule({
@@ -36,7 +37,8 @@ import {GridFlatDataService} from './grid-flat-data.service';
         GridColumnWidthComponent,
         GridPrintComponent,
         GridFilterControlComponent,
-        GridToolbarActionsEditorComponent
+        GridToolbarActionsEditorComponent,
+        GridManageFiltersDialogComponent
     ],
     imports: [
         EgCommonModule,
index fa1c28a..e0852f3 100644 (file)
@@ -1,7 +1,7 @@
 /**
  * Collection of grid related classses and interfaces.
  */
-import {TemplateRef, EventEmitter, QueryList} from '@angular/core';
+import {TemplateRef, EventEmitter, QueryList, ViewChild} from '@angular/core';
 import {Observable, Subscription, empty} from 'rxjs';
 import {IdlService, IdlObject} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
@@ -78,6 +78,31 @@ export class GridColumn {
         this.filterIncludeOrgDescendants = false;
     }
 
+    loadFilter(f) {
+        this.isFiltered = f.isFiltered;
+        this.filterValue = f.filterValue;
+        this.filterOperator = f.filterOperator;
+        this.filterInputDisabled = f.filterInputDisabled;
+        this.filterIncludeOrgAncestors = f.filterIncludeOrgAncestors;
+        this.filterIncludeOrgDescendants = f.IncludeOrgDescendants;
+    }
+
+    getIdlId(value: any) {
+        const obj: IdlObject = (value as unknown) as IdlObject;
+        return obj.id();
+    }
+
+    getFilter() {
+        return {
+            'isFiltered': this.isFiltered,
+            'filterValue': typeof this.filterValue === 'object' ? this.getIdlId(this.filterValue) : this.filterValue,
+            'filterOperator': this.filterOperator,
+            'filterInputDisabled': this.filterInputDisabled,
+            'filterIncludeOrgAncestors': this.filterIncludeOrgAncestors,
+            'filterIncludeOrgDescendants': this.filterIncludeOrgDescendants
+        }
+    }
+
     clone(): GridColumn {
         const col = new GridColumn();
 
@@ -637,6 +662,8 @@ export class GridContext {
     idlClass: string;
     isSortable: boolean;
     isFilterable: boolean;
+    allowNamedFilterSets: boolean;
+    migrateLegacyFilterSets: string;
     stickyGridHeader: boolean;
     isMultiSortable: boolean;
     useLocalSort: boolean;
@@ -1242,6 +1269,60 @@ export class GridContext {
         this.filterControls.forEach(ctl => ctl.reset());
         this.reload();
     }
+    saveFilters(asName: string): void {
+        const obj = {
+            'filters' : this.dataSource.filters, // filters isn't 100% reversible to column filter values, so...
+            'controls' : Object.fromEntries(new Map( this.columnSet.columns.map( c => [c.name, c.getFilter()] ) ))
+        }
+        this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
+            console.log('saveFilters, setting = ', setting);
+            setting ||= {};
+            setting[asName] = obj;
+            console.log('saving ' + asName, JSON.stringify(obj));
+            this.store.setItem('eg.grid.filters.' + this.persistKey, setting).then( res => {
+                console.log('save toast here',res);
+            });
+        });
+    }
+    deleteFilters(withName: string): void {
+        this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
+            if (setting) {
+                setting[withName] = undefined;
+                if (setting[withName]) {
+                    delete setting[withName]; /* not releasing right away */
+                } else {
+                    console.warn('Could not find ' + withName + ' in eg.grid.filters.' + this.persistKey,setting);
+                }
+                this.store.setItem('eg.grid.filters.' + this.persistKey, setting).then( res => {
+                    console.log('delete toast here',res);
+                });
+            } else {
+                console.warn('Could not find setting eg.grid.filters.' + this.persistKey, setting);
+            }
+        });
+    }
+    loadFilters(fromName: string): void {
+        console.log('fromName',fromName);
+        this.store.getItem('eg.grid.filters.' + this.persistKey).then( setting => {
+            if (setting) {
+                const obj = setting[fromName];
+                if (obj) {
+                    this.dataSource.filters = obj.filters;
+                    Object.keys(obj.controls).forEach( col_name => {
+                        let col = this.columnSet.columns.find(c => c.name === col_name);
+                        if (col) {
+                            col.loadFilter( obj.controls[col_name] );
+                        }
+                    });
+                    this.reload();
+                } else {
+                    console.warn('Could not find ' + fromName + ' in eg.grid.filters.' + this.persistKey, obj);
+                }
+            } else {
+                console.warn('Could not find setting eg.grid.filters.' + this.persistKey, setting);
+            }
+        });
+    }
     filtersSet(): boolean {
         return Object.keys(this.dataSource.filters).length > 0;
     }
index 381f990..3ce5dbb 100644 (file)
@@ -18,7 +18,7 @@
       "node_modules/@types"
     ],
     "lib": [
-      "es2018",
+      "es2019",
       "dom"
     ]
   }