From 86a7abec412afe4f348e53c5a449c599c3648319 Mon Sep 17 00:00:00 2001 From: Lebbeous Fogle-Weekley Date: Mon, 9 Jan 2012 14:45:04 -0500 Subject: [PATCH] Major enhancements to openils.widget.PCrudFilterDialog openils.widget.PCrudFilterDialog is (now) a dijit that allows users to define inclusionary filters based on any number of rows that specify a field from a given fieldmapper class, an operator, and a value (or range of values, for the [not] between operator). The resulting filter is suitable for use as the where clause of a pcrud search call. AutoGrid has an option, showLoadFilter, that allows you to easily provide a filter dialog in any AutoGrid-based interface, with some minor caveats (see the comments at the top of the source for openils.widget.PCrudFilterDialog). AutoGrid will also pass along the suppressFilterFields option, which works analagously to the suppressEditFields option. You can also build your own interfaces to use this dijit without AutoGrid, if desired. Signed-off-by: Lebbeous Fogle-Weekley Signed-off-by: Mike Rylander --- Open-ILS/web/css/skin/default.css | 7 + .../web/js/dojo/openils/widget/AutoGrid.js | 17 +- .../dojo/openils/widget/PCrudFilterDialog.js | 636 +++++++++++++++--- .../openils/widget/nls/PCrudFilterDialog.js | 19 + 4 files changed, 590 insertions(+), 89 deletions(-) create mode 100644 Open-ILS/web/js/dojo/openils/widget/nls/PCrudFilterDialog.js diff --git a/Open-ILS/web/css/skin/default.css b/Open-ILS/web/css/skin/default.css index 4fa7215174..1973b4987b 100644 --- a/Open-ILS/web/css/skin/default.css +++ b/Open-ILS/web/css/skin/default.css @@ -87,6 +87,13 @@ table { border-collapse: collapse; } vertical-align:middle; } .oils-fm-edit-dialog td { border:1px solid #999;} +.oils-pcrudfilterdialog-table tr td { padding: 0.75ex 0.5em; } +.oils-pcrudfilterdialog-remover { + background-color: #ccc; color: #f00; + padding: 0.25em; border: 1px outset #000; + text-decoration: none; +} +.oils-pcrudfilterdialog-buttonholder > * { padding: 0 2em; } .oils-header-panel { width: 100%; margin-top:20px; diff --git a/Open-ILS/web/js/dojo/openils/widget/AutoGrid.js b/Open-ILS/web/js/dojo/openils/widget/AutoGrid.js index ab42a00813..921f97766d 100644 --- a/Open-ILS/web/js/dojo/openils/widget/AutoGrid.js +++ b/Open-ILS/web/js/dojo/openils/widget/AutoGrid.js @@ -23,6 +23,7 @@ if(!dojo._hasResource['openils.widget.AutoGrid']) { editReadOnly : false, suppressFields : null, suppressEditFields : null, + suppressFilterFields : null, hideSelector : false, selectorWidth : '1.5', showColumnPicker : false, @@ -119,13 +120,15 @@ if(!dojo._hasResource['openils.widget.AutoGrid']) { style : 'padding-right:6px;', href : 'javascript:void(0);', onclick : function() { - var dialog = new openils.widget.PCrudFilterDialog({fmClass:self.fmClass}) - dialog.onApply = function(filter) { - self.resetStore(); - self.loadAll(self.cachedQueryOpts, filter); - }; - dialog.startup(); - dialog.show(); + if (!self.filterDialog) { + self.filterDialog = new openils.widget.PCrudFilterDialog({fmClass:self.fmClass, suppressFilterFields:self.suppressFilterFields}) + self.filterDialog.onApply = function(filter) { + self.resetStore(); + self.loadAll(self.cachedQueryOpts, filter); + }; + self.filterDialog.startup(); + } + self.filterDialog.show(); } }), this.paginator.domNode diff --git a/Open-ILS/web/js/dojo/openils/widget/PCrudFilterDialog.js b/Open-ILS/web/js/dojo/openils/widget/PCrudFilterDialog.js index 5d9fb0c9a5..9ed23ce7dd 100644 --- a/Open-ILS/web/js/dojo/openils/widget/PCrudFilterDialog.js +++ b/Open-ILS/web/js/dojo/openils/widget/PCrudFilterDialog.js @@ -1,5 +1,36 @@ +if (!dojo._hasResource['openils.widget.PCrudFilterDialog']) { -if(!dojo._hasResource['openils.widget.PCrudFilterDialog']) { + /* openils.widget.PCrudFilterDialog is a dijit that, given a fieldmapper + * class, provides a dialog in which users can define inclusionary + * filters based on fields selected from the fieldmapper class and values + * for those fields. Operators can be selected so that not only equality + * comparisons are possible in the filter, but also inequality filters, + * likeness (for text fields only) betweenness, and nullity tests. + * + * The dijit yields its result in the form of a JSON query suitable for + * use as the where clause of a pcrud search, via the onApply callback. + * + * In addition to its fmClass paramter, note the useful parameter + * suppressFilterFields. Say for instance you're using this dijit + * on an fmClass like "brt" which has a field "record" that points to the + * bre class. The AutoWidget provided for users to enter values for + * comparisons on the record field would be a dropdown containing all + * the bre ID's in the system! That would be unusable in any realistic + * system, unless/until we teach AutoWidget to use a lazy-loading store + * for dropdowns. + * + * The comparisons in each filter row are "and-ed" together in the JSON + * query yielded, except for repetitions of the same field, which are + * "or-ed" together /within/ the overall "and" group. Look at comments + * within PCrudFilterRowManager.compile() for more information. + * + * AutoGrid has some ability to use this dijit to offer a filtering dialog, + * but be aware that the filtering dialog is /not/ aware of other + * fitering measures in place in a given AutoGrid-based interface, such as + * (typically) context org unit selectors, and therefore using the context + * org unit selector will not respect selected filters in this dijit, and + * vice-versa. + */ dojo.provide('openils.widget.PCrudFilterDialog'); dojo.require('openils.widget.AutoFieldWidget'); dojo.require('dijit.form.FilteringSelect'); @@ -8,9 +39,455 @@ if(!dojo._hasResource['openils.widget.PCrudFilterDialog']) { dojo.require('dijit.Dialog'); dojo.require('openils.Util'); - /** - * Given a fieldmapper object, this builds a pop-up dialog used for editing the object + dojo.requireLocalization("openils.widget", "PCrudFilterDialog"); + var localeStrings = dojo.i18n.getLocalization( + "openils.widget", "PCrudFilterDialog" + ); + + /* These are the operators that make up the central dropdown in each + * row of the widget. When fields of different datatypes are selected, + * some of these operators may be masked via the "minimal" and "strict" + * properties. */ + var _operator_store = new dojo.data.ItemFileReadStore( + { + "data": { + "identifier": "name", + "items": [ + { + "name": "=", + "label": localeStrings.OPERATOR_EQ, + "param_count": 1, + "minimal": true, + "strict": true + }, { + "name": "!=", + "label": localeStrings.OPERATOR_NE, + "param_count": 1, + "minimal": true, + "strict": true + }, { + "name": "null", + "label": localeStrings.OPERATOR_IS_NULL, + "param_count": 0, + "minimal": true, + "strict": true + }, { + "name": "not null", + "label": localeStrings.OPERATOR_IS_NOT_NULL, + "param_count": 0, + "minimal": true, + "strict": true + }, { + "name": ">", + "label": localeStrings.OPERATOR_GT, + "param_count": 1, + "strict": true + }, { + "name": "<", + "label": localeStrings.OPERATOR_LT, + "param_count": 1, + "strict": true + }, { + "name": ">=", + "label": localeStrings.OPERATOR_GTE, + "param_count": 1, + "strict": true + }, { + "name": "<=", + "label": localeStrings.OPERATOR_LTE, + "param_count": 1, + "strict": true + }, { + "name": "between", + "label": localeStrings.OPERATOR_BETWEEN, + "param_count": 2, + "strict": true + }, { + "name": "not between", + "label": localeStrings.OPERATOR_NOT_BETWEEN, + "param_count": 2, + "strict": true + }, { + "name": "like", + "label": localeStrings.OPERATOR_LIKE, + "param_count": 1 + }, { + "name": "not like", + "label": localeStrings.OPERATOR_NOT_LIKE, + "param_count": 1 + } + ] + } + } + ); + + /* The text datatype supports all the above operators for comparisons. */ + var _store_query_by_datatype = {"text": {}}; + + /* These three datatypes support only minimal operators. */ + ["bool", "link", "org_unit"].forEach( + function(type) { + _store_query_by_datatype[type] = {"minimal": true}; + } + ); + + /* These datatypes support strict operators (everything save [not] like). */ + ["float", "id", "int", "interval", "money", "number", "timestamp"].forEach( + function(type) { + _store_query_by_datatype[type] = {"strict": true}; + } + ); + + /* This helps convert things that pcrud won't accept ("not between", "not + * like") into proper JSON query expressions. + * It returns false if a clause doesn't have any such negative operator, + * or it returns true AND gets rid of the "not " part in the clause + * object itself. It's up to the caller to wrap it in {"-not": {}} in + * the right place. */ + function _clause_was_negative(clause) { + /* clause objects really only ever have one property */ + var ops = openils.Util.objectProperties(clause); + var op = ops.pop(); + var matches = op.match(/^not (\w+)$/); + if (matches) { + clause[matches[1]] = clause[op]; + delete clause[op]; + return true; + } + return false; + } + + /* This is not the dijit per se. Search further in this file for + * "dojo.declare" for the beginning of the dijit. + * + * This is, however, the object that represents a collection of filter + * rows and knows how to compile a filter from those rows. */ + function PCrudFilterRowManager() { + var self = this; + + this._init = function(container, field_store, fm_class) { + this.container = container; + this.field_store = field_store; + this.fm_class = fm_class; + + this.rows = {}; + this.row_index = 0; + + this._build_table(); + }; + + this._build_table = function() { + this.table = dojo.create( + "table", { + "className": "oils-pcrudfilterdialog-table" + }, this.container + ); + + var tr = dojo.create( + "tr", { + "id": "pcrudfilterdialog-empty", + "className": "hidden" + }, this.table + ); + + dojo.create( + "td", { + "colspan": 4, + "innerHTML": localeStrings.EMPTY_CASE + }, tr + ); + + this.add_row(); + }; + + this._compile_second_pass = function(first_pass) { + var and = []; + var result = {"-and": and}; + + for (var field in first_pass) { + var list = first_pass[field]; + if (list.length == 1) { + var obj = {}; + var clause = list.pop(); + if (_clause_was_negative(clause)) { + obj["-not"] = {}; + obj["-not"][field] = clause; + } else { + obj[field] = clause; + } + and.push(obj); + } else { + var or = list.map( + function(clause) { + var obj = {}; + if (_clause_was_negative(clause)) { + obj["-not"] = {}; + obj["-not"][field] = clause; + } else { + obj[field] = clause; + } + return obj; + } + ); + and.push({"-or": or}); + } + } + + return result; + }; + + this.add_row = function() { + this.hide_empty_placeholder(); + var row_id = this.row_index++; + this.rows[row_id] = new PCrudFilterRow(this, row_id); + }; + + this.remove_row = function(row_id) { + this.rows[row_id].destroy(); + delete this.rows[row_id]; + + if (openils.Util.objectProperties(this.rows).length < 1) + this.show_empty_placeholder(); + }; + + this.hide_empty_placeholder = function() { + openils.Util.hide("pcrudfilterdialog-empty"); + }; + + this.show_empty_placeholder = function() { + openils.Util.show("pcrudfilterdialog-empty"); + }; + + this.compile = function() { + /* We'll prepare a first-pass data structure that looks like: + * { + * field1: [{"op": "one value"}], + * field2: [{"op": "a value"}, {"op": "b value"}], + * field3: [{"op": "first value"}, {"op": ["range start", "range end"]}] + * } + * + * which will be passed to _compile_second_pass() to yield an + * actual filter suitable for pcrud (with -and and -or in all the + * right places) so the above example would come out like: + * + * { "-and": [ + * {"field1": {"op": "one value"}}, + * {"-or": [ {"field2": {"op": "a value"}}, {"field2": {"op": "b value"}} ] }, + * {"-or": [ + * {"field3": {"op": "first value"}}, + * {"field3": {"op": ["range start", "range end"]}} + * ] } + * ] } + */ + var first_pass = {}; + + for (var row_id in this.rows) { + var row = this.rows[row_id]; + var value = row.compile(); + var field = row.selected_field; + + if (typeof(value) != "undefined" && + typeof(field) != "undefined") { + if (!first_pass[field]) + first_pass[field] = []; + first_pass[field].push(value); + } + } + + /* Don't return an empty filter: pcrud can't use that. */ + if (openils.Util.objectProperties(first_pass).length < 1) { + var result = {}; + result[fieldmapper[this.fm_class].Identifier] = {"!=": null}; + return result; + } else { + return this._compile_second_pass(first_pass); + } + }; + + this._init.apply(this, arguments); + } + + /* As the name implies, objects of this class manage a single row of the + * query. Therefore they know about their own field dropdown, their own + * selector dropdown, and their own value widget (or widgets in the case + * of between searches, which call for two widgets to define a range), + * and not much else. */ + function PCrudFilterRow() { + var self = this; + + this._init = function(filter_row_manager, row_id) { + this.filter_row_manager = filter_row_manager; + this.row_id = row_id; + + this._build(); + }; + + this._build = function() { + this.tr = dojo.create("tr", {}, this.filter_row_manager.table); + + this._create_field_selector(); + this._create_operator_selector(); + this._create_value_slot(); + this._create_remover(); + }; + + this._create_field_selector = function() { + var td = dojo.create("td", {}, this.tr); + this.field_selector = new dijit.form.FilteringSelect( + { + "labelAttr": "label", + "searchAttr": "label", + "scrollOnFocus": false, + "onChange": function(value) { + self.update_selected_field(value); + }, + "store": this.filter_row_manager.field_store + }, dojo.create("span", {}, td) + ); + }; + + this._create_operator_selector = function() { + var td = dojo.create("td", {}, this.tr); + this.operator_selector = new dijit.form.FilteringSelect( + { + "labelAttr": "label", + "searchAttr": "label", + "scrollOnFocus": false, + "onChange": function(value) { + self.update_selected_operator(value); + }, + "store": _operator_store + }, dojo.create("span", {}, td) + ); + }; + + this._adjust_operator_selector = function() { + this.operator_selector.attr( + "query", _store_query_by_datatype[this.selected_field_type] + ); + this.operator_selector.reset(); + }; + + this._create_value_slot = function() { + this.value_slot = dojo.create("td", {"innerHTML": "-"}, this.tr); + }; + + this._create_remover = function() { + var td = dojo.create("td", {}, this.tr); + var anchor = dojo.create( + "a", { + "className": "oils-pcrudfilterdialog-remover", + "innerHTML": "X", + "href": "#", + "onclick": function() { + self.filter_row_manager.remove_row(self.row_id); + } + }, td + ); + }; + + this._clear_value_slot = function() { + if (this.value_widgets) { + this.value_widgets.forEach( + function(autowidg) { autowidg.widget.destroy(); } + ); + delete this.value_widgets; + } + + dojo.empty(this.value_slot); + }; + + this._rebuild_value_widgets = function() { + this._clear_value_slot(); + + if (!this.get_selected_operator() || !this.selected_field) + return; + + this.value_widgets = []; + + var param_count = this.operator_selector.item.param_count; + + for (var i = 0; i < param_count; i++) { + var widg = new openils.widget.AutoFieldWidget({ + "fmClass": this.filter_row_manager.fm_class, + "fmField": this.selected_field, + "parentNode": dojo.create("span", {}, this.value_slot), + "dijitArgs": {"scrollOnFocus": false} + }); + + widg.build(); + this.value_widgets.push(widg); + } + }; + + /* for ugly special cases in compliation */ + this._null_clause = function() { + var opname = this.get_selected_operator_name(); + if (opname == "not null") + return {"!=": null}; + else if (opname == "null") + return null; + else + return; + }; + + this.get_selected_operator = function() { + if (this.operator_selector) + return this.operator_selector.item; + }; + + this.get_selected_operator_name = function() { + var op = this.get_selected_operator(); + return op ? op.name : null; + }; + + this.update_selected_operator = function(value) { + this._rebuild_value_widgets(); + }; + + this.update_selected_field = function(value) { + if (this.field_selector.item) { + this.selected_field = value; + this.selected_field_type = this.field_selector.item.type; + this._adjust_operator_selector(); + this._rebuild_value_widgets(); + } + }; + + this.compile = function() { + if (this.value_widgets) { + var values = this.value_widgets.map( + function(widg) { return widg.getFormattedValue(); } + ); + + if (!values.length) { + return this._null_clause(); /* null/not null */ + } else { + var clause = {}; + var op = this.get_selected_operator_name(); + if (values.length == 1) + clause[op] = values.pop(); + else + clause[op] = values; + return clause; + } + } else { + return; + } + }; + + this.destroy = function() { + this._clear_value_slot(); + this.field_selector.destroy(); + if (this.operator_selector) + this.operator_selector.destroy(); + + dojo.destroy(this.tr); + }; + + this._init.apply(this, arguments); + } dojo.declare( 'openils.widget.PCrudFilterDialog', @@ -20,109 +497,104 @@ if(!dojo._hasResource['openils.widget.PCrudFilterDialog']) { constructor : function(args) { for(var k in args) this[k] = args[k]; + this.title = this.title || localeStrings.DEFAULT_DIALOG_TITLE; this.widgetIndex = 0; this.widgetCache = {}; }, - /** - * Builds a basic table of key / value pairs. Keys are IDL display labels. - * Values are dijit's, when values set + /* All we really do here is create a data store out of the fields + * from the IDL for our given class, place a few buttons at the + * bottom of the dialog, and hand off to PCrudFilterRowManager to + * do the actual work. */ + startup : function() { var self = this; this.inherited(arguments); this.initAutoEnv(); var realFieldList = this.sortedFieldList.filter( - function(item) { return !(item.virtual || item.nonIdl); }); + function(item) { return !(item.virtual || item.nonIdl); } + ); + + /* Prevent any explicitly unwanted fields from being available + * in our field dropdowns. */ + if (dojo.isArray(this.suppressFilterFields)) { + realFieldList = realFieldList.filter( + function(item) { + for ( + var i = 0; + i < self.suppressFilterFields.length; + i++ + ) { + if (item.name == self.suppressFilterFields[i]) + return false; + } + return true; + } + ); + } + this.fieldStore = new dojo.data.ItemFileReadStore({ - data : { - identifier : 'name', - name : 'label', - items : realFieldList.map( + "data": { + "identifier": "name", + "name": "label", + "items": realFieldList.map( function(item) { - return {label:item.label, name:item.name}; + return { + "label": item.label, + "name": item.name, + "type": item.datatype + }; } ) } }); - - // TODO i18n/CSS - dojo.place( - dojo.create( - 'div', - {innerHTML:'Filter Selector', style:'text-align:center;width:100%;padding:10px;'} - ), this.domNode); - - dojo.place( - new dijit.form.Button({ - label:"Apply", - onClick : function() { - if(self.onApply) - self.onApply(self.compileFilter()); - self.hide(); - } - }).domNode, this.domNode); - - dojo.place( - new dijit.form.Button({ - label:"Cancel", - onClick : function() { - if(self.onCancel) - self.onCancel(); - self.hide(); - } - }).domNode, this.domNode); - - this.table = dojo.place(dojo.create('table'), this.domNode); - openils.Util.addCSSClass(this.table, 'oils-fm-edit-dialog'); - this.insertFieldSelector(); - }, - insertFieldSelector : function() { - var selector = new dijit.form.FilteringSelect({labelAttr:'label', store:this.fieldStore}); - var row = dojo.place(dojo.create('tr'), this.table); - var selectorTd = dojo.place(dojo.create('td'), row); - var valueTd = dojo.place(dojo.create('td'), row); - dojo.place(selector.domNode, selectorTd); + this.filter_row_manager = new PCrudFilterRowManager( + dojo.create("div", {}, this.domNode), + this.fieldStore, this.fmClass + ); - // dummy text box - dojo.place(new dijit.form.TextBox().domNode, valueTd); + var button_holder = dojo.create( + "div", { + "className": "oils-pcrudfilterdialog-buttonholder" + }, this.domNode + ); - // when a field is selected, update the value widget - var self = this; - dojo.connect(selector, 'onChange', - function(value) { - - if(valueTd.childNodes[0]) - valueTd.removeChild(valueTd.childNodes[0]); - - var widget = new openils.widget.AutoFieldWidget({ - fmClass : self.fmClass, - fmField : value, - parentNode : dojo.place(dojo.create('div'), valueTd) - }); - widget.build(); - - if(self.widgetCache[selector.widgetIndex]) { - self.widgetCache[selector.widgetIndex].widget.destroy(); - delete self.widgetCache[selector.widgetIndex]; + new dijit.form.Button( + { + "label": localeStrings.ADD_ROW, + "scrollOnFocus": false, /* almost always better */ + "onClick": function() { + self.filter_row_manager.add_row(); } + }, dojo.create("span", {}, button_holder) + ); - selector.widgetIndex = this.widgetIndex; - self.widgetCache[self.widgetIndex++] = widget; - } + new dijit.form.Button( + { + "label": localeStrings.APPLY, + "scrollOnFocus": false, + "onClick": function() { + if (self.onApply) + self.onApply(self.filter_row_manager.compile()); + self.hide(); + } + }, dojo.create("span", {}, button_holder) ); - }, - compileFilter : function() { - var filter = {}; - for(var i in this.widgetCache) { - var widget = this.widgetCache[i]; - filter[widget.fmField] = widget.getFormattedValue(); - } - return filter; + new dijit.form.Button( + { + "label": localeStrings.CANCEL, + "scrollOnFocus": false, + "onClick": function() { + if (self.onCancel) + self.onCancel(); + self.hide(); + } + }, dojo.create("span", {}, button_holder) + ); } } ); } - diff --git a/Open-ILS/web/js/dojo/openils/widget/nls/PCrudFilterDialog.js b/Open-ILS/web/js/dojo/openils/widget/nls/PCrudFilterDialog.js new file mode 100644 index 0000000000..9ad4e97d31 --- /dev/null +++ b/Open-ILS/web/js/dojo/openils/widget/nls/PCrudFilterDialog.js @@ -0,0 +1,19 @@ +{ + "OPERATOR_EQ": "is", + "OPERATOR_NE": "is not", + "OPERATOR_IS_NULL": "is null", + "OPERATOR_IS_NOT_NULL": "is not null", + "OPERATOR_LT": "is less than", + "OPERATOR_GT": "is greater than", + "OPERATOR_LTE": "is less than or equal to", + "OPERATOR_GTE": "is greater than or equal to", + "OPERATOR_BETWEEN": "is between", + "OPERATOR_NOT_BETWEEN": "is not between", + "OPERATOR_LIKE": "is like", + "OPERATOR_NOT_LIKE": "is not like", + "EMPTY_CASE": "Add rows to filter results, or just click Apply to see unfiltered results.", + "DEFAULT_DIALOG_TITLE": "Filter Results", + "ADD_ROW": "Add Row", + "APPLY": "Apply", + "CANCEL": "Cancel" +} -- 2.43.2