New pull list interface taking advantage of flattener for speed,
[working/Evergreen.git] / Open-ILS / web / js / dojo / openils / FlattenerStore.js
1 if (!dojo._hasResource["openils.FlattenerStore"]) {
2     dojo._hasResource["openils.FlattenerStore"] = true;
3
4     dojo.provide("openils.FlattenerStore");
5
6     dojo.require("DojoSRF");
7     dojo.require("openils.User");
8     dojo.require("openils.Util");
9
10     /* An exception class specific to openils.FlattenerStore */
11     function FlattenerStoreError(message) { this.message = message; }
12     FlattenerStoreError.prototype.toString = function() {
13         return "openils.FlattenerStore: " + this.message;
14     };
15
16     dojo.declare(
17         "openils.FlattenerStore", null, {
18
19         "_last_fetch": null,        /* used internally */
20         "_flattener_url": "/opac/extras/flattener",
21
22         /* Everything between here and the constructor can be specified in
23          * the constructor's args object. */
24
25         "fmClass": null,
26         "mapClause": null,
27         "sloClause": null,
28         "limit": 25,
29         "offset": 0,
30         "baseSort": null,
31         "defaultSort": null,
32         "sortFieldReMap": null,
33
34         "constructor": function(/* object */ args) {
35             dojo.mixin(this, args);
36             this._current_items = {};
37         },
38
39         /* turn dojo-style sort into flattener-style sort */
40         "_prepare_sort": function(dsort) {
41             if (!dsort || !dsort.length)
42                 return this.baseSort || this.defaultSort || [];
43
44             return (this.baseSort || []).concat(
45                 dsort.map(
46                     function(d) {
47                         var o = {};
48                         o[d.attribute] = d.descending ? "desc" : "asc";
49                         return o;
50                     }
51                 )
52             );
53         },
54
55         "_remap_sort": function(prepared_sort) {
56             if (this.sortFieldReMap) {
57                 return prepared_sort.map(
58                     dojo.hitch(
59                         this, function(exp) {
60                             if (typeof exp == "object") {
61                                 var key;
62                                 for (key in exp)
63                                     break;
64                                 var newkey = (key in this.sortFieldReMap) ?
65                                     this.sortFieldReMap[key] : key;
66                                 var o = {};
67                                 o[newkey] = exp[key];
68                                 return o;
69                             } else {
70                                 return (exp in this.sortFieldReMap) ?
71                                     this.sortFieldReMap[exp] : exp;
72                             }
73                         }
74                     )
75                 );
76             } else {
77                 return prepared_sort;
78             }
79         },
80
81         "_build_flattener_params": function(req) {
82             var params = {
83                 "hint": this.fmClass,
84                 "ses": openils.User.authtoken
85             };
86
87             /* If we're asked for a specific identity, we don't use
88              * any query or sort/count/start (sort/limit/offset).  */
89             if ("identity" in req) {
90                 var where = {};
91                 where[this.fmIdentifier] = req.identity;
92
93                 params.where = dojo.toJson(where);
94             } else {
95                 params.where =  dojo.toJson(req.query);
96
97                 var slo = {
98                     "sort": this._remap_sort(this._prepare_sort(req.sort))
99                 };
100
101                 if (!req.queryOptions.all) {
102                     slo.limit =
103                         (!isNaN(req.count) && req.count != Infinity) ?
104                             req.count : this.limit;
105
106                     slo.offset =
107                         (!isNaN(req.start) && req.start != Infinity) ?
108                             req.start : this.offset;
109                 }
110
111                 if (req.queryOptions.columns)
112                     params.columns = req.queryOptions.columns;
113                 if (req.queryOptions.labels)
114                     params.labels = req.queryOptions.labels;
115
116                 params.slo = dojo.toJson(slo);
117             }
118
119             if (this.mapKey) {
120                 params.key = this.mapKey;
121             } else {
122                 params.map = dojo.toJson(this.mapClause);
123             }
124
125 //            for (var key in params)
126 //                console.debug("flattener param " + key + " -> " + params[key]);
127
128             return params;
129         },
130
131         "_display_attributes": function() {
132             var self = this;
133
134             return openils.Util.objectProperties(this.mapClause).filter(
135                 function(key) { return self.mapClause[key].display; }
136             );
137         },
138
139         "_get_map_key": function() {
140             //console.debug("mapClause: " + dojo.toJson(this.mapClause));
141             this.mapKey = fieldmapper.standardRequest(
142                 ["open-ils.fielder",
143                     "open-ils.fielder.flattened_search.prepare"], {
144                     "params": [openils.User.authtoken, this.fmClass,
145                         this.mapClause],
146                     "async": false
147                 }
148             );
149         },
150
151         "_on_http_error": function(response, ioArgs, req, retry_method) {
152             if (response.status == 402) {   /* 'Payment Required' stands
153                                                in for cache miss */
154                 if (this._retried_map_key_already) {
155                     var e = new FlattenerStoreError(
156                         "Server won't cache flattener map?"
157                     );
158                     if (typeof req.onError == "function")
159                         req.onError.call(callback_scope, e);
160                     else
161                         throw e;
162                 } else {
163                     this._retried_map_key_already = true;
164                     delete this.mapKey;
165                     if (retry_method)
166                         return this[retry_method](req);
167                 }
168             }
169         },
170
171         "_fetch_prepare": function(req) {
172             req.queryOptions = req.queryOptions || {};
173             req.abort = function() { console.warn("[unimplemented] abort()"); };
174
175             if (!this.mapKey)
176                 this._get_map_key();
177
178             return this._build_flattener_params(req);
179         },
180
181         "_fetch_execute": function(params,handle_as,mime_type,onload,onerror) {
182             dojo.xhrPost({
183                 "url": this._flattener_url,
184                 "content": params,
185                 "handleAs": handle_as,
186                 "sync": false,
187                 "preventCache": true,
188                 "headers": {"Accept": mime_type},
189                 "load": onload,
190                 "error": onerror
191             });
192         },
193
194         /* *** Nonstandard but public API - Please think hard about doing
195          * things the Dojo Way whenever possible before extending the API
196          * here. *** */
197
198         /* fetchToPrint() acts like a lot like fetch(), but doesn't call
199          * onBegin or onComplete.  */
200         "fetchToPrint": function(req) {
201             var callback_scope = req.scope || dojo.global;
202             var post_params;
203
204             try {
205                 post_params = this._fetch_prepare(req);
206             } catch (E) {
207                 if (typeof req.onError == "function")
208                     req.onError.call(callback_scope, E);
209                 else
210                     throw E;
211             }
212
213             var process_fetch_all = dojo.hitch(
214                 this, function(text) {
215                     this._retried_map_key_already = false;
216
217                     if (typeof req.onComplete == "function")
218                         req.onComplete.call(callback_scope, text, req);
219                 }
220             );
221
222             var process_error = dojo.hitch(
223                 this, function(response, ioArgs) {
224                     this._on_http_error(response, ioArgs, req, "fetchToPrint");
225                 }
226             );
227
228             this._fetch_execute(
229                 post_params,
230                 "text",
231                 "text/html",
232                 process_fetch_all,
233                 process_error
234             );
235
236             return req;
237         },
238
239         /* *** Begin dojo.data.api.Read methods *** */
240
241         "getValue": function(
242             /* object */ item,
243             /* string */ attribute,
244             /* anything */ defaultValue) {
245             //console.log("getValue(" + lazy(item) + ", " + attribute + ", " + defaultValue + ")")
246             if (!this.isItem(item))
247                 throw new FlattenerStoreError("getValue(): bad item " + item);
248             else if (typeof attribute != "string")
249                 throw new FlattenerStoreError("getValue(): bad attribute");
250
251             var value = item[attribute];
252             return (typeof value == "undefined") ? defaultValue : value;
253         },
254
255         "getValues": function(/* object */ item, /* string */ attribute) {
256             //console.log("getValues(" + item + ", " + attribute + ")");
257             if (!this.isItem(item) || typeof attribute != "string")
258                 throw new FlattenerStoreError("bad arguments");
259
260             var result = this.getValue(item, attribute, []);
261             return dojo.isArray(result) ? result : [result];
262         },
263
264         "getAttributes": function(/* object */ item) {
265             //console.log("getAttributes(" + item + ")");
266             if (!this.isItem(item))
267                 throw new FlattenerStoreError("getAttributes(): bad args");
268             else
269                 return this._display_attributes();
270         },
271
272         "hasAttribute": function(/* object */ item, /* string */ attribute) {
273             //console.log("hasAttribute(" + item + ", " + attribute + ")");
274             if (!this.isItem(item) || typeof attribute != "string") {
275                 throw new FlattenerStoreError("hasAttribute(): bad args");
276             } else {
277                 return dojo.indexOf(this._display_attributes(), attribute) > -1;
278             }
279         },
280
281         "containsValue": function(
282             /* object */ item,
283             /* string */ attribute,
284             /* anything */ value) {
285             //console.log("containsValue(" + item + ", " + attribute + ", " + value + ")");
286             if (!this.isItem(item) || typeof attribute != "string")
287                 throw new FlattenerStoreError("bad data");
288             else
289                 return (
290                     dojo.indexOf(this.getValues(item, attribute), value) >= -1
291                 );
292         },
293
294         "isItem": function(/* anything */ something) {
295             //console.log("isItem(" + lazy(something) + ")");
296             if (typeof something != "object" || something === null)
297                 return false;
298
299             var fields = this._display_attributes();
300
301             for (var i = 0; i < fields.length; i++) {
302                 var cur = fields[i];
303                 if (!(cur in something))
304                     return false;
305             }
306             return true;
307         },
308
309         "isItemLoaded": function(/* anything */ something) {
310             /* XXX if 'something' is not an item at all, are we just supposed
311              * to return false or throw an exception? */
312             return this.isItem(something) && (
313                 something[this.fmIdentifier] in this._current_items
314             );
315         },
316
317         "close": function(/* object */ request) { /* no-op */ return; },
318
319         "getLabel": function(/* object */ item) {
320             console.warn("[unimplemented] getLabel()");
321         },
322
323         "getLabelAttributes": function(/* object */ item) {
324             console.warn("[unimplemented] getLabelAttributes()");
325         },
326
327         "loadItem": function(/* object */ keywordArgs) {
328             if (!keywordArgs.force && this.isItemLoaded(keywordArgs.item))
329                 return;
330
331             keywordArgs.identity = this.getIdentity(keywordArgs.item);
332             return this.fetchItemByIdentity(keywordArgs);
333         },
334
335         "fetch": function(/* request-object */ req) {
336             //  Respect the following properties of the *req* object:
337             //
338             //      query    a dojo-style query, which will need modest
339             //                  translation for our server-side service
340             //      count    an int
341             //      onBegin  a callback that takes the number of items
342             //                  that this call to fetch() *could* have
343             //                  returned, with a higher limit. We do
344             //                  tricks with this.
345             //      onItem   a callback that takes each item as we get it
346             //      onComplete  a callback that takes the list of items
347             //                      after they're all fetched
348
349             var self = this;
350             var callback_scope = req.scope || dojo.global;
351             var post_params;
352
353             try {
354                 post_params = this._fetch_prepare(req);
355             } catch (E) {
356                 if (typeof req.onError == "function")
357                     req.onError.call(callback_scope, E);
358                 else
359                     throw E;
360             }
361
362             var process_fetch = function(obj, when) {
363                 if (when < self._last_fetch) /* Stale response. Discard. */
364                     return;
365
366                 self._retried_map_key_already = false;
367
368                 /* The following is apparently the "right" way to call onBegin,
369                  * and is very necessary (at least in Dojo 1.3.3) to get
370                  * the Grid's fetch-more-when-I-need-it logic to work
371                  * correctly. *grumble* crummy documentation *snarl!*
372                  */
373                 if (typeof req.onBegin == "function") {
374                     /* We lie to onBegin like this because we don't know how
375                      * many more rows we might be able to fetch if the
376                      * user keeps scrolling.  Once we get a number of
377                      * results that is less than the limit we asked for,
378                      * we stop exaggerating, and the grid is smart enough to
379                      * know we're at the end and it does the right thing. */
380                     var might_be_a_lie = req.start;
381                     if (obj.length >= req.count)
382                         might_be_a_lie += obj.length + req.count;
383                     else
384                         might_be_a_lie += obj.length;
385
386                     req.onBegin.call(callback_scope, might_be_a_lie, req);
387                 }
388
389                 dojo.forEach(
390                     obj,
391                     function(item) {
392                         /* Cache items internally. */
393                         self._current_items[item[self.fmIdentifier]] = item;
394
395                         if (typeof req.onItem == "function")
396                             req.onItem.call(callback_scope, item, req);
397                     }
398                 );
399
400                 if (typeof req.onComplete == "function")
401                     req.onComplete.call(callback_scope, obj, req);
402             };
403
404             var process_error = dojo.hitch(
405                 this, function(response, ioArgs) {
406                     this._on_http_error(response, ioArgs, req, "fetch");
407                 }
408             );
409
410             var fetch_time = this._last_fetch = (new Date().getTime());
411
412             this._fetch_execute(
413                 post_params,
414                 "json",
415                 "application/json",
416                 function(obj) { process_fetch(obj, fetch_time); },
417                 process_error
418             );
419
420             return req;
421         },
422
423         /* *** Begin dojo.data.api.Identity methods *** */
424
425         "getIdentity": function(/* object */ item) {
426             if (!this.isItem(item))
427                 throw new FlattenerStoreError("not an item");
428
429             return item[this.fmIdentifier];
430         },
431
432         "getIdentityAttributes": function(/* object */ item) {
433             // console.log("getIdentityAttributes(" + item + ")");
434             return [this.fmIdentifier];
435         },
436
437         "fetchItemByIdentity": function(/* object */ keywordArgs) {
438             var callback_scope = keywordArgs.scope || dojo.global;
439             var identity = keywordArgs.identity;
440
441             if (typeof identity == "undefined")
442                 throw new FlattenerStoreError(
443                     "fetchItemByIdentity() needs identity in keywordArgs"
444                 );
445
446             /* First of force's two implications:
447              * fetch even if already loaded. */
448             if (this._current_items[identity] && !keywordArgs.force) {
449                 keywordArgs.onItem.call(
450                     callback_scope, this._current_items[identity]
451                 );
452
453                 return;
454             }
455
456             var post_params;
457             try {
458                 post_params = this._fetch_prepare(keywordArgs);
459             } catch (E) {
460                 if (typeof keywordArgs.onError == "function")
461                     keywordArgs.onError.call(callback_scope, E);
462                 else
463                     throw E;
464             }
465
466             var process_fetch_one = dojo.hitch(
467                 this, function(obj, when) {
468                     if (when < this._last_fetch) /* Stale response. Discard. */
469                         return;
470
471                     if (dojo.isArray(obj)) {
472                         if (obj.length <= 1) {
473                             obj = obj.pop() || null;    /* safe enough */
474                             /* Second of force's two implications: call setValue
475                              * ourselves.  Makes a DataGrid update. */
476                             if (keywordArgs.force && obj &&
477                                 (origitem = this._current_items[identity])) {
478                                 for (var prop in origitem)
479                                     this.setValue(origitem, prop, obj[prop]);
480                             }
481                             if (keywordArgs.onItem)
482                                 keywordArgs.onItem.call(callback_scope, obj);
483                         } else {
484                             var e = new FlattenerStoreError("Too many results");
485                             if (keywordArgs.onError)
486                                 keywordArgs.onError.call(callback_scope, e);
487                             else
488                                 throw e;
489                         }
490                     } else {
491                         var e = new FlattenerStoreError("Bad response");
492                         if (keywordArgs.onError)
493                             keywordArgs.onError.call(callback_scope, e);
494                         else
495                             throw e;
496                     }
497                 }
498             );
499
500             var process_error = dojo.hitch(
501                 this, function(response, ioArgs) {
502                     this._on_http_error(
503                         response, ioArgs, keywordArgs, "fetchItemByIdentity"
504                     );
505                 }
506             );
507
508             var fetch_time = this._last_fetch = (new Date().getTime());
509
510             this._fetch_execute(
511                 post_params,
512                 "json",
513                 "application/json",
514                 function(obj) { process_fetch_one(obj, fetch_time); },
515                 process_error
516             );
517         },
518
519         /* dojo.data.api.Write - only very partially implemented, because
520          * for FlattenerGrid, the intended client of this store, we don't
521          * need most of the methods. */
522
523         "deleteItem": function(item) {
524             //console.log("deleteItem()");
525
526             var identity = this.getIdentity(item);
527             delete this._current_items[identity];   /* safe even if missing */
528
529             this.onDelete(item);
530         },
531
532         "setValue": function(item, attribute, value) {
533             /* Silently do nothing when setValue()'s caller wants to change
534              * the identifier.  They must be confused anyway. */
535             if (attribute == this.fmIdentifier)
536                 return;
537
538             var old_value = dojo.clone(item[attribute]);
539
540             item[attribute] = dojo.clone(value);
541             this.onSet(item, attribute, old_value, value);
542         },
543
544         "setValues": function(item, attribute, values) {
545             console.warn("[unimplemented] setValues()");    /* unneeded */
546         },
547
548         "newItem": function(keywordArgs, parentInfo) {
549             console.warn("[unimplemented] newItem()");    /* unneeded */
550         },
551
552         "unsetAttribute": function() {
553             console.warn("[unimplemented] unsetAttribute()");   /* unneeded */
554         },
555
556         "save": function() {
557             console.warn("[unimplemented] save()"); /* unneeded */
558         },
559
560         "revert": function() {
561             console.warn("[unimplemented] revert()");   /* unneeded */
562         },
563
564         "isDirty": function() { /* I /think/ this will be ok for our purposes */
565             console.info("[stub] isDirty() will always return false");
566
567             return false;
568         },
569
570         /* dojo.data.api.Notification - Keep these no-op methods because
571          * clients will dojo.connect() to them.  */
572
573         "onNew" : function(item) { /* no-op */ },
574         "onDelete" : function(item) { /* no-op */ },
575         "onSet": function(item, attr, oldval, newval) { /* no-op */ },
576
577         /* *** Classes implementing any Dojo APIs do this to list which
578          *     APIs they're implementing. *** */
579
580         "getFeatures": function() {
581             return {
582                 "dojo.data.api.Read": true,
583                 "dojo.data.api.Identity": true,
584                 "dojo.data.api.Notification": true,
585                 "dojo.data.api.Write": true     /* well, only partly */
586             };
587         }
588     });
589 }