e25361f8abd3b92ed2e39a2a68898944d3508d7d
[sitka/overdrive-evergreen-opac.git] / src / od_action.coffee
1 # TODO cannot auto-focus on close button of action dialog
2 # probably because it needs to be done asynchronously using setTimeout
3 #
4 define [
5         'jquery'
6         'lodash'
7         'json'
8         'od_api'
9         'jquery-ui'
10 ], ($, _, json, od) ->
11
12         # Load a CSS file related to our use of the jqueryui dialog widget.
13         # We manually load the file in order to avoid modifying any .tt2 files.
14         do (url = '//ajax.googleapis.com/ajax/libs/jqueryui/1.11.1/themes/smoothness/jquery-ui.min.css') ->
15                 link = document.createElement('link')
16                 link.type = 'text/css'
17                 link.rel  = 'stylesheet'
18                 link.href = url
19                 document.getElementsByTagName('head')[0].appendChild(link)
20
21         # Return an abbreviation of the pathname of the current page,
22         # eg, if window.location.pathname equals 'eg/opac/record' or
23         # 'eg/opac/record/123', then return 'record', otherwise return ''
24         brief_name = ->
25                 xs = window.location.pathname.match /eg\/opac\/(.+)/
26                 if xs then xs[1].replace /\/\d+/, '' else ''
27         # TODO also defined in od_page_rewrite, but we don't want this module to
28         # depend on that module, because it depends on this module.
29
30         # Pluck out a sensible message from the reply to an action request
31         responseMessage = (x) -> (json.parse x.responseText).message
32
33         # Customize the dialog widget to guide a user through the intention of
34         # making a transaction
35         #
36         # Usage: $('<div>').dialogAction action: action, scenario: scenario
37         #
38         $.widget 'ui.dialogAction', $.ui.dialog,
39                 options:
40                         draggable: false
41                         resizable: true
42                         modal: true
43                         buttons: [
44                                 {
45                                         text: 'Yes'
46                                         click: (ev) ->
47                                                 ev.stopPropagation()
48                                                 $(@).dialogAction 'yes_action'
49                                                 return
50                                 }
51                                 {
52                                         text: 'No'
53                                         click: (ev) ->
54                                                 ev.stopPropagation()
55                                                 $(@).dialogAction 'non_action'
56                                                 return
57                                 }
58                         ]
59
60                 # On create, perform custom positioning, and show custom title and
61                 # body.  When the dialog finally closes, destroy it.
62                 _create: ->
63
64                         intent = @options._scenario?.intent
65                         position = @options._action?._of
66
67                         # Text of Yes/No buttons in the intent scenario may be overridden
68                         if intent
69                                 ob = @options.buttons
70                                 ib = intent.buttons
71                                 ob[0].text = ib?[0] or 'Yes'
72                                 ob[1].text = ib?[1] or 'No'
73
74                         # Position of dialog box may be overridden
75                         @options.position = of: position, at: 'top', my: 'top' if position
76
77                         @_super()
78
79                         # On creation, dialog message may be overidden by the intent scenario
80                         @set_message 'intent', false if intent
81
82                         @_on 'dialogactionclose': -> @_destroy()
83
84                 # Depending on the given scenario, the title and body of the dialog
85                 # screen may be set, and the close button may be shown or hidden.
86                 # The title is a text string specified as the title property of the
87                 # scenario, or it defaults to the etitle of the attched action object.
88                 # The body is a text string specified as an argument or it defaults to
89                 # the body property of the scenario.
90                 set_message: (scenario, close, details) ->
91                         s = @options._scenario[scenario]
92                         @element.empty().append details or s.body
93                         @option 'title', s.title or @options._action._of._etitle()
94                         @_close_button close
95                         return @
96
97                 non_action: ->
98                         @_on 'dialogactionclose': reroute if reroute = @options._scenario?.intent?.reroute
99                         @close()
100                         return @
101
102                 # Respond to the Yes button by going ahead with the intended action
103                 yes_action: ->
104
105                         # At this point, dialog buttons are turned off.
106                         @option 'buttons', []
107
108                         # Make an API call
109                         action = @options._action
110                         progress = => @set_message 'progress', true
111                         od.api action.href, action.method, fields: $('form', @element).serializeArray(), progress
112
113                         # Re-use the dialog to show notifications with a close button
114                         .then(
115                                 (x) => @set_message 'done', true
116                                 (x) => @set_message 'fail', true, responseMessage x
117                         )
118
119                         # On done and when the user closes the dialog, reroute the page
120                         .done =>
121                                 @_on 'dialogactionclose': reroute if reroute = this.options._scenario?.done?.reroute
122
123                         return @
124
125                 # Show or hide the dialog close button
126                 _close_button: (close) ->
127                         @element.parent()
128                                 .find('.ui-dialog-titlebar-close')[if close then 'show' else 'hide']()
129                                 .end()
130                         .end()
131
132
133         # Map action names to labels
134         # TODO also use this mapping in scenarios
135         Labels =
136                 hold: 'Place hold'
137                 addSuspension: 'Suspend'
138                 releaseSuspension: 'Activate'
139                 removeHold: 'Cancel'
140                 checkout: 'Check out'
141                 earlyReturn: 'Return title'
142                 format: 'Select format'
143
144
145         # We define custom jQuery extensions to perform the actions defined by the
146         # Labels object.  The main role of each of these extensions is to build a
147         # scenario object that specifies the layout and behaviour of an instance of
148         # the action dialog widget.  The specification is dependent on the content
149         # of a given action object and hence the scenario object must be built
150         # dynamically.
151         #
152         # TODO Since all of these extensions end with making an identical call to
153         # the dialogAction widget, it would be good to abstract the call to the
154         # outside environment, perhaps redefine the extensions as simple functions.
155         # eg, fn:: action -> scenario
156         #
157         # TODO Map action names to re-routed page names.  The rerouting function
158         # depends on current page, current action, and current scenario
159         #
160         $.fn.extend
161
162                 # Build a dialog to place a hold
163                 _hold: (action) ->
164
165                         scenario =
166                                 intent:
167                                         body: $('<div>')._action_fields action.fields
168                                         buttons: [ 'Place hold', 'Cancel' ]
169                                         # TODO clicking cancel should return to search results, not
170                                         # to rerouted page
171                                         reroute: -> window.history.back()
172                                 done:
173                                         body: 'Hold was successfully placed. Close this box to be redirected to your holds list.'
174                                         reroute: -> window.location.replace '/eg/opac/myopac/holds?e_items'
175                                 fail: body: 'Hold was not placed. There may have been a network or server problem. Please try again.'
176                                 progress: body: 'Hold is being placed...'
177
178                         @dialogAction _scenario: scenario, _action: action
179
180                 # Build a dialog to cancel a hold
181                 _removeHold: (action) ->
182
183                         scenario =
184                                 intent: body: 'Are you sure you want to cancel this hold?'
185                                 done: body: 'Hold was successfully cancelled.'
186                                 fail: body: 'Hold was not cancelled. There may have been a network or server problem. Please try again.'
187                                 progress: body: 'Hold is being cancelled...'
188
189                         @dialogAction _scenario: scenario, _action: action
190
191                 # Build a dialog to suspend a hold
192                 _addSuspension: (action) ->
193
194                         scenario =
195                                 intent:
196                                         body: $('<div>')._action_fields action.fields
197                                         buttons: [ 'Suspend', 'Cancel' ]
198                                 done: body: 'Hold was successfully suspended'
199                                 fail: body: 'Hold was not suspended. There may have been a network or server problem. Please try again.'
200                                 progress: body: 'Hold is being suspended...'
201
202                         @dialogAction _scenario: scenario, _action: action
203
204                 # Build a dialog to release a suspension
205                 _releaseSuspension: (action) ->
206
207                         scenario =
208                                 intent: body: 'Are you sure you want this hold to activate again?'
209                                 done:
210                                         body: 'Suspension was successfully released. The page will reload to update your account status.'
211                                         reroute: -> window.location.reload true
212                                 fail: body: 'Suspension was not released. There may have been a network or server problem. Please try again.'
213                                 progress: body: 'Suspension is being released...'
214
215                         @dialogAction _scenario: scenario, _action: action
216
217                 # Build a dialog to checkout a title
218                 _checkout: (action) ->
219
220                         scenario =
221                                 intent:
222                                         body: $('<div>')._action_fields action.fields
223                                         buttons: [ 'Check out', 'Cancel' ]
224                                         reroute: ->
225                                                 # if at placehold page, go back; otherwise, stay on same page
226                                                 # TODO place_hold no longer relevant
227                                                 window.history.back() if brief_name() is 'place_hold'
228                                 done:
229                                         body: 'Title was successfully checked out. Close this page to be redirected to your checkouts list.'
230                                         reroute: -> window.location.replace '/eg/opac/myopac/circs?e_items'
231                                 fail: body: 'Title was not checked out. There may have been a network or server problem. Please try again.'
232                                 progress: body: 'Title is being checked out...'
233
234                         @dialogAction _scenario: scenario, _action: action
235
236                 # Build a dialog to select a format of a title
237                 _format: (action) ->
238
239                         scenario =
240                                 intent:
241                                         body: $('<div>')._action_fields action.fields
242                                         buttons: [ 'Select format', 'Cancel' ]
243                                 done: body: 'Format was successfully selected.'
244                                 fail: body: 'Format was not selected. There may have been a network or server problem. Please try again.'
245                                 progress: body: 'Format is being selected...'
246
247                         @dialogAction _scenario: scenario, _action: action
248
249                 # Build a dialog to return a title early
250                 _earlyReturn: (action) ->
251
252                         scenario =
253                                 intent: body: 'Are you sure you want to return this title before it expires?'
254                                 done: body: 'Title was successfully returned.'
255                                 fail: body: 'Title was not returned. There may have been a network or server problem. Please try again.'
256                                 progress: body: 'Title is being returned...'
257
258                         @dialogAction _scenario: scenario, _action: action
259
260                 # Build format buttons given specifications as follows.
261                 # formats = [ { formatType: type, linkTemplates: { downloadLink: { href: href } } } ]
262                 # actions = { downloadLink: { href: href, method: get, type: type } }
263                 #
264                 # TODO no need to define an action dialog because this is the only example of a get action
265                 # and we can allow the default behaviour to occur.
266                 #
267                 # Do we need a dialogDownload widget?
268                 # Confirm -> HTTP GET downloadLink.
269                 # Fail -> Browser navigates to errorURL and shows error status.
270                 # Done -> Response is a contentLink. HTTP Get contentLink.
271                         
272                 _formats: (formats) ->
273                         return @ unless formats
274
275                         tpl = _.template """
276                         <div>
277                                 <a href="<%= href %>" class="opac-button" style="margin-top: 0px; margin-bottom: 0px"><%= label %></a>
278                         </div>
279                         """
280
281                         $buttons = for format in formats
282                                 {
283                                         formatType: n
284                                         linkTemplates:
285                                                 downloadLink:
286                                                         href: href
287                                                         type: type
288                                 } = format
289
290                                 # Create a button for this action
291                                 $ tpl href: href, label: "Download #{od.labels n}"
292
293                         @empty().append $buttons
294
295                 # Build action buttons and dialogs given specifications as follows.
296                 # actions = [ { name: { href: h, method: m, fields: [ { name: n, value: v, options: [...] } ] } ]
297                 _actions: (actions) ->
298
299                         tpl = _.template """
300                         <div>
301                                 <a href="<%= href %>" class="opac-button" style="margin-top: 0px; margin-bottom: 0px"><%= label %></a>
302                         </div>
303                         """
304
305                         # Find the related row
306                         $tr = @closest('tr')
307
308                         $buttons = for n, action of actions
309
310                                 # Extend the action object with context
311                                 $.extend action, _of: $tr, _name: n
312
313                                 # Create a button for this action
314                                 $ tpl href: action.href, label: Labels?[n] or n
315
316                                 # On clicking the button, build a new dialog using the extended action object
317                                 .on 'click', action, (ev) ->
318                                         ev.preventDefault()
319                                         $('<div>')['_' + ev.data._name] ev.data
320                                         # TODO apply dialogAction method directly as follows.
321                                         #$('<div>').dialogAction _scenario: Actions.scenario[ev.data._name], _action: ev.data
322                                         return false
323
324                         @empty().append $buttons
325
326                 # Build a form of input fields out of a list of action fields
327                 _action_fields: (fields) ->
328
329                         $('<form>')
330                         ._action_field_hidden  _.where(fields, name: 'reserveId')[0]
331                         ._action_field_email   _.where(fields, name: 'emailAddress')[0]
332                         ._action_field_radio   _.where(fields, name: 'formatType')[0]
333                         ._action_field_suspend _.where(fields, name: 'suspensionType')[0]
334                         ._action_field_date    _.where(fields, name: 'numberOfDays')[0]
335
336                         # Show a date field only if suspensionType of indefinite is selected
337                         .on 'click', '[name=suspensionType]', (ev) ->
338                                 $input = $('[name=numberOfDays]')
339                                 switch @defaultValue
340                                         when 'limited'    then $input.show()
341                                         when 'indefinite' then $input.hide()
342
343                         .on 'submit', (ev) ->
344                                 $input = $('[name=numberOfDays]')
345
346                 # Build a date field and initially hide it
347                 _action_field_date: (field) ->
348
349                         return @ unless field
350
351                         $input = $ """
352                         <input type="date" name="#{field.name}" value="#{field.value}" />
353                         """
354                         @append $input.hide()
355
356                 # Build a hidden input
357                 _action_field_hidden: (field) ->
358
359                         return @ unless field
360
361                         @append """
362                         <input type="hidden" name="#{field.name}" value="#{field.value}" />
363                         """
364
365                 # Build an email input
366                 _action_field_email: (field) ->
367
368                         return @ unless field
369
370                         $input = $ """
371                         <div>
372                                 You will be notified by email when a copy becomes available
373                         </div>
374                         <div>
375                                 <label>Email address: <input type="email" name="#{field.name}" value="#{field.value}" />
376                                 </label>
377                         </div>
378                         """
379                         $input.find('input').prop 'required', true unless Boolean field.optional
380
381                         @append $input
382
383                 # Build a group of radio buttons
384                 _action_field_radio: (field) ->
385
386                         return @ unless field
387
388                         # If one of the format types is ebook reader, omit it from the
389                         # list, because it is not a downloadable type
390                         _.remove field.options, (f) -> f is 'ebook-overdrive'
391
392                         # A hint specific to whether format types are optional or not is added to the form
393                         hint = switch
394                                 when field.options.length is 1 then """
395                                         <div>Only one #{field.name} is available and it has been selected for you</div>
396                                         """
397                                 when Boolean field.optional then """
398                                         <div>You may select a #{field.name} at this time</div>
399                                         """
400                                 else """
401                                         <div>Please select one of the available #{field.name}s</div>
402                                         """
403                         inputs = for v in field.options
404                                 $x = $ """
405                                 <div>
406                                         <input type="radio" name="#{field.name}" value="#{v}" />#{od.labels v}
407                                 </div>
408                                 """
409                                 $y = $x.find 'input'
410                                 $y.prop('required', true) unless Boolean field.optional
411                                 $y.prop('checked', true) unless field.options.length > 1
412                                 $x
413
414                         @append hint
415                         .append inputs
416
417                 _action_field_suspend: (field) ->
418
419                         return @ unless field
420
421                         label =
422                                 indefinite: 'Suspend this hold indefinitely'
423                                 limited: 'Suspend this hold for a limited time'
424
425                         inputs = for v in field.options
426                                 $x = $ """
427                                 <div><label>
428                                         <input type="radio" name="#{field.name}" value="#{v}" /> #{label[v]}
429                                 </label></div>
430                                 """
431                                 $y = $x.find 'input'
432                                 $y.prop('required', true) unless Boolean field.optional
433                                 $y.prop('checked', true) if v is 'indefinite'
434                                 $x
435
436                         @append inputs
437
438                         # We will delegate the handling of download links to the page's
439                         # tbody.  The sequence of operation is as follows.  We need to get
440                         # from a download link to make the download request, receive a
441                         # content link as a response, and then perform a 'normal' get of
442                         # the content link.  A complexity is to handle the error responses.
443                         #
444                         # TODO The initial get could fail, in which case, the errorpageurl
445                         # will be used to convey the failure status as a query string. We
446                         # will redirect the error page to the current page and we recognize
447                         # the error condition by analysing the query parameters.
448                         #
449                         # TODO Another error condition could occur if an Overdrive Read
450                         # ebook is attempted to be downloaded.  Here, the odreadauthurl
451                         # will be used as a redirect location. We will also redirect to the
452                         # current page and hopefully will be able to discern the state and
453                         # show it accordingly.
454                         #
455                 _download_format: ->
456                         @on 'click', 'td.formats a', (ev) ->
457                                 ev.preventDefault()
458
459                                 # We will return to the current page to handle errors
460                                 x = encodeURIComponent window.location.href
461                                 dl = @href
462                                         .replace /\{errorpageurl\}/, x
463                                         .replace /\{odreadauthurl\}/, x
464
465                                 od.api dl
466                                 .then(
467                                         (x) ->
468                                                 window.open(
469                                                         od.proxy x.links.contentlink.href # url
470                                                         '_blank' #'Overdrive Read format' # title
471                                                         'resizable, scrollbars, status, menubar, toolbar, personalbar' # features
472                                                 )
473                                         -> console.log 'failed to get download link'
474                                 )
475                                 .then(
476                                         -> # not expected to arrive here ever
477                                         -> console.log 'failed to get contentLink'
478                                 )
479                                 
480                                 return
481
482
483                 _notify: (title, text) ->
484
485                         @dialog
486                                 position: my: 'top', at: 'right top'
487                                 minHeight: 0
488                                 autoOpen: true
489                                 draggable: false
490                                 resizable: true
491                                 show: effect: 'slideDown'
492                                 hide: effect: 'slideUp'
493                                 close: -> $(@).dialog 'destroy'
494                                 title: title
495
496                         .text text
497
498                 # Get the title of e-item in a row context
499                 _etitle: -> @find('.title a').text()