]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/web/js/dojo/openils/widget/PCrudFilterPane.js
684980dabf9a5719cf002261cd6e924aef49b756
[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         /* wrap s in %'s unless it already contains at least one %. */
490         this._add_like_wildcards = function(s) {
491             return s.indexOf("%") == -1 ? ("%" + s + "%") : s;
492         };
493
494         this.get_selected_operator = function() {
495             if (this.operator_selector)
496                 return this.operator_selector.item;
497         };
498
499         this.get_selected_operator_name = function() {
500             var op = this.get_selected_operator();
501             return op ? op.name : null;
502         };
503
504         this.update_selected_operator = function(value) {
505             this._rebuild_value_widgets();
506         };
507
508         this.update_selected_field = function(value) {
509             if (this.field_selector.item) {
510                 this.selected_field = value;
511                 this.selected_field_type = this.field_selector.item.type;
512
513                 /* This is really about supporting flattenergrid, of which
514                  * we're in the superclass (in a sloppy sad way). From now
515                  * on I won't mix this kind of lazy object with Dojo modules. */
516                 this.selected_field_fm_field = this.field_selector.item.name;
517                 this.selected_field_is_indirect =
518                     this.field_selector.item.indirect || false;
519                 this.selected_field_fm_class =
520                     this.field_selector.item.fmClass ||
521                     this.filter_row_manager.fm_class;
522
523                 this._adjust_operator_selector();
524                 this._rebuild_value_widgets();
525             }
526         };
527
528         this.compile = function() {
529             if (this.value_widgets) {
530                 var values = this.value_widgets.map(
531                     function(widg) {
532                         if (widg.useCorrectly)
533                             return widg.widget.attr("value");
534                         else if (self.selected_field_is_indirect)
535                             return widg.widget.attr("displayedValue");
536                         else
537                             return widg.getFormattedValue();
538                     }
539                 );
540
541                 if (!values.length) {
542                     return this._null_clause(); /* null/not null */
543                 } else {
544                     var clause = {};
545                     var op = this.get_selected_operator_name();
546
547                     var prep_function = function(o) { return o; /* no-op */ };
548                     if (String(op).match(/like/))
549                         prep_function = this._add_like_wildcards;
550
551                     if (values.length == 1)
552                         clause[op] = prep_function(values.pop());
553                     else
554                         clause[op] = dojo.map(values, prep_function);
555                     return clause;
556                 }
557             } else {
558                 return;
559             }
560         };
561
562         this.destroy = function() {
563             this._clear_value_slot();
564             this.field_selector.destroy();
565             if (this.operator_selector)
566                 this.operator_selector.destroy();
567
568             dojo.destroy(this.tr);
569         };
570
571         this.initialize = function(initializer) {
572             this.field_selector.attr("value", initializer.field);
573             this.operator_selector.attr("value", initializer.operator);
574
575             /* Caller supplies value for one value, values (array) for
576              * multiple. */
577             if (!initializer.values || !dojo.isArray(initializer.values))
578                 initializer.values = [initializer.values || initializer.value];
579
580             for (var i = 0; i < initializer.values.length; i++) {
581                 this.value_widgets[i].widget.attr(
582                     "value", initializer.values[i]
583                 );
584             }
585         };
586
587         this._init.apply(this, arguments);
588     }
589
590     dojo.declare(
591         "openils.widget.PCrudFilterPane", [openils.widget.AutoWidget],
592         {
593             "useDiv": null, /* should always be null for subclass dialogs */
594             "initializers": null,
595             "compact": false,
596             "widgetBuilders": null,
597
598             "constructor": function(args) {
599                 for(var k in args)
600                     this[k] = args[k];
601                 this.widgetIndex = 0;
602                 this.widgetCache = {};
603
604                 /* Meaningless in a pane, but better here than in
605                  * PCrudFilterDialog so that we don't need to load i18n
606                  * strings there: */
607                 this.title = this.title || localeStrings.DEFAULT_DIALOG_TITLE;
608             },
609
610             "_buildButtons": function() {
611                 var self = this;
612
613                 var button_holder = dojo.create(
614                     "div", {
615                         "className": "oils-pcrudfilterdialog-buttonholder"
616                     }, this.domNode
617                 );
618
619                 new dijit.form.Button(
620                     {
621                         "label": localeStrings.ADD_ROW,
622                         "scrollOnFocus": false, /* almost always better */
623                         "onClick": function() {
624                             self.filter_row_manager.add_row();
625                         }
626                     }, dojo.create("span", {}, button_holder)
627                 );
628
629                 this._apply_button = new dijit.form.Button(
630                     {
631                         "label": localeStrings.APPLY,
632                         "scrollOnFocus": false,
633                         "onClick": function() { self.doApply(); }
634                     }, dojo.create("span", {}, button_holder)
635                 );
636
637                 if (!this.useDiv) {
638                     new dijit.form.Button(
639                         {
640                             "label": localeStrings.CANCEL,
641                             "scrollOnFocus": false,
642                             "onClick": function() {
643                                 if (self.onCancel)
644                                     self.onCancel();
645                                 self.hide();
646                             }
647                         }, dojo.create("span", {}, button_holder)
648                     );
649                 }
650             },
651
652             "_buildFieldStore": function() {
653                 var self = this;
654                 var realFieldList = this.sortedFieldList.filter(
655                     function(item) { return !(item.virtual || item.nonIdl); }
656                 );
657
658                 /* Prevent any explicitly unwanted fields from being available
659                  * in our field dropdowns. */
660                 if (dojo.isArray(this.suppressFilterFields)) {
661                     realFieldList = realFieldList.filter(
662                         function(item) {
663                             for (
664                                 var i = 0;
665                                 i < self.suppressFilterFields.length;
666                                 i++
667                             ) {
668                                 if (item.name == self.suppressFilterFields[i])
669                                     return false;
670                             }
671                             return true;
672                         }
673                     );
674                 }
675
676                 this.fieldStore = new dojo.data.ItemFileReadStore({
677                     "data": {
678                         "identifier": "name",
679                         "name": "label",
680                         "items": realFieldList.map(
681                             function(item) {
682                                 return {
683                                     "label": item.label,
684                                     "name": item.name,
685                                     "type": item.datatype
686                                 };
687                             }
688                         )
689                     }
690                 });
691             },
692
693             "hide": function() {
694                 try {
695                     this.inherited(arguments);
696                 } catch (E) {
697                     /* When using *FilterPane directly (without a *Dialog
698                      * subclass), do nothing.  */
699                     void(0);
700                 }
701             },
702
703             /* All we really do here is create a data store out of the fields
704              * from the IDL for our given class, place a few buttons at the
705              * bottom of the dialog, and hand off to PCrudFilterRowManager to
706              * do the actual work.
707              */
708
709             "startup": function() {
710                 if (this.useDiv)
711                     this.domNode = this.useDiv;
712
713                 try {
714                     this.inherited(arguments);
715                 } catch (E) {
716                     /* When using *FilterPane directly (without a *Dialog
717                      * subclass), there is no startup method in any ancestor
718                      * class. XXX Refactor?
719                      */
720                     void(0);
721                 }
722
723                 this.initAutoEnv();
724
725                 this._buildFieldStore();
726
727                 this.filter_row_manager = new PCrudFilterRowManager(
728                     dojo.create("div", {}, this.domNode),
729                     this.fieldStore, this.fmClass, this.compact,
730                     this.widgetBuilders,
731                     Boolean(this.initializers)  /* avoid adding empty row */,
732                     dojo.hitch(this, function() { this.doApply(); })
733                 );
734
735                 this._buildButtons();
736
737                 if (this.initializers) {
738                     this.initializers.forEach(
739                         dojo.hitch(this, function(initializer) {
740                             this.filter_row_manager.add_row(initializer);
741                         })
742                     );
743                 }
744             },
745
746             /* This should just be named 'apply', but that is kind of a special
747              * word in Javascript, no? */
748             "doApply": function() {
749                 this._apply_button.attr("disabled", true);
750
751                 var _E; /* Try pretty hard not to leave the apply button
752                            disabled forever, even if 'apply' blows up. */
753                 try {
754                     if (this.onApply)
755                         this.onApply(this.filter_row_manager.compile());
756                 } catch (E) {
757                     _E = E;
758                 }
759                 this.hide();
760                 this._apply_button.attr("disabled", false);
761
762                 if (_E) throw _E;
763             }
764         }
765     );
766 }