]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/web/js/dojo/openils/widget/PCrudFilterPane.js
Trigger Event Log
[working/Evergreen.git] / Open-ILS / web / js / dojo / openils / widget / PCrudFilterPane.js
1 if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
2
3     /* openils.widget.PCrudFilterPane is a dijit that, given a fieldmapper
4      * class, provides a pane in which users can define inclusionary
5      * filters based on fields selected from the fieldmapper class and values
6      * for those fields.  Operators can be selected so that not only equality
7      * comparisons are possible in the filter, but also inequality filters,
8      * likeness (for text fields only) betweenness, and nullity tests.  *
9      * The dijit yields its result in the form of a JSON query suitable for
10      * use as the where clause of a pcrud search, via the onApply callback.
11      *
12      * In addition to its fmClass paramter, note the useful parameter
13      * suppressFilterFields.  Say for instance you're using this dijit
14      * on an fmClass like "brt" which has a field "record" that points to the
15      * bre class.  The AutoWidget provided for users to enter values for
16      * comparisons on the record field would be a dropdown containing all
17      * the bre ID's in the system!  That would be unusable in any realistic
18      * system, unless/until we teach AutoWidget to use a lazy-loading store
19      * for dropdowns.
20      *
21      * The comparisons in each filter row are "and-ed" together in the JSON
22      * query yielded, except for repetitions of the same field, which are
23      * "or-ed" together /within/ the overall "and" group.  Look at comments
24      * within PCrudFilterRowManager.compile() for more information.
25      *
26      * AutoGrid has some ability to use this dijits based on this to offer a
27      * filtering dialog, but be aware that the filtering dialog is /not/ aware
28      * of other fitering measures in place in a given AutoGrid-based interface,
29      * such as (typically) context org unit selectors, and therefore using the
30      * context org unit selector will not respect selected filters in this
31      * dijit, and vice-versa.
32      */
33
34     dojo.provide('openils.widget.PCrudFilterPane');
35     dojo.require('openils.widget.AutoFieldWidget');
36     dojo.require('dijit.form.FilteringSelect');
37     dojo.require('dijit.form.Button');
38     dojo.require('dojo.data.ItemFileReadStore');
39     dojo.require('openils.Util');
40
41     dojo.requireLocalization("openils.widget", "PCrudFilterPane");
42
43     /* XXX namespace pollution! arg! Fix this whole module sometime. */
44     var localeStrings = dojo.i18n.getLocalization(
45         "openils.widget", "PCrudFilterPane"
46     );
47
48     /* These are the operators that make up the central dropdown in each
49      * row of the widget.  When fields of different datatypes are selected,
50      * some of these operators may be masked via the "minimal" and "strict"
51      * properties.
52      */
53     var _operator_store = new dojo.data.ItemFileReadStore(
54         {
55             "data": {
56                 "identifier": "name",
57                 "items": [
58                     {
59                         "name": "=",
60                         "label": localeStrings.OPERATOR_EQ,
61                         "param_count": 1,
62                         "minimal": true,
63                         "strict": true
64                     }, {
65                         "name": "!=",
66                         "label": localeStrings.OPERATOR_NE,
67                         "param_count": 1,
68                         "minimal": true,
69                         "strict": true
70                     }, {
71                         "name": "null",
72                         "label": localeStrings.OPERATOR_IS_NULL,
73                         "param_count": 0,
74                         "minimal": true,
75                         "strict": true
76                     }, {
77                         "name": "not null",
78                         "label": localeStrings.OPERATOR_IS_NOT_NULL,
79                         "param_count": 0,
80                         "minimal": true,
81                         "strict": true
82                     }, {
83                         "name": ">",
84                         "label": localeStrings.OPERATOR_GT,
85                         "param_count": 1,
86                         "strict": true
87                     }, {
88                         "name": "<",
89                         "label": localeStrings.OPERATOR_LT,
90                         "param_count": 1,
91                         "strict": true
92                     }, {
93                         "name": ">=",
94                         "label": localeStrings.OPERATOR_GTE,
95                         "param_count": 1,
96                         "strict": true
97                     }, {
98                         "name": "<=",
99                         "label": localeStrings.OPERATOR_LTE,
100                         "param_count": 1,
101                         "strict": true
102                     }, {
103                         "name": "between",
104                         "label": localeStrings.OPERATOR_BETWEEN,
105                         "param_count": 2,
106                         "strict": true
107                     }, {
108                         "name": "not between",
109                         "label": localeStrings.OPERATOR_NOT_BETWEEN,
110                         "param_count": 2,
111                         "strict": true
112                     }, {
113                         "name": "like",
114                         "label": localeStrings.OPERATOR_LIKE,
115                         "param_count": 1
116                     }, {
117                         "name": "not like",
118                         "label": localeStrings.OPERATOR_NOT_LIKE,
119                         "param_count": 1
120                     }
121                 ]
122             }
123         }
124     );
125
126     /* The text datatype supports all the above operators for comparisons. */
127     var _store_query_by_datatype = {"text": {}};
128
129     /* These three datatypes support only minimal operators. */
130     ["bool", "link", "org_unit"].forEach(
131         function(type) {
132             _store_query_by_datatype[type] = {"minimal": true};
133         }
134     );
135
136     /* These datatypes support strict operators (everything save [not] like). */
137     ["float", "id", "int", "interval", "money", "number", "timestamp"].forEach(
138         function(type) {
139             _store_query_by_datatype[type] = {"strict": true};
140         }
141     );
142
143     /* This helps convert things that pcrud won't accept ("not between", "not
144      * like") into proper JSON query expressions.
145      * It returns false if a clause doesn't have any such negative operator,
146      * or it returns true AND gets rid of the "not " part in the clause
147      * object itself.  It's up to the caller to wrap it in {"-not": {}} in
148      * the right place. */
149     function _clause_was_negative(clause) {
150         /* clause objects really only ever have one property */
151         var ops = openils.Util.objectProperties(clause);
152         var op = ops.pop();
153         var matches = op.match(/^not (\w+)$/);
154         if (matches) {
155             clause[matches[1]] = clause[op];
156             delete clause[op];
157             return true;
158         }
159         return false;
160     }
161
162     /* This is not the dijit per se. Search further in this file for
163      * "dojo.declare" for the beginning of the dijit.
164      *
165      * This is, however, the object that represents a collection of filter
166      * rows and knows how to compile a filter from those rows. */
167     function PCrudFilterRowManager() {
168         var self = this;
169
170         this._init = function(
171             container, field_store, fm_class, compact, widget_builders,
172             skip_first_add_row, do_apply
173         ) {
174             this.container = container;
175             this.field_store = field_store;
176             this.fm_class = fm_class;
177             this.compact = compact;
178             this.widget_builders = widget_builders || {};
179             this.skip_first_add_row = skip_first_add_row;
180             this.do_apply = do_apply;
181
182             this.rows = {};
183             this.row_index = 0;
184
185             this._build_table();
186         };
187
188         this._build_table = function() {
189             this.table = dojo.create(
190                 "table", {
191                     "className": "oils-pcrudfilterdialog-table"
192                 }, this.container
193             );
194
195             var tr = dojo.create(
196                 "tr", {
197                     "id": "pcrudfilterdialog-empty",
198                     "className": "hidden"
199                 }, this.table
200             );
201
202             dojo.create(
203                 "td", {
204                     "colspan": 4,
205                     "innerHTML": localeStrings[
206                         this.compact ? "EMPTY_CASE_COMPACT" : "EMPTY_CASE"
207                     ]
208                 }, tr
209             );
210
211             if (!this.skip_first_add_row)
212                 this.add_row();
213         };
214
215         this._compile_second_pass = function(first_pass) {
216             var and = [];
217             var result = {"-and": and};
218
219             for (var field in first_pass) {
220                 var list = first_pass[field];
221                 if (list.length == 1) {
222                     var obj = {};
223                     var clause = list.pop();
224                     if (_clause_was_negative(clause)) {
225                         obj["-not"] = {};
226                         obj["-not"][field] = clause;
227                     } else {
228                         obj[field] = clause;
229                     }
230                     and.push(obj);
231                 } else {
232                     var or = list.map(
233                         function(clause) {
234                             var obj = {};
235                             if (_clause_was_negative(clause)) {
236                                 obj["-not"] = {};
237                                 obj["-not"][field] = clause;
238                             } else {
239                                 obj[field] = clause;
240                             }
241                             return obj;
242                         }
243                     );
244                     and.push({"-or": or});
245                 }
246             }
247
248             return result;
249         };
250
251         this.add_row = function(initializer) {
252             this.hide_empty_placeholder();
253             var row_id = this.row_index++;
254             this.rows[row_id] = new PCrudFilterRow(this, row_id, initializer);
255         };
256
257         this.remove_row = function(row_id) {
258             this.rows[row_id].destroy();
259             delete this.rows[row_id];
260
261             if (openils.Util.objectProperties(this.rows).length < 1)
262                 this.show_empty_placeholder();
263
264             if (this.compact)
265                 this.do_apply();
266         };
267
268         this.hide_empty_placeholder = function() {
269             openils.Util.hide("pcrudfilterdialog-empty");
270         };
271
272         this.show_empty_placeholder = function() {
273             openils.Util.show("pcrudfilterdialog-empty");
274         };
275
276         this.compile = function() {
277             /* We'll prepare a first-pass data structure that looks like:
278              * {
279              *  field1: [{"op": "one value"}],
280              *  field2: [{"op": "a value"}, {"op": "b value"}],
281              *  field3: [{"op": "first value"}, {"op": ["range start", "range end"]}]
282              * }
283              *
284              * which will be passed to _compile_second_pass() to yield an
285              * actual filter suitable for pcrud (with -and and -or in all the
286              * right places) so the above example would come out like:
287              *
288              * { "-and": [
289              *   {"field1": {"op": "one value"}},
290              *   {"-or": [ {"field2": {"op": "a value"}}, {"field2": {"op": "b value"}} ] },
291              *   {"-or": [
292              *     {"field3": {"op": "first value"}},
293              *     {"field3": {"op": ["range start", "range end"]}}
294              *   ] }
295              * ] }
296              */
297             var first_pass = {};
298
299             for (var row_id in this.rows) {
300                 var row = this.rows[row_id];
301                 var value = row.compile();
302                 var field = row.selected_field;
303
304                 if (typeof(value) != "undefined" &&
305                     typeof(field) != "undefined") {
306                     if (!first_pass[field])
307                         first_pass[field] = [];
308                     first_pass[field].push(value);
309                 }
310             }
311
312             /* Don't return an empty filter: pcrud can't use that. */
313             if (openils.Util.objectProperties(first_pass).length < 1) {
314                 var result = {};
315                 result[fieldmapper[this.fm_class].Identifier] = {"!=": null};
316                 return result;
317             } else {
318                 return this._compile_second_pass(first_pass);
319             }
320         };
321
322         this._init.apply(this, arguments);
323     }
324
325     /* As the name implies, objects of this class manage a single row of the
326      * query.  Therefore they know about their own field dropdown, their own
327      * selector dropdown, and their own value widget (or widgets in the case
328      * of between searches, which call for two widgets to define a range),
329      * and not much else. */
330     function PCrudFilterRow() {
331         var self = this;
332
333         this._init = function(filter_row_manager, row_id, initializer) {
334             this.filter_row_manager = filter_row_manager;
335             this.row_id = row_id;
336
337             if (this.filter_row_manager.compact)
338                 this._build_compact();
339             else
340                 this._build();
341
342             if (initializer)
343                 this.initialize(initializer);
344         };
345
346         this._build = function() {
347             this.tr = dojo.create("tr", {}, this.filter_row_manager.table);
348
349             this._create_field_selector();
350             this._create_operator_selector();
351             this._create_value_slot();
352             this._create_remover();
353         };
354
355         this._build_compact = function() {
356             this.tr = dojo.create("tr", {}, this.filter_row_manager.table);
357
358             var td = dojo.create("td", {}, this.tr);
359
360             this._create_field_selector(td);
361             this._create_operator_selector(td);
362
363             dojo.create("br", {}, td);
364             this._create_value_slot(td);
365
366             td = dojo.create(
367                 "td",
368                 {"className": "oils-pcrudfilterdialog-remover-holder"},
369                 this.tr
370             );
371
372             this._create_remover(td);
373         };
374
375         this._create_field_selector = function(use_element) {
376             var td = use_element || dojo.create("td", {}, this.tr);
377
378             this.field_selector = new dijit.form.FilteringSelect(
379                 {
380                     "labelAttr": "label",
381                     "searchAttr": "label",
382                     "scrollOnFocus": false,
383                     "onChange": function(value) {
384                         self.update_selected_field(value);
385                     },
386                     "store": this.filter_row_manager.field_store
387                 }, dojo.create("span", {}, td)
388             );
389         };
390
391         this._create_operator_selector = function(use_element) {
392             var td = use_element || dojo.create("td", {}, this.tr);
393
394             this.operator_selector = new dijit.form.FilteringSelect(
395                 {
396                     "labelAttr": "label",
397                     "searchAttr": "label",
398                     "scrollOnFocus": false,
399                     "onChange": function(value) {
400                         self.update_selected_operator(value);
401                     },
402                     "store": _operator_store
403                 }, dojo.create("span", {}, td)
404             );
405         };
406
407         this._adjust_operator_selector = function() {
408             this.operator_selector.attr(
409                 "query", _store_query_by_datatype[this.selected_field_type]
410             );
411             this.operator_selector.reset();
412         };
413
414         this._create_value_slot = function(use_element) {
415             if (use_element)
416                 this.value_slot = dojo.create(
417                     "span", {"innerHTML": "-"}, use_element
418                 );
419             else
420                 this.value_slot = dojo.create("td",{"innerHTML":"-"},this.tr);
421         };
422
423         this._create_remover = function(use_element) {
424             var td = use_element || dojo.create("td", {}, this.tr);
425             var anchor = dojo.create(
426                 "a", {
427                     "className": "oils-pcrudfilterdialog-remover",
428                     "innerHTML": "X",
429                     "href": "#",
430                     "onclick": function() {
431                         self.filter_row_manager.remove_row(self.row_id);
432                     }
433                 }, td
434             );
435         };
436
437         this._clear_value_slot = function() {
438             if (this.value_widgets) {
439                 this.value_widgets.forEach(
440                     function(autowidg) { autowidg.widget.destroy(); }
441                 );
442                 delete this.value_widgets;
443             }
444
445             dojo.empty(this.value_slot);
446         };
447
448         this._rebuild_value_widgets = function() {
449             this._clear_value_slot();
450
451             if (!this.get_selected_operator() || !this.selected_field)
452                 return;
453
454             this.value_widgets = [];
455
456             var param_count = this.operator_selector.item.param_count;
457
458             /* This is where find and deploy custom widget builders. */
459             var widget_builder_key = this.selected_field_fm_class + ":" +
460                 this.selected_field_fm_field;
461             var constr =
462                 this.filter_row_manager.widget_builders[widget_builder_key] ||
463                 openils.widget.AutoFieldWidget;
464
465             for (var i = 0; i < param_count; i++) {
466                 var widg = new constr({
467                     "fmClass": this.selected_field_fm_class,
468                     "fmField": this.selected_field_fm_field,
469                     "parentNode": dojo.create("span", {}, this.value_slot),
470                     "dijitArgs": {"scrollOnFocus": false}
471                 });
472
473                 widg.build();
474                 this.value_widgets.push(widg);
475             }
476         };
477
478         /* for ugly special cases in compliation */
479         this._null_clause = function() {
480             var opname = this.get_selected_operator_name();
481             if (opname == "not null")
482                 return {"!=": null};
483             else if (opname == "null")
484                 return null;
485             else
486                 return;
487         };
488
489         this.get_selected_operator = function() {
490             if (this.operator_selector)
491                 return this.operator_selector.item;
492         };
493
494         this.get_selected_operator_name = function() {
495             var op = this.get_selected_operator();
496             return op ? op.name : null;
497         };
498
499         this.update_selected_operator = function(value) {
500             this._rebuild_value_widgets();
501         };
502
503         this.update_selected_field = function(value) {
504             if (this.field_selector.item) {
505                 this.selected_field = value;
506                 this.selected_field_type = this.field_selector.item.type;
507
508                 /* This is really about supporting flattenergrid, of which
509                  * we're in the superclass (in a sloppy sad way). From now
510                  * on I won't mix this kind of lazy object with Dojo modules. */
511                 this.selected_field_fm_field = this.field_selector.item.name;
512                 this.selected_field_is_indirect =
513                     this.field_selector.item.indirect || false;
514                 this.selected_field_fm_class =
515                     this.field_selector.item.fmClass ||
516                     this.filter_row_manager.fm_class;
517
518                 this._adjust_operator_selector();
519                 this._rebuild_value_widgets();
520             }
521         };
522
523         this.compile = function() {
524             if (this.value_widgets) {
525                 var values = this.value_widgets.map(
526                     function(widg) {
527                         if (widg.useCorrectly)
528                             return widg.widget.attr("value");
529                         else if (self.selected_field_is_indirect)
530                             return widg.widget.attr("displayedValue");
531                         else
532                             return widg.getFormattedValue();
533                     }
534                 );
535
536                 if (!values.length) {
537                     return this._null_clause(); /* null/not null */
538                 } else {
539                     var clause = {};
540                     var op = this.get_selected_operator_name();
541                     if (values.length == 1)
542                         clause[op] = values.pop();
543                     else
544                         clause[op] = values;
545                     return clause;
546                 }
547             } else {
548                 return;
549             }
550         };
551
552         this.destroy = function() {
553             this._clear_value_slot();
554             this.field_selector.destroy();
555             if (this.operator_selector)
556                 this.operator_selector.destroy();
557
558             dojo.destroy(this.tr);
559         };
560
561         this.initialize = function(initializer) {
562             this.field_selector.attr("value", initializer.field);
563             this.operator_selector.attr("value", initializer.operator);
564
565             /* Caller supplies value for one value, values (array) for
566              * multiple. */
567             if (!initializer.values || !dojo.isArray(initializer.values))
568                 initializer.values = [initializer.values || initializer.value];
569
570             for (var i = 0; i < initializer.values.length; i++) {
571                 this.value_widgets[i].widget.attr(
572                     "value", initializer.values[i]
573                 );
574             }
575         };
576
577         this._init.apply(this, arguments);
578     }
579
580     dojo.declare(
581         "openils.widget.PCrudFilterPane", [openils.widget.AutoWidget],
582         {
583             "useDiv": null, /* should always be null for subclass dialogs */
584             "initializers": null,
585             "compact": false,
586             "widgetBuilders": null,
587
588             "constructor": function(args) {
589                 for(var k in args)
590                     this[k] = args[k];
591                 this.widgetIndex = 0;
592                 this.widgetCache = {};
593
594                 /* Meaningless in a pane, but better here than in
595                  * PCrudFilterDialog so that we don't need to load i18n
596                  * strings there: */
597                 this.title = this.title || localeStrings.DEFAULT_DIALOG_TITLE;
598             },
599
600             "_buildButtons": function() {
601                 var self = this;
602
603                 var button_holder = dojo.create(
604                     "div", {
605                         "className": "oils-pcrudfilterdialog-buttonholder"
606                     }, this.domNode
607                 );
608
609                 new dijit.form.Button(
610                     {
611                         "label": localeStrings.ADD_ROW,
612                         "scrollOnFocus": false, /* almost always better */
613                         "onClick": function() {
614                             self.filter_row_manager.add_row();
615                         }
616                     }, dojo.create("span", {}, button_holder)
617                 );
618
619                 this._apply_button = new dijit.form.Button(
620                     {
621                         "label": localeStrings.APPLY,
622                         "scrollOnFocus": false,
623                         "onClick": function() { self.doApply(); }
624                     }, dojo.create("span", {}, button_holder)
625                 );
626
627                 if (!this.useDiv) {
628                     new dijit.form.Button(
629                         {
630                             "label": localeStrings.CANCEL,
631                             "scrollOnFocus": false,
632                             "onClick": function() {
633                                 if (self.onCancel)
634                                     self.onCancel();
635                                 self.hide();
636                             }
637                         }, dojo.create("span", {}, button_holder)
638                     );
639                 }
640             },
641
642             "_buildFieldStore": function() {
643                 var self = this;
644                 var realFieldList = this.sortedFieldList.filter(
645                     function(item) { return !(item.virtual || item.nonIdl); }
646                 );
647
648                 /* Prevent any explicitly unwanted fields from being available
649                  * in our field dropdowns. */
650                 if (dojo.isArray(this.suppressFilterFields)) {
651                     realFieldList = realFieldList.filter(
652                         function(item) {
653                             for (
654                                 var i = 0;
655                                 i < self.suppressFilterFields.length;
656                                 i++
657                             ) {
658                                 if (item.name == self.suppressFilterFields[i])
659                                     return false;
660                             }
661                             return true;
662                         }
663                     );
664                 }
665
666                 this.fieldStore = new dojo.data.ItemFileReadStore({
667                     "data": {
668                         "identifier": "name",
669                         "name": "label",
670                         "items": realFieldList.map(
671                             function(item) {
672                                 return {
673                                     "label": item.label,
674                                     "name": item.name,
675                                     "type": item.datatype
676                                 };
677                             }
678                         )
679                     }
680                 });
681             },
682
683             "hide": function() {
684                 try {
685                     this.inherited(arguments);
686                 } catch (E) {
687                     /* When using *FilterPane directly (without a *Dialog
688                      * subclass), do nothing.  */
689                     void(0);
690                 }
691             },
692
693             /* All we really do here is create a data store out of the fields
694              * from the IDL for our given class, place a few buttons at the
695              * bottom of the dialog, and hand off to PCrudFilterRowManager to
696              * do the actual work.
697              */
698
699             "startup": function() {
700                 if (this.useDiv)
701                     this.domNode = this.useDiv;
702
703                 try {
704                     this.inherited(arguments);
705                 } catch (E) {
706                     /* When using *FilterPane directly (without a *Dialog
707                      * subclass), there is no startup method in any ancestor
708                      * class. XXX Refactor?
709                      */
710                     void(0);
711                 }
712
713                 this.initAutoEnv();
714
715                 this._buildFieldStore();
716
717                 this.filter_row_manager = new PCrudFilterRowManager(
718                     dojo.create("div", {}, this.domNode),
719                     this.fieldStore, this.fmClass, this.compact,
720                     this.widgetBuilders,
721                     Boolean(this.initializers)  /* avoid adding empty row */,
722                     dojo.hitch(this, function() { this.doApply(); })
723                 );
724
725                 this._buildButtons();
726
727                 if (this.initializers) {
728                     this.initializers.forEach(
729                         dojo.hitch(this, function(initializer) {
730                             this.filter_row_manager.add_row(initializer);
731                         })
732                     );
733                 }
734             },
735
736             /* This should just be named 'apply', but that is kind of a special
737              * word in Javascript, no? */
738             "doApply": function() {
739                 this._apply_button.attr("disabled", true);
740
741                 var _E; /* Try pretty hard not to leave the apply button
742                            disabled forever, even if 'apply' blows up. */
743                 try {
744                     if (this.onApply)
745                         this.onApply(this.filter_row_manager.compile());
746                 } catch (E) {
747                     _E = E;
748                 }
749                 this.hide();
750                 this._apply_button.attr("disabled", false);
751
752                 if (_E) throw _E;
753             }
754         }
755     );
756 }