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