Object.extend = function(destination) { $A(arguments).slice(1).each(function (src) { for (var property in src) { destination[property] = src[property]; } }) return destination } Object.merge = function() { return Object.extend.apply(this, [{}].concat($A(arguments))) } var Hobo = { searchRequest: null, uidCounter: 0, ipeOldValues: {}, spinnerMinTime: 500, // milliseconds uid: function() { Hobo.uidCounter += 1 return "uid" + Hobo.uidCounter }, updatesForElement: function(el) { el = $(el) var updates = Hobo.getClassData(el, 'update') return updates ? updates.split(':') : [] }, ajaxSetFieldForElement: function(el, val, options) { var updates = Hobo.updatesForElement(el) var params = Hobo.fieldSetParam(el, val) var p = el.getAttribute("hobo-ajax-params") if (p) params = params + "&" + p var opts = Object.merge(options || {}, { params: params, message: el.getAttribute("hobo-ajax-message")}) Hobo.ajaxRequest(Hobo.putUrl(el), updates, opts) }, ajaxUpdateParams: function(updates, resultUpdates) { var params = [] var i = 0 if (updates.length > 0) { updates.each(function(id_or_el) { var el = $(id_or_el) if (el) { // ignore update of parts that do not exist var partDomId = el.id if (!hoboParts[partDomId]) { throw "Update of dom-id that is not a part: " + partDomId } params.push("render["+i+"][part_context]=" + encodeURIComponent(hoboParts[partDomId])) params.push("render["+i+"][id]=" + partDomId) i += 1 } }) params.push("page_path=" + hoboPagePath) } if (resultUpdates) { resultUpdates.each(function (resultUpdate) { params.push("render["+i+"][id]=" + resultUpdate.id) params.push("render["+i+"][result]=" + resultUpdate.result) if (resultUpdate.func) { params.push("render["+i+"][function]=" + resultUpdate.func) } i += 1 }) } return params.join('&') }, ajaxRequest: function(url_or_form, updates, options) { options = Object.merge({ asynchronous:true, evalScripts:true, resetForm: false, refocusForm: false, message: "Saving..." }, options) if (typeof url_or_form == "string") { var url = url_or_form var form = false } else { var form = url_or_form var url = form.action } var params = [] if (typeof(formAuthToken) != "undefined") { params.push(formAuthToken.name + "=" + formAuthToken.value) } updateParams = Hobo.ajaxUpdateParams(updates, options.resultUpdate) if (updateParams != "") { params.push(updateParams) } if (options.params) { params.push(options.params) delete options.params } if (form) { params.push(Form.serialize(form)) } if (options.message != false) Hobo.showSpinner(options.message, options.spinnerNextTo) var complete = function() { if (options.message != false) Hobo.hideSpinner(); if (options.onComplete) options.onComplete.apply(this, arguments) if (form && options.refocusForm) Form.focusFirstElement(form) Event.addBehavior.reload() } var success = function() { if (options.onSuccess) options.onSuccess.apply(this, arguments) if (form && options.resetForm) form.reset(); } if (options.method && options.method.toLowerCase() == "put") { delete options.method params.push("_method=PUT") } if (!options.onFailure) { options.onFailure = function(response) { alert(response.responseText) } } new Ajax.Request(url, Object.merge(options, { parameters: params.join("&"), onComplete: complete, onSuccess: success })) }, hide: function() { for (i = 0; i < arguments.length; i++) { if ($(arguments[i])) { Element.addClassName(arguments[i], 'hidden') } } }, show: function() { for (i = 0; i < arguments.length; i++) { if ($(arguments[i])) { Element.removeClassName(arguments[i], 'hidden') } } }, toggle: function() { for (i = 0; i < arguments.length; i++) { if ($(arguments[i])) { if(Element.hasClassName(arguments[i], 'hidden')) { Element.removeClassName(arguments[i], 'hidden') } else { Element.addClassName(arguments[i], 'hidden') } } } }, onFieldEditComplete: function(el, newValue) { el = $(el) var oldValue = Hobo.ipeOldValues[el.id] delete Hobo.ipeOldValues[el.id] var blank = el.getAttribute("hobo-blank-message") if (blank && newValue.strip().length == 0) { el.update(blank) } else { el.update(newValue) } var modelId = Hobo.getModelId(el) if (oldValue) { $$(".model:" + modelId).each(function(e) { if (e != el && e.innerHTML == oldValue) e.update(newValue) }) } }, _makeInPlaceEditor: function(el, options) { var old var updates = Hobo.updatesForElement(el) var id = el.id if (!id) { id = el.id = Hobo.uid() } var updateParams = Hobo.ajaxUpdateParams(updates, [{id: id, result: 'new_field_value', func: "Hobo.onFieldEditComplete"}]) var opts = {okButton: false, cancelLink: false, submitOnBlur: true, evalScripts: true, htmlResponse: false, ajaxOptions: { method: "put" }, onEnterHover: null, onLeaveHover: null, callback: function(form, val) { old = val return (Hobo.fieldSetParam(el, val) + "&" + updateParams) }, onFailure: function(_, resp) { alert(resp.responseText); el.innerHTML = old }, onEnterEditMode: function() { var blank_message = el.getAttribute("hobo-blank-message") if (el.innerHTML.gsub(" ", " ") == blank_message) { el.innerHTML = "" } else { Hobo.ipeOldValues[el.id] = el.innerHTML } } } Object.extend(opts, options) return new Ajax.InPlaceEditor(el, Hobo.putUrl(el), opts) }, doSearch: function(el) { el = $(el) var spinner = $(el.getAttribute("search-spinner") || "search-spinner") var search_results = $(el.getAttribute("search-results") || "search-results") var search_results_panel = $(el.getAttribute("search-results-panel") || "search-results-panel") var url = el.getAttribute("search-url") || (urlBase + "/search") var clear = function() { Hobo.hide(search_results_panel); el.clear() } // Close window on [Escape] Event.observe(el, 'keypress', function(ev) { if (ev.keyCode == 27) clear() }); Event.observe(search_results_panel.down('.close-button'), 'click', clear) var value = $F(el) if (Hobo.searchRequest) { Hobo.searchRequest.transport.abort() } if (value.length >= 3) { if (spinner) Hobo.show(spinner); Hobo.searchRequest = new Ajax.Updater(search_results, url, { asynchronous:true, evalScripts:true, onSuccess:function(request) { if (spinner) Hobo.hide(spinner) if (search_results_panel) { Hobo.show(search_results_panel) } }, method: "get", parameters:"query=" + value }); } else { Hobo.updateElement(search_results, '') Hobo.hide(search_results_panel) } }, putUrl: function(el) { var spec = Hobo.modelSpecForElement(el) return urlBase + "/" + Hobo.pluralise(spec.name) + "/" + spec.id + "?_method=PUT" }, urlForId: function(id) { var spec = Hobo.parseModelSpec(id) var url = urlBase + "/" + Hobo.pluralise(spec.name) if (spec.id) { url += "/" + spec.id } return url }, fieldSetParam: function(el, val) { var spec = Hobo.modelSpecForElement(el) var res = spec.name + '[' + spec.field + ']=' + encodeURIComponent(val) if (typeof(formAuthToken) != "undefined") { res = res + "&" + formAuthToken.name + "=" + formAuthToken.value } return res }, fadeObjectElement: function(el) { var fadeEl = Hobo.objectElementFor(el) new Effect.Fade(fadeEl, { duration: 0.5, afterFinish: function (ef) { ef.element.remove() } }); Hobo.showEmptyMessageAfterLastRemove(fadeEl) }, removeButton: function(el, url, updates, options) { if (options.fade == null) { options.fade = true; } if (options.confirm == null) { options.fade = "Are you sure?"; } if (options.confirm == false || confirm(options.confirm)) { var objEl = Hobo.objectElementFor(el) Hobo.showSpinner('Removing'); function complete() { if (options.fade) { Hobo.fadeObjectElement(objEl) } Hobo.hideSpinner() } if (updates && updates.length > 0) { new Hobo.ajaxRequest(url, updates, { method:'delete', message: "Removing...", onComplete: complete}); } else { var ajaxOptions = {asynchronous:true, evalScripts:true, method:'delete', onComplete: complete} if (typeof(formAuthToken) != "undefined") { ajaxOptions.parameters = formAuthToken.name + "=" + formAuthToken.value } new Ajax.Request(url, ajaxOptions); } } }, ajaxUpdateField: function(element, field, value, updates) { var objectElement = Hobo.objectElementFor(element) var url = Hobo.putUrl(objectElement) var spec = Hobo.modelSpecForElement(objectElement) var params = spec.name + '[' + field + ']=' + encodeURIComponent(value) new Hobo.ajaxRequest(url, updates, { method:'put', message: "Saving...", params: params }); }, showEmptyMessageAfterLastRemove: function(el) { var empty var container = $(el.parentNode) if (container.getElementsByTagName(el.nodeName).length == 1 && (empty = container.next('.empty-collection-message'))) { new Effect.Appear(empty, {delay:0.3}) } }, getClassData: function(el, name) { var match = el.className.match(new RegExp("(^| )" + name + "::(\\S+)($| )")) return match && match[2] }, getModelId: function(el) { return Hobo.getClassData(el, 'model') }, modelSpecForElement: function(el) { var id = Hobo.getModelId(el) return id && Hobo.parseModelSpec(id) }, parseModelSpec: function(id) { m = id.gsub('-', '_').match(/^([^:]+)(?::([^:]+)(?::([^:]+))?)?$/) if (m) return { name: m[1], id: m[2], field: m[3] } }, objectElementFor: function(el) { var m while(el.getAttribute) { id = Hobo.getModelId(el) if (id) m = id.match(/^[^:]+:[^:]+$/); if (m) break; el = el.parentNode; } if (m) return el; }, modelIdFor: function(el) { var e = Hobo.objectElementFor(el) return e && Hobo.getModelId(e) }, showSpinner: function(message, nextTo) { clearTimeout(Hobo.spinnerTimer) Hobo.spinnerHideAt = new Date().getTime() + Hobo.spinnerMinTime; if (t = $('ajax-progress-text')) { if (!message || message.length == 0) { t.hide() } else { Element.update(t, message); t.show() } } if (e = $('ajax-progress')) { if (nextTo) { var e_nextTo = $(nextTo); var pos = e_nextTo.cumulativeOffset() e.style.top = pos.top - e_nextTo.offsetHeight + "px" e.style.left = (pos.left + e_nextTo.offsetWidth + 5) + "px" } e.style.display = "block"; } }, hideSpinner: function() { if (e = $('ajax-progress')) { var remainingTime = Hobo.spinnerHideAt - new Date().getTime() if (remainingTime <= 0) { e.visualEffect('Fade') } else { Hobo.spinnerTimer = setTimeout(function () { e.visualEffect('Fade') }, remainingTime) } } }, updateElement: function(id, content) { // TODO: Do we need this method? Element.update(id, content) }, getStyle: function(el, styleProp) { if (el.currentStyle) var y = el.currentStyle[styleProp]; else if (window.getComputedStyle) var y = document.defaultView.getComputedStyle(el, null).getPropertyValue(styleProp); return y; }, partFor: function(el) { while (el) { if (el.id && hoboParts[el.id]) { return el } el = el.parentNode } return null }, pluralise: function(s) { return pluralisations[s] || s + "s" }, addUrlParams: function(params, options) { params = $H(window.location.search.toQueryParams()).merge(params) if (options.remove) { var remove = (options.remove instanceof Array) ? options.remove : [options.remove] remove.each(function(k) { params.unset(k) }) } return window.location.href.sub(/(\?.*|$)/, "?" + params.toQueryString()) }, fixSectionGroup: function(e) { rows = e.childElements().map(function(e, i) { cells = e.childElements().map(function(e, i) { return e.outerHTML.sub("$/i, "") }).join('') var attrs = e.outerHTML.match(/]+)/)[1] return "" + cells + "" }).join("\n") var attrs = e.outerHTML.match(/]+)/)[1] var table= "" + rows + "
" e.outerHTML = table }, makeHtmlEditor: function(textarea) { // do nothing - plugins can overwrite this method } } Element.findContaining = function(el, tag) { el = $(el) tag = tag.toLowerCase() e = el.parentNode while (el) { if (el.nodeName.toLowerCase() == tag) { return el; } e = el.parentNode } return null; } // Add an afterEnterEditMode hook to in-place-editor origEnterEditMode = Ajax.InPlaceEditor.prototype.enterEditMode Ajax.InPlaceEditor.prototype.enterEditMode = function(evt) { origEnterEditMode.bind(this)(evt) if (this.afterEnterEditMode) this.afterEnterEditMode() return false } // Fix Safari in-place-editor bug Ajax.InPlaceEditor.prototype.removeForm = function() { if (!this._form) return; if (this._form.parentNode) { try { Element.remove(this._form); } catch (e) {}} this._form = null; this._controls = { }; } // Silence errors from IE :-( Field.scrollFreeActivate = function(field) { setTimeout(function() { try { Field.activate(field); } catch(e) {} }, 1); } Element.Methods.$$ = function(e, css) { return new Selector(css).findElements(e) } HoboBehavior = Class.create({ initialize: function(mainSelector, features) { this.mainSelector = mainSelector this.features = features this.addEvents(mainSelector, features.events) }, addEvents: function(parentSelector, events) { var self = this for (selector in events) { fullSelector = parentSelector + ' ' + selector var rhs = events[selector] if (Object.isString(rhs)) { this.addBehavior(fullSelector, this.features[rhs]) } else { this.addEvents(fullSelector, rhs) } } }, addBehavior: function(selector, handler) { var self = this behavior = {} behavior[selector] = function(ev) { self.features.element = this.up(self.mainSelector) handler.call(self.features, ev, this) } Event.addBehavior(behavior) } }) new HoboBehavior("ul.input-many", { events: { "> li > div.buttons": { ".add-item:click": 'addOne', ".remove-item:click": 'removeOne' } }, addOne: function(ev, el) { Event.stop(ev) var ul = el.up('ul'), li = el.up('li') var thisItem = li.down('div.input-many-item') var newItem = "
  • " + thisItem.innerHTML + "
    " + "
    " + "
  • " var newItem = DOM.Builder.fromHTML(newItem) ul.appendChild(newItem); this.clearInputs(newItem); this.updateButtons() this.updateInputNames() ul.fire("rapid:add", { element: newItem }) ul.fire("rapid:change", { element: newItem }) new Effect.BlindDown(newItem, {duration: 0.3}) }, removeOne: function(ev, el) { Event.stop(ev) var self = this; var ul = el.up('ul'), li = el.up('li') if (li.parentNode.childElements().length == 1) { // It's the last one - don't remove it, just clear it this.clearInputs(li) } else { new Effect.BlindUp(li, { duration: 0.3, afterFinish: function (ef) { li.remove() self.updateButtons() self.updateInputNames() } }); } ul.fire("rapid:remove") ul.fire("rapid:change") }, clearInputs: function(item) { $(item).select('input,select,textarea').each(function(input){ t = input.getAttribute('type') if (t && t.match(/hidden/i)) { input.remove() } else { input.value = "" } }) }, updateButtons: function() { var removeButton = "" var addButton = "" var ul = this.element var children = ul.childElements(); // assumption: only get here after add or remove, so only second last button needs the "+" removed if(children.length > 1) { // cannot use .down() because that's a depth-first search. Did I mention that I hate Prototype? children[children.length-2].childElements().last().innerHTML = removeButton; } if(children.length > 0) { children[children.length-1].childElements().last().innerHTML = removeButton + ' ' + addButton; } Event.addBehavior.reload() }, updateInputNames: function() { var prefix = Hobo.getClassData(this.element, 'input-many-prefix') this.element.selectChildren('li').each(function(li, index) { li.select('*[name]').each(function(control) { var changeId = control.id == control.name control.name = control.name.sub(new RegExp("^" + RegExp.escape(prefix) + "\[[0-9]+\]"), prefix + '[' + index +']') if (changeId) control.id = control.name }) }) } }) SelectManyInput = Behavior.create({ initialize : function() { // onchange doesn't bubble in IE6 so... Event.observe(this.element.down('select'), 'change', this.addOne.bind(this)) }, addOne : function() { var select = this.element.down('select') var selected = select.options[select.selectedIndex] if ($F(select) != "") { var newItem = $(DOM.Builder.fromHTML(this.element.down('.item-proto').innerHTML.strip())) this.element.down('.items').appendChild(newItem); newItem.down('span').innerHTML = selected.innerHTML this.itemAdded(newItem, selected) selected.disabled = true select.value = "" Event.addBehavior.reload() this.element.fire("rapid:add", { element: newItem }) this.element.fire("rapid:change", { element: newItem }) } }, onclick : function(ev) { var el = Event.element(ev); if (el.match(".remove-item")) { this.removeOne(el.parentNode) } }, removeOne : function(el) { var element = this.element new Effect.BlindUp(el, { duration: 0.3, afterFinish: function (ef) { ef.element.remove() element.fire("rapid:remove", { element: el }) element.fire("rapid:change", { element: el }) } } ) var label = el.down('span').innerHTML var option = $A(element.getElementsByTagName('option')).find(function(o) { return o.innerHTML == label }) option.disabled = false }, itemAdded: function(item, option) { this.hiddenField(item).value = option.value }, hiddenField: function(item) { return item.down('input[type=hidden]') //return item.getElementsByClassName("hidden-field")[0] } }) NameManyInput = Object.extend(SelectManyInput, { addOne : function() { var select = this.element.down('select') var selected = select.options[select.selectedIndex] if (selected.value != "") { var newItem = $(DOM.Builder.fromHTML(this.element.down('.item-proto').innerHTML.strip())) this.element.down('.items').appendChild(newItem); newItem.down('span').innerHTML = selected.innerHTML this.itemAdded(newItem, selected) selected.disabled = true select.value = "" Event.addBehavior.reload() } } }) AutocompleteBehavior = Behavior.create({ initialize : function() { var match = this.element.className.match(/complete-on::([\S]+)/) var target = match[1].split('::') var typedId = target[0] var completer = target[1] var spec = Hobo.parseModelSpec(typedId) var url = urlBase + "/" + Hobo.pluralise(spec.name) + "/complete_" + completer var parameters = spec.id ? "id=" + spec.id : "" new Ajax.Autocompleter(this.element, this.element.next('.completions-popup'), url, {paramName:'query', method:'get', parameters: parameters}); } }) Event.addBehavior.reassignAfterAjax = true; Event.addBehavior({ 'div.section-group' : function() { if (Prototype.Browser.IE) Hobo.fixSectionGroup(this); }, 'div.select-many.input' : SelectManyInput(), 'textarea.html' : function() { Hobo.makeHtmlEditor(this) }, 'form.filter-menu select:change': function(event) { var paramName = this.getAttribute('name') var params = {} var remove = [ 'page' ] if ($F(this) == '') { remove.push(paramName) } else { params[paramName] = $F(this) } location.href = Hobo.addUrlParams(params, {remove: remove}) }, '.autocompleter' : AutocompleteBehavior(), '.string.in-place-edit, .datetime.in-place-edit, .date.in-place-edit, .integer.in-place-edit, .float.in-place-edit, big-integer.in-place-edit' : function (ev) { var ipe = Hobo._makeInPlaceEditor(this) ipe.getText = function() { return this.element.innerHTML.gsub(//, "\n").unescapeHTML() } }, '.text.in-place-edit, .markdown.in-place-edit, .textile.in-place-edit' : function (ev) { var ipe = Hobo._makeInPlaceEditor(this, {rows: 2}) ipe.getText = function() { return this.element.innerHTML.gsub(//, "\n").unescapeHTML() } }, ".html.in-place-edit" : function (ev) { if (Hobo.makeInPlaceHtmlEditor) { Hobo.makeInPlaceHtmlEditor(this) } else { var options = { rows: 2, handleLineBreaks: false, okButton: true, cancelLink: true, okText: "Save", submitOnBlur: false } var ipe = Hobo._makeInPlaceEditor(this, options) } }, "select.integer.editor" : function(e) { var el = this el.onchange = function() { Hobo.ajaxSetFieldForElement(el, $F(el)) } }, "input.live-search[type=search]" : function(e) { var element = this new Form.Element.Observer(element, 1.0, function() { Hobo.doSearch(element) }) } }); ElementSet = Class.create(Enumerable, { initialize: function(array) { this.items = array }, _each: function(fn) { return this.items.each(fn) }, selectChildren: function(selector) { return new ElementSet(this.items.invoke('selectChildren', selector).pluck('items').flatten()) }, child: function(selector) { return this.selectChildren(selector).first() }, select: function(selector) { return new ElementSet(this.items.invoke('select', selector).flatten()) }, down: function(selector) { for (var i = 0; i < this.items.length; i++) { var match = this.items[i].down(selector) if (match) return match } return null }, size: function() { return this.items.length }, first: function() { return this.items.first() }, last: function() { return this.items.last() } }) Element.addMethods({ selectChildren: function(element, selector) { return new ElementSet(Selector.matchElements(element.childElements(), selector)) } })